From 263db7485231c5ed7d3c423659f5a9becef7f733 Mon Sep 17 00:00:00 2001 From: gohwan Date: Tue, 10 Mar 2026 01:54:39 +0900 Subject: [PATCH 1/2] fix autocomplete focus recovery after virtual focus --- .../autocomplete/src/useAutocomplete.ts | 18 +++++- .../stories/Autocomplete.stories.tsx | 23 +++++++ .../test/Autocomplete.test.tsx | 62 +++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 9ff0d4fd2b0..cae29efb9db 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -18,7 +18,7 @@ import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, m import {getInteractionModality, getPointerType} from '@react-aria/interactions'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; export interface CollectionOptions extends DOMProps, AriaLabelingProps { @@ -420,6 +420,19 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut } }; + // Clicking an already-focused input won't emit a new focus event, so clear virtual focus + // on pointer down to restore the input's focused styling before the click completes. + // Touch is excluded because touch interactions should not move focus back to the input. + let onPointerDown = (e: ReactPointerEvent) => { + if (e.button !== 0 || e.pointerType === 'touch' || queuedActiveDescendant.current == null || inputRef.current == null) { + return; + } + + if (getEventTarget(e) === inputRef.current && getActiveElement(getOwnerDocument(inputRef.current)) === inputRef.current) { + clearVirtualFocus(); + } + }; + // Only apply the autocomplete specific behaviors if the collection component wrapped by it is actually // being filtered/allows filtering by the Autocomplete. let inputProps = { @@ -431,7 +444,8 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut onKeyDown, 'aria-activedescendant': state.focusedNodeId ?? undefined, onBlur, - onFocus + onFocus, + onPointerDown }; if (hasCollection) { diff --git a/packages/react-aria-components/stories/Autocomplete.stories.tsx b/packages/react-aria-components/stories/Autocomplete.stories.tsx index 5bc6de445fe..b2368147ff9 100644 --- a/packages/react-aria-components/stories/Autocomplete.stories.tsx +++ b/packages/react-aria-components/stories/Autocomplete.stories.tsx @@ -166,6 +166,29 @@ export const AutocompleteSearchfield: AutocompleteStory = { } }; +export const AutocompleteFocusRecovery: AutocompleteStory = { + render: (args) => { + return ( + +
+ + + + Focus the input, move virtual focus to an option, then click the input again. + + +
+
+ ); + }, + name: 'Autocomplete focus recovery after virtual focus', + parameters: { + description: { + data: 'Manual check: focus the input, hover or keyboard navigate to an option, then click the input again. The input should regain focused styling and the active descendant should clear.' + } + } +}; + // Note that the trigger items in this array MUST have an id, even if the underlying MenuItem might apply its own // id. If it is omitted, we can't build the collection node for the trigger node and an error will throw let dynamicAutocompleteSubdialog: MenuNode[] = [ diff --git a/packages/react-aria-components/test/Autocomplete.test.tsx b/packages/react-aria-components/test/Autocomplete.test.tsx index e30cee9a5f1..b25b70196ad 100644 --- a/packages/react-aria-components/test/Autocomplete.test.tsx +++ b/packages/react-aria-components/test/Autocomplete.test.tsx @@ -496,6 +496,68 @@ describe('Autocomplete', () => { expect(input).toHaveAttribute('data-focus-visible'); }); + it('should restore focused styles to the input when clicking it after hovering an option', async () => { + let {getByRole} = render( + + + + ); + + let input = getByRole('searchbox'); + await user.click(input); + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute('data-focused'); + + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + await user.hover(options[1]); + options = within(menu).getAllByRole('menuitem'); + + expect(options[1]).toHaveAttribute('data-focused'); + expect(input).not.toHaveAttribute('data-focused'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + + await user.click(input); + act(() => jest.runAllTimers()); + + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute('data-focused'); + expect(input).not.toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(options[1]).not.toHaveAttribute('data-focused'); + }); + + it('should restore focused styles to the input when clicking it after keyboard focusing an option', async () => { + let {getByRole} = render( + + + + ); + + let input = getByRole('searchbox'); + await user.click(input); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + act(() => jest.runAllTimers()); + + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + expect(options[0]).toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focused'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + + await user.click(input); + act(() => jest.runAllTimers()); + + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute('data-focused'); + expect(input).not.toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(options[0]).not.toHaveAttribute('data-focused'); + expect(options[0]).not.toHaveAttribute('data-focus-visible'); + }); + it('should not display focus in the virtually focused menu if focus isn\'t in the autocomplete input', async function () { let {getByRole} = render( <> From 6663a606cabc172f27b2b6e747168e23e324b831 Mon Sep 17 00:00:00 2001 From: gohwan Date: Thu, 12 Mar 2026 15:42:26 +0900 Subject: [PATCH 2/2] fix autocomplete focus recovery after outside click --- .../autocomplete/src/useAutocomplete.ts | 7 ++-- .../test/Autocomplete.test.tsx | 36 +++++++++++++++++-- starters/docs/src/TextField.css | 2 +- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index cae29efb9db..40eee24ae05 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -420,15 +420,16 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut } }; - // Clicking an already-focused input won't emit a new focus event, so clear virtual focus - // on pointer down to restore the input's focused styling before the click completes. + // Clicking back into the input can happen after focus moved elsewhere in the dialog, while + // virtual focus is still on an option. Clear virtual focus on pointer down so mouse + // interactions restore the input state before the click's focus handling runs. // Touch is excluded because touch interactions should not move focus back to the input. let onPointerDown = (e: ReactPointerEvent) => { if (e.button !== 0 || e.pointerType === 'touch' || queuedActiveDescendant.current == null || inputRef.current == null) { return; } - if (getEventTarget(e) === inputRef.current && getActiveElement(getOwnerDocument(inputRef.current)) === inputRef.current) { + if (getEventTarget(e) === inputRef.current) { clearVirtualFocus(); } }; diff --git a/packages/react-aria-components/test/Autocomplete.test.tsx b/packages/react-aria-components/test/Autocomplete.test.tsx index b25b70196ad..d935a8e61da 100644 --- a/packages/react-aria-components/test/Autocomplete.test.tsx +++ b/packages/react-aria-components/test/Autocomplete.test.tsx @@ -511,14 +511,46 @@ describe('Autocomplete', () => { let menu = getByRole('menu'); let options = within(menu).getAllByRole('menuitem'); await user.hover(options[1]); - options = within(menu).getAllByRole('menuitem'); expect(options[1]).toHaveAttribute('data-focused'); expect(input).not.toHaveAttribute('data-focused'); expect(input).toHaveAttribute('aria-activedescendant', options[1].id); await user.click(input); - act(() => jest.runAllTimers()); + + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute('data-focused'); + expect(input).not.toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(options[1]).not.toHaveAttribute('data-focused'); + }); + + it('should restore the input state after clicking outside the autocomplete and then back into the input', async () => { + let {getByRole} = render( + <> + + + + + + ); + + let input = getByRole('searchbox'); + await user.click(input); + + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + await user.hover(options[1]); + + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + expect(options[1]).toHaveAttribute('data-focused'); + + await user.click(getByRole('button', {name: 'Outside'})); + expect(document.activeElement).toHaveTextContent('Outside'); + expect(input).not.toHaveAttribute('data-focused'); + expect(input).not.toHaveAttribute('data-focus-visible'); + + await user.click(input); expect(document.activeElement).toBe(input); expect(input).toHaveAttribute('data-focused'); diff --git a/starters/docs/src/TextField.css b/starters/docs/src/TextField.css index cd613396730..2fbcd5ca075 100644 --- a/starters/docs/src/TextField.css +++ b/starters/docs/src/TextField.css @@ -24,7 +24,7 @@ color: var(--text-color-placeholder); } - &[data-focused] { + &[data-focus-visible] { outline: 2px solid var(--focus-ring-color); outline-offset: -1px; }