Skip to content

fix: Clear Autocomplete virtualFocus upon paste/undo/redo and other focus fixes #8438

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 8, 2025
Merged
19 changes: 12 additions & 7 deletions packages/@react-aria/autocomplete/src/useAutocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down