diff --git a/packages/@adobe/react-spectrum/test/checkbox/CheckboxGroup.test.js b/packages/@adobe/react-spectrum/test/checkbox/CheckboxGroup.test.js index 318b0cdf710..0201e86fbf2 100644 --- a/packages/@adobe/react-spectrum/test/checkbox/CheckboxGroup.test.js +++ b/packages/@adobe/react-spectrum/test/checkbox/CheckboxGroup.test.js @@ -743,8 +743,11 @@ describe('CheckboxGroup', () => { let group = getByRole('group'); expect(group).not.toHaveAttribute('aria-describedby'); - act(() => { + await act(async () => { getByTestId('form').checkValidity(); + // Flush the microtask queued by the global invalid event handler, + // which moves focus to the first invalid field and updates modality. + await Promise.resolve(); }); expect(group).toHaveAttribute('aria-describedby'); expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent( diff --git a/packages/@adobe/react-spectrum/test/radio/Radio.test.js b/packages/@adobe/react-spectrum/test/radio/Radio.test.js index 6a668365f13..e2d6ce649a7 100644 --- a/packages/@adobe/react-spectrum/test/radio/Radio.test.js +++ b/packages/@adobe/react-spectrum/test/radio/Radio.test.js @@ -1051,8 +1051,11 @@ describe('Radios', function () { let group = getByRole('radiogroup'); expect(group).not.toHaveAttribute('aria-describedby'); - act(() => { + await act(async () => { getByTestId('form').checkValidity(); + // Flush the microtask queued by the global invalid event handler, + // which moves focus to the first invalid field and updates modality. + await Promise.resolve(); }); expect(group).toHaveAttribute('aria-describedby'); expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent( diff --git a/packages/@adobe/react-spectrum/test/textfield/TextField.test.js b/packages/@adobe/react-spectrum/test/textfield/TextField.test.js index a63420f4122..ae7c9b23673 100644 --- a/packages/@adobe/react-spectrum/test/textfield/TextField.test.js +++ b/packages/@adobe/react-spectrum/test/textfield/TextField.test.js @@ -768,8 +768,11 @@ describe('Shared TextField behavior', () => { let input = getByTestId('input'); expect(input).not.toHaveAttribute('aria-describedby'); - act(() => { + await act(async () => { getByTestId('form').checkValidity(); + // Flush the microtask queued by the global invalid event handler, + // which moves focus to the first invalid field and updates modality. + await Promise.resolve(); }); expect(input).toHaveAttribute('aria-describedby'); expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent( diff --git a/packages/react-aria-components/test/Form.test.js b/packages/react-aria-components/test/Form.test.js index 7be04208d54..891fd74cef1 100644 --- a/packages/react-aria-components/test/Form.test.js +++ b/packages/react-aria-components/test/Form.test.js @@ -199,6 +199,38 @@ describe('Form', () => { expect(form).toHaveAttribute('data-custom', 'true'); }); + (parseInt(React.version, 10) >= 19 ? it : it.skip)('shows focus-visible when a form library moves focus to the first invalid field on submit', async () => { + function Test() { + return ( +
+ + +
+ ); + } + + let {getByRole} = render(); + let input = getByRole('textbox'); + let button = getByRole('button'); + + await user.click(button); + expect(input).not.toHaveAttribute('data-focus-visible'); + + // On submit the form is invalid, so the browser fires an invalid event on + // the required field. react-hook-form (shouldFocusError) then moves focus + // to the first invalid field with a plain ref.focus(). + act(() => { + input.checkValidity(); + input.focus(); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute('data-focus-visible'); + }); + it('should not throw when form contains elements without validity property', async () => { function Test() { return ( diff --git a/packages/react-aria/src/interactions/useFocusVisible.ts b/packages/react-aria/src/interactions/useFocusVisible.ts index 9e992f5334f..85734a4590c 100644 --- a/packages/react-aria/src/interactions/useFocusVisible.ts +++ b/packages/react-aria/src/interactions/useFocusVisible.ts @@ -147,6 +147,18 @@ function handleWindowBlur() { hasBlurredWindowRecently = true; } +function handleInvalidEvent(e: Event) { + let startingActiveElement = getActiveElement(getOwnerDocument(getEventTarget(e))); + queueMicrotask(() => { + // If focus was moved to a different element after the form became invalid, + // then it was likely a forms library that moved focus to the first invalid field. + // In this case, we want to set the modality to keyboard. + if (getActiveElement(getOwnerDocument(getEventTarget(e))) !== startingActiveElement) { + setInteractionModality('keyboard'); + } + }); +} + /** * Setup global event listeners to control when keyboard focus style should be visible. */ @@ -184,6 +196,8 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { documentObject.addEventListener('keyup', handleKeyboardEvent, true); documentObject.addEventListener('click', handleClickEvent, true); + documentObject.addEventListener('invalid', handleInvalidEvent, true); + // Register focus events on the window so they are sure to happen // before React's event listeners (registered on the document). windowObject.addEventListener('focus', handleFocusEvent, true); @@ -230,6 +244,8 @@ const tearDownWindowFocusTracking = (element, loadListener?: () => void) => { documentObject.removeEventListener('keyup', handleKeyboardEvent, true); documentObject.removeEventListener('click', handleClickEvent, true); + documentObject.removeEventListener('invalid', handleInvalidEvent, true); + windowObject.removeEventListener('focus', handleFocusEvent, true); windowObject.removeEventListener('blur', handleWindowBlur, false);