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 12f81ac54f3..92c4cd07a8e 100644 --- a/packages/@react-aria/form/package.json +++ b/packages/@react-aria/form/package.json @@ -26,11 +26,14 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-aria/i18n": "^3.12.10", "@react-aria/interactions": "^3.25.3", + "@react-aria/live-announcer": "^3.4.3", "@react-aria/utils": "^3.29.1", "@react-stately/form": "^3.1.5", "@react-types/shared": "^3.30.0", - "@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 e876ca66173..b43554e7284 100644 --- a/packages/@react-aria/form/src/useFormValidation.ts +++ b/packages/@react-aria/form/src/useFormValidation.ts @@ -10,25 +10,59 @@ * governing permissions and limitations under the License. */ +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} from 'react'; -import {useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +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 } +const TIMEOUT_DURATION = 325; + export function useFormValidation(props: FormValidationProps, state: FormValidationState, ref: RefObject | undefined): void { let {validationBehavior, focus} = props; + let justBlurredRef = useRef(false); + let timeoutIdRef = useRef | null>(null); + function announceErrorMessage(errorMessage: string = ''): void { + if (timeoutIdRef.current != null) { + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = null; + } + if (ref && ref.current && errorMessage !== '' && ( + ref.current.contains(getActiveElement(getOwnerDocument(ref.current))) || + justBlurredRef.current + ) + ) { + timeoutIdRef.current = setTimeout(() => announce(errorMessage, 'polite'), TIMEOUT_DURATION); + } + } + + 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. @@ -56,11 +90,17 @@ 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) { + + // Announce the current error message + announceErrorMessage(ref?.current?.validationMessage || ''); + + if (getFirstInvalidInput(form) === ref.current) { + if (focus) { + focus(); + } else { + ref.current?.focus(); + } } // Always show focus ring. @@ -75,6 +115,38 @@ export function useFormValidation(props: FormValidationProps, state: FormV state.commitValidation(); }); + 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 + const accessibleName = computeAccessibleName(groupElement || input); + const validationMessage = input.validationMessage; + announceErrorMessage( + accessibleName && validationMessage ? + stringFormatter.format( + 'reviewField', + { + accessibleName, + validationMessage + }) : + validationMessage + ); + justBlurredRef.current = false; + }); + useEffect(() => { let input = ref?.current; if (!input) { @@ -82,18 +154,25 @@ 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 () => { + 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]); + }, [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/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, @@ -114,15 +120,17 @@ export const Input = React.forwardRef(function Input(props: any, ref: any) { 'spectrum-Textfield-validationIcon' ); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/datepicker'); + 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 ( -
+