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

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions packages/@react-aria/autocomplete/src/useAutocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
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 {dispatchVirtualBlur, dispatchVirtualFocus, moveVirtualFocus} from '@react-aria/focus';
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isCtrlKeyPressed, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useObjectRef} from '@react-aria/utils';
import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, moveVirtualFocus} from '@react-aria/focus';
import {getInteractionModality} from '@react-aria/interactions';
// @ts-ignore
import intlMessages from '../intl/*.json';
Expand Down Expand Up @@ -163,15 +163,25 @@ 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);

// 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);
Expand Down
10 changes: 6 additions & 4 deletions packages/@react-aria/selection/src/useSelectableCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -417,9 +416,12 @@ 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();
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.
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
91 changes: 91 additions & 0 deletions packages/react-aria-components/test/Autocomplete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<AutocompleteWrapper>
<StaticMenu disabledKeys={['2']} />
</AutocompleteWrapper>
);

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(
<AutocompleteWrapper>
<StaticMenu />
</AutocompleteWrapper>
);

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(
<AutocompleteWrapper>
<StaticMenu />
</AutocompleteWrapper>
);

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));
Comment on lines +502 to +503
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

500 v 300?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, if I advance more than 500ms, the aria-activedescendant will be set which is the typical flow tested by the other tests via runAllTimers. The buggy flow was if the user then backspaces before that 500ms finishes resulting in a state where the input "blurred" away from itself, but then interrupts updating the activedescendant, resulting in a state where getVirtuallyFocusedElement doesn't return a value and moveVirtualFocus will think that virtual focus didn't move away from the input.

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(
<Select>
Expand Down