diff --git a/packages/@adobe/spectrum-css-temp/components/inlinealert/index.css b/packages/@adobe/spectrum-css-temp/components/inlinealert/index.css index eb176362222..ea1b1da5618 100644 --- a/packages/@adobe/spectrum-css-temp/components/inlinealert/index.css +++ b/packages/@adobe/spectrum-css-temp/components/inlinealert/index.css @@ -48,6 +48,12 @@ governing permissions and limitations under the License. } .spectrum-InLineAlert { + composes: spectrum-FocusRing; + --spectrum-focus-ring-gap: var(--spectrum-alias-focus-ring-gap); + --spectrum-focus-ring-border-size: var(--spectrum-inlinealert-border-width); + --spectrum-focus-ring-border-radius: var(--spectrum-inlinealert-border-radius); + --spectrum-focus-ring-size: var(--spectrum-button-primary-focus-ring-size-key-focus); + position: relative; display: inline-block; @@ -61,6 +67,8 @@ governing permissions and limitations under the License. border-inline-width: var(--spectrum-inlinealert-border-width); border-style: solid; border-radius: var(--spectrum-inlinealert-border-radius); + + outline: none; } .spectrum-InLineAlert .spectrum-InLineAlert-grid { diff --git a/packages/@react-aria/datepicker/src/useDateField.ts b/packages/@react-aria/datepicker/src/useDateField.ts index c4c3816c223..d5b76b1b5e0 100644 --- a/packages/@react-aria/datepicker/src/useDateField.ts +++ b/packages/@react-aria/datepicker/src/useDateField.ts @@ -139,7 +139,12 @@ export function useDateField(props: AriaDateFieldOptions }, [focusManager]); useFormReset(props.inputRef, state.value, state.setValue); - useFormValidation(props, state, props.inputRef); + useFormValidation({ + ...props, + focus() { + focusManager.focusFirst(); + } + }, state, props.inputRef); let inputProps: InputHTMLAttributes = { type: 'hidden', diff --git a/packages/@react-aria/form/package.json b/packages/@react-aria/form/package.json index 336e20100dc..7afbf5556f4 100644 --- a/packages/@react-aria/form/package.json +++ b/packages/@react-aria/form/package.json @@ -22,6 +22,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-aria/interactions": "^3.19.1", "@react-aria/utils": "^3.21.0", "@react-stately/form": "3.0.0-alpha.1", "@react-types/shared": "^3.21.0", diff --git a/packages/@react-aria/form/src/useFormValidation.ts b/packages/@react-aria/form/src/useFormValidation.ts index 2a2d7e3ce24..4fad0b2a23e 100644 --- a/packages/@react-aria/form/src/useFormValidation.ts +++ b/packages/@react-aria/form/src/useFormValidation.ts @@ -12,13 +12,18 @@ import {FormValidationState} from '@react-stately/form'; import {RefObject, useEffect} from 'react'; +import {setInteractionModality} from '@react-aria/interactions'; import {useEffectEvent, useLayoutEffect} from '@react-aria/utils'; import {Validation, ValidationResult} from '@react-types/shared'; type ValidatableElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; -export function useFormValidation(props: Validation, state: FormValidationState, ref: RefObject) { - let {validationBehavior} = props; +interface FormValidationProps extends Validation { + focus?: () => void +} + +export function useFormValidation(props: FormValidationProps, state: FormValidationState, ref: RefObject) { + let {validationBehavior, focus} = props; // This is a useLayoutEffect so that it runs before the useEffect in useFormValidationState, which commits the validation change. useLayoutEffect(() => { @@ -43,14 +48,27 @@ export function useFormValidation(props: Validation, state: FormValidation }); let onInvalid = useEffectEvent((e: Event) => { - // Prevent default browser error UI from appearing. - e.preventDefault(); - // Only commit validation if we are not already displaying one. // This avoids clearing server errors that the user didn't actually fix. if (!state.displayValidation.isInvalid) { state.commitValidation(); } + + // 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 && form && getFirstInvalidInput(form) === ref.current) { + if (focus) { + focus(); + } else { + ref.current?.focus(); + } + + // Always show focus ring. + setInteractionModality('keyboard'); + } + + // Prevent default browser error UI from appearing. + e.preventDefault(); }); let onChange = useEffectEvent(() => { @@ -101,3 +119,14 @@ function getNativeValidity(input: ValidatableElement): ValidationResult { validationErrors: input.validationMessage ? [input.validationMessage] : [] }; } + +function getFirstInvalidInput(form: HTMLFormElement): ValidatableElement | null { + for (let i = 0; i < form.elements.length; i++) { + let element = form.elements[i] as ValidatableElement; + if (!element.validity.valid) { + return element; + } + } + + return null; +} diff --git a/packages/@react-aria/select/src/HiddenSelect.tsx b/packages/@react-aria/select/src/HiddenSelect.tsx index 4918ae1e279..1d671b674b9 100644 --- a/packages/@react-aria/select/src/HiddenSelect.tsx +++ b/packages/@react-aria/select/src/HiddenSelect.tsx @@ -61,7 +61,10 @@ export function useHiddenSelect(props: AriaHiddenSelectOptions, state: Select let {visuallyHiddenProps} = useVisuallyHidden(); useFormReset(props.selectRef, state.selectedKey, state.setSelectedKey); - useFormValidation({validationBehavior}, state, props.selectRef); + useFormValidation({ + validationBehavior, + focus: () => triggerRef.current.focus() + }, state, props.selectRef); // In Safari, the whereas other browsers diff --git a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx index d7f5128c303..b68dff4dcd7 100644 --- a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx +++ b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx @@ -87,7 +87,10 @@ function _MobileSearchAutocomplete(props: SpectrumSearchAutoco let {triggerProps, overlayProps} = useOverlayTrigger({type: 'listbox'}, state, buttonRef); let inputRef = useRef(null); - useFormValidation(props, state, inputRef); + useFormValidation({ + ...props, + focus: () => buttonRef.current?.focus() + }, state, inputRef); let {isInvalid, validationErrors, validationDetails} = state.displayValidation; let validationState = props.validationState || (isInvalid ? 'invalid' : undefined); let errorMessage = props.errorMessage ?? validationErrors.join(' '); diff --git a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js index 5bad635761a..b7b3ee49ebb 100644 --- a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js +++ b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js @@ -2504,10 +2504,10 @@ describe('SearchAutocomplete', function () { act(() => {getByTestId('form').checkValidity();}); + expect(document.activeElement).toBe(input); expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); - await user.tab(); await user.keyboard('Tw'); act(() => { jest.runAllTimers(); @@ -2541,10 +2541,11 @@ describe('SearchAutocomplete', function () { act(() => {getByTestId('form').checkValidity();}); + expect(document.activeElement).toBe(input); expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Invalid value'); - await user.tab(); + await user.clear(input); await user.keyboard('On'); act(() => { jest.runAllTimers(); diff --git a/packages/@react-spectrum/checkbox/test/CheckboxGroup.test.js b/packages/@react-spectrum/checkbox/test/CheckboxGroup.test.js index a2574b42a0f..fc6dc469ea6 100644 --- a/packages/@react-spectrum/checkbox/test/CheckboxGroup.test.js +++ b/packages/@react-spectrum/checkbox/test/CheckboxGroup.test.js @@ -485,6 +485,7 @@ describe('CheckboxGroup', () => { expect(group).toHaveAttribute('aria-describedby'); expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); + expect(document.activeElement).toBe(checkboxes[0]); await user.click(checkboxes[0]); expect(checkboxes[0].validity.valid).toBe(true); @@ -523,6 +524,7 @@ describe('CheckboxGroup', () => { expect(group).toHaveAttribute('aria-describedby'); expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent(['You must accept all terms']); + expect(document.activeElement).toBe(checkboxes[0]); await user.click(checkboxes[0]); expect(group).toHaveAttribute('aria-describedby'); @@ -568,6 +570,7 @@ describe('CheckboxGroup', () => { expect(group).toHaveAttribute('aria-describedby'); expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent('You must accept the terms. You must accept the cookies.'); + expect(document.activeElement).toBe(checkboxes[0]); await user.click(checkboxes[0]); expect(checkboxes[0].validity.valid).toBe(true); diff --git a/packages/@react-spectrum/color/test/ColorField.test.js b/packages/@react-spectrum/color/test/ColorField.test.js index 5b0ee8b0712..ee8a006862e 100644 --- a/packages/@react-spectrum/color/test/ColorField.test.js +++ b/packages/@react-spectrum/color/test/ColorField.test.js @@ -395,8 +395,8 @@ describe('ColorField', function () { expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); + expect(document.activeElement).toBe(input); - await user.tab(); await user.keyboard('#000'); expect(input).toHaveAttribute('aria-describedby'); @@ -424,8 +424,9 @@ describe('ColorField', function () { expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Invalid value'); + expect(document.activeElement).toBe(input); - await user.tab(); + await user.clear(input); await user.keyboard('#111'); expect(input).toHaveAttribute('aria-describedby'); @@ -515,8 +516,8 @@ describe('ColorField', function () { act(() => {getByTestId('form').checkValidity();}); expect(input).toHaveAttribute('aria-describedby'); + expect(document.activeElement).toBe(input); - await user.tab(); await user.keyboard('333'); expect(input).toHaveAttribute('aria-describedby'); diff --git a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx index 3ff6831ecb1..15d2e82e66f 100644 --- a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx +++ b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx @@ -78,7 +78,10 @@ export const MobileComboBox = React.forwardRef(function MobileComboBox(null); - useFormValidation(props, state, inputRef); + useFormValidation({ + ...props, + focus: () => buttonRef.current.focus() + }, state, inputRef); let {isInvalid, validationErrors, validationDetails} = state.displayValidation; let validationState = props.validationState || (isInvalid ? 'invalid' : null); let errorMessage = props.errorMessage ?? validationErrors.join(' '); diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index 7796006d58e..139f61d7f40 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -5383,10 +5383,10 @@ describe('ComboBox', function () { act(() => {getByTestId('form').checkValidity();}); + expect(document.activeElement).toBe(input); expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); - await user.tab(); await user.keyboard('[ArrowRight]Tw'); act(() => { @@ -5421,6 +5421,7 @@ describe('ComboBox', function () { act(() => {getByTestId('form').checkValidity();}); + expect(document.activeElement).toBe(input); expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Invalid value'); diff --git a/packages/@react-spectrum/datepicker/test/DateField.test.js b/packages/@react-spectrum/datepicker/test/DateField.test.js index 8bb823881ba..e457ad93c0d 100644 --- a/packages/@react-spectrum/datepicker/test/DateField.test.js +++ b/packages/@react-spectrum/datepicker/test/DateField.test.js @@ -392,8 +392,9 @@ describe('DateField', function () { expect(group).toHaveAttribute('aria-describedby'); let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Constraints not satisfied'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); + await user.keyboard('[ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); expect(getDescription()).toContain('Constraints not satisfied'); expect(input.validity.valid).toBe(true); @@ -422,8 +423,9 @@ describe('DateField', function () { expect(group).toHaveAttribute('aria-describedby'); expect(getDescription()).toContain('Value must be 2/3/2020 or later.'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][Tab][Tab][ArrowUp]'); + await user.keyboard('[Tab][Tab][ArrowUp]'); expect(getDescription()).toContain('Value must be 2/3/2020 or later.'); expect(input.validity.valid).toBe(true); @@ -440,9 +442,9 @@ describe('DateField', function () { act(() => {getByTestId('form').checkValidity();}); expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.tab({shift: true}); - await user.keyboard('[ArrowDown]'); + await user.keyboard('[Tab][Tab][ArrowDown]'); expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); expect(input.validity.valid).toBe(true); await user.tab(); @@ -469,8 +471,9 @@ describe('DateField', function () { expect(group).toHaveAttribute('aria-describedby'); expect(getDescription()).toContain('Invalid value'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][ArrowRight][ArrowRight]2024'); + await user.keyboard('[ArrowRight][ArrowRight]2024'); expect(getDescription()).toContain('Invalid value'); expect(input.validity.valid).toBe(true); @@ -589,8 +592,9 @@ describe('DateField', function () { expect(group).toHaveAttribute('aria-describedby'); let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Constraints not satisfied'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab]232023'); + await user.keyboard('232023'); expect(group).toHaveAttribute('aria-describedby'); expect(input.validity.valid).toBe(true); diff --git a/packages/@react-spectrum/datepicker/test/DatePicker.test.js b/packages/@react-spectrum/datepicker/test/DatePicker.test.js index fb52c6d5387..e2715955f0e 100644 --- a/packages/@react-spectrum/datepicker/test/DatePicker.test.js +++ b/packages/@react-spectrum/datepicker/test/DatePicker.test.js @@ -1956,8 +1956,9 @@ describe('DatePicker', function () { expect(group).toHaveAttribute('aria-describedby'); let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Constraints not satisfied'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); + await user.keyboard('[ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); expect(getDescription()).toContain('Constraints not satisfied'); expect(input.validity.valid).toBe(true); @@ -1986,8 +1987,9 @@ describe('DatePicker', function () { expect(group).toHaveAttribute('aria-describedby'); expect(getDescription()).toContain('Value must be 2/3/2020 or later.'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][Tab][Tab][ArrowUp]'); + await user.keyboard('[Tab][Tab][ArrowUp]'); expect(getDescription()).toContain('Value must be 2/3/2020 or later.'); expect(input.validity.valid).toBe(true); @@ -2004,9 +2006,9 @@ describe('DatePicker', function () { act(() => {getByTestId('form').checkValidity();}); expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.tab({shift: true}); - await user.keyboard('[ArrowDown]'); + await user.keyboard('[Tab][Tab][ArrowDown]'); expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); expect(input.validity.valid).toBe(true); await user.tab(); @@ -2033,8 +2035,9 @@ describe('DatePicker', function () { expect(group).toHaveAttribute('aria-describedby'); expect(getDescription()).toContain('Invalid value'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][ArrowRight][ArrowRight]2024'); + await user.keyboard('[ArrowRight][ArrowRight]2024'); expect(getDescription()).toContain('Invalid value'); expect(input.validity.valid).toBe(true); diff --git a/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js b/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js index 26d26161430..0c969a0f7ef 100644 --- a/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js +++ b/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js @@ -1548,8 +1548,9 @@ describe('DateRangePicker', function () { expect(group).toHaveAttribute('aria-describedby'); let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Constraints not satisfied'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); + await user.keyboard('[ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); await user.keyboard('[Tab][ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); expect(getDescription()).toContain('Constraints not satisfied'); @@ -1582,8 +1583,9 @@ describe('DateRangePicker', function () { expect(group).toHaveAttribute('aria-describedby'); expect(getDescription()).toContain('Value must be 2/3/2020 or later. Value must be 2/3/2024 or earlier.'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][Tab][Tab][ArrowUp]'); + await user.keyboard('[Tab][Tab][ArrowUp]'); expect(getDescription()).toContain('Value must be 2/3/2020 or later. Value must be 2/3/2024 or earlier.'); expect(startInput.validity.valid).toBe(false); @@ -1624,8 +1626,9 @@ describe('DateRangePicker', function () { expect(group).toHaveAttribute('aria-describedby'); expect(getDescription()).toContain('Invalid value'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][ArrowRight][ArrowRight]2024'); + await user.keyboard('[ArrowRight][ArrowRight]2024'); expect(getDescription()).toContain('Invalid value'); expect(startInput.validity.valid).toBe(false); expect(endInput.validity.valid).toBe(false); diff --git a/packages/@react-spectrum/datepicker/test/TimeField.test.js b/packages/@react-spectrum/datepicker/test/TimeField.test.js index 20cb8cfa141..8188fd0eac5 100644 --- a/packages/@react-spectrum/datepicker/test/TimeField.test.js +++ b/packages/@react-spectrum/datepicker/test/TimeField.test.js @@ -301,8 +301,9 @@ describe('TimeField', function () { expect(group).toHaveAttribute('aria-describedby'); let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Constraints not satisfied'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); + await user.keyboard('[ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); expect(getDescription()).toContain('Constraints not satisfied'); expect(input.validity.valid).toBe(true); @@ -331,8 +332,9 @@ describe('TimeField', function () { expect(group).toHaveAttribute('aria-describedby'); expect(getDescription()).toContain('Value must be 9:00 AM or later.'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][ArrowUp]'); + await user.keyboard('[ArrowUp]'); expect(getDescription()).toContain('Value must be 9:00 AM or later.'); expect(input.validity.valid).toBe(true); @@ -348,8 +350,8 @@ describe('TimeField', function () { act(() => {getByTestId('form').checkValidity();}); expect(getDescription()).toContain('Value must be 5:00 PM or earlier.'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.tab(); await user.keyboard('[ArrowDown]'); expect(getDescription()).toContain('Value must be 5:00 PM or earlier.'); expect(input.validity.valid).toBe(true); @@ -377,8 +379,9 @@ describe('TimeField', function () { expect(group).toHaveAttribute('aria-describedby'); expect(getDescription()).toContain('Invalid value'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab]10'); + await user.keyboard('10'); expect(getDescription()).toContain('Invalid value'); expect(input.validity.valid).toBe(true); diff --git a/packages/@react-spectrum/form/docs/Form.mdx b/packages/@react-spectrum/form/docs/Form.mdx index 7d6fd3e5fae..78bf7336292 100644 --- a/packages/@react-spectrum/form/docs/Form.mdx +++ b/packages/@react-spectrum/form/docs/Form.mdx @@ -108,6 +108,54 @@ import {ButtonGroup, Button} from '@adobe/react-spectrum'; ``` +### Focus management + +By default, after a user submits a form with validation errors, the first invalid field will be focused. You can prevent this by calling `preventDefault` during the `onInvalid` event, and move focus yourself. + +This example shows how to move focus to an [InlineAlert](InlineAlert.html) using the `autoFocus` prop when displaying validation errors at the top of a form. + +```tsx example +import {InlineAlert, Heading, Content} from '@adobe/react-spectrum'; + +function Example() { + let [isInvalid, setInvalid] = React.useState(false); + + return ( +
{ + e.preventDefault(); + setInvalid(true); + }} + /*- end highlight -*/ + onSubmit={e => { + e.preventDefault(); + setInvalid(false); + }} + onReset={() => setInvalid(false)} + maxWidth="size-3600"> + {isInvalid && + /*- begin highlight -*/ + + {/*- end highlight -*/} + Unable to submit + + Please fix the validation errors below, and re-submit the form. + + + } + + + + + + + + ); +} +``` + ## Props diff --git a/packages/@react-spectrum/form/src/Form.tsx b/packages/@react-spectrum/form/src/Form.tsx index ffd9cbb47fd..86fc1095f1b 100644 --- a/packages/@react-spectrum/form/src/Form.tsx +++ b/packages/@react-spectrum/form/src/Form.tsx @@ -39,7 +39,9 @@ const formPropNames = new Set([ 'encType', 'method', 'target', - 'onSubmit' + 'onSubmit', + 'onReset', + 'onInvalid' ]); function Form(props: SpectrumFormProps, ref: DOMRef) { diff --git a/packages/@react-spectrum/inlinealert/package.json b/packages/@react-spectrum/inlinealert/package.json index 2c05af95369..8c8cf5b467f 100644 --- a/packages/@react-spectrum/inlinealert/package.json +++ b/packages/@react-spectrum/inlinealert/package.json @@ -36,6 +36,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-aria/focus": "^3.14.3", "@react-aria/i18n": "^3.8.4", "@react-aria/utils": "^3.21.1", "@react-spectrum/layout": "^3.5.7", diff --git a/packages/@react-spectrum/inlinealert/src/InlineAlert.tsx b/packages/@react-spectrum/inlinealert/src/InlineAlert.tsx index 53b6eaf8ce6..a81d1d64624 100644 --- a/packages/@react-spectrum/inlinealert/src/InlineAlert.tsx +++ b/packages/@react-spectrum/inlinealert/src/InlineAlert.tsx @@ -14,11 +14,12 @@ import AlertMedium from '@spectrum-icons/ui/AlertMedium'; import {classNames, SlotProvider, useDOMRef, useStyleProps} from '@react-spectrum/utils'; import {DOMProps, DOMRef, StyleProps} from '@react-types/shared'; import {filterDOMProps} from '@react-aria/utils'; +import {FocusRing} from '@react-aria/focus'; import {Grid} from '@react-spectrum/layout'; import InfoMedium from '@spectrum-icons/ui/InfoMedium'; // @ts-ignore import intlMessages from '../intl/*.json'; -import React, {ReactNode} from 'react'; +import React, {ReactNode, useEffect, useRef} from 'react'; import styles from '@adobe/spectrum-css-temp/components/inlinealert/vars.css'; import SuccessMedium from '@spectrum-icons/ui/SuccessMedium'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -33,7 +34,11 @@ export interface SpectrumInlineAlertProps extends DOMProps, StyleProps { /** * The contents of the Inline Alert. */ - children: ReactNode + children: ReactNode, + /** + * Whether to automatically focus the Inline Alert when it first renders. + */ + autoFocus?: boolean } let ICONS = { @@ -48,6 +53,7 @@ function InlineAlert(props: SpectrumInlineAlertProps, ref: DOMRef { + if (autoFocusRef.current && domRef.current) { + domRef.current.focus(); + } + autoFocusRef.current = false; + }, [domRef]); + return ( -
- - - {Icon && } - {children} - - -
+ +
+ + + {Icon && } + {children} + + +
+
); } diff --git a/packages/@react-spectrum/inlinealert/stories/InlineAlert.stories.tsx b/packages/@react-spectrum/inlinealert/stories/InlineAlert.stories.tsx index 5dd9665cb17..1c70c459f3e 100644 --- a/packages/@react-spectrum/inlinealert/stories/InlineAlert.stories.tsx +++ b/packages/@react-spectrum/inlinealert/stories/InlineAlert.stories.tsx @@ -62,7 +62,7 @@ function DynamicExample(args) { <> {shown && - + {args.title} {args.content} diff --git a/packages/@react-spectrum/inlinealert/test/InlineAlert.test.js b/packages/@react-spectrum/inlinealert/test/InlineAlert.test.js index 7e3ec25c146..b121ec3eb71 100644 --- a/packages/@react-spectrum/inlinealert/test/InlineAlert.test.js +++ b/packages/@react-spectrum/inlinealert/test/InlineAlert.test.js @@ -71,4 +71,17 @@ describe('InlineAlert', function () { let alert = getByTestId('testid1'); expect(alert).toHaveClass(`spectrum-InLineAlert--${variant}`); }); + + it('supports autoFocus', () => { + let {getByRole} = render( + +
Title
+ Content +
+ ); + + let alert = getByRole('alert'); + expect(alert).toHaveAttribute('tabIndex', '-1'); + expect(document.activeElement).toBe(alert); + }); }); diff --git a/packages/@react-spectrum/numberfield/test/NumberField.test.js b/packages/@react-spectrum/numberfield/test/NumberField.test.js index aca06c31166..05cdb9a5f8f 100644 --- a/packages/@react-spectrum/numberfield/test/NumberField.test.js +++ b/packages/@react-spectrum/numberfield/test/NumberField.test.js @@ -2283,8 +2283,8 @@ describe('NumberField', function () { expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); + expect(document.activeElement).toBe(input); - await user.tab(); await user.keyboard('4'); expect(input).toHaveAttribute('aria-describedby'); @@ -2312,6 +2312,7 @@ describe('NumberField', function () { act(() => {getByTestId('form').checkValidity();}); + expect(document.activeElement).toBe(input); expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); @@ -2338,8 +2339,9 @@ describe('NumberField', function () { expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Invalid value'); + expect(document.activeElement).toBe(input); - await user.tab(); + await user.clear(input); await user.keyboard('3'); expect(input).toHaveAttribute('aria-describedby'); @@ -2428,9 +2430,9 @@ describe('NumberField', function () { act(() => {getByTestId('form').checkValidity();}); + expect(document.activeElement).toBe(input); expect(input).toHaveAttribute('aria-describedby'); - await user.tab(); await user.keyboard('4'); expect(input).toHaveAttribute('aria-describedby'); diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index 8adda397871..8735a20a210 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -2253,6 +2253,7 @@ describe('Picker', function () { expect(picker).toHaveAttribute('aria-describedby'); expect(document.getElementById(picker.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); + expect(document.activeElement).toBe(picker); await user.click(picker); act(() => jest.runAllTimers()); @@ -2286,6 +2287,7 @@ describe('Picker', function () { expect(picker).toHaveAttribute('aria-describedby'); expect(document.getElementById(picker.getAttribute('aria-describedby'))).toHaveTextContent('Invalid value'); + expect(document.activeElement).toBe(picker); await user.click(picker); act(() => jest.runAllTimers()); diff --git a/packages/@react-spectrum/radio/test/Radio.test.js b/packages/@react-spectrum/radio/test/Radio.test.js index 76e4eac68e1..186842fe008 100644 --- a/packages/@react-spectrum/radio/test/Radio.test.js +++ b/packages/@react-spectrum/radio/test/Radio.test.js @@ -709,6 +709,7 @@ describe('Radios', function () { expect(group).toHaveAttribute('aria-describedby'); expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); + expect(document.activeElement).toBe(radios[0]); await user.click(radios[0]); for (let input of radios) { @@ -745,8 +746,8 @@ describe('Radios', function () { expect(group).toHaveAttribute('aria-describedby'); expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); + expect(document.activeElement).toBe(radios[0]); - await user.tab(); await user.keyboard('[ArrowDown]'); for (let input of radios) { expect(input.validity.valid).toBe(true); @@ -782,6 +783,7 @@ describe('Radios', function () { expect(group).toHaveAttribute('aria-describedby'); expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent(['Too scary']); + expect(document.activeElement).toBe(radios[0]); await user.click(radios[0]); expect(group).not.toHaveAttribute('aria-describedby'); diff --git a/packages/@react-spectrum/textfield/test/TextField.test.js b/packages/@react-spectrum/textfield/test/TextField.test.js index 7785dcdf265..e0c7a8e3ac0 100644 --- a/packages/@react-spectrum/textfield/test/TextField.test.js +++ b/packages/@react-spectrum/textfield/test/TextField.test.js @@ -499,10 +499,10 @@ describe('Shared TextField behavior', () => { act(() => {getByTestId('form').checkValidity();}); + expect(document.activeElement).toBe(input); expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); - await user.tab(); await user.keyboard('Devon'); expect(input).toHaveAttribute('aria-describedby'); @@ -533,10 +533,10 @@ describe('Shared TextField behavior', () => { act(() => {getByTestId('form').checkValidity();}); + expect(document.activeElement).toBe(input); expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Invalid name'); - await user.tab(); await user.keyboard('Devon'); expect(input).toHaveAttribute('aria-describedby'); @@ -588,11 +588,11 @@ describe('Shared TextField behavior', () => { await user.click(getByRole('button')); act(() => {getByTestId('form').checkValidity();}); + expect(document.activeElement).toBe(input); expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Invalid name.'); expect(input.validity.valid).toBe(false); - await user.tab({shift: true}); await user.keyboard('Devon'); await user.tab(); @@ -621,6 +621,25 @@ describe('Shared TextField behavior', () => { expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Please enter a name'); }); + + it.each` + Name | Component + ${'v3 TextField'} | ${TextField} + ${'v3 TextArea'} | ${TextArea} + ${'v3 SearchField'} | ${SearchField} + `('$Name does not auto focus invalid input if default is prevented', async ({Component}) => { + let {getByTestId} = render( + +
e.preventDefault()}> + + +
+ ); + + let input = getByTestId('input'); + act(() => {getByTestId('form').checkValidity();}); + expect(document.activeElement).not.toBe(input); + }); }); describe('validationBehavior=aria', () => { diff --git a/packages/@react-types/form/src/index.d.ts b/packages/@react-types/form/src/index.d.ts index b6f34c6e36f..2c079706340 100644 --- a/packages/@react-types/form/src/index.d.ts +++ b/packages/@react-types/form/src/index.d.ts @@ -47,6 +47,10 @@ export interface FormProps extends AriaLabelingProps { * Triggered when a user resets the form. */ onReset?: (event: FormEvent) => void, + /** + * Triggered for each invalid field when a user submits the form. + */ + onInvalid?: (event: FormEvent) => void, /** * Indicates whether input elements can by default have their values automatically completed by the browser. * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#autocomplete). diff --git a/packages/react-aria-components/docs/Form.mdx b/packages/react-aria-components/docs/Form.mdx index b69241378f0..235c8be6fbd 100644 --- a/packages/react-aria-components/docs/Form.mdx +++ b/packages/react-aria-components/docs/Form.mdx @@ -152,6 +152,83 @@ To provide validation errors, the `validationErrors` prop should be set to an ob See the [Forms](forms.html) guide to learn more about form validation in React Aria, including client-side validation, and integration with other frameworks and libraries. +### Focus management + +By default, after a user submits a form with validation errors, the first invalid field will be focused. You can prevent this by calling `preventDefault` during the `onInvalid` event, and move focus yourself. This example shows how to move focus to an [alert](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/alert_role) element at the top of a form. + +```tsx example +function Example() { + let [isInvalid, setInvalid] = React.useState(false); + + return ( +
{ + e.preventDefault(); + setInvalid(true); + }} + /*- end highlight -*/ + onSubmit={e => { + e.preventDefault(); + setInvalid(false); + }} + onReset={() => setInvalid(false)}> + {isInvalid && + /*- begin highlight -*/ +
e?.focus()}> + {/*- end highlight -*/} +

Unable to submit

+

Please fix the validation errors below, and re-submit the form.

+
+ } + + + + + + + + + + +
+ + +
+
+ ); +} +``` + +
+ Show CSS + +```css +.react-aria-Form [role=alert] { + border: 2px solid var(--spectrum-red-800); + background: var(--spectrum-gray-50); + border-radius: 6px; + padding: 12px; + max-width: 250px; + outline: none; + + &:focus-visible { + outline: 2px solid slateblue; + outline-offset: 2px; + } + + h3 { + margin-top: 0; + } + + p { + margin-bottom: 0; + } +} +``` + +
+ ## Props diff --git a/packages/react-aria-components/docs/Select.mdx b/packages/react-aria-components/docs/Select.mdx index d8f0fcdada3..c7cc76693dc 100644 --- a/packages/react-aria-components/docs/Select.mdx +++ b/packages/react-aria-components/docs/Select.mdx @@ -155,6 +155,11 @@ import {Select, SelectValue, Label, Button, Popover, ListBox, ListBoxItem} from background: var(--highlight-background); color: var(--highlight-foreground); } + + &[data-focus-visible] { + border-color: var(--focus-ring-color); + box-shadow: 0 0 0 1px var(--focus-ring-color); + } } } } diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index aed0c9dfd20..3ffe12ed589 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -240,11 +240,11 @@ describe('ComboBox', () => { act(() => {getByTestId('form').checkValidity();}); + expect(document.activeElement).toBe(input); expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); expect(combobox).toHaveAttribute('data-invalid'); - await user.tab(); await user.keyboard('C'); let listbox = getByRole('listbox'); diff --git a/packages/react-aria-components/test/DateField.test.js b/packages/react-aria-components/test/DateField.test.js index b30fa992d7a..c5882e2902a 100644 --- a/packages/react-aria-components/test/DateField.test.js +++ b/packages/react-aria-components/test/DateField.test.js @@ -240,8 +240,9 @@ describe('DateField', () => { let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Constraints not satisfied'); expect(group).toHaveAttribute('data-invalid'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); + await user.keyboard('[ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); expect(getDescription()).toContain('Constraints not satisfied'); expect(input.validity.valid).toBe(true); diff --git a/packages/react-aria-components/test/DatePicker.test.js b/packages/react-aria-components/test/DatePicker.test.js index c2a6eb3fd68..e6275ca87c4 100644 --- a/packages/react-aria-components/test/DatePicker.test.js +++ b/packages/react-aria-components/test/DatePicker.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, pointerMap, render} from '@react-spectrum/test-utils'; +import {act, pointerMap, render, within} from '@react-spectrum/test-utils'; import {Button, Calendar, CalendarCell, CalendarGrid, DateInput, DatePicker, DatePickerContext, DateSegment, Dialog, FieldError, Group, Heading, Label, Popover, Text} from 'react-aria-components'; import {CalendarDate} from '@internationalized/date'; import React from 'react'; @@ -210,8 +210,9 @@ describe('DatePicker', () => { let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Constraints not satisfied'); expect(datepicker).toHaveAttribute('data-invalid'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); + await user.keyboard('[ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); expect(getDescription()).toContain('Constraints not satisfied'); expect(input.validity.valid).toBe(true); diff --git a/packages/react-aria-components/test/DateRangePicker.test.js b/packages/react-aria-components/test/DateRangePicker.test.js index e5fd2d291da..03a20cf7b57 100644 --- a/packages/react-aria-components/test/DateRangePicker.test.js +++ b/packages/react-aria-components/test/DateRangePicker.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, pointerMap, render} from '@react-spectrum/test-utils'; +import {act, pointerMap, render, within} from '@react-spectrum/test-utils'; import {Button, CalendarCell, CalendarGrid, DateInput, DateRangePicker, DateRangePickerContext, DateSegment, Dialog, FieldError, Group, Heading, Label, Popover, RangeCalendar, Text} from 'react-aria-components'; import {CalendarDate} from '@internationalized/date'; import React from 'react'; @@ -229,8 +229,9 @@ describe('DateRangePicker', () => { let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Constraints not satisfied'); expect(datepicker).toHaveAttribute('data-invalid'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); + await user.keyboard('[ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); await user.keyboard('[Tab][ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); expect(getDescription()).toContain('Constraints not satisfied'); diff --git a/packages/react-aria-components/test/Form.test.js b/packages/react-aria-components/test/Form.test.js index 745b1e7f088..8f1756109eb 100644 --- a/packages/react-aria-components/test/Form.test.js +++ b/packages/react-aria-components/test/Form.test.js @@ -57,6 +57,7 @@ describe('TextField', () => { expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Invalid name.'); expect(input.validity.valid).toBe(false); + expect(document.activeElement).toBe(input); // Clicking twice doesn't clear server errors. await user.click(getByRole('button')); @@ -65,8 +66,9 @@ describe('TextField', () => { expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Invalid name.'); expect(input.validity.valid).toBe(false); + expect(document.activeElement).toBe(input); - await user.tab({shift: true}); + await user.clear(input); await user.keyboard('Devon'); await user.tab(); diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 6373757a3d2..dfba715d995 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -171,8 +171,8 @@ describe('NumberField', () => { expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); expect(numberfield).toHaveAttribute('data-invalid'); + expect(document.activeElement).toBe(input); - await user.tab(); await user.keyboard('3'); expect(input).toHaveAttribute('aria-describedby'); diff --git a/packages/react-aria-components/test/RadioGroup.test.js b/packages/react-aria-components/test/RadioGroup.test.js index f15c278bbda..3092d54f9a5 100644 --- a/packages/react-aria-components/test/RadioGroup.test.js +++ b/packages/react-aria-components/test/RadioGroup.test.js @@ -435,6 +435,7 @@ describe('RadioGroup', () => { expect(group).toHaveAttribute('aria-describedby'); expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); expect(group).toHaveAttribute('data-invalid'); + expect(document.activeElement).toBe(radios[0]); await user.click(radios[0]); for (let input of radios) { diff --git a/packages/react-aria-components/test/SearchField.test.js b/packages/react-aria-components/test/SearchField.test.js index abd2653f623..8e76c2bbaa2 100644 --- a/packages/react-aria-components/test/SearchField.test.js +++ b/packages/react-aria-components/test/SearchField.test.js @@ -116,8 +116,8 @@ describe('SearchField', () => { expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); + expect(document.activeElement).toBe(input); - await user.tab(); await user.keyboard('Devon'); expect(input).toHaveAttribute('aria-describedby'); diff --git a/packages/react-aria-components/test/Select.test.js b/packages/react-aria-components/test/Select.test.js index 5df32536de6..8444c8cecf7 100644 --- a/packages/react-aria-components/test/Select.test.js +++ b/packages/react-aria-components/test/Select.test.js @@ -232,6 +232,7 @@ describe('Select', () => { expect(button).toHaveAttribute('aria-describedby'); expect(document.getElementById(button.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); expect(select).toHaveAttribute('data-invalid'); + expect(document.activeElement).toBe(button); await user.click(button); diff --git a/packages/react-aria-components/test/TextField.test.js b/packages/react-aria-components/test/TextField.test.js index 5e8f89235e3..bac656a763c 100644 --- a/packages/react-aria-components/test/TextField.test.js +++ b/packages/react-aria-components/test/TextField.test.js @@ -133,8 +133,8 @@ describe('TextField', () => { expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Constraints not satisfied'); expect(input.closest('.react-aria-TextField')).toHaveAttribute('data-invalid'); + expect(document.activeElement).toBe(input); - await user.tab(); await user.keyboard('Devon'); expect(input).toHaveAttribute('aria-describedby'); @@ -167,8 +167,8 @@ describe('TextField', () => { expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent('Please enter a name'); + expect(document.activeElement).toBe(input); - await user.tab(); await user.keyboard('Devon'); expect(input).toHaveAttribute('aria-describedby'); diff --git a/packages/react-aria-components/test/TimeField.test.js b/packages/react-aria-components/test/TimeField.test.js index 44a80051f2a..cb64eac85e3 100644 --- a/packages/react-aria-components/test/TimeField.test.js +++ b/packages/react-aria-components/test/TimeField.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, installPointerEvent, pointerMap, render} from '@react-spectrum/test-utils'; +import {act, installPointerEvent, pointerMap, render, within} from '@react-spectrum/test-utils'; import {DateInput, DateSegment, FieldError, Label, Text, TimeField, TimeFieldContext} from '../'; import React from 'react'; import {Time} from '@internationalized/date'; @@ -149,8 +149,9 @@ describe('TimeField', () => { expect(group).toHaveAttribute('aria-describedby'); let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Constraints not satisfied'); + expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); + await user.keyboard('[ArrowUp][Tab][ArrowUp][Tab][ArrowUp]'); expect(getDescription()).toContain('Constraints not satisfied'); expect(input.validity.valid).toBe(true);