Skip to content

Commit fbd7356

Browse files
author
Michael Jordan
committed
fix(accessibility): refactor announcement into useFormValidation
1 parent f002a5b commit fbd7356

File tree

4 files changed

+28
-20
lines changed

4 files changed

+28
-20
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
},
2828
"dependencies": {
2929
"@react-aria/interactions": "^3.25.1",
30+
"@react-aria/live-announcer": "^3.4.2",
3031
"@react-aria/utils": "^3.29.0",
3132
"@react-stately/form": "^3.1.4",
3233
"@react-types/shared": "^3.29.1",

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

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {announce} from '@react-aria/live-announcer';
1314
import {FormValidationState} from '@react-stately/form';
15+
import {getActiveElement, getOwnerDocument, useEffectEvent, useLayoutEffect} from '@react-aria/utils';
1416
import {RefObject, Validation, ValidationResult} from '@react-types/shared';
1517
import {setInteractionModality} from '@react-aria/interactions';
16-
import {useEffect} from 'react';
17-
import {useEffectEvent, useLayoutEffect} from '@react-aria/utils';
18+
import {useEffect, useRef} from 'react';
1819

1920
type ValidatableElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
2021

@@ -25,12 +26,24 @@ interface FormValidationProps<T> extends Validation<T> {
2526
export function useFormValidation<T>(props: FormValidationProps<T>, state: FormValidationState, ref: RefObject<ValidatableElement | null> | undefined): void {
2627
let {validationBehavior, focus} = props;
2728

29+
let timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
30+
function announceErrorMessage(errorMessage: string = ''): void {
31+
clearTimeout(timeoutId.current!);
32+
if (ref?.current &&
33+
errorMessage !== '' &&
34+
ref.current.contains(getActiveElement(getOwnerDocument(ref.current)))) {
35+
timeoutId.current = setTimeout(() => announce(errorMessage, 'polite'), 250);
36+
}
37+
}
38+
2839
// This is a useLayoutEffect so that it runs before the useEffect in useFormValidationState, which commits the validation change.
2940
useLayoutEffect(() => {
3041
if (validationBehavior === 'native' && ref?.current && !ref.current.disabled) {
3142
let errorMessage = state.realtimeValidation.isInvalid ? state.realtimeValidation.validationErrors.join(' ') || 'Invalid value.' : '';
3243
ref.current.setCustomValidity(errorMessage);
3344

45+
announceErrorMessage(errorMessage);
46+
3447
// Prevent default tooltip for validation message.
3548
// https://bugzilla.mozilla.org/show_bug.cgi?id=605277
3649
if (!ref.current.hasAttribute('title')) {
@@ -56,11 +69,14 @@ export function useFormValidation<T>(props: FormValidationProps<T>, state: FormV
5669

5770
// Auto focus the first invalid input in a form, unless the error already had its default prevented.
5871
let form = ref?.current?.form;
59-
if (!e.defaultPrevented && ref && form && getFirstInvalidInput(form) === ref.current) {
60-
if (focus) {
61-
focus();
62-
} else {
63-
ref.current?.focus();
72+
if (!e.defaultPrevented && ref && form) {
73+
announceErrorMessage(ref?.current?.validationMessage || '');
74+
if (getFirstInvalidInput(form) === ref.current) {
75+
if (focus) {
76+
focus();
77+
} else {
78+
ref.current?.focus();
79+
}
6480
}
6581

6682
// Always show focus ring.
@@ -86,6 +102,7 @@ export function useFormValidation<T>(props: FormValidationProps<T>, state: FormV
86102
input.addEventListener('change', onChange);
87103
form?.addEventListener('reset', onReset);
88104
return () => {
105+
clearTimeout(timeoutId.current!);
89106
input!.removeEventListener('invalid', onInvalid);
90107
input!.removeEventListener('change', onChange);
91108
form?.removeEventListener('reset', onReset);

packages/@react-spectrum/label/src/Field.tsx

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,13 @@
99
* OF ANY KIND, either express or implied. See the License for the specific language
1010
* governing permissions and limitations under the License.
1111
*/
12-
import {announce} from '@react-aria/live-announcer';
1312
import {classNames, SlotProvider, useStyleProps} from '@react-spectrum/utils';
1413
import {Flex} from '@react-spectrum/layout';
15-
import {getActiveElement, mergeProps, useId} from '@react-aria/utils';
1614
import {HelpText} from './HelpText';
1715
import {Label} from './Label';
1816
import {LabelPosition, RefObject} from '@react-types/shared';
1917
import labelStyles from '@adobe/spectrum-css-temp/components/fieldlabel/vars.css';
18+
import {mergeProps, useId} from '@react-aria/utils';
2019
import React, {ReactNode, Ref} from 'react';
2120
import {SpectrumFieldProps} from '@react-types/label';
2221
import {useFormProps} from '@react-spectrum/form';
@@ -64,19 +63,9 @@ export const Field = React.forwardRef(function Field(props: SpectrumFieldProps,
6463
} else {
6564
errorMessageString = errorMessage;
6665
}
67-
let hasErrorMessage = !!errorMessageString && (isInvalid || validationState === 'invalid');
68-
let hasHelpText = !!description || hasErrorMessage;
66+
let hasHelpText = !!description || errorMessageString && (isInvalid || validationState === 'invalid');
6967
let contextualHelpId = useId();
7068

71-
React.useEffect(() => {
72-
if (hasErrorMessage &&
73-
(ref as RefObject<HTMLElement>)?.current?.contains(getActiveElement()) &&
74-
typeof errorMessageString === 'string' &&
75-
errorMessageString.length > 0) {
76-
announce(errorMessageString, 'polite');
77-
}
78-
}, [errorMessageString, hasErrorMessage, ref]);
79-
8069
let fallbackLabelPropsId = useId();
8170
if (label && contextualHelp && !labelProps.id) {
8271
labelProps.id = fallbackLabelPropsId;

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6245,6 +6245,7 @@ __metadata:
62456245
resolution: "@react-aria/form@workspace:packages/@react-aria/form"
62466246
dependencies:
62476247
"@react-aria/interactions": "npm:^3.25.1"
6248+
"@react-aria/live-announcer": "npm:^3.4.2"
62486249
"@react-aria/utils": "npm:^3.29.0"
62496250
"@react-stately/form": "npm:^3.1.4"
62506251
"@react-types/shared": "npm:^3.29.1"

0 commit comments

Comments
 (0)