[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 authored and Claire committed 2026-03-14 11:43:31 +01:00
1 parent 3afb58ad18
commit 8ec882df0c
30 files changed
+330 -52

No files matched your search

@@ -76,7 +76,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};
@@ -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'
>
@@ -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} />}
@@ -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) => (
@@ -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,
@@ -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));
}
}
@@ -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>
);
};
@@ -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;
}
@@ -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;
}
@@ -71,7 +71,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};
@@ -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'
>
@@ -51,7 +51,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};
@@ -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) => (
@@ -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',
},
},
};
@@ -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}
>
@@ -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);
}
@@ -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',
},
},
};
@@ -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}
>
@@ -45,7 +45,7 @@ export const Optional: Story = {
export const WithError: Story = {
args: {
required: false,
hasError: true,
status: 'error',
},
};
@@ -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'
>