Skip to content

Commit 2e59636

Browse files
authored
Auto focus the first invalid field when submitting a form (#5344)
1 parent 0ee6a00 commit 2e59636

39 files changed

+347
-75
lines changed

packages/@adobe/spectrum-css-temp/components/inlinealert/index.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ governing permissions and limitations under the License.
4848
}
4949

5050
.spectrum-InLineAlert {
51+
composes: spectrum-FocusRing;
52+
--spectrum-focus-ring-gap: var(--spectrum-alias-focus-ring-gap);
53+
--spectrum-focus-ring-border-size: var(--spectrum-inlinealert-border-width);
54+
--spectrum-focus-ring-border-radius: var(--spectrum-inlinealert-border-radius);
55+
--spectrum-focus-ring-size: var(--spectrum-button-primary-focus-ring-size-key-focus);
56+
5157
position: relative;
5258

5359
display: inline-block;
@@ -61,6 +67,8 @@ governing permissions and limitations under the License.
6167
border-inline-width: var(--spectrum-inlinealert-border-width);
6268
border-style: solid;
6369
border-radius: var(--spectrum-inlinealert-border-radius);
70+
71+
outline: none;
6472
}
6573

6674
.spectrum-InLineAlert .spectrum-InLineAlert-grid {

packages/@react-aria/datepicker/src/useDateField.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,12 @@ export function useDateField<T extends DateValue>(props: AriaDateFieldOptions<T>
139139
}, [focusManager]);
140140

141141
useFormReset(props.inputRef, state.value, state.setValue);
142-
useFormValidation(props, state, props.inputRef);
142+
useFormValidation({
143+
...props,
144+
focus() {
145+
focusManager.focusFirst();
146+
}
147+
}, state, props.inputRef);
143148

144149
let inputProps: InputHTMLAttributes<HTMLInputElement> = {
145150
type: 'hidden',

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"url": "https://github.com/adobe/react-spectrum"
2323
},
2424
"dependencies": {
25+
"@react-aria/interactions": "^3.19.1",
2526
"@react-aria/utils": "^3.21.0",
2627
"@react-stately/form": "3.0.0-alpha.1",
2728
"@react-types/shared": "^3.21.0",

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

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@
1212

1313
import {FormValidationState} from '@react-stately/form';
1414
import {RefObject, useEffect} from 'react';
15+
import {setInteractionModality} from '@react-aria/interactions';
1516
import {useEffectEvent, useLayoutEffect} from '@react-aria/utils';
1617
import {Validation, ValidationResult} from '@react-types/shared';
1718

1819
type ValidatableElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
1920

20-
export function useFormValidation<T>(props: Validation<T>, state: FormValidationState, ref: RefObject<ValidatableElement>) {
21-
let {validationBehavior} = props;
21+
interface FormValidationProps<T> extends Validation<T> {
22+
focus?: () => void
23+
}
24+
25+
export function useFormValidation<T>(props: FormValidationProps<T>, state: FormValidationState, ref: RefObject<ValidatableElement>) {
26+
let {validationBehavior, focus} = props;
2227

2328
// This is a useLayoutEffect so that it runs before the useEffect in useFormValidationState, which commits the validation change.
2429
useLayoutEffect(() => {
@@ -43,14 +48,27 @@ export function useFormValidation<T>(props: Validation<T>, state: FormValidation
4348
});
4449

4550
let onInvalid = useEffectEvent((e: Event) => {
46-
// Prevent default browser error UI from appearing.
47-
e.preventDefault();
48-
4951
// Only commit validation if we are not already displaying one.
5052
// This avoids clearing server errors that the user didn't actually fix.
5153
if (!state.displayValidation.isInvalid) {
5254
state.commitValidation();
5355
}
56+
57+
// Auto focus the first invalid input in a form, unless the error already had its default prevented.
58+
let form = ref.current?.form;
59+
if (!e.defaultPrevented && form && getFirstInvalidInput(form) === ref.current) {
60+
if (focus) {
61+
focus();
62+
} else {
63+
ref.current?.focus();
64+
}
65+
66+
// Always show focus ring.
67+
setInteractionModality('keyboard');
68+
}
69+
70+
// Prevent default browser error UI from appearing.
71+
e.preventDefault();
5472
});
5573

5674
let onChange = useEffectEvent(() => {
@@ -101,3 +119,14 @@ function getNativeValidity(input: ValidatableElement): ValidationResult {
101119
validationErrors: input.validationMessage ? [input.validationMessage] : []
102120
};
103121
}
122+
123+
function getFirstInvalidInput(form: HTMLFormElement): ValidatableElement | null {
124+
for (let i = 0; i < form.elements.length; i++) {
125+
let element = form.elements[i] as ValidatableElement;
126+
if (!element.validity.valid) {
127+
return element;
128+
}
129+
}
130+
131+
return null;
132+
}

packages/@react-aria/select/src/HiddenSelect.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ export function useHiddenSelect<T>(props: AriaHiddenSelectOptions, state: Select
6161
let {visuallyHiddenProps} = useVisuallyHidden();
6262

6363
useFormReset(props.selectRef, state.selectedKey, state.setSelectedKey);
64-
useFormValidation({validationBehavior}, state, props.selectRef);
64+
useFormValidation({
65+
validationBehavior,
66+
focus: () => triggerRef.current.focus()
67+
}, state, props.selectRef);
6568

6669
// In Safari, the <select> cannot have `display: none` or `hidden` for autofill to work.
6770
// In Firefox, there must be a <label> to identify the <select> whereas other browsers

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,10 @@ function _MobileSearchAutocomplete<T extends object>(props: SpectrumSearchAutoco
8787
let {triggerProps, overlayProps} = useOverlayTrigger({type: 'listbox'}, state, buttonRef);
8888

8989
let inputRef = useRef<HTMLInputElement>(null);
90-
useFormValidation(props, state, inputRef);
90+
useFormValidation({
91+
...props,
92+
focus: () => buttonRef.current?.focus()
93+
}, state, inputRef);
9194
let {isInvalid, validationErrors, validationDetails} = state.displayValidation;
9295
let validationState = props.validationState || (isInvalid ? 'invalid' : undefined);
9396
let errorMessage = props.errorMessage ?? validationErrors.join(' ');

packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2504,10 +2504,10 @@ describe('SearchAutocomplete', function () {
25042504

25052505
act(() => {getByTestId('form').checkValidity();});
25062506

2507+
expect(document.activeElement).toBe(input);
25072508
expect(input).toHaveAttribute('aria-describedby');
25082509
expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied');
25092510

2510-
await user.tab();
25112511
await user.keyboard('Tw');
25122512
act(() => {
25132513
jest.runAllTimers();
@@ -2541,10 +2541,11 @@ describe('SearchAutocomplete', function () {
25412541

25422542
act(() => {getByTestId('form').checkValidity();});
25432543

2544+
expect(document.activeElement).toBe(input);
25442545
expect(input).toHaveAttribute('aria-describedby');
25452546
expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Invalid value');
25462547

2547-
await user.tab();
2548+
await user.clear(input);
25482549
await user.keyboard('On');
25492550
act(() => {
25502551
jest.runAllTimers();

packages/@react-spectrum/checkbox/test/CheckboxGroup.test.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ describe('CheckboxGroup', () => {
485485

486486
expect(group).toHaveAttribute('aria-describedby');
487487
expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied');
488+
expect(document.activeElement).toBe(checkboxes[0]);
488489

489490
await user.click(checkboxes[0]);
490491
expect(checkboxes[0].validity.valid).toBe(true);
@@ -523,6 +524,7 @@ describe('CheckboxGroup', () => {
523524

524525
expect(group).toHaveAttribute('aria-describedby');
525526
expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent(['You must accept all terms']);
527+
expect(document.activeElement).toBe(checkboxes[0]);
526528

527529
await user.click(checkboxes[0]);
528530
expect(group).toHaveAttribute('aria-describedby');
@@ -568,6 +570,7 @@ describe('CheckboxGroup', () => {
568570

569571
expect(group).toHaveAttribute('aria-describedby');
570572
expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent('You must accept the terms. You must accept the cookies.');
573+
expect(document.activeElement).toBe(checkboxes[0]);
571574

572575
await user.click(checkboxes[0]);
573576
expect(checkboxes[0].validity.valid).toBe(true);

packages/@react-spectrum/color/test/ColorField.test.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -395,8 +395,8 @@ describe('ColorField', function () {
395395

396396
expect(input).toHaveAttribute('aria-describedby');
397397
expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied');
398+
expect(document.activeElement).toBe(input);
398399

399-
await user.tab();
400400
await user.keyboard('#000');
401401

402402
expect(input).toHaveAttribute('aria-describedby');
@@ -424,8 +424,9 @@ describe('ColorField', function () {
424424

425425
expect(input).toHaveAttribute('aria-describedby');
426426
expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Invalid value');
427+
expect(document.activeElement).toBe(input);
427428

428-
await user.tab();
429+
await user.clear(input);
429430
await user.keyboard('#111');
430431

431432
expect(input).toHaveAttribute('aria-describedby');
@@ -515,8 +516,8 @@ describe('ColorField', function () {
515516
act(() => {getByTestId('form').checkValidity();});
516517

517518
expect(input).toHaveAttribute('aria-describedby');
519+
expect(document.activeElement).toBe(input);
518520

519-
await user.tab();
520521
await user.keyboard('333');
521522

522523
expect(input).toHaveAttribute('aria-describedby');

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ export const MobileComboBox = React.forwardRef(function MobileComboBox<T extends
7878
let {triggerProps, overlayProps} = useOverlayTrigger({type: 'listbox'}, state, buttonRef);
7979

8080
let inputRef = useRef<HTMLInputElement>(null);
81-
useFormValidation(props, state, inputRef);
81+
useFormValidation({
82+
...props,
83+
focus: () => buttonRef.current.focus()
84+
}, state, inputRef);
8285
let {isInvalid, validationErrors, validationDetails} = state.displayValidation;
8386
let validationState = props.validationState || (isInvalid ? 'invalid' : null);
8487
let errorMessage = props.errorMessage ?? validationErrors.join(' ');

0 commit comments

Comments
 (0)