diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 9ff0d4fd2b0..40eee24ae05 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,20 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut } }; + // 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) { + 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 +445,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..d935a8e61da 100644 --- a/packages/react-aria-components/test/Autocomplete.test.tsx +++ b/packages/react-aria-components/test/Autocomplete.test.tsx @@ -496,6 +496,100 @@ 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]); + + expect(options[1]).toHaveAttribute('data-focused'); + expect(input).not.toHaveAttribute('data-focused'); + expect(input).toHaveAttribute('aria-activedescendant', options[1].id); + + await user.click(input); + + 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'); + 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( <> 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; }