[Glitch] Allow displaying field status (error, warning, info) under form fields

Port 890b2673fc to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion
2026-03-13 14:55:17 +01:00
committed by Claire
parent 3afb58ad18
commit 8ec882df0c
30 changed files with 330 additions and 52 deletions

View File

@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { A11yLiveRegion } from '.';
const meta = {
title: 'Components/A11yLiveRegion',
component: A11yLiveRegion,
} satisfies Meta<typeof A11yLiveRegion>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Polite: Story = {
args: {
children: "This field can't be empty.",
},
};
export const Assertive: Story = {
args: {
...Polite.args,
role: 'alert',
},
};

View File

@@ -0,0 +1,28 @@
import { polymorphicForwardRef } from '@/types/polymorphic';
/**
* A live region is a content region that announces changes of its contents
* to users of assistive technology like screen readers.
*
* Dynamically added warnings, errors, or live status updates should be wrapped
* in a live region to ensure they are not missed when they appear.
*
* **Important:**
* Live regions must be present in the DOM _before_
* the to-be announced content is rendered into it.
*/
export const A11yLiveRegion = polymorphicForwardRef<'div'>(
({ role = 'status', as: Component = 'div', children, ...props }, ref) => {
return (
<Component
role={role}
aria-live={role === 'alert' ? 'assertive' : 'polite'}
ref={ref}
{...props}
>
{children}
</Component>
);
},
);

View File

@@ -0,0 +1,39 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { CalloutInline } from '.';
const meta = {
title: 'Components/CalloutInline',
args: {
children: 'Contents here',
},
component: CalloutInline,
} satisfies Meta<typeof CalloutInline>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Error: Story = {
args: {
variant: 'error',
},
};
export const Warning: Story = {
args: {
variant: 'warning',
},
};
export const Success: Story = {
args: {
variant: 'success',
},
};
export const Info: Story = {
args: {
variant: 'info',
},
};

View File

@@ -0,0 +1,39 @@
import type { FC } from 'react';
import classNames from 'classnames';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import ErrorIcon from '@/material-icons/400-24px/error.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { Icon } from '../icon';
import classes from './styles.module.css';
export interface FieldStatus {
variant: 'error' | 'warning' | 'info' | 'success';
message?: string;
}
const iconMap: Record<FieldStatus['variant'], React.FunctionComponent> = {
error: ErrorIcon,
warning: WarningIcon,
info: InfoIcon,
success: CheckIcon,
};
export const CalloutInline: FC<
Partial<FieldStatus> & React.ComponentPropsWithoutRef<'div'>
> = ({ variant = 'error', message, className, children, ...props }) => {
return (
<div
{...props}
className={classNames(className, classes.wrapper)}
data-variant={variant}
>
<Icon id={variant} icon={iconMap[variant]} className={classes.icon} />
{message ?? children}
</div>
);
};

View File

@@ -0,0 +1,29 @@
.wrapper {
display: flex;
align-items: start;
gap: 4px;
font-size: 13px;
font-weight: 500;
&[data-variant='success'] {
color: var(--color-text-success);
}
&[data-variant='warning'] {
color: var(--color-text-warning);
}
&[data-variant='error'] {
color: var(--color-text-error);
}
&[data-variant='info'] {
color: var(--color-text-primary);
}
}
.icon {
width: 16px;
height: 16px;
margin-top: 1px;
}

View File

@@ -76,7 +76,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};

View File

@@ -13,12 +13,12 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
export const CheckboxField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => (
>(({ id, label, hint, status, required, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
inputPlacement='inline-start'
>

View File

@@ -86,14 +86,14 @@ interface Props<T extends ComboboxItem>
*/
export const ComboboxFieldWithRef = <T extends ComboboxItem>(
{ id, label, hint, hasError, required, ...otherProps }: Props<T>,
{ id, label, hint, status, required, ...otherProps }: Props<T>,
ref: React.ForwardedRef<HTMLInputElement>,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
>
{(inputProps) => <Combobox {...otherProps} {...inputProps} ref={ref} />}

View File

@@ -22,7 +22,7 @@ interface CopyLinkFieldProps extends CommonFieldWrapperProps, TextInputProps {
export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
(
{ id, label, hint, hasError, value, required, className, ...otherProps },
{ id, label, hint, status, value, required, className, ...otherProps },
ref,
) => {
const intl = useIntl();
@@ -48,7 +48,7 @@ export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
>
{(inputProps) => (

View File

@@ -37,7 +37,7 @@ export const EmojiTextInputField: FC<
value,
label,
hint,
hasError,
status,
maxLength,
counterMax = maxLength,
recommended,
@@ -49,7 +49,7 @@ export const EmojiTextInputField: FC<
const wrapperProps = {
label,
hint,
hasError,
status,
counterMax,
recommended,
disabled,
@@ -84,7 +84,7 @@ export const EmojiTextAreaField: FC<
recommended,
disabled,
hint,
hasError,
status,
...otherProps
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -92,7 +92,7 @@ export const EmojiTextAreaField: FC<
const wrapperProps = {
label,
hint,
hasError,
status,
counterMax,
recommended,
disabled,

View File

@@ -1,7 +1,9 @@
.fieldset {
--container-gap: 12px;
display: flex;
flex-direction: column;
gap: 12px;
gap: var(--container-gap);
color: var(--color-text-primary);
font-size: 15px;
}
@@ -17,3 +19,11 @@
column-gap: 24px;
}
}
.status {
// If there's no content, we need to compensate for the parent's
// flex gap to avoid extra spacing below the field.
&:empty {
margin-top: calc(-1 * var(--container-gap));
}
}

View File

@@ -3,14 +3,19 @@
import type { ReactNode, FC } from 'react';
import { createContext, useId } from 'react';
import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region';
import type { FieldStatus } from 'flavours/glitch/components/callout_inline';
import { CalloutInline } from 'flavours/glitch/components/callout_inline';
import classes from './fieldset.module.scss';
import { getFieldStatus } from './form_field_wrapper';
import formFieldWrapperClasses from './form_field_wrapper.module.scss';
interface FieldsetProps {
legend: ReactNode;
hint?: ReactNode;
name?: string;
hasError?: boolean;
status?: FieldStatus | FieldStatus['variant'];
layout?: 'vertical' | 'horizontal';
children: ReactNode;
}
@@ -26,22 +31,33 @@ export const Fieldset: FC<FieldsetProps> = ({
legend,
hint,
name,
hasError,
status,
layout,
children,
}) => {
const uniqueId = useId();
const labelId = `${uniqueId}-label`;
const hintId = `${uniqueId}-hint`;
const statusId = `${uniqueId}-status`;
const fieldsetName = name || `${uniqueId}-fieldset-name`;
const hasHint = !!hint;
const fieldStatus = getFieldStatus(status);
const hasStatusMessage = !!fieldStatus?.message;
const descriptionIds = [
hasHint ? hintId : '',
hasStatusMessage ? statusId : '',
]
.filter((id) => !!id)
.join(' ');
return (
<fieldset
className={classes.fieldset}
data-has-error={hasError}
data-has-error={status === 'error'}
aria-labelledby={labelId}
aria-describedby={hintId}
aria-describedby={descriptionIds}
>
<div className={formFieldWrapperClasses.labelWrapper}>
<div id={labelId} className={formFieldWrapperClasses.label}>
@@ -59,6 +75,11 @@ export const Fieldset: FC<FieldsetProps> = ({
{children}
</FieldsetNameContext.Provider>
</div>
{/* Live region must be rendered even when empty */}
<A11yLiveRegion className={classes.status} id={statusId}>
{hasStatusMessage && <CalloutInline {...fieldStatus} />}
</A11yLiveRegion>
</fieldset>
);
};

View File

@@ -46,6 +46,14 @@
font-size: 13px;
}
.status {
// If there's no content, we need to compensate for the parent's
// flex gap to avoid extra spacing below the field.
&:empty {
margin-top: calc(-1 * var(--form-field-label-gap));
}
}
.inputWrapper {
display: block;
}

View File

@@ -7,6 +7,10 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region';
import type { FieldStatus } from 'flavours/glitch/components/callout_inline';
import { CalloutInline } from 'flavours/glitch/components/callout_inline';
import { FieldsetNameContext } from './fieldset';
import classes from './form_field_wrapper.module.scss';
@@ -20,7 +24,7 @@ interface FieldWrapperProps {
label: ReactNode;
hint?: ReactNode;
required?: boolean;
hasError?: boolean;
status?: FieldStatus['variant'] | FieldStatus;
inputId?: string;
describedById?: string;
inputPlacement?: 'inline-start' | 'inline-end';
@@ -33,7 +37,7 @@ interface FieldWrapperProps {
*/
export type CommonFieldWrapperProps = Pick<
FieldWrapperProps,
'label' | 'hint' | 'hasError'
'label' | 'hint' | 'status'
> & { wrapperClassName?: string };
/**
@@ -48,27 +52,31 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
hint,
describedById,
required,
hasError,
status,
inputPlacement,
children,
className,
}) => {
const uniqueId = useId();
const inputId = inputIdProp || `${uniqueId}-input`;
const statusId = `${inputIdProp || uniqueId}-status`;
const hintId = `${inputIdProp || uniqueId}-hint`;
const hasHint = !!hint;
const fieldStatus = getFieldStatus(status);
const hasStatusMessage = !!fieldStatus?.message;
const hasParentFieldset = !!useContext(FieldsetNameContext);
const descriptionIds =
[hasHint ? hintId : '', hasStatusMessage ? statusId : '', describedById]
.filter((id) => !!id)
.join(' ') || undefined;
const inputProps: InputProps = {
required,
id: inputId,
'aria-describedby': descriptionIds,
};
if (hasHint) {
inputProps['aria-describedby'] = describedById
? `${describedById} ${hintId}`
: hintId;
}
const input = (
<div className={classes.inputWrapper}>{children(inputProps)}</div>
@@ -77,7 +85,7 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
return (
<div
className={classNames(classes.wrapper, className)}
data-has-error={hasError}
data-has-error={fieldStatus?.variant === 'error'}
data-input-placement={inputPlacement}
>
{inputPlacement === 'inline-start' && input}
@@ -100,6 +108,11 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
</div>
{inputPlacement !== 'inline-start' && input}
{/* Live region must be rendered even when empty */}
<A11yLiveRegion className={classes.status} id={statusId}>
{hasStatusMessage && <CalloutInline {...fieldStatus} />}
</A11yLiveRegion>
</div>
);
};
@@ -121,3 +134,19 @@ const RequiredMark: FC<{ required?: boolean }> = ({ required }) =>
<FormattedMessage id='form_field.optional' defaultMessage='(optional)' />
</>
);
export function getFieldStatus(status: FieldWrapperProps['status']) {
if (!status) {
return null;
}
if (typeof status === 'string') {
const fieldStatus: FieldStatus = {
variant: status,
message: '',
};
return fieldStatus;
}
return status;
}

View File

@@ -71,7 +71,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};

View File

@@ -15,7 +15,7 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
export const RadioButtonField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => {
>(({ id, label, hint, status, required, ...otherProps }, ref) => {
const fieldsetName = useContext(FieldsetNameContext);
return (
@@ -23,7 +23,7 @@ export const RadioButtonField = forwardRef<
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
inputPlacement='inline-start'
>

View File

@@ -51,7 +51,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};

View File

@@ -19,12 +19,12 @@ interface Props
*/
export const SelectField = forwardRef<HTMLSelectElement, Props>(
({ id, label, hint, required, hasError, children, ...otherProps }, ref) => (
({ id, label, hint, required, status, children, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
>
{(inputProps) => (

View File

@@ -38,7 +38,17 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: { variant: 'error', message: "This field can't be empty" },
},
};
export const WithWarning: Story = {
args: {
required: false,
status: {
variant: 'warning',
message: 'Special characters are not allowed',
},
},
};

View File

@@ -26,14 +26,14 @@ export const TextAreaField = forwardRef<
TextAreaProps & CommonFieldWrapperProps
>(
(
{ id, label, hint, required, hasError, wrapperClassName, ...otherProps },
{ id, label, hint, required, status, wrapperClassName, ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
className={wrapperClassName}
>

View File

@@ -29,16 +29,16 @@
color: var(--color-text-secondary);
}
&:focus {
outline-color: var(--color-text-brand);
}
&:focus:user-invalid,
&:required:user-invalid,
[data-has-error='true'] & {
outline-color: var(--color-text-error);
}
&:focus {
outline-color: var(--color-text-brand);
}
&:required:user-valid {
outline-color: var(--color-text-success);
}

View File

@@ -40,7 +40,17 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};
export const WithWarning: Story = {
args: {
required: false,
status: {
variant: 'warning',
message: 'Special characters are not allowed',
},
},
};

View File

@@ -25,14 +25,14 @@ interface Props extends TextInputProps, CommonFieldWrapperProps {}
export const TextInputField = forwardRef<HTMLInputElement, Props>(
(
{ id, label, hint, hasError, required, wrapperClassName, ...otherProps },
{ id, label, hint, status, required, wrapperClassName, ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
className={wrapperClassName}
>

View File

@@ -45,7 +45,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};

View File

@@ -14,12 +14,12 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
export const ToggleField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => (
>(({ id, label, hint, status, required, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
status={status}
inputId={id}
inputPlacement='inline-end'
>

View File

@@ -141,8 +141,11 @@ const InnerNodeModal: FC<{
onChange={handleChange}
label={intl.formatMessage(messages.fieldLabel)}
className={classes.noteInput}
hasError={state === 'error'}
hint={errorText}
status={
state === 'error'
? { variant: 'error', message: errorText }
: undefined
}
// eslint-disable-next-line jsx-a11y/no-autofocus -- We want to focus here as it's a modal.
autoFocus
/>

View File

@@ -1,12 +1,15 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import { inputToHashtag } from '@/flavours/glitch/utils/hashtags';
import {
hasSpecialCharacters,
inputToHashtag,
} from '@/flavours/glitch/utils/hashtags';
import type {
ApiCreateCollectionPayload,
ApiUpdateCollectionPayload,
@@ -31,6 +34,7 @@ import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header';
export const CollectionDetails: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const history = useHistory();
const { id, name, description, topic, discoverable, sensitive, accountIds } =
@@ -152,6 +156,11 @@ export const CollectionDetails: React.FC = () => {
],
);
const topicHasSpecialCharacters = useMemo(
() => hasSpecialCharacters(topic),
[topic],
);
return (
<form onSubmit={handleSubmit} className={classes.form}>
<FormStack className={classes.formFieldStack}>
@@ -224,6 +233,18 @@ export const CollectionDetails: React.FC = () => {
autoCorrect='off'
spellCheck='false'
maxLength={40}
status={
topicHasSpecialCharacters
? {
variant: 'warning',
message: intl.formatMessage({
id: 'collections.topic_special_chars_hint',
defaultMessage:
'Special characters will be removed when saving',
}),
}
: undefined
}
/>
<Fieldset

View File

@@ -233,7 +233,7 @@ export const Profile: React.FC<{
}
value={displayName}
onChange={handleDisplayNameChange}
hasError={!!errors?.display_name}
status={errors?.display_name ? 'error' : undefined}
id='display_name'
/>
</div>
@@ -255,7 +255,7 @@ export const Profile: React.FC<{
}
value={note}
onChange={handleNoteChange}
hasError={!!errors?.note}
status={errors?.note ? 'error' : undefined}
id='note'
/>
</div>

View File

@@ -23,6 +23,7 @@ import {
createAppSelector,
createDataLoadingThunk,
} from '@/flavours/glitch/store/typed_functions';
import { inputToHashtag } from '@/flavours/glitch/utils/hashtags';
type QueryStatus = 'idle' | 'loading' | 'error';
@@ -82,7 +83,7 @@ const collectionSlice = createSlice({
id: collection?.id ?? null,
name: collection?.name ?? '',
description: collection?.description ?? '',
topic: collection?.tag?.name ?? '',
topic: inputToHashtag(collection?.tag?.name ?? ''),
language: collection?.language ?? '',
discoverable: collection?.discoverable ?? true,
sensitive: collection?.sensitive ?? false,

View File

@@ -59,3 +59,8 @@ export const inputToHashtag = (input: string): string => {
return `#${words.join('')}${trailingSpace}`;
};
export const hasSpecialCharacters = (input: string) => {
// Regex matches any character NOT a letter/digit, except the #
return /[^a-zA-Z0-9# ]/.test(input);
};