From 1801b6954006cf7c7a2c9b0be02a8a96468edcc7 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 24 Jun 2025 15:44:17 -0700 Subject: [PATCH 1/8] update autocomplete to clear virtual focus on paste/cut/undo/redo/etc --- .../autocomplete/src/useAutocomplete.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 6f700db89f3..2e03778a146 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -13,7 +13,7 @@ import {AriaLabelingProps, BaseEvent, DOMProps, RefObject} from '@react-types/shared'; import {AriaTextFieldProps} from '@react-aria/textfield'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isCtrlKeyPressed, mergeProps, mergeRefs, useEffectEvent, useId, useLabels, useObjectRef} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isCtrlKeyPressed, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useObjectRef} from '@react-aria/utils'; import {dispatchVirtualBlur, dispatchVirtualFocus, moveVirtualFocus} from '@react-aria/focus'; import {getInteractionModality} from '@react-aria/interactions'; // @ts-ignore @@ -163,14 +163,19 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl collectionRef.current?.dispatchEvent(clearFocusEvent); }); - // TODO: update to see if we can tell what kind of event (paste vs backspace vs typing) is happening instead + + let lastInputType = useRef(''); + useEvent(inputRef, 'input', e => { + let {inputType} = e as InputEvent; + lastInputType.current = inputType; + }); + let onChange = (value: string) => { - // Tell wrapped collection to focus the first element in the list when typing forward and to clear focused key when deleting text - // for screen reader announcements - if (state.inputValue !== value && state.inputValue.length <= value.length && !disableAutoFocusFirst) { + // Tell wrapped collection to focus the first element in the list when typing forward and to clear focused key when modifying the text via + // copy paste/backspacing/undo/redo for screen reader announcements + if (lastInputType.current === 'insertText' && !disableAutoFocusFirst) { focusFirstItem(); - } else { - // Fully clear focused key when backspacing since the list may change and thus we'd want to start fresh again + } else if (lastInputType.current.includes('insert') || lastInputType.current.includes('delete') || lastInputType.current.includes('history')) { clearVirtualFocus(true); } From 5df2049219f677e612a3758db6d780c50a795e95 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 24 Jun 2025 16:04:56 -0700 Subject: [PATCH 2/8] add test for pasting --- .../test/AriaAutocomplete.test-util.tsx | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx index 9fb6ca08ce5..79dc28c6ab4 100644 --- a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx +++ b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx @@ -178,6 +178,32 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' expect(options[0]).toHaveTextContent('Foo'); }); + it('should completely clear the focused key when pasting', async function () { + let {getByRole} = renderers.standard(); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('B'); + act(() => jest.runAllTimers()); + let options = within(menu).getAllByRole(collectionItemRole); + let firstActiveDescendant = options[0].id; + expect(input).toHaveAttribute('aria-activedescendant', firstActiveDescendant); + + await user.paste('az'); + act(() => jest.runAllTimers()); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + options = within(menu).getAllByRole(collectionItemRole); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(firstActiveDescendant).not.toEqual(options[0].id); + expect(options[0]).toHaveTextContent('Baz'); + }); + it('should delay the aria-activedescendant being set when autofocusing the first option', async function () { let {getByRole} = renderers.standard(); let input = getByRole('searchbox'); @@ -706,7 +732,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' describe('pointer events', function () { installPointerEvent(); - + it('should close the menu when hovering an adjacent menu item in the virtual focus list', async function () { let {getByRole, getAllByRole} = (renderers.submenus!)(); let menu = getByRole('menu'); From 68b16cc6b1ae9df42b41a53999430b214e85ac47 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 24 Jun 2025 17:08:41 -0700 Subject: [PATCH 3/8] fix inconsistent focus ring when backspacing too quickly after typing forward --- packages/@react-aria/autocomplete/src/useAutocomplete.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 2e03778a146..49380686248 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -14,7 +14,7 @@ import {AriaLabelingProps, BaseEvent, DOMProps, RefObject} from '@react-types/sh import {AriaTextFieldProps} from '@react-aria/textfield'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isCtrlKeyPressed, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useObjectRef} from '@react-aria/utils'; -import {dispatchVirtualBlur, dispatchVirtualFocus, moveVirtualFocus} from '@react-aria/focus'; +import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, moveVirtualFocus} from '@react-aria/focus'; import {getInteractionModality} from '@react-aria/interactions'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -177,6 +177,12 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl focusFirstItem(); } else if (lastInputType.current.includes('insert') || lastInputType.current.includes('delete') || lastInputType.current.includes('history')) { clearVirtualFocus(true); + + // If onChange was triggered before the timeout actually updated the activedescendant, we need to fire + // our own dispatchVirtualFocus so focusVisible gets reapplied on the input + if (getVirtuallyFocusedElement(document) === inputRef.current) { + dispatchVirtualFocus(inputRef.current!, null); + } } state.setInputValue(value); From 9725f8d4e3d46803586185c3419b1df7c663f72e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 24 Jun 2025 17:36:52 -0700 Subject: [PATCH 4/8] fix case where focus ring isnt restored when filtered list becomes empty --- .../@react-aria/selection/src/useSelectableCollection.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 317d6e48873..08cffa50866 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -10,12 +10,12 @@ * governing permissions and limitations under the License. */ -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, isCtrlKeyPressed, mergeProps, scrollIntoView, scrollIntoViewport, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, mergeProps, scrollIntoView, scrollIntoViewport, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; +import {dispatchVirtualFocus, getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus'; import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; import {FocusEvent, KeyboardEvent, useEffect, useRef} from 'react'; import {focusSafely, getInteractionModality} from '@react-aria/interactions'; -import {getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus'; import {getItemElement, isNonContiguousSelectionModifier, useCollectionId} from './utils'; import {MultipleSelectionManager} from '@react-stately/selection'; import {useLocale} from '@react-aria/i18n'; @@ -407,7 +407,6 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let {detail} = e; e.stopPropagation(); manager.setFocused(true); - // If the user is typing forwards, autofocus the first option in the list. if (detail?.focusStrategy === 'first') { shouldVirtualFocusFirst.current = true; @@ -419,7 +418,11 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist if (keyToFocus == null) { + let previousActiveElement = getActiveElement(); + // TODO: Bit gross, but we need the first moveVirtualFocus to clear the previous aria-activeDescendant, then + // we need to refocus the input element so the focus ring comes back... moveVirtualFocus(ref.current); + dispatchVirtualFocus(previousActiveElement!, null); // If there wasn't a focusable key but the collection had items, then that means we aren't in an intermediate load state and all keys are disabled. // Reset shouldVirtualFocusFirst so that we don't erronously autofocus an item when the collection is filtered again. From b61d5b4063c1ca34893b8d7ddf574af64a334da1 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 26 Jun 2025 11:12:11 -0700 Subject: [PATCH 5/8] add tests for focus fixes --- .../autocomplete/src/useAutocomplete.ts | 1 - .../selection/src/useSelectableCollection.ts | 5 +- .../test/Autocomplete.test.tsx | 91 +++++++++++++++++++ 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 49380686248..03d24b8c3f7 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -163,7 +163,6 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl collectionRef.current?.dispatchEvent(clearFocusEvent); }); - let lastInputType = useRef(''); useEvent(inputRef, 'input', e => { let {inputType} = e as InputEvent; diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 08cffa50866..825888ffea6 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -416,11 +416,10 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let updateActiveDescendant = useEffectEvent(() => { let keyToFocus = delegate.getFirstKey?.() ?? null; - // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist + // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist and move focus back to + // the original active element (e.g. the autocomplete input) if (keyToFocus == null) { let previousActiveElement = getActiveElement(); - // TODO: Bit gross, but we need the first moveVirtualFocus to clear the previous aria-activeDescendant, then - // we need to refocus the input element so the focus ring comes back... moveVirtualFocus(ref.current); dispatchVirtualFocus(previousActiveElement!, null); diff --git a/packages/react-aria-components/test/Autocomplete.test.tsx b/packages/react-aria-components/test/Autocomplete.test.tsx index eaa2c274347..550507b0edb 100644 --- a/packages/react-aria-components/test/Autocomplete.test.tsx +++ b/packages/react-aria-components/test/Autocomplete.test.tsx @@ -427,6 +427,97 @@ describe('Autocomplete', () => { expect(input).not.toHaveAttribute('data-focused'); }); + it('should restore focus visible styles back to the input when typing forward results in only disabled items', async function () { + let {getByRole} = render( + + + + ); + + let input = getByRole('searchbox'); + await user.tab(); + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute('data-focused'); + expect(input).toHaveAttribute('data-focus-visible'); + + await user.keyboard('Ba'); + act(() => jest.runAllTimers()); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + let baz = options[1]; + expect(baz).toHaveTextContent('Baz'); + expect(input).toHaveAttribute('aria-activedescendant', baz.id); + expect(baz).toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focused'); + expect(input).not.toHaveAttribute('data-focus-visible'); + + await user.keyboard('r'); + act(() => jest.runAllTimers()); + options = within(menu).getAllByRole('menuitem'); + let bar = options[0]; + expect(bar).toHaveTextContent('Bar'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(bar).not.toHaveAttribute('data-focus-visible'); + expect(input).toHaveAttribute('data-focused'); + expect(input).toHaveAttribute('data-focus-visible'); + }); + + it('should maintain focus styles on the input if typing forward results in an completely empty collection', async function () { + let {getByRole} = render( + + + + ); + + let input = getByRole('searchbox'); + await user.tab(); + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute('data-focused'); + expect(input).toHaveAttribute('data-focus-visible'); + + await user.keyboard('Q'); + act(() => jest.runAllTimers()); + let menu = getByRole('menu'); + let options = within(menu).queryAllByRole('menuitem'); + expect(options).toHaveLength(0); + expect(input).toHaveAttribute('data-focused'); + expect(input).toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + }); + + it('should restore focus visible styles back to the input if the user types forward and backspaces in quick succession', async function () { + let {getByRole} = render( + + + + ); + + let input = getByRole('searchbox'); + await user.tab(); + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute('data-focused'); + expect(input).toHaveAttribute('data-focus-visible'); + + await user.keyboard('F'); + // If 500ms hasn't elapsed the aria-activedecendant hasn't been updated + act(() => jest.advanceTimersByTime(300)); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + let foo = options[0]; + expect(foo).toHaveTextContent('Foo'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(foo).toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focused'); + expect(input).not.toHaveAttribute('data-focus-visible'); + + await user.keyboard('{Backspace}'); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('data-focused'); + expect(input).toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(foo).not.toHaveAttribute('data-focus-visible'); + }); + it('should work inside a Select', async function () { let {getByRole} = render(