Skip to content

Commit

Permalink
feat(Form fields): improve accesibility of errors (#1246)
Browse files Browse the repository at this point in the history
WEB-1958

- Added an icon to error messages (this makes the error message more
accesible over inverse and for color-blind users)
- Added an aria-live region to announce all the fields with error when
the form is submitted and there is more than one field with error
- Make sure all mandatory form fields are validated
- Form fields are validated onBlur. Previously, we only validated fields
with a special format, like email.
- Fixed an error making VoiceOver read fields label twice
  • Loading branch information
atabel authored Sep 26, 2024
1 parent b077417 commit e35a99e
Show file tree
Hide file tree
Showing 66 changed files with 160 additions and 79 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/__stories__/custom-field-story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ const AutocompleteSelectField = ({name, options}: AutocompleteSelectFieldProps)

const fieldProps = useFieldProps({
name,
label: 'Autocomplete',
defaultValue: undefined,
value: filterValue,
processValue: (value: string) => value.trim(),
Expand Down Expand Up @@ -249,7 +250,6 @@ const AutocompleteSelectField = ({name, options}: AutocompleteSelectFieldProps)
<TextField
{...fieldProps}
fullWidth
label="Autocomplete"
ref={combineRefs(ref, inputRef)}
onFocus={onPress}
onPress={() => {
Expand Down
23 changes: 23 additions & 0 deletions src/__tests__/form-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,29 @@ test('custom validator', async () => {
);
});

test('when there are multiple errors on submit, the fields with error are anounced by screen reader', async () => {
const handleSubmitSpy = jest.fn();

render(
<ThemeContextProvider theme={makeTheme()}>
<Form onSubmit={handleSubmitSpy}>
<TextField label="Username" name="username" />
<TextField label="Password" name="password" validate={() => 'wrong password'} />
<ButtonPrimary submit>Submit</ButtonPrimary>
</Form>
</ThemeContextProvider>
);

await userEvent.type(screen.getByLabelText('Password'), 'letmein');
await userEvent.click(screen.getByText('Submit'));

expect(screen.getByText('Este campo es obligatorio')).toBeInTheDocument();
expect(screen.getByText('wrong password')).toBeInTheDocument();
const liveRegion = screen.getByRole('alert');
expect(liveRegion).toBeInTheDocument();
expect(liveRegion).toHaveTextContent('Revisa los siguientes errores: Username, Password');
});

test('fields are disabled during submit', async () => {
let resolveSubmitPromise: (value?: unknown) => void = () => {};
const submitPromise = new Promise((r) => {
Expand Down
6 changes: 4 additions & 2 deletions src/box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Props = {
paddingBottom?: ByBreakpoint<PadSize>;
paddingLeft?: ByBreakpoint<PadSize>;
paddingRight?: ByBreakpoint<PadSize>;
as?: React.ComponentType<any> | string;
children?: React.ReactNode;
className?: string;
role?: string;
Expand All @@ -31,6 +32,7 @@ const Box = React.forwardRef<HTMLDivElement, Props>(
{
className,
children,
as: Component = 'div',
width,
padding = 0,
paddingX = padding,
Expand Down Expand Up @@ -64,7 +66,7 @@ const Box = React.forwardRef<HTMLDivElement, Props>(
}

return (
<div
<Component
{...getPrefixedDataAttributes(dataAttributes)}
role={role}
aria-label={ariaLabel}
Expand All @@ -78,7 +80,7 @@ const Box = React.forwardRef<HTMLDivElement, Props>(
id={id}
>
{children}
</div>
</Component>
);
}
);
Expand Down
5 changes: 2 additions & 3 deletions src/credit-card-expiration-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const CreditCardExpirationField = ({
error,
helperText,
name,
label,
optional,
validate: validateProp,
onChange,
Expand All @@ -112,9 +113,6 @@ const CreditCardExpirationField = ({
const {setFormError, jumpToNext} = useForm();

const validate = (value: ExpirationDateValue, rawValue: string): string | undefined => {
if (!rawValue) {
return optional ? '' : texts.formFieldErrorIsMandatory || t(tokens.formFieldErrorIsMandatory);
}
const {month, year} = value;
if (!month || !year) {
return texts.formCreditCardExpirationError || t(tokens.formCreditCardExpirationError);
Expand Down Expand Up @@ -142,6 +140,7 @@ const CreditCardExpirationField = ({

const fieldProps = useFieldProps({
name,
label,
value,
defaultValue,
processValue,
Expand Down
7 changes: 3 additions & 4 deletions src/credit-card-number-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ const CreditCardNumberField = ({
error,
helperText,
name,
label,
optional,
validate: validateProp,
onChange,
Expand All @@ -174,9 +175,6 @@ const CreditCardNumberField = ({

const validate = (value: string | undefined, rawValue: string) => {
const error = texts.formCreditCardNumberError || t(tokens.formCreditCardNumberError);
if (!value) {
return optional ? '' : texts.formFieldErrorIsMandatory || t(tokens.formFieldErrorIsMandatory);
}
if (isAmericanExpress(value) && !acceptedCards.americanExpress) {
return error;
}
Expand All @@ -189,7 +187,7 @@ const CreditCardNumberField = ({
if (!isValidCreditCardNumber(value)) {
return error;
}
if (getCreditCardNumberLength(value) !== value.length) {
if (getCreditCardNumberLength(value) !== value?.length) {
return error;
}
return validateProp?.(value, rawValue);
Expand All @@ -199,6 +197,7 @@ const CreditCardNumberField = ({

const fieldProps = useFieldProps({
name,
label,
value,
defaultValue,
processValue,
Expand Down
6 changes: 2 additions & 4 deletions src/cvv-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ const TooltipContent = ({acceptedCards}: {acceptedCards: CardOptions}) => {
<Text2>
{texts.formCreditCardCvvTooltipAmex || t(tokens.formCreditCardCvvTooltipAmex)}
</Text2>
)
</Inline>
)}
</Stack>
Expand All @@ -61,6 +60,7 @@ const CvvField = ({
error,
helperText,
name,
label,
optional,
validate: validateProp,
onChange,
Expand All @@ -79,9 +79,6 @@ const CvvField = ({
const [isCvvHelpOpen, setIsCvvHelpOpen] = React.useState(false);

const validate = (value: string, rawValue: string) => {
if (!value) {
return optional ? '' : texts.formFieldErrorIsMandatory || t(tokens.formFieldErrorIsMandatory);
}
if (value.length !== maxLength) {
return texts.formCreditCardCvvError || t(tokens.formCreditCardCvvError);
}
Expand All @@ -92,6 +89,7 @@ const CvvField = ({

const fieldProps = useFieldProps({
name,
label,
value,
defaultValue,
processValue,
Expand Down
2 changes: 2 additions & 0 deletions src/date-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const DateField = ({
error,
helperText,
name,
label,
optional,
validate: validateProp,
onChange,
Expand Down Expand Up @@ -77,6 +78,7 @@ const DateField = ({

const fieldProps = useFieldProps({
name,
label,
value,
defaultValue,
processValue,
Expand Down
2 changes: 2 additions & 0 deletions src/date-time-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const FormDateField = ({
error,
helperText,
name,
label,
optional,
validate: validateProp,
onChange,
Expand Down Expand Up @@ -83,6 +84,7 @@ const FormDateField = ({

const fieldProps = useFieldProps({
name,
label,
value,
defaultValue,
processValue,
Expand Down
14 changes: 3 additions & 11 deletions src/decimal-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {TextFieldBaseAutosuggest} from './text-field-base';
import {createChangeEvent} from './utils/dom';
import {useRifm} from 'rifm';
import {combineRefs} from './utils/common';
import * as tokens from './text-tokens';

import type {Locale} from './utils/locale';
import type {CommonFormFieldProps} from './text-field-base';
Expand Down Expand Up @@ -115,8 +114,9 @@ const DecimalField = ({
error,
helperText,
name,
label,
optional,
validate: validateProp,
validate,
onChange,
onChangeValue,
onBlur,
Expand All @@ -126,19 +126,11 @@ const DecimalField = ({
dataAttributes,
...rest
}: DecimalFieldProps): JSX.Element => {
const {texts, t} = useTheme();

const validate = (value: string | undefined, rawValue: string) => {
if (!value) {
return optional ? '' : texts.formFieldErrorIsMandatory || t(tokens.formFieldErrorIsMandatory);
}
return validateProp?.(value, rawValue);
};

const processValue = (value: string) => value.trim();

const fieldProps = useFieldProps({
name,
label,
value,
defaultValue,
processValue,
Expand Down
7 changes: 3 additions & 4 deletions src/email-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const EmailField = ({
error,
helperText,
name,
label,
optional,
validate: validateProp,
onChange,
Expand All @@ -34,10 +35,7 @@ const EmailField = ({
const {texts, t} = useTheme();

const validate = (value: string | undefined, rawValue: string) => {
if (!value) {
return optional ? '' : texts.formFieldErrorIsMandatory || t(tokens.formFieldErrorIsMandatory);
}
if (!RE_EMAIL.test(value)) {
if (!RE_EMAIL.test(value ?? '')) {
return texts.formEmailError || t(tokens.formEmailError);
}
return validateProp?.(value, rawValue);
Expand All @@ -47,6 +45,7 @@ const EmailField = ({

const fieldProps = useFieldProps({
name,
label,
value,
defaultValue,
processValue,
Expand Down
23 changes: 21 additions & 2 deletions src/form-context.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use client';
import * as React from 'react';
import {useTheme} from './hooks';
import * as tokens from './text-tokens';

export type FormStatus = 'filling' | 'sending';
export type FormErrors = {[name: string]: string | undefined};
Expand All @@ -9,6 +11,7 @@ export type FieldRegistration = {
input?: HTMLInputElement | HTMLSelectElement | null;
validator?: FieldValidator;
focusableElement?: HTMLDivElement | HTMLSelectElement | null;
label?: string;
};

type Context = {
Expand Down Expand Up @@ -47,18 +50,21 @@ export const useForm = (): Context => React.useContext(FormContext);

export const useControlProps = <T,>({
name,
label,
value,
defaultValue,
onChange,
disabled,
}: {
name: string;
label?: string;
value: undefined | T;
defaultValue: undefined | T;
onChange: undefined | ((value: T) => void);
disabled?: boolean;
}): {
name: string;
label?: string;
value?: T;
defaultValue?: T;
onChange: (value: T) => void;
Expand All @@ -77,11 +83,13 @@ export const useControlProps = <T,>({

return {
name,
label,
value,
defaultValue: defaultValue ?? (value === undefined ? rawValues[name] ?? false : undefined),
focusableRef: (focusableElement: HTMLDivElement | null) =>
register(name, {
focusableElement,
label,
}),
onChange: (value: T) => {
setRawValue({name, value});
Expand All @@ -95,6 +103,7 @@ export const useControlProps = <T,>({

export const useFieldProps = ({
name,
label,
value,
defaultValue,
processValue,
Expand All @@ -108,6 +117,7 @@ export const useFieldProps = ({
onChangeValue,
}: {
name: string;
label: string;
value?: string;
defaultValue?: string;
processValue: (value: string) => unknown;
Expand All @@ -123,6 +133,7 @@ export const useFieldProps = ({
value?: string;
defaultValue?: string;
name: string;
label: string;
helperText?: string;
required: boolean;
error: boolean;
Expand All @@ -131,6 +142,7 @@ export const useFieldProps = ({
inputRef: (field: HTMLInputElement | null) => void;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
} => {
const {texts, t} = useTheme();
const {setRawValue, setValue, rawValues, values, formErrors, formStatus, setFormError, register} =
useForm();
const rawValue = value ?? defaultValue ?? rawValues[name] ?? '';
Expand All @@ -151,15 +163,22 @@ export const useFieldProps = ({
value,
defaultValue: defaultValue ?? (value === undefined ? rawValues[name] ?? '' : undefined),
name,
label,
helperText: formErrors[name] || helperText,
required: !optional,
error: error || !!formErrors[name],
disabled: disabled || formStatus === 'sending',
onBlur: (e: React.FocusEvent) => {
setFormError({name, error: validate?.(values[name], rawValues[name])});
let error: string | undefined;
if (!rawValues[name] && !optional) {
error = texts.formFieldErrorIsMandatory || t(tokens.formFieldErrorIsMandatory);
} else if (validate) {
error = validate(values[name], rawValues[name]);
}
setFormError({name, error});
onBlur?.(e);
},
inputRef: (input: HTMLInputElement | null) => register(name, {input, validator: validate}),
inputRef: (input: HTMLInputElement | null) => register(name, {input, validator: validate, label}),
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
const rawValue = event.currentTarget.value;
const value = processValue(rawValue);
Expand Down
Loading

0 comments on commit e35a99e

Please sign in to comment.