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 4 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
27 changes: 19 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,26 @@ 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
9 changes: 6 additions & 3 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 @@ -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
Copy link
Member

Choose a reason for hiding this comment

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

i don't quite follow, what's going on here? and what are the repercussions across other collections?

Copy link
Member Author

Choose a reason for hiding this comment

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

ah sorry, I'll add a more illustrative comment once I confirm this is the only way to do this. What this is handling is the case where the user types into the autocomplete input -> tells the filtered collection to try to virtually focus its first key -> the filtered collection filters itself and ends up in a state where there are no focusable items (aka only disabled items/no matching items at all) -> it needs to then tell the autocomplete input to clear its old aria-activeDescendant and that the input should be focused instead.

I was hoping to remove moveVirtualFocus(ref.current) in favor of the dispatchVirtualFocus below since it feels weird to do two separate virtual focus move operation but the autocomplete input will only clear its focused node when it detects that a focusin on the collection itself

This shouldn't have an repercussions for other standalone collections because it only runs when FOCUS_EVENT is fired from useAutocomplete

Copy link
Member

Choose a reason for hiding this comment

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

That's very helpful, thank you for explaining again <3

// 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.
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