Skip to content

Commit b495793

Browse files
author
Michael Jordan
committed
fix(accessibility): improve announcement of error message for a just blurred field
1. Improves announcement of validation error for just blurred field, by including accessible name in announcement for context. 2. Forgo announcement when the field receiving focus already has a validation error. It can be too much information. 3. Add alt text, "(valid)," to validationIcon, and include it in the `aria-describedby` for input for TextFieldBase. 4. fix tests after adding alt text to validationIcon
1 parent 72b183c commit b495793

File tree

14 files changed

+120
-25
lines changed

14 files changed

+120
-25
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"invalidValue": "Invalid value.",
3+
"reviewField": "Please review {accessibleName} field: {validationMessage}"
4+
}

packages/@react-aria/form/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@
2626
"url": "https://github.com/adobe/react-spectrum"
2727
},
2828
"dependencies": {
29+
"@react-aria/i18n": "^3.12.9",
2930
"@react-aria/interactions": "^3.25.1",
3031
"@react-aria/live-announcer": "^3.4.2",
3132
"@react-aria/utils": "^3.29.0",
3233
"@react-stately/form": "^3.1.4",
3334
"@react-types/shared": "^3.29.1",
34-
"@swc/helpers": "^0.5.0"
35+
"@swc/helpers": "^0.5.0",
36+
"dom-accessibility-api": "^0.7.0"
3537
},
3638
"peerDependencies": {
3739
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",

packages/@react-aria/form/src/useFormValidation.ts

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,24 @@
1111
*/
1212

1313
import {announce} from '@react-aria/live-announcer';
14+
import {computeAccessibleName} from 'dom-accessibility-api';
1415
import {FormValidationState} from '@react-stately/form';
1516
import {getActiveElement, getOwnerDocument, useEffectEvent, useLayoutEffect} from '@react-aria/utils';
17+
// @ts-ignore
18+
import intlMessages from '../intl/*.json';
1619
import {RefObject, Validation, ValidationResult} from '@react-types/shared';
1720
import {setInteractionModality} from '@react-aria/interactions';
1821
import {useEffect, useRef} from 'react';
22+
import {useLocalizedStringFormatter} from '@react-aria/i18n';
1923

2024
type ValidatableElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
2125

26+
function isValidatableElement(element: Element): boolean {
27+
return element instanceof HTMLInputElement ||
28+
element instanceof HTMLTextAreaElement ||
29+
element instanceof HTMLSelectElement;
30+
}
31+
2232
interface FormValidationProps<T> extends Validation<T> {
2333
focus?: () => void
2434
}
@@ -33,9 +43,7 @@ export function useFormValidation<T>(props: FormValidationProps<T>, state: FormV
3343
clearTimeout(timeoutIdRef.current);
3444
timeoutIdRef.current = null;
3545
}
36-
if (ref?.current &&
37-
errorMessage !== '' &&
38-
(
46+
if (ref && ref.current && errorMessage !== '' && (
3947
ref.current.contains(getActiveElement(getOwnerDocument(ref.current))) ||
4048
justBlurredRef.current
4149
)
@@ -44,10 +52,15 @@ export function useFormValidation<T>(props: FormValidationProps<T>, state: FormV
4452
}
4553
}
4654

55+
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/form');
56+
4757
// This is a useLayoutEffect so that it runs before the useEffect in useFormValidationState, which commits the validation change.
4858
useLayoutEffect(() => {
4959
if (validationBehavior === 'native' && ref?.current && !ref.current.disabled) {
50-
let errorMessage = state.realtimeValidation.isInvalid ? state.realtimeValidation.validationErrors.join(' ') || 'Invalid value.' : '';
60+
let errorMessage =
61+
state.realtimeValidation.isInvalid ?
62+
(state.realtimeValidation.validationErrors?.join(' ') || stringFormatter.format('invalidValue') || '') :
63+
'';
5164
ref.current.setCustomValidity(errorMessage);
5265

5366
// Prevent default tooltip for validation message.
@@ -100,10 +113,35 @@ export function useFormValidation<T>(props: FormValidationProps<T>, state: FormV
100113
state.commitValidation();
101114
});
102115

103-
let onBlur = useEffectEvent(() => {
116+
let onBlur = useEffectEvent((event: Event) => {
117+
const input = ref?.current;
118+
const relatedTarget = (event as FocusEvent).relatedTarget as Element | null;
119+
if (
120+
(!input || !input.validationMessage) ||
121+
(relatedTarget && isValidatableElement(relatedTarget) && (relatedTarget as ValidatableElement).validationMessage)
122+
) {
123+
// If the input has no validation message,
124+
// or the relatedTarget has a validation message, don't announce the error message.
125+
// This prevents announcing the error message when the user is navigating
126+
// between inputs that may already have an error message.
127+
return;
128+
}
104129
justBlurredRef.current = true;
130+
const isRadioOrCheckbox = input.type === 'radio' || input.type === 'checkbox';
131+
const groupElement = isRadioOrCheckbox ? input.closest('[role="group"][aria-labelledby], [role=\'group\'][aria-label], fieldset') : undefined;
105132
// Announce the current error message
106-
announceErrorMessage(ref?.current?.validationMessage || '');
133+
const accessibleName = computeAccessibleName(groupElement || input);
134+
const validationMessage = input.validationMessage;
135+
announceErrorMessage(
136+
accessibleName && validationMessage ?
137+
stringFormatter.format(
138+
'reviewField',
139+
{
140+
accessibleName,
141+
validationMessage
142+
}) :
143+
validationMessage
144+
);
107145
justBlurredRef.current = false;
108146
});
109147

@@ -132,7 +170,7 @@ export function useFormValidation<T>(props: FormValidationProps<T>, state: FormV
132170
}, [justBlurredRef, onBlur, onChange, onInvalid, onReset, ref, validationBehavior]);
133171
}
134172

135-
function getValidity(input: ValidatableElement) {
173+
function getValidity(input: ValidatableElement): ValidityState {
136174
// The native ValidityState object is live, meaning each property is a getter that returns the current state.
137175
// We need to create a snapshot of the validity state at the time this function is called to avoid unpredictable React renders.
138176
let validity = input.validity;

packages/@react-spectrum/autocomplete/intl/en-US.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
"loading": "Loading...",
33
"noResults": "No results",
44
"clear": "Clear",
5-
"invalid": "(invalid)"
5+
"invalid": "(invalid)",
6+
"valid": "(valid)"
67
}

packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,10 @@ const SearchAutocompleteButton = React.forwardRef(function SearchAutocompleteBut
207207
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/autocomplete');
208208
let valueId = useId();
209209
let invalidId = useId();
210+
let validId = useId();
210211
let validationIcon = validationState === 'invalid'
211212
? <AlertMedium id={invalidId} aria-label={stringFormatter.format('invalid')} />
212-
: <CheckmarkMedium />;
213+
: <CheckmarkMedium id={validId} aria-label={stringFormatter.format('valid')} />;
213214

214215
if (icon) {
215216
icon = React.cloneElement(icon, {
@@ -262,7 +263,8 @@ const SearchAutocompleteButton = React.forwardRef(function SearchAutocompleteBut
262263
props['aria-labelledby'],
263264
props['aria-label'] && !props['aria-labelledby'] ? props.id : null,
264265
valueId,
265-
validationState === 'invalid' ? invalidId : null
266+
validationState === 'invalid' ? invalidId : null,
267+
validationState === 'valid' ? validId : null
266268
].filter(Boolean).join(' '),
267269
elementType: 'div'
268270
}, ref);

packages/@react-spectrum/combobox/intl/en-US.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
"loading": "Loading...",
33
"noResults": "No results",
44
"clear": "Clear",
5-
"invalid": "(invalid)"
5+
"invalid": "(invalid)",
6+
"valid": "(valid)"
67
}

packages/@react-spectrum/combobox/src/MobileComboBox.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,10 @@ export const ComboBoxButton = React.forwardRef(function ComboBoxButton(props: Co
179179
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/combobox');
180180
let valueId = useId();
181181
let invalidId = useId();
182+
let validId = useId();
182183
let validationIcon = validationState === 'invalid'
183184
? <AlertMedium id={invalidId} aria-label={stringFormatter.format('invalid')} />
184-
: <CheckmarkMedium />;
185+
: <CheckmarkMedium id={validId} aria-label={stringFormatter.format('valid')} />;
185186

186187
let validation = React.cloneElement(validationIcon, {
187188
UNSAFE_className: classNames(
@@ -202,7 +203,8 @@ export const ComboBoxButton = React.forwardRef(function ComboBoxButton(props: Co
202203
props['aria-labelledby'],
203204
props['aria-label'] && !props['aria-labelledby'] ? props.id : null,
204205
valueId,
205-
validationState === 'invalid' ? invalidId : null
206+
validationState === 'invalid' ? invalidId : null,
207+
validationState === 'valid' ? validId : null
206208
].filter(Boolean).join(' '),
207209
elementType: 'div'
208210
}, objRef);
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"time": "Time",
33
"startTime": "Start time",
4-
"endTime": "End time"
4+
"endTime": "End time",
5+
"valid": "(valid)"
56
}

packages/@react-spectrum/datepicker/src/Input.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@ import Alert from '@spectrum-icons/ui/AlertMedium';
1414
import Checkmark from '@spectrum-icons/ui/CheckmarkMedium';
1515
import {classNames, useValueEffect} from '@react-spectrum/utils';
1616
import datepickerStyles from './styles.css';
17+
// @ts-ignore
18+
import intlMessages from '../intl/*.json';
1719
import {mergeProps, mergeRefs, useEvent, useLayoutEffect, useResizeObserver} from '@react-aria/utils';
1820
import React, {ReactElement, useCallback, useRef} from 'react';
1921
import textfieldStyles from '@adobe/spectrum-css-temp/components/textfield/vars.css';
2022
import {useFocusRing} from '@react-aria/focus';
23+
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2124

2225
export const Input = React.forwardRef(function Input(props: any, ref: any) {
2326
let inputRef = useRef<HTMLInputElement | null>(null);
@@ -114,11 +117,13 @@ export const Input = React.forwardRef(function Input(props: any, ref: any) {
114117
'spectrum-Textfield-validationIcon'
115118
);
116119

120+
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/datepicker');
121+
let validId = fieldProps?.id ? `${fieldProps.id}-valid-icon` : undefined;
117122
let validationIcon: ReactElement | null = null;
118123
if (validationState === 'invalid' && !isDisabled) {
119124
validationIcon = <Alert data-testid="invalid-icon" UNSAFE_className={iconClass} />;
120125
} else if (validationState === 'valid' && !isDisabled) {
121-
validationIcon = <Checkmark data-testid="valid-icon" UNSAFE_className={iconClass} />;
126+
validationIcon = <Checkmark id={validId} aria-label={stringFormatter.format('valid')} data-testid="valid-icon" UNSAFE_className={iconClass} />;
122127
}
123128

124129
return (
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"valid": "(valid)"
3+
}

packages/@react-spectrum/textfield/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
},
4242
"dependencies": {
4343
"@react-aria/focus": "^3.20.3",
44+
"@react-aria/i18n": "^3.12.9",
4445
"@react-aria/interactions": "^3.25.1",
4546
"@react-aria/textfield": "^3.17.3",
4647
"@react-aria/utils": "^3.29.0",

packages/@react-spectrum/textfield/src/TextFieldBase.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ import AlertMedium from '@spectrum-icons/ui/AlertMedium';
1414
import CheckmarkMedium from '@spectrum-icons/ui/CheckmarkMedium';
1515
import {classNames, createFocusableRef} from '@react-spectrum/utils';
1616
import {Field} from '@react-spectrum/label';
17-
import {mergeProps} from '@react-aria/utils';
17+
// @ts-ignore
18+
import intlMessages from '../intl/*.json';
19+
import {mergeProps, useId} from '@react-aria/utils';
1820
import {PressEvents, RefObject, ValidationResult} from '@react-types/shared';
1921
import React, {cloneElement, forwardRef, HTMLAttributes, InputHTMLAttributes, LabelHTMLAttributes, ReactElement, Ref, TextareaHTMLAttributes, useImperativeHandle, useRef} from 'react';
2022
import {SpectrumTextFieldProps, TextFieldRef} from '@react-types/textfield';
2123
import styles from '@adobe/spectrum-css-temp/components/textfield/vars.css';
2224
import {useFocusRing} from '@react-aria/focus';
2325
import {useHover} from '@react-aria/interactions';
26+
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2427

2528
interface TextFieldBaseProps extends Omit<SpectrumTextFieldProps, 'onChange' | 'validate'>, PressEvents, Partial<ValidationResult> {
2629
wrapperChildren?: ReactElement | ReactElement[],
@@ -61,6 +64,7 @@ export const TextFieldBase = forwardRef(function TextFieldBase(props: TextFieldB
6164
let domRef = useRef<HTMLDivElement>(null);
6265
let defaultInputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
6366
let inputRef = userInputRef || defaultInputRef;
67+
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/textfield');
6468

6569
// Expose imperative interface for ref
6670
useImperativeHandle(ref, () => ({
@@ -91,7 +95,8 @@ export const TextFieldBase = forwardRef(function TextFieldBase(props: TextFieldB
9195
} as any);
9296
}
9397

94-
let validationIcon = isInvalid ? <AlertMedium /> : <CheckmarkMedium />;
98+
let validId = useId();
99+
let validationIcon = isInvalid ? <AlertMedium /> : <CheckmarkMedium id={validId} aria-label={stringFormatter.format('valid')} />;
95100
let validation = cloneElement(validationIcon, {
96101
UNSAFE_className: classNames(
97102
styles,
@@ -100,6 +105,15 @@ export const TextFieldBase = forwardRef(function TextFieldBase(props: TextFieldB
100105
)
101106
});
102107

108+
// Add validation icon IDREF to aria-describedby when validationState is valid
109+
let inputPropsAriaDescribedBy = inputProps['aria-describedby'];
110+
if (
111+
!isInvalid && validationState === 'valid' && !isLoading && !isDisabled &&
112+
(!inputPropsAriaDescribedBy || !inputPropsAriaDescribedBy.includes(validId))
113+
) {
114+
inputProps['aria-describedby'] = [inputPropsAriaDescribedBy, validId].join(' ').trim();
115+
}
116+
103117
let {focusProps, isFocusVisible} = useFocusRing({
104118
isTextInput: true,
105119
autoFocus

packages/@react-spectrum/textfield/test/TextField.test.js

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -312,9 +312,9 @@ describe('Shared TextField behavior', () => {
312312
${'v3 TextField'} | ${TextField}
313313
${'v3 TextArea'} | ${TextArea}
314314
${'v3 SearchField'} | ${SearchField}
315-
`('$Name supports description or error message', ({Component}) => {
315+
`('$Name supports description or error message', async ({Component}) => {
316316
function Example(props) {
317-
let [value, setValue] = React.useState('0');
317+
let [value, setValue] = React.useState(0);
318318
let isValid = React.useMemo(() => /^\d$/.test(value), [value]);
319319

320320
return (
@@ -337,7 +337,9 @@ describe('Shared TextField behavior', () => {
337337
let input = tree.getByTestId(testId);
338338
let helpText = tree.getByText('Enter a single digit number.');
339339
expect(helpText).toHaveAttribute('id');
340-
expect(input).toHaveAttribute('aria-describedby', `${helpText.id}`);
340+
let validIcon = tree.getByRole('img', {'aria-label': '(valid)'});
341+
expect(validIcon).toHaveAttribute('id');
342+
expect(input).toHaveAttribute('aria-describedby', `${helpText.id} ${validIcon.id}`);
341343
expect(input.value).toBe('0');
342344
let newValue = 's';
343345
fireEvent.change(input, {target: {value: newValue}});
@@ -353,10 +355,12 @@ describe('Shared TextField behavior', () => {
353355
expect(input).toHaveAttribute('aria-describedby', `${helpText.id}`);
354356
newValue = '4';
355357
fireEvent.change(input, {target: {value: newValue}});
356-
expect(input.value).toBe(newValue);
358+
expect(input.value).toEqual('4');
357359
helpText = tree.getByText('Enter a single digit number.');
358360
expect(helpText).toHaveAttribute('id');
359-
expect(input).toHaveAttribute('aria-describedby', `${helpText.id}`);
361+
validIcon = tree.getByRole('img', {'aria-label': '(valid)'});
362+
expect(validIcon).toHaveAttribute('id');
363+
expect(input).toHaveAttribute('aria-describedby', `${helpText.id} ${validIcon.id}`);
360364
});
361365

362366
it.each`
@@ -387,7 +391,10 @@ describe('Shared TextField behavior', () => {
387391
let tree = renderComponent(Example);
388392
let input = tree.getByTestId(testId);
389393
let helpText;
390-
expect(tree.getByTestId(testId)).not.toHaveAttribute('aria-describedby');
394+
let validIcon = tree.queryByRole('img', {'aria-label': '(valid)'});
395+
expect(validIcon).toBeTruthy();
396+
expect(validIcon).toHaveAttribute('id');
397+
expect(tree.getByTestId(testId)).toHaveAttribute('aria-describedby', `${validIcon.id}`);
391398

392399
fireEvent.change(input, {target: {value: 's'}});
393400

@@ -418,7 +425,11 @@ describe('Shared TextField behavior', () => {
418425
expect(input.value).toEqual('4');
419426
});
420427

421-
expect(input).not.toHaveAttribute('aria-describedby');
428+
validIcon = tree.getByRole('img', {'aria-label': '(valid)'});
429+
expect(validIcon).toBeTruthy();
430+
expect(validIcon).toHaveAttribute('id');
431+
432+
expect(input).toHaveAttribute('aria-describedby', `${validIcon.id}`);
422433
});
423434

424435
it.each`

yarn.lock

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6244,12 +6244,14 @@ __metadata:
62446244
version: 0.0.0-use.local
62456245
resolution: "@react-aria/form@workspace:packages/@react-aria/form"
62466246
dependencies:
6247+
"@react-aria/i18n": "npm:^3.12.9"
62476248
"@react-aria/interactions": "npm:^3.25.1"
62486249
"@react-aria/live-announcer": "npm:^3.4.2"
62496250
"@react-aria/utils": "npm:^3.29.0"
62506251
"@react-stately/form": "npm:^3.1.4"
62516252
"@react-types/shared": "npm:^3.29.1"
62526253
"@swc/helpers": "npm:^0.5.0"
6254+
dom-accessibility-api: "npm:^0.7.0"
62536255
peerDependencies:
62546256
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
62556257
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
@@ -8314,6 +8316,7 @@ __metadata:
83148316
dependencies:
83158317
"@adobe/spectrum-css-temp": "npm:3.0.0-alpha.1"
83168318
"@react-aria/focus": "npm:^3.20.3"
8319+
"@react-aria/i18n": "npm:^3.12.9"
83178320
"@react-aria/interactions": "npm:^3.25.1"
83188321
"@react-aria/textfield": "npm:^3.17.3"
83198322
"@react-aria/utils": "npm:^3.29.0"
@@ -16053,6 +16056,13 @@ __metadata:
1605316056
languageName: node
1605416057
linkType: hard
1605516058

16059+
"dom-accessibility-api@npm:^0.7.0":
16060+
version: 0.7.0
16061+
resolution: "dom-accessibility-api@npm:0.7.0"
16062+
checksum: 10c0/51d3fe283b0b4882442ba7cd7c76d27aa96b57e1854f0373be62c0abfaa95e89f4c1a1b883712814dc52b0a0853147f6de681fae20467a29d3a485df9b7329d8
16063+
languageName: node
16064+
linkType: hard
16065+
1605616066
"dom-helpers@npm:^5.0.1":
1605716067
version: 5.2.1
1605816068
resolution: "dom-helpers@npm:5.2.1"

0 commit comments

Comments
 (0)