From 269e93e730489c84fe4674709bd48b14c202b2c8 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 21 Aug 2026 08:33:19 +1000 Subject: [PATCH 1/4] feat: detect invalid form focus movement --- .../react-aria-components/test/Form.test.js | 32 +++++++++++++++++++ .../src/interactions/useFocusVisible.ts | 16 ++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/react-aria-components/test/Form.test.js b/packages/react-aria-components/test/Form.test.js index 7be04208d54..00ac382fbc0 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'); }); + it('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..74722f89b76 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 differen 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); From 192a9417503b9710551ce8ecec08e46b82ea53e1 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 21 Aug 2026 08:51:34 +1000 Subject: [PATCH 2/4] update failing tests --- .../react-spectrum/test/checkbox/CheckboxGroup.test.js | 5 ++++- packages/@adobe/react-spectrum/test/radio/Radio.test.js | 5 ++++- .../@adobe/react-spectrum/test/textfield/TextField.test.js | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) 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( From 87c5d04ae091c313f7c9b25c25f65bf02a69a391 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 21 Aug 2026 08:53:01 +1000 Subject: [PATCH 3/4] fix typo --- packages/react-aria/src/interactions/useFocusVisible.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria/src/interactions/useFocusVisible.ts b/packages/react-aria/src/interactions/useFocusVisible.ts index 74722f89b76..85734a4590c 100644 --- a/packages/react-aria/src/interactions/useFocusVisible.ts +++ b/packages/react-aria/src/interactions/useFocusVisible.ts @@ -150,7 +150,7 @@ function handleWindowBlur() { function handleInvalidEvent(e: Event) { let startingActiveElement = getActiveElement(getOwnerDocument(getEventTarget(e))); queueMicrotask(() => { - // If focus was moved to a differen element after the form became invalid, + // 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) { From c746883d132d81d5981034f7dd113016f2a8a022 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 21 Aug 2026 16:41:32 +1000 Subject: [PATCH 4/4] skip test on lower react versions, the act timing is too hard to compensate for --- packages/react-aria-components/test/Form.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria-components/test/Form.test.js b/packages/react-aria-components/test/Form.test.js index 00ac382fbc0..891fd74cef1 100644 --- a/packages/react-aria-components/test/Form.test.js +++ b/packages/react-aria-components/test/Form.test.js @@ -199,7 +199,7 @@ describe('Form', () => { expect(form).toHaveAttribute('data-custom', 'true'); }); - it('shows focus-visible when a form library moves focus to the first invalid field on submit', async () => { + (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 (