From 3289789dc1558c60eff6d052ad4a9ec14de0b86c Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Fri, 30 May 2025 18:04:06 -0400 Subject: [PATCH 1/9] fix(accessibility): Field announce error message for focused field --- .../datepicker/test/DateField.test.js | 4 +-- .../stories/InlineAlert.stories.tsx | 6 +++- packages/@react-spectrum/label/package.json | 17 +++++----- packages/@react-spectrum/label/src/Field.tsx | 16 +++++++-- yarn.lock | 33 ++++++++++--------- 5 files changed, 46 insertions(+), 30 deletions(-) diff --git a/packages/@react-spectrum/datepicker/test/DateField.test.js b/packages/@react-spectrum/datepicker/test/DateField.test.js index 829607e366b..9dbdb96e6d1 100644 --- a/packages/@react-spectrum/datepicker/test/DateField.test.js +++ b/packages/@react-spectrum/datepicker/test/DateField.test.js @@ -236,7 +236,7 @@ describe('DateField', function () { ); await user.tab(); await user.keyboard('01011980'); - expect(tree.getByText('Date unavailable.')).toBeInTheDocument(); + expect(tree.getAllByText('Date unavailable.')[0]).toBeInTheDocument(); }); it('does not crash on unknown segment types', async () => { @@ -245,7 +245,7 @@ describe('DateField', function () { ); - + let segments = Array.from(getByRole('group').querySelectorAll('[data-testid]')); let segmentTypes = segments.map(s => s.getAttribute('data-testid')); expect(segmentTypes).toEqual(['year', 'month', 'day']); diff --git a/packages/@react-spectrum/inlinealert/stories/InlineAlert.stories.tsx b/packages/@react-spectrum/inlinealert/stories/InlineAlert.stories.tsx index 1c70c459f3e..24ad936ea0b 100644 --- a/packages/@react-spectrum/inlinealert/stories/InlineAlert.stories.tsx +++ b/packages/@react-spectrum/inlinealert/stories/InlineAlert.stories.tsx @@ -55,6 +55,10 @@ export const Dynamic = { render: (args) => }; +export const DynamicWithAriaLivePolite = { + render: (args) => +}; + function DynamicExample(args) { let [shown, setShown] = useState(false); @@ -62,7 +66,7 @@ function DynamicExample(args) { <> {shown && - + {args.title} {args.content} diff --git a/packages/@react-spectrum/label/package.json b/packages/@react-spectrum/label/package.json index 317f7e67a9d..2a2bb359bdc 100644 --- a/packages/@react-spectrum/label/package.json +++ b/packages/@react-spectrum/label/package.json @@ -40,14 +40,15 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-aria/i18n": "^3.12.10", - "@react-aria/utils": "^3.29.1", - "@react-spectrum/form": "^3.7.16", - "@react-spectrum/layout": "^3.6.16", - "@react-spectrum/utils": "^3.12.6", - "@react-types/label": "^3.9.12", - "@react-types/shared": "^3.30.0", - "@spectrum-icons/ui": "^3.6.17", + "@react-aria/i18n": "^3.12.9", + "@react-aria/live-announcer": "^3.4.3", + "@react-aria/utils": "^3.29.0", + "@react-spectrum/form": "^3.7.15", + "@react-spectrum/layout": "^3.6.15", + "@react-spectrum/utils": "^3.12.5", + "@react-types/label": "^3.9.11", + "@react-types/shared": "^3.29.1", + "@spectrum-icons/ui": "^3.6.16", "@swc/helpers": "^0.5.0" }, "devDependencies": { diff --git a/packages/@react-spectrum/label/src/Field.tsx b/packages/@react-spectrum/label/src/Field.tsx index 45feae08714..9a3ebf254d8 100644 --- a/packages/@react-spectrum/label/src/Field.tsx +++ b/packages/@react-spectrum/label/src/Field.tsx @@ -9,14 +9,14 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ - +import {announce} from '@react-aria/live-announcer'; import {classNames, SlotProvider, useStyleProps} from '@react-spectrum/utils'; import {Flex} from '@react-spectrum/layout'; +import {getActiveElement, mergeProps, useId} from '@react-aria/utils'; import {HelpText} from './HelpText'; import {Label} from './Label'; import {LabelPosition, RefObject} from '@react-types/shared'; import labelStyles from '@adobe/spectrum-css-temp/components/fieldlabel/vars.css'; -import {mergeProps, useId} from '@react-aria/utils'; import React, {ReactNode, Ref} from 'react'; import {SpectrumFieldProps} from '@react-types/label'; import {useFormProps} from '@react-spectrum/form'; @@ -64,9 +64,19 @@ export const Field = React.forwardRef(function Field(props: SpectrumFieldProps, } else { errorMessageString = errorMessage; } - let hasHelpText = !!description || errorMessageString && (isInvalid || validationState === 'invalid'); + let hasErrorMessage = !!errorMessageString && (isInvalid || validationState === 'invalid'); + let hasHelpText = !!description || hasErrorMessage; let contextualHelpId = useId(); + React.useEffect(() => { + if (hasErrorMessage && + (ref as RefObject)?.current?.contains(getActiveElement()) && + typeof errorMessageString === 'string' && + errorMessageString.length > 0) { + announce(errorMessageString, 'polite'); + } + }, [errorMessageString, hasErrorMessage, ref]); + let fallbackLabelPropsId = useId(); if (label && contextualHelp && !labelProps.id) { labelProps.id = fallbackLabelPropsId; diff --git a/yarn.lock b/yarn.lock index 78be5d2ec63..2a4aeb5963e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6299,7 +6299,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/i18n@npm:^3.12.10, @react-aria/i18n@workspace:packages/@react-aria/i18n": +"@react-aria/i18n@npm:^3.12.10, @react-aria/i18n@npm:^3.12.9, @react-aria/i18n@workspace:packages/@react-aria/i18n": version: 0.0.0-use.local resolution: "@react-aria/i18n@workspace:packages/@react-aria/i18n" dependencies: @@ -6858,7 +6858,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/utils@npm:^3.29.1, @react-aria/utils@npm:^3.8.0, @react-aria/utils@workspace:packages/@react-aria/utils": +"@react-aria/utils@npm:^3.29.0, @react-aria/utils@npm:^3.29.1, @react-aria/utils@npm:^3.8.0, @react-aria/utils@workspace:packages/@react-aria/utils": version: 0.0.0-use.local resolution: "@react-aria/utils@workspace:packages/@react-aria/utils" dependencies: @@ -7493,7 +7493,7 @@ __metadata: languageName: unknown linkType: soft -"@react-spectrum/form@npm:^3.7.16, @react-spectrum/form@workspace:packages/@react-spectrum/form": +"@react-spectrum/form@npm:^3.7.15, @react-spectrum/form@npm:^3.7.16, @react-spectrum/form@workspace:packages/@react-spectrum/form": version: 0.0.0-use.local resolution: "@react-spectrum/form@workspace:packages/@react-spectrum/form" dependencies: @@ -7587,14 +7587,15 @@ __metadata: resolution: "@react-spectrum/label@workspace:packages/@react-spectrum/label" dependencies: "@adobe/spectrum-css-temp": "npm:3.0.0-alpha.1" - "@react-aria/i18n": "npm:^3.12.10" - "@react-aria/utils": "npm:^3.29.1" - "@react-spectrum/form": "npm:^3.7.16" - "@react-spectrum/layout": "npm:^3.6.16" - "@react-spectrum/utils": "npm:^3.12.6" - "@react-types/label": "npm:^3.9.12" - "@react-types/shared": "npm:^3.30.0" - "@spectrum-icons/ui": "npm:^3.6.17" + "@react-aria/i18n": "npm:^3.12.9" + "@react-aria/live-announcer": "npm:^3.4.3" + "@react-aria/utils": "npm:^3.29.0" + "@react-spectrum/form": "npm:^3.7.15" + "@react-spectrum/layout": "npm:^3.6.15" + "@react-spectrum/utils": "npm:^3.12.5" + "@react-types/label": "npm:^3.9.11" + "@react-types/shared": "npm:^3.29.1" + "@spectrum-icons/ui": "npm:^3.6.16" "@swc/helpers": "npm:^0.5.0" peerDependencies: "@react-spectrum/provider": ^3.0.0 @@ -7622,7 +7623,7 @@ __metadata: languageName: unknown linkType: soft -"@react-spectrum/layout@npm:^3.1.0, @react-spectrum/layout@npm:^3.6.16, @react-spectrum/layout@workspace:packages/@react-spectrum/layout": +"@react-spectrum/layout@npm:^3.1.0, @react-spectrum/layout@npm:^3.6.15, @react-spectrum/layout@npm:^3.6.16, @react-spectrum/layout@workspace:packages/@react-spectrum/layout": version: 0.0.0-use.local resolution: "@react-spectrum/layout@workspace:packages/@react-spectrum/layout" dependencies: @@ -8457,7 +8458,7 @@ __metadata: languageName: unknown linkType: soft -"@react-spectrum/utils@npm:^3.12.6, @react-spectrum/utils@workspace:packages/@react-spectrum/utils": +"@react-spectrum/utils@npm:^3.12.5, @react-spectrum/utils@npm:^3.12.6, @react-spectrum/utils@workspace:packages/@react-spectrum/utils": version: 0.0.0-use.local resolution: "@react-spectrum/utils@workspace:packages/@react-spectrum/utils" dependencies: @@ -9155,7 +9156,7 @@ __metadata: languageName: unknown linkType: soft -"@react-types/label@npm:^3.9.12, @react-types/label@workspace:packages/@react-types/label": +"@react-types/label@npm:^3.9.11, @react-types/label@workspace:packages/@react-types/label": version: 0.0.0-use.local resolution: "@react-types/label@workspace:packages/@react-types/label" dependencies: @@ -9300,7 +9301,7 @@ __metadata: languageName: unknown linkType: soft -"@react-types/shared@npm:^3.1.0, @react-types/shared@npm:^3.30.0, @react-types/shared@workspace:packages/@react-types/shared": +"@react-types/shared@npm:^3.1.0, @react-types/shared@npm:^3.29.1, @react-types/shared@npm:^3.30.0, @react-types/shared@workspace:packages/@react-types/shared": version: 0.0.0-use.local resolution: "@react-types/shared@workspace:packages/@react-types/shared" peerDependencies: @@ -9600,7 +9601,7 @@ __metadata: languageName: unknown linkType: soft -"@spectrum-icons/ui@npm:^3.1.0, @spectrum-icons/ui@npm:^3.6.17, @spectrum-icons/ui@workspace:packages/@spectrum-icons/ui": +"@spectrum-icons/ui@npm:^3.1.0, @spectrum-icons/ui@npm:^3.6.16, @spectrum-icons/ui@npm:^3.6.17, @spectrum-icons/ui@workspace:packages/@spectrum-icons/ui": version: 0.0.0-use.local resolution: "@spectrum-icons/ui@workspace:packages/@spectrum-icons/ui" dependencies: From bf1384cf8b14e38e7fe5479cbceae2ddce2796bc Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Mon, 2 Jun 2025 20:05:44 -0400 Subject: [PATCH 2/9] fix(accessibility): refactor announcement into useFormValidation --- packages/@react-aria/form/package.json | 9 +++--- .../@react-aria/form/src/useFormValidation.ts | 31 ++++++++++++++----- packages/@react-spectrum/label/src/Field.tsx | 15 ++------- yarn.lock | 13 ++++---- 4 files changed, 38 insertions(+), 30 deletions(-) diff --git a/packages/@react-aria/form/package.json b/packages/@react-aria/form/package.json index 29029bae73a..05e4a513239 100644 --- a/packages/@react-aria/form/package.json +++ b/packages/@react-aria/form/package.json @@ -26,10 +26,11 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-aria/interactions": "^3.25.2", - "@react-aria/utils": "^3.29.1", - "@react-stately/form": "^3.1.5", - "@react-types/shared": "^3.30.0", + "@react-aria/interactions": "^3.25.1", + "@react-aria/live-announcer": "^3.4.3", + "@react-aria/utils": "^3.29.0", + "@react-stately/form": "^3.1.4", + "@react-types/shared": "^3.29.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { diff --git a/packages/@react-aria/form/src/useFormValidation.ts b/packages/@react-aria/form/src/useFormValidation.ts index e876ca66173..48126cc81fc 100644 --- a/packages/@react-aria/form/src/useFormValidation.ts +++ b/packages/@react-aria/form/src/useFormValidation.ts @@ -10,11 +10,12 @@ * governing permissions and limitations under the License. */ +import {announce} from '@react-aria/live-announcer'; import {FormValidationState} from '@react-stately/form'; +import {getActiveElement, getOwnerDocument, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; import {RefObject, Validation, ValidationResult} from '@react-types/shared'; import {setInteractionModality} from '@react-aria/interactions'; -import {useEffect} from 'react'; -import {useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +import {useEffect, useRef} from 'react'; type ValidatableElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; @@ -25,12 +26,24 @@ interface FormValidationProps extends Validation { export function useFormValidation(props: FormValidationProps, state: FormValidationState, ref: RefObject | undefined): void { let {validationBehavior, focus} = props; + let timeoutId = useRef | null>(null); + function announceErrorMessage(errorMessage: string = ''): void { + clearTimeout(timeoutId.current!); + if (ref?.current && + errorMessage !== '' && + ref.current.contains(getActiveElement(getOwnerDocument(ref.current)))) { + timeoutId.current = setTimeout(() => announce(errorMessage, 'polite'), 250); + } + } + // This is a useLayoutEffect so that it runs before the useEffect in useFormValidationState, which commits the validation change. useLayoutEffect(() => { if (validationBehavior === 'native' && ref?.current && !ref.current.disabled) { let errorMessage = state.realtimeValidation.isInvalid ? state.realtimeValidation.validationErrors.join(' ') || 'Invalid value.' : ''; ref.current.setCustomValidity(errorMessage); + announceErrorMessage(errorMessage); + // Prevent default tooltip for validation message. // https://bugzilla.mozilla.org/show_bug.cgi?id=605277 if (!ref.current.hasAttribute('title')) { @@ -56,11 +69,14 @@ export function useFormValidation(props: FormValidationProps, state: FormV // Auto focus the first invalid input in a form, unless the error already had its default prevented. let form = ref?.current?.form; - if (!e.defaultPrevented && ref && form && getFirstInvalidInput(form) === ref.current) { - if (focus) { - focus(); - } else { - ref.current?.focus(); + if (!e.defaultPrevented && ref && form) { + announceErrorMessage(ref?.current?.validationMessage || ''); + if (getFirstInvalidInput(form) === ref.current) { + if (focus) { + focus(); + } else { + ref.current?.focus(); + } } // Always show focus ring. @@ -86,6 +102,7 @@ export function useFormValidation(props: FormValidationProps, state: FormV input.addEventListener('change', onChange); form?.addEventListener('reset', onReset); return () => { + clearTimeout(timeoutId.current!); input!.removeEventListener('invalid', onInvalid); input!.removeEventListener('change', onChange); form?.removeEventListener('reset', onReset); diff --git a/packages/@react-spectrum/label/src/Field.tsx b/packages/@react-spectrum/label/src/Field.tsx index 9a3ebf254d8..9f85fa43429 100644 --- a/packages/@react-spectrum/label/src/Field.tsx +++ b/packages/@react-spectrum/label/src/Field.tsx @@ -9,14 +9,13 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import {announce} from '@react-aria/live-announcer'; import {classNames, SlotProvider, useStyleProps} from '@react-spectrum/utils'; import {Flex} from '@react-spectrum/layout'; -import {getActiveElement, mergeProps, useId} from '@react-aria/utils'; import {HelpText} from './HelpText'; import {Label} from './Label'; import {LabelPosition, RefObject} from '@react-types/shared'; import labelStyles from '@adobe/spectrum-css-temp/components/fieldlabel/vars.css'; +import {mergeProps, useId} from '@react-aria/utils'; import React, {ReactNode, Ref} from 'react'; import {SpectrumFieldProps} from '@react-types/label'; import {useFormProps} from '@react-spectrum/form'; @@ -64,19 +63,9 @@ export const Field = React.forwardRef(function Field(props: SpectrumFieldProps, } else { errorMessageString = errorMessage; } - let hasErrorMessage = !!errorMessageString && (isInvalid || validationState === 'invalid'); - let hasHelpText = !!description || hasErrorMessage; + let hasHelpText = !!description || errorMessageString && (isInvalid || validationState === 'invalid'); let contextualHelpId = useId(); - React.useEffect(() => { - if (hasErrorMessage && - (ref as RefObject)?.current?.contains(getActiveElement()) && - typeof errorMessageString === 'string' && - errorMessageString.length > 0) { - announce(errorMessageString, 'polite'); - } - }, [errorMessageString, hasErrorMessage, ref]); - let fallbackLabelPropsId = useId(); if (label && contextualHelp && !labelProps.id) { labelProps.id = fallbackLabelPropsId; diff --git a/yarn.lock b/yarn.lock index 2a4aeb5963e..fa51b8b8457 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6244,10 +6244,11 @@ __metadata: version: 0.0.0-use.local resolution: "@react-aria/form@workspace:packages/@react-aria/form" dependencies: - "@react-aria/interactions": "npm:^3.25.2" - "@react-aria/utils": "npm:^3.29.1" - "@react-stately/form": "npm:^3.1.5" - "@react-types/shared": "npm:^3.30.0" + "@react-aria/interactions": "npm:^3.25.1" + "@react-aria/live-announcer": "npm:^3.4.3" + "@react-aria/utils": "npm:^3.29.0" + "@react-stately/form": "npm:^3.1.4" + "@react-types/shared": "npm:^3.29.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -6317,7 +6318,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/interactions@npm:^3.1.0, @react-aria/interactions@npm:^3.25.2, @react-aria/interactions@workspace:packages/@react-aria/interactions": +"@react-aria/interactions@npm:^3.1.0, @react-aria/interactions@npm:^3.25.1, @react-aria/interactions@npm:^3.25.2, @react-aria/interactions@workspace:packages/@react-aria/interactions": version: 0.0.0-use.local resolution: "@react-aria/interactions@workspace:packages/@react-aria/interactions" dependencies: @@ -8653,7 +8654,7 @@ __metadata: languageName: unknown linkType: soft -"@react-stately/form@npm:^3.1.5, @react-stately/form@workspace:packages/@react-stately/form": +"@react-stately/form@npm:^3.1.4, @react-stately/form@npm:^3.1.5, @react-stately/form@workspace:packages/@react-stately/form": version: 0.0.0-use.local resolution: "@react-stately/form@workspace:packages/@react-stately/form" dependencies: From 66a35b9fefd55e540954d1f84f19513f3d663382 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Tue, 3 Jun 2025 11:14:49 -0400 Subject: [PATCH 3/9] Update packages/@react-aria/form/src/useFormValidation.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/@react-aria/form/src/useFormValidation.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/form/src/useFormValidation.ts b/packages/@react-aria/form/src/useFormValidation.ts index 48126cc81fc..9745374026a 100644 --- a/packages/@react-aria/form/src/useFormValidation.ts +++ b/packages/@react-aria/form/src/useFormValidation.ts @@ -28,7 +28,9 @@ export function useFormValidation(props: FormValidationProps, state: FormV let timeoutId = useRef | null>(null); function announceErrorMessage(errorMessage: string = ''): void { - clearTimeout(timeoutId.current!); + if (timeoutId.current != null) { + clearTimeout(timeoutId.current); + } if (ref?.current && errorMessage !== '' && ref.current.contains(getActiveElement(getOwnerDocument(ref.current)))) { From 9462223b2e47b2aa0406f0a1cfcc669b6f9a5ddd Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Wed, 4 Jun 2025 15:01:50 -0400 Subject: [PATCH 4/9] fix(accessibility): add announcement of error message for a just blurred field --- .../@react-aria/form/src/useFormValidation.ts | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/@react-aria/form/src/useFormValidation.ts b/packages/@react-aria/form/src/useFormValidation.ts index 9745374026a..0361d3d9ebc 100644 --- a/packages/@react-aria/form/src/useFormValidation.ts +++ b/packages/@react-aria/form/src/useFormValidation.ts @@ -26,15 +26,21 @@ interface FormValidationProps extends Validation { export function useFormValidation(props: FormValidationProps, state: FormValidationState, ref: RefObject | undefined): void { let {validationBehavior, focus} = props; - let timeoutId = useRef | null>(null); + let justBlurredRef = useRef(false); + let timeoutIdRef = useRef | null>(null); function announceErrorMessage(errorMessage: string = ''): void { - if (timeoutId.current != null) { - clearTimeout(timeoutId.current); + if (timeoutIdRef.current != null) { + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = null; } if (ref?.current && errorMessage !== '' && - ref.current.contains(getActiveElement(getOwnerDocument(ref.current)))) { - timeoutId.current = setTimeout(() => announce(errorMessage, 'polite'), 250); + ( + ref.current.contains(getActiveElement(getOwnerDocument(ref.current))) || + justBlurredRef.current + ) + ) { + timeoutIdRef.current = setTimeout(() => announce(errorMessage, 'polite'), 250); } } @@ -44,8 +50,6 @@ export function useFormValidation(props: FormValidationProps, state: FormV let errorMessage = state.realtimeValidation.isInvalid ? state.realtimeValidation.validationErrors.join(' ') || 'Invalid value.' : ''; ref.current.setCustomValidity(errorMessage); - announceErrorMessage(errorMessage); - // Prevent default tooltip for validation message. // https://bugzilla.mozilla.org/show_bug.cgi?id=605277 if (!ref.current.hasAttribute('title')) { @@ -72,7 +76,10 @@ export function useFormValidation(props: FormValidationProps, state: FormV // Auto focus the first invalid input in a form, unless the error already had its default prevented. let form = ref?.current?.form; if (!e.defaultPrevented && ref && form) { + + // Announce the current error message announceErrorMessage(ref?.current?.validationMessage || ''); + if (getFirstInvalidInput(form) === ref.current) { if (focus) { focus(); @@ -93,6 +100,13 @@ export function useFormValidation(props: FormValidationProps, state: FormV state.commitValidation(); }); + let onBlur = useEffectEvent(() => { + justBlurredRef.current = true; + // Announce the current error message + announceErrorMessage(ref?.current?.validationMessage || ''); + justBlurredRef.current = false; + }); + useEffect(() => { let input = ref?.current; if (!input) { @@ -100,16 +114,22 @@ export function useFormValidation(props: FormValidationProps, state: FormV } let form = input.form; + input.addEventListener('blur', onBlur); input.addEventListener('invalid', onInvalid); input.addEventListener('change', onChange); form?.addEventListener('reset', onReset); return () => { - clearTimeout(timeoutId.current!); + if (timeoutIdRef.current != null) { + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = null; + } + justBlurredRef.current = false; + input!.removeEventListener('blur', onBlur); input!.removeEventListener('invalid', onInvalid); input!.removeEventListener('change', onChange); form?.removeEventListener('reset', onReset); }; - }, [ref, onInvalid, onChange, onReset, validationBehavior]); + }, [justBlurredRef, onBlur, onChange, onInvalid, onReset, ref, validationBehavior]); } function getValidity(input: ValidatableElement) { From 4acca66641e58d6cf65ba03d13665ceaf20b065c Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Thu, 5 Jun 2025 19:09:42 -0400 Subject: [PATCH 5/9] 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 --- packages/@react-aria/form/intl/en-US.json | 4 ++ packages/@react-aria/form/package.json | 4 +- .../@react-aria/form/src/useFormValidation.ts | 52 ++++++++++++++++--- .../autocomplete/intl/en-US.json | 3 +- .../src/MobileSearchAutocomplete.tsx | 6 ++- .../@react-spectrum/combobox/intl/en-US.json | 3 +- .../combobox/src/MobileComboBox.tsx | 6 ++- .../datepicker/intl/en-US.json | 3 +- .../@react-spectrum/datepicker/src/Input.tsx | 7 ++- .../@react-spectrum/textfield/intl/en-US.json | 3 ++ .../@react-spectrum/textfield/package.json | 23 ++++---- .../textfield/src/TextFieldBase.tsx | 18 ++++++- .../textfield/test/TextField.test.js | 25 ++++++--- yarn.lock | 42 +++++++++------ 14 files changed, 147 insertions(+), 52 deletions(-) create mode 100644 packages/@react-aria/form/intl/en-US.json create mode 100644 packages/@react-spectrum/textfield/intl/en-US.json diff --git a/packages/@react-aria/form/intl/en-US.json b/packages/@react-aria/form/intl/en-US.json new file mode 100644 index 00000000000..9094f2607f6 --- /dev/null +++ b/packages/@react-aria/form/intl/en-US.json @@ -0,0 +1,4 @@ +{ + "invalidValue": "Invalid value.", + "reviewField": "Please review {accessibleName} field: {validationMessage}" +} diff --git a/packages/@react-aria/form/package.json b/packages/@react-aria/form/package.json index 05e4a513239..b365103b44a 100644 --- a/packages/@react-aria/form/package.json +++ b/packages/@react-aria/form/package.json @@ -26,12 +26,14 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-aria/i18n": "^3.12.9", "@react-aria/interactions": "^3.25.1", "@react-aria/live-announcer": "^3.4.3", "@react-aria/utils": "^3.29.0", "@react-stately/form": "^3.1.4", "@react-types/shared": "^3.29.1", - "@swc/helpers": "^0.5.0" + "@swc/helpers": "^0.5.0", + "dom-accessibility-api": "^0.7.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", diff --git a/packages/@react-aria/form/src/useFormValidation.ts b/packages/@react-aria/form/src/useFormValidation.ts index 0361d3d9ebc..acba28186ce 100644 --- a/packages/@react-aria/form/src/useFormValidation.ts +++ b/packages/@react-aria/form/src/useFormValidation.ts @@ -11,14 +11,24 @@ */ import {announce} from '@react-aria/live-announcer'; +import {computeAccessibleName} from 'dom-accessibility-api'; import {FormValidationState} from '@react-stately/form'; import {getActiveElement, getOwnerDocument, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +// @ts-ignore +import intlMessages from '../intl/*.json'; import {RefObject, Validation, ValidationResult} from '@react-types/shared'; import {setInteractionModality} from '@react-aria/interactions'; import {useEffect, useRef} from 'react'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; type ValidatableElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; +function isValidatableElement(element: Element): boolean { + return element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement; +} + interface FormValidationProps extends Validation { focus?: () => void } @@ -33,9 +43,7 @@ export function useFormValidation(props: FormValidationProps, state: FormV clearTimeout(timeoutIdRef.current); timeoutIdRef.current = null; } - if (ref?.current && - errorMessage !== '' && - ( + if (ref && ref.current && errorMessage !== '' && ( ref.current.contains(getActiveElement(getOwnerDocument(ref.current))) || justBlurredRef.current ) @@ -44,10 +52,15 @@ export function useFormValidation(props: FormValidationProps, state: FormV } } + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/form'); + // This is a useLayoutEffect so that it runs before the useEffect in useFormValidationState, which commits the validation change. useLayoutEffect(() => { if (validationBehavior === 'native' && ref?.current && !ref.current.disabled) { - let errorMessage = state.realtimeValidation.isInvalid ? state.realtimeValidation.validationErrors.join(' ') || 'Invalid value.' : ''; + let errorMessage = + state.realtimeValidation.isInvalid ? + (state.realtimeValidation.validationErrors?.join(' ') || stringFormatter.format('invalidValue') || '') : + ''; ref.current.setCustomValidity(errorMessage); // Prevent default tooltip for validation message. @@ -100,10 +113,35 @@ export function useFormValidation(props: FormValidationProps, state: FormV state.commitValidation(); }); - let onBlur = useEffectEvent(() => { + let onBlur = useEffectEvent((event: Event) => { + const input = ref?.current; + const relatedTarget = (event as FocusEvent).relatedTarget as Element | null; + if ( + (!input || !input.validationMessage) || + (relatedTarget && isValidatableElement(relatedTarget) && (relatedTarget as ValidatableElement).validationMessage) + ) { + // If the input has no validation message, + // or the relatedTarget has a validation message, don't announce the error message. + // This prevents announcing the error message when the user is navigating + // between inputs that may already have an error message. + return; + } justBlurredRef.current = true; + const isRadioOrCheckbox = input.type === 'radio' || input.type === 'checkbox'; + const groupElement = isRadioOrCheckbox ? input.closest('[role="group"][aria-labelledby], [role=\'group\'][aria-label], fieldset') : undefined; // Announce the current error message - announceErrorMessage(ref?.current?.validationMessage || ''); + const accessibleName = computeAccessibleName(groupElement || input); + const validationMessage = input.validationMessage; + announceErrorMessage( + accessibleName && validationMessage ? + stringFormatter.format( + 'reviewField', + { + accessibleName, + validationMessage + }) : + validationMessage + ); justBlurredRef.current = false; }); @@ -132,7 +170,7 @@ export function useFormValidation(props: FormValidationProps, state: FormV }, [justBlurredRef, onBlur, onChange, onInvalid, onReset, ref, validationBehavior]); } -function getValidity(input: ValidatableElement) { +function getValidity(input: ValidatableElement): ValidityState { // The native ValidityState object is live, meaning each property is a getter that returns the current state. // We need to create a snapshot of the validity state at the time this function is called to avoid unpredictable React renders. let validity = input.validity; diff --git a/packages/@react-spectrum/autocomplete/intl/en-US.json b/packages/@react-spectrum/autocomplete/intl/en-US.json index 38d1892e05d..16d5f1a9511 100644 --- a/packages/@react-spectrum/autocomplete/intl/en-US.json +++ b/packages/@react-spectrum/autocomplete/intl/en-US.json @@ -2,5 +2,6 @@ "loading": "Loading...", "noResults": "No results", "clear": "Clear", - "invalid": "(invalid)" + "invalid": "(invalid)", + "valid": "(valid)" } diff --git a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx index b023b9d0888..e9726e2b4c2 100644 --- a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx +++ b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx @@ -207,9 +207,10 @@ const SearchAutocompleteButton = React.forwardRef(function SearchAutocompleteBut let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/autocomplete'); let valueId = useId(); let invalidId = useId(); + let validId = useId(); let validationIcon = validationState === 'invalid' ? - : ; + : ; if (icon) { icon = React.cloneElement(icon, { @@ -262,7 +263,8 @@ const SearchAutocompleteButton = React.forwardRef(function SearchAutocompleteBut props['aria-labelledby'], props['aria-label'] && !props['aria-labelledby'] ? props.id : null, valueId, - validationState === 'invalid' ? invalidId : null + validationState === 'invalid' ? invalidId : null, + validationState === 'valid' ? validId : null ].filter(Boolean).join(' '), elementType: 'div' }, ref); diff --git a/packages/@react-spectrum/combobox/intl/en-US.json b/packages/@react-spectrum/combobox/intl/en-US.json index 38d1892e05d..16d5f1a9511 100644 --- a/packages/@react-spectrum/combobox/intl/en-US.json +++ b/packages/@react-spectrum/combobox/intl/en-US.json @@ -2,5 +2,6 @@ "loading": "Loading...", "noResults": "No results", "clear": "Clear", - "invalid": "(invalid)" + "invalid": "(invalid)", + "valid": "(valid)" } diff --git a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx index 198c090bedd..3b7b892dec1 100644 --- a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx +++ b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx @@ -179,9 +179,10 @@ export const ComboBoxButton = React.forwardRef(function ComboBoxButton(props: Co let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/combobox'); let valueId = useId(); let invalidId = useId(); + let validId = useId(); let validationIcon = validationState === 'invalid' ? - : ; + : ; let validation = React.cloneElement(validationIcon, { UNSAFE_className: classNames( @@ -202,7 +203,8 @@ export const ComboBoxButton = React.forwardRef(function ComboBoxButton(props: Co props['aria-labelledby'], props['aria-label'] && !props['aria-labelledby'] ? props.id : null, valueId, - validationState === 'invalid' ? invalidId : null + validationState === 'invalid' ? invalidId : null, + validationState === 'valid' ? validId : null ].filter(Boolean).join(' '), elementType: 'div' }, objRef); diff --git a/packages/@react-spectrum/datepicker/intl/en-US.json b/packages/@react-spectrum/datepicker/intl/en-US.json index fcd26e0574f..c72adf1d560 100644 --- a/packages/@react-spectrum/datepicker/intl/en-US.json +++ b/packages/@react-spectrum/datepicker/intl/en-US.json @@ -1,5 +1,6 @@ { "time": "Time", "startTime": "Start time", - "endTime": "End time" + "endTime": "End time", + "valid": "(valid)" } diff --git a/packages/@react-spectrum/datepicker/src/Input.tsx b/packages/@react-spectrum/datepicker/src/Input.tsx index ba8f56eb937..24209fb767b 100644 --- a/packages/@react-spectrum/datepicker/src/Input.tsx +++ b/packages/@react-spectrum/datepicker/src/Input.tsx @@ -14,10 +14,13 @@ import Alert from '@spectrum-icons/ui/AlertMedium'; import Checkmark from '@spectrum-icons/ui/CheckmarkMedium'; import {classNames, useValueEffect} from '@react-spectrum/utils'; import datepickerStyles from './styles.css'; +// @ts-ignore +import intlMessages from '../intl/*.json'; import {mergeProps, mergeRefs, useEvent, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import React, {ReactElement, useCallback, useRef} from 'react'; import textfieldStyles from '@adobe/spectrum-css-temp/components/textfield/vars.css'; import {useFocusRing} from '@react-aria/focus'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; export const Input = React.forwardRef(function Input(props: any, ref: any) { let inputRef = useRef(null); @@ -114,11 +117,13 @@ export const Input = React.forwardRef(function Input(props: any, ref: any) { 'spectrum-Textfield-validationIcon' ); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/datepicker'); + let validId = fieldProps?.id ? `${fieldProps.id}-valid-icon` : undefined; let validationIcon: ReactElement | null = null; if (validationState === 'invalid' && !isDisabled) { validationIcon = ; } else if (validationState === 'valid' && !isDisabled) { - validationIcon = ; + validationIcon = ; } return ( diff --git a/packages/@react-spectrum/textfield/intl/en-US.json b/packages/@react-spectrum/textfield/intl/en-US.json new file mode 100644 index 00000000000..143324e86a5 --- /dev/null +++ b/packages/@react-spectrum/textfield/intl/en-US.json @@ -0,0 +1,3 @@ +{ + "valid": "(valid)" +} diff --git a/packages/@react-spectrum/textfield/package.json b/packages/@react-spectrum/textfield/package.json index 150de770b02..7e884e098bf 100644 --- a/packages/@react-spectrum/textfield/package.json +++ b/packages/@react-spectrum/textfield/package.json @@ -40,17 +40,18 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-aria/focus": "^3.20.4", - "@react-aria/interactions": "^3.25.2", - "@react-aria/textfield": "^3.17.4", - "@react-aria/utils": "^3.29.1", - "@react-spectrum/form": "^3.7.16", - "@react-spectrum/label": "^3.16.16", - "@react-spectrum/utils": "^3.12.6", - "@react-stately/utils": "^3.10.7", - "@react-types/shared": "^3.30.0", - "@react-types/textfield": "^3.12.3", - "@spectrum-icons/ui": "^3.6.17", + "@react-aria/focus": "^3.20.3", + "@react-aria/i18n": "^3.12.10", + "@react-aria/interactions": "^3.25.1", + "@react-aria/textfield": "^3.17.3", + "@react-aria/utils": "^3.29.0", + "@react-spectrum/form": "^3.7.15", + "@react-spectrum/label": "^3.16.15", + "@react-spectrum/utils": "^3.12.5", + "@react-stately/utils": "^3.10.6", + "@react-types/shared": "^3.29.1", + "@react-types/textfield": "^3.12.2", + "@spectrum-icons/ui": "^3.6.16", "@swc/helpers": "^0.5.0" }, "devDependencies": { diff --git a/packages/@react-spectrum/textfield/src/TextFieldBase.tsx b/packages/@react-spectrum/textfield/src/TextFieldBase.tsx index 05db443198a..59544fafa39 100644 --- a/packages/@react-spectrum/textfield/src/TextFieldBase.tsx +++ b/packages/@react-spectrum/textfield/src/TextFieldBase.tsx @@ -14,13 +14,16 @@ import AlertMedium from '@spectrum-icons/ui/AlertMedium'; import CheckmarkMedium from '@spectrum-icons/ui/CheckmarkMedium'; import {classNames, createFocusableRef} from '@react-spectrum/utils'; import {Field} from '@react-spectrum/label'; -import {mergeProps} from '@react-aria/utils'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {mergeProps, useId} from '@react-aria/utils'; import {PressEvents, RefObject, ValidationResult} from '@react-types/shared'; import React, {cloneElement, forwardRef, HTMLAttributes, InputHTMLAttributes, LabelHTMLAttributes, ReactElement, Ref, TextareaHTMLAttributes, useImperativeHandle, useRef} from 'react'; import {SpectrumTextFieldProps, TextFieldRef} from '@react-types/textfield'; import styles from '@adobe/spectrum-css-temp/components/textfield/vars.css'; import {useFocusRing} from '@react-aria/focus'; import {useHover} from '@react-aria/interactions'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; interface TextFieldBaseProps extends Omit, PressEvents, Partial { wrapperChildren?: ReactElement | ReactElement[], @@ -61,6 +64,7 @@ export const TextFieldBase = forwardRef(function TextFieldBase(props: TextFieldB let domRef = useRef(null); let defaultInputRef = useRef(null); let inputRef = userInputRef || defaultInputRef; + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/textfield'); // Expose imperative interface for ref useImperativeHandle(ref, () => ({ @@ -91,7 +95,8 @@ export const TextFieldBase = forwardRef(function TextFieldBase(props: TextFieldB } as any); } - let validationIcon = isInvalid ? : ; + let validId = useId(); + let validationIcon = isInvalid ? : ; let validation = cloneElement(validationIcon, { UNSAFE_className: classNames( styles, @@ -100,6 +105,15 @@ export const TextFieldBase = forwardRef(function TextFieldBase(props: TextFieldB ) }); + // Add validation icon IDREF to aria-describedby when validationState is valid + let inputPropsAriaDescribedBy = inputProps['aria-describedby']; + if ( + !isInvalid && validationState === 'valid' && !isLoading && !isDisabled && + (!inputPropsAriaDescribedBy || !inputPropsAriaDescribedBy.includes(validId)) + ) { + inputProps['aria-describedby'] = [inputPropsAriaDescribedBy, validId].join(' ').trim(); + } + let {focusProps, isFocusVisible} = useFocusRing({ isTextInput: true, autoFocus diff --git a/packages/@react-spectrum/textfield/test/TextField.test.js b/packages/@react-spectrum/textfield/test/TextField.test.js index e5cdead0993..9857e8d8032 100644 --- a/packages/@react-spectrum/textfield/test/TextField.test.js +++ b/packages/@react-spectrum/textfield/test/TextField.test.js @@ -312,9 +312,9 @@ describe('Shared TextField behavior', () => { ${'v3 TextField'} | ${TextField} ${'v3 TextArea'} | ${TextArea} ${'v3 SearchField'} | ${SearchField} - `('$Name supports description or error message', ({Component}) => { + `('$Name supports description or error message', async ({Component}) => { function Example(props) { - let [value, setValue] = React.useState('0'); + let [value, setValue] = React.useState(0); let isValid = React.useMemo(() => /^\d$/.test(value), [value]); return ( @@ -337,7 +337,9 @@ describe('Shared TextField behavior', () => { let input = tree.getByTestId(testId); let helpText = tree.getByText('Enter a single digit number.'); expect(helpText).toHaveAttribute('id'); - expect(input).toHaveAttribute('aria-describedby', `${helpText.id}`); + let validIcon = tree.getByRole('img', {'aria-label': '(valid)'}); + expect(validIcon).toHaveAttribute('id'); + expect(input).toHaveAttribute('aria-describedby', `${helpText.id} ${validIcon.id}`); expect(input.value).toBe('0'); let newValue = 's'; fireEvent.change(input, {target: {value: newValue}}); @@ -353,10 +355,12 @@ describe('Shared TextField behavior', () => { expect(input).toHaveAttribute('aria-describedby', `${helpText.id}`); newValue = '4'; fireEvent.change(input, {target: {value: newValue}}); - expect(input.value).toBe(newValue); + expect(input.value).toEqual('4'); helpText = tree.getByText('Enter a single digit number.'); expect(helpText).toHaveAttribute('id'); - expect(input).toHaveAttribute('aria-describedby', `${helpText.id}`); + validIcon = tree.getByRole('img', {'aria-label': '(valid)'}); + expect(validIcon).toHaveAttribute('id'); + expect(input).toHaveAttribute('aria-describedby', `${helpText.id} ${validIcon.id}`); }); it.each` @@ -387,7 +391,10 @@ describe('Shared TextField behavior', () => { let tree = renderComponent(Example); let input = tree.getByTestId(testId); let helpText; - expect(tree.getByTestId(testId)).not.toHaveAttribute('aria-describedby'); + let validIcon = tree.queryByRole('img', {'aria-label': '(valid)'}); + expect(validIcon).toBeTruthy(); + expect(validIcon).toHaveAttribute('id'); + expect(tree.getByTestId(testId)).toHaveAttribute('aria-describedby', `${validIcon.id}`); fireEvent.change(input, {target: {value: 's'}}); @@ -418,7 +425,11 @@ describe('Shared TextField behavior', () => { expect(input.value).toEqual('4'); }); - expect(input).not.toHaveAttribute('aria-describedby'); + validIcon = tree.getByRole('img', {'aria-label': '(valid)'}); + expect(validIcon).toBeTruthy(); + expect(validIcon).toHaveAttribute('id'); + + expect(input).toHaveAttribute('aria-describedby', `${validIcon.id}`); }); it.each` diff --git a/yarn.lock b/yarn.lock index fa51b8b8457..893bb8eb101 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6225,7 +6225,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/focus@npm:^3.20.4, @react-aria/focus@workspace:packages/@react-aria/focus": +"@react-aria/focus@npm:^3.20.3, @react-aria/focus@npm:^3.20.4, @react-aria/focus@workspace:packages/@react-aria/focus": version: 0.0.0-use.local resolution: "@react-aria/focus@workspace:packages/@react-aria/focus" dependencies: @@ -6244,12 +6244,14 @@ __metadata: version: 0.0.0-use.local resolution: "@react-aria/form@workspace:packages/@react-aria/form" dependencies: + "@react-aria/i18n": "npm:^3.12.9" "@react-aria/interactions": "npm:^3.25.1" "@react-aria/live-announcer": "npm:^3.4.3" "@react-aria/utils": "npm:^3.29.0" "@react-stately/form": "npm:^3.1.4" "@react-types/shared": "npm:^3.29.1" "@swc/helpers": "npm:^0.5.0" + dom-accessibility-api: "npm:^0.7.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -6757,7 +6759,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/textfield@npm:^3.17.4, @react-aria/textfield@workspace:packages/@react-aria/textfield": +"@react-aria/textfield@npm:^3.17.3, @react-aria/textfield@npm:^3.17.4, @react-aria/textfield@workspace:packages/@react-aria/textfield": version: 0.0.0-use.local resolution: "@react-aria/textfield@workspace:packages/@react-aria/textfield" dependencies: @@ -7583,7 +7585,7 @@ __metadata: languageName: unknown linkType: soft -"@react-spectrum/label@npm:^3.16.16, @react-spectrum/label@workspace:packages/@react-spectrum/label": +"@react-spectrum/label@npm:^3.16.15, @react-spectrum/label@npm:^3.16.16, @react-spectrum/label@workspace:packages/@react-spectrum/label": version: 0.0.0-use.local resolution: "@react-spectrum/label@workspace:packages/@react-spectrum/label" dependencies: @@ -8310,17 +8312,18 @@ __metadata: resolution: "@react-spectrum/textfield@workspace:packages/@react-spectrum/textfield" dependencies: "@adobe/spectrum-css-temp": "npm:3.0.0-alpha.1" - "@react-aria/focus": "npm:^3.20.4" - "@react-aria/interactions": "npm:^3.25.2" - "@react-aria/textfield": "npm:^3.17.4" - "@react-aria/utils": "npm:^3.29.1" - "@react-spectrum/form": "npm:^3.7.16" - "@react-spectrum/label": "npm:^3.16.16" - "@react-spectrum/utils": "npm:^3.12.6" - "@react-stately/utils": "npm:^3.10.7" - "@react-types/shared": "npm:^3.30.0" - "@react-types/textfield": "npm:^3.12.3" - "@spectrum-icons/ui": "npm:^3.6.17" + "@react-aria/focus": "npm:^3.20.3" + "@react-aria/i18n": "npm:^3.12.10" + "@react-aria/interactions": "npm:^3.25.1" + "@react-aria/textfield": "npm:^3.17.3" + "@react-aria/utils": "npm:^3.29.0" + "@react-spectrum/form": "npm:^3.7.15" + "@react-spectrum/label": "npm:^3.16.15" + "@react-spectrum/utils": "npm:^3.12.5" + "@react-stately/utils": "npm:^3.10.6" + "@react-types/shared": "npm:^3.29.1" + "@react-types/textfield": "npm:^3.12.2" + "@spectrum-icons/ui": "npm:^3.6.16" "@spectrum-icons/workflow": "npm:^4.0.0" "@swc/helpers": "npm:^0.5.0" peerDependencies: @@ -8911,7 +8914,7 @@ __metadata: languageName: unknown linkType: soft -"@react-stately/utils@npm:^3.10.7, @react-stately/utils@workspace:packages/@react-stately/utils": +"@react-stately/utils@npm:^3.10.6, @react-stately/utils@npm:^3.10.7, @react-stately/utils@workspace:packages/@react-stately/utils": version: 0.0.0-use.local resolution: "@react-stately/utils@workspace:packages/@react-stately/utils" dependencies: @@ -9383,7 +9386,7 @@ __metadata: languageName: unknown linkType: soft -"@react-types/textfield@npm:^3.12.3, @react-types/textfield@workspace:packages/@react-types/textfield": +"@react-types/textfield@npm:^3.12.2, @react-types/textfield@npm:^3.12.3, @react-types/textfield@workspace:packages/@react-types/textfield": version: 0.0.0-use.local resolution: "@react-types/textfield@workspace:packages/@react-types/textfield" dependencies: @@ -16040,6 +16043,13 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.7.0": + version: 0.7.0 + resolution: "dom-accessibility-api@npm:0.7.0" + checksum: 10c0/51d3fe283b0b4882442ba7cd7c76d27aa96b57e1854f0373be62c0abfaa95e89f4c1a1b883712814dc52b0a0853147f6de681fae20467a29d3a485df9b7329d8 + languageName: node + linkType: hard + "dom-helpers@npm:^5.0.1": version: 5.2.1 resolution: "dom-helpers@npm:5.2.1" From a2350451298fd4e7e83c19ad1ecba456404bd433 Mon Sep 17 00:00:00 2001 From: Michael Jordan Date: Tue, 10 Jun 2025 18:39:04 -0400 Subject: [PATCH 6/9] fix(accessibility): add aria-describedby to first segment in Date/Time fields when validationState is 'valid' --- packages/@react-spectrum/datepicker/src/DateField.tsx | 8 +++++++- packages/@react-spectrum/datepicker/src/DatePicker.tsx | 6 +++++- .../@react-spectrum/datepicker/src/DatePickerField.tsx | 9 +++++++++ .../@react-spectrum/datepicker/src/DatePickerSegment.tsx | 8 +++++++- .../@react-spectrum/datepicker/src/DateRangePicker.tsx | 7 ++++++- packages/@react-spectrum/datepicker/src/Input.tsx | 9 ++++++--- packages/@react-spectrum/datepicker/src/TimeField.tsx | 8 +++++++- .../@react-spectrum/textfield/test/TextField.test.js | 5 +---- 8 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/@react-spectrum/datepicker/src/DateField.tsx b/packages/@react-spectrum/datepicker/src/DateField.tsx index 8d281711bc1..ecc7ac28595 100644 --- a/packages/@react-spectrum/datepicker/src/DateField.tsx +++ b/packages/@react-spectrum/datepicker/src/DateField.tsx @@ -17,12 +17,13 @@ import datepickerStyles from './styles.css'; import {DateValue, SpectrumDateFieldProps} from '@react-types/datepicker'; import {Field} from '@react-spectrum/label'; import {FocusableRef} from '@react-types/shared'; -import {Input} from './Input'; +import {Input, VALID_ICON_POSTFIX} from './Input'; import React, {ReactElement, useRef} from 'react'; import {useDateField} from '@react-aria/datepicker'; import {useDateFieldState} from '@react-stately/datepicker'; import {useFocusManagerRef, useFormatHelpText, useFormattedDateWidth} from './utils'; import {useFormProps} from '@react-spectrum/form'; +import {useId} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; import {useProviderProps} from '@react-spectrum/provider'; @@ -67,6 +68,9 @@ export const DateField = React.forwardRef(function DateField {state.segments.map((segment, i) => ( extends SpectrumDatePickerProps { inputClassName?: string, @@ -28,11 +29,13 @@ interface DatePickerFieldProps extends SpectrumDatePickerPr export function DatePickerField(props: DatePickerFieldProps): JSX.Element { let { + id: datePickerInputId, isDisabled, isReadOnly, isRequired, inputClassName } = props; + let ref = useRef(null); let {locale} = useLocale(); let state = useDateFieldState({ @@ -44,10 +47,16 @@ export function DatePickerField(props: DatePickerFieldProps let inputRef = useRef(null); let {fieldProps, inputProps} = useDateField({...props, inputRef}, state, ref); + let validIconId = datePickerInputId ? datePickerInputId + VALID_ICON_POSTFIX : undefined; + + // field props is container element that does not need an id + fieldProps.id = undefined; + return ( {state.segments.map((segment, i) => ((null); let {segmentProps} = useDateSegment(segment, state, ref); + let {'aria-describedby': ariaDescribedByProp} = otherProps; + + if (ariaDescribedByProp) { + // Merge aria-describedby from segmentProps and otherProps + segmentProps['aria-describedby'] = segmentProps['aria-describedby'] ? `${segmentProps['aria-describedby']} ${ariaDescribedByProp}` : ariaDescribedByProp; + } return (
(null); let { + id, isDisabled, isQuiet, inputClassName, @@ -118,16 +121,16 @@ export const Input = React.forwardRef(function Input(props: any, ref: any) { ); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/datepicker'); - let validId = fieldProps?.id ? `${fieldProps.id}-valid-icon` : undefined; + let validIconId = id ? id + VALID_ICON_POSTFIX : undefined; let validationIcon: ReactElement | null = null; if (validationState === 'invalid' && !isDisabled) { validationIcon = ; } else if (validationState === 'valid' && !isDisabled) { - validationIcon = ; + validationIcon = ; } return ( -
+