Skip to content

Commit 9fb96f1

Browse files
committed
fix(accessibility): add announcement of error message for a just blurred field
1 parent 758c44e commit 9fb96f1

File tree

1 file changed

+29
-9
lines changed

1 file changed

+29
-9
lines changed

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

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,21 @@ interface FormValidationProps<T> extends Validation<T> {
2626
export function useFormValidation<T>(props: FormValidationProps<T>, state: FormValidationState, ref: RefObject<ValidatableElement | null> | undefined): void {
2727
let {validationBehavior, focus} = props;
2828

29-
let timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
29+
let justBlurredRef = useRef(false);
30+
let timeoutIdRef = useRef<ReturnType<typeof setTimeout> | null>(null);
3031
function announceErrorMessage(errorMessage: string = ''): void {
31-
if (timeoutId.current != null) {
32-
clearTimeout(timeoutId.current);
32+
if (timeoutIdRef.current != null) {
33+
clearTimeout(timeoutIdRef.current);
34+
timeoutIdRef.current = null;
3335
}
3436
if (ref?.current &&
3537
errorMessage !== '' &&
36-
ref.current.contains(getActiveElement(getOwnerDocument(ref.current)))) {
37-
timeoutId.current = setTimeout(() => announce(errorMessage, 'polite'), 250);
38+
(
39+
ref.current.contains(getActiveElement(getOwnerDocument(ref.current))) ||
40+
justBlurredRef.current
41+
)
42+
) {
43+
timeoutIdRef.current = setTimeout(() => announce(errorMessage, 'polite'), 250);
3844
}
3945
}
4046

@@ -44,8 +50,6 @@ export function useFormValidation<T>(props: FormValidationProps<T>, state: FormV
4450
let errorMessage = state.realtimeValidation.isInvalid ? state.realtimeValidation.validationErrors.join(' ') || 'Invalid value.' : '';
4551
ref.current.setCustomValidity(errorMessage);
4652

47-
announceErrorMessage(errorMessage);
48-
4953
// Prevent default tooltip for validation message.
5054
// https://bugzilla.mozilla.org/show_bug.cgi?id=605277
5155
if (!ref.current.hasAttribute('title')) {
@@ -72,7 +76,10 @@ export function useFormValidation<T>(props: FormValidationProps<T>, state: FormV
7276
// Auto focus the first invalid input in a form, unless the error already had its default prevented.
7377
let form = ref?.current?.form;
7478
if (!e.defaultPrevented && ref && form) {
79+
80+
// Announce the current error message
7581
announceErrorMessage(ref?.current?.validationMessage || '');
82+
7683
if (getFirstInvalidInput(form) === ref.current) {
7784
if (focus) {
7885
focus();
@@ -93,23 +100,36 @@ export function useFormValidation<T>(props: FormValidationProps<T>, state: FormV
93100
state.commitValidation();
94101
});
95102

103+
let onBlur = useEffectEvent(() => {
104+
justBlurredRef.current = true;
105+
// Announce the current error message
106+
announceErrorMessage(ref?.current?.validationMessage || '');
107+
justBlurredRef.current = false;
108+
});
109+
96110
useEffect(() => {
97111
let input = ref?.current;
98112
if (!input) {
99113
return;
100114
}
101115

102116
let form = input.form;
117+
input.addEventListener('blur', onBlur);
103118
input.addEventListener('invalid', onInvalid);
104119
input.addEventListener('change', onChange);
105120
form?.addEventListener('reset', onReset);
106121
return () => {
107-
clearTimeout(timeoutId.current!);
122+
if (timeoutIdRef.current != null) {
123+
clearTimeout(timeoutIdRef.current);
124+
timeoutIdRef.current = null;
125+
}
126+
justBlurredRef.current = false;
127+
input!.removeEventListener('blur', onBlur);
108128
input!.removeEventListener('invalid', onInvalid);
109129
input!.removeEventListener('change', onChange);
110130
form?.removeEventListener('reset', onReset);
111131
};
112-
}, [ref, onInvalid, onChange, onReset, validationBehavior]);
132+
}, [justBlurredRef, onBlur, onChange, onInvalid, onReset, ref, validationBehavior]);
113133
}
114134

115135
function getValidity(input: ValidatableElement) {

0 commit comments

Comments
 (0)