Skip to content

Commit e8dcc2b

Browse files
fix: Clear Autocomplete virtualFocus upon paste/undo/redo and other focus fixes (#8438)
* update autocomplete to clear virtual focus on paste/cut/undo/redo/etc * add test for pasting * fix inconsistent focus ring when backspacing too quickly after typing forward * fix case where focus ring isnt restored when filtered list becomes empty * add tests for focus fixes * add chromatic story for copy paste * Update packages/@react-spectrum/s2/chromatic/Autocomplete.stories.tsx Co-authored-by: Robert Snow <[email protected]> * fix lint * remove chromatic story since it is buggy --------- Co-authored-by: Robert Snow <[email protected]>
1 parent 9c6057f commit e8dcc2b

File tree

4 files changed

+142
-13
lines changed

4 files changed

+142
-13
lines changed

packages/@react-aria/autocomplete/src/useAutocomplete.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
import {AriaLabelingProps, BaseEvent, DOMProps, RefObject} from '@react-types/shared';
1414
import {AriaTextFieldProps} from '@react-aria/textfield';
1515
import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete';
16-
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isCtrlKeyPressed, mergeProps, mergeRefs, useEffectEvent, useId, useLabels, useObjectRef} from '@react-aria/utils';
17-
import {dispatchVirtualBlur, dispatchVirtualFocus, moveVirtualFocus} from '@react-aria/focus';
16+
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isCtrlKeyPressed, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useObjectRef} from '@react-aria/utils';
17+
import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, moveVirtualFocus} from '@react-aria/focus';
1818
import {getInteractionModality} from '@react-aria/interactions';
1919
// @ts-ignore
2020
import intlMessages from '../intl/*.json';
@@ -163,15 +163,25 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl
163163
collectionRef.current?.dispatchEvent(clearFocusEvent);
164164
});
165165

166-
// TODO: update to see if we can tell what kind of event (paste vs backspace vs typing) is happening instead
166+
let lastInputType = useRef('');
167+
useEvent(inputRef, 'input', e => {
168+
let {inputType} = e as InputEvent;
169+
lastInputType.current = inputType;
170+
});
171+
167172
let onChange = (value: string) => {
168-
// Tell wrapped collection to focus the first element in the list when typing forward and to clear focused key when deleting text
169-
// for screen reader announcements
170-
if (state.inputValue !== value && state.inputValue.length <= value.length && !disableAutoFocusFirst) {
173+
// Tell wrapped collection to focus the first element in the list when typing forward and to clear focused key when modifying the text via
174+
// copy paste/backspacing/undo/redo for screen reader announcements
175+
if (lastInputType.current === 'insertText' && !disableAutoFocusFirst) {
171176
focusFirstItem();
172-
} else {
173-
// Fully clear focused key when backspacing since the list may change and thus we'd want to start fresh again
177+
} else if (lastInputType.current.includes('insert') || lastInputType.current.includes('delete') || lastInputType.current.includes('history')) {
174178
clearVirtualFocus(true);
179+
180+
// If onChange was triggered before the timeout actually updated the activedescendant, we need to fire
181+
// our own dispatchVirtualFocus so focusVisible gets reapplied on the input
182+
if (getVirtuallyFocusedElement(document) === inputRef.current) {
183+
dispatchVirtualFocus(inputRef.current!, null);
184+
}
175185
}
176186

177187
state.setInputValue(value);

packages/@react-aria/selection/src/useSelectableCollection.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, isCtrlKeyPressed, mergeProps, scrollIntoView, scrollIntoViewport, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils';
13+
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, mergeProps, scrollIntoView, scrollIntoViewport, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils';
14+
import {dispatchVirtualFocus, getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus';
1415
import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared';
1516
import {flushSync} from 'react-dom';
1617
import {FocusEvent, KeyboardEvent, useEffect, useRef} from 'react';
1718
import {focusSafely, getInteractionModality} from '@react-aria/interactions';
18-
import {getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus';
1919
import {getItemElement, isNonContiguousSelectionModifier, useCollectionId} from './utils';
2020
import {MultipleSelectionManager} from '@react-stately/selection';
2121
import {useLocale} from '@react-aria/i18n';
@@ -407,7 +407,6 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
407407
let {detail} = e;
408408
e.stopPropagation();
409409
manager.setFocused(true);
410-
411410
// If the user is typing forwards, autofocus the first option in the list.
412411
if (detail?.focusStrategy === 'first') {
413412
shouldVirtualFocusFirst.current = true;
@@ -417,9 +416,12 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
417416
let updateActiveDescendant = useEffectEvent(() => {
418417
let keyToFocus = delegate.getFirstKey?.() ?? null;
419418

420-
// If no focusable items exist in the list, make sure to clear any activedescendant that may still exist
419+
// If no focusable items exist in the list, make sure to clear any activedescendant that may still exist and move focus back to
420+
// the original active element (e.g. the autocomplete input)
421421
if (keyToFocus == null) {
422+
let previousActiveElement = getActiveElement();
422423
moveVirtualFocus(ref.current);
424+
dispatchVirtualFocus(previousActiveElement!, null);
423425

424426
// 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.
425427
// Reset shouldVirtualFocusFirst so that we don't erronously autofocus an item when the collection is filtered again.

packages/react-aria-components/test/AriaAutocomplete.test-util.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,32 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = '
178178
expect(options[0]).toHaveTextContent('Foo');
179179
});
180180

181+
it('should completely clear the focused key when pasting', async function () {
182+
let {getByRole} = renderers.standard();
183+
let input = getByRole('searchbox');
184+
let menu = getByRole(collectionNodeRole);
185+
expect(input).not.toHaveAttribute('aria-activedescendant');
186+
187+
await user.tab();
188+
expect(document.activeElement).toBe(input);
189+
190+
await user.keyboard('B');
191+
act(() => jest.runAllTimers());
192+
let options = within(menu).getAllByRole(collectionItemRole);
193+
let firstActiveDescendant = options[0].id;
194+
expect(input).toHaveAttribute('aria-activedescendant', firstActiveDescendant);
195+
196+
await user.paste('az');
197+
act(() => jest.runAllTimers());
198+
expect(input).not.toHaveAttribute('aria-activedescendant');
199+
200+
options = within(menu).getAllByRole(collectionItemRole);
201+
await user.keyboard('{ArrowDown}');
202+
expect(input).toHaveAttribute('aria-activedescendant', options[0].id);
203+
expect(firstActiveDescendant).not.toEqual(options[0].id);
204+
expect(options[0]).toHaveTextContent('Baz');
205+
});
206+
181207
it('should delay the aria-activedescendant being set when autofocusing the first option', async function () {
182208
let {getByRole} = renderers.standard();
183209
let input = getByRole('searchbox');
@@ -706,7 +732,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = '
706732

707733
describe('pointer events', function () {
708734
installPointerEvent();
709-
735+
710736
it('should close the menu when hovering an adjacent menu item in the virtual focus list', async function () {
711737
let {getByRole, getAllByRole} = (renderers.submenus!)();
712738
let menu = getByRole('menu');

packages/react-aria-components/test/Autocomplete.test.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,97 @@ describe('Autocomplete', () => {
427427
expect(input).not.toHaveAttribute('data-focused');
428428
});
429429

430+
it('should restore focus visible styles back to the input when typing forward results in only disabled items', async function () {
431+
let {getByRole} = render(
432+
<AutocompleteWrapper>
433+
<StaticMenu disabledKeys={['2']} />
434+
</AutocompleteWrapper>
435+
);
436+
437+
let input = getByRole('searchbox');
438+
await user.tab();
439+
expect(document.activeElement).toBe(input);
440+
expect(input).toHaveAttribute('data-focused');
441+
expect(input).toHaveAttribute('data-focus-visible');
442+
443+
await user.keyboard('Ba');
444+
act(() => jest.runAllTimers());
445+
let menu = getByRole('menu');
446+
let options = within(menu).getAllByRole('menuitem');
447+
let baz = options[1];
448+
expect(baz).toHaveTextContent('Baz');
449+
expect(input).toHaveAttribute('aria-activedescendant', baz.id);
450+
expect(baz).toHaveAttribute('data-focus-visible');
451+
expect(input).not.toHaveAttribute('data-focused');
452+
expect(input).not.toHaveAttribute('data-focus-visible');
453+
454+
await user.keyboard('r');
455+
act(() => jest.runAllTimers());
456+
options = within(menu).getAllByRole('menuitem');
457+
let bar = options[0];
458+
expect(bar).toHaveTextContent('Bar');
459+
expect(input).not.toHaveAttribute('aria-activedescendant');
460+
expect(bar).not.toHaveAttribute('data-focus-visible');
461+
expect(input).toHaveAttribute('data-focused');
462+
expect(input).toHaveAttribute('data-focus-visible');
463+
});
464+
465+
it('should maintain focus styles on the input if typing forward results in an completely empty collection', async function () {
466+
let {getByRole} = render(
467+
<AutocompleteWrapper>
468+
<StaticMenu />
469+
</AutocompleteWrapper>
470+
);
471+
472+
let input = getByRole('searchbox');
473+
await user.tab();
474+
expect(document.activeElement).toBe(input);
475+
expect(input).toHaveAttribute('data-focused');
476+
expect(input).toHaveAttribute('data-focus-visible');
477+
478+
await user.keyboard('Q');
479+
act(() => jest.runAllTimers());
480+
let menu = getByRole('menu');
481+
let options = within(menu).queryAllByRole('menuitem');
482+
expect(options).toHaveLength(0);
483+
expect(input).toHaveAttribute('data-focused');
484+
expect(input).toHaveAttribute('data-focus-visible');
485+
expect(input).not.toHaveAttribute('aria-activedescendant');
486+
});
487+
488+
it('should restore focus visible styles back to the input if the user types forward and backspaces in quick succession', async function () {
489+
let {getByRole} = render(
490+
<AutocompleteWrapper>
491+
<StaticMenu />
492+
</AutocompleteWrapper>
493+
);
494+
495+
let input = getByRole('searchbox');
496+
await user.tab();
497+
expect(document.activeElement).toBe(input);
498+
expect(input).toHaveAttribute('data-focused');
499+
expect(input).toHaveAttribute('data-focus-visible');
500+
501+
await user.keyboard('F');
502+
// If 500ms hasn't elapsed the aria-activedecendant hasn't been updated
503+
act(() => jest.advanceTimersByTime(300));
504+
let menu = getByRole('menu');
505+
let options = within(menu).getAllByRole('menuitem');
506+
let foo = options[0];
507+
expect(foo).toHaveTextContent('Foo');
508+
expect(input).not.toHaveAttribute('aria-activedescendant');
509+
expect(foo).toHaveAttribute('data-focus-visible');
510+
expect(input).not.toHaveAttribute('data-focused');
511+
expect(input).not.toHaveAttribute('data-focus-visible');
512+
513+
await user.keyboard('{Backspace}');
514+
act(() => jest.runAllTimers());
515+
expect(input).toHaveAttribute('data-focused');
516+
expect(input).toHaveAttribute('data-focus-visible');
517+
expect(input).not.toHaveAttribute('aria-activedescendant');
518+
expect(foo).not.toHaveAttribute('data-focus-visible');
519+
});
520+
430521
it('should work inside a Select', async function () {
431522
let {getByRole} = render(
432523
<Select>

0 commit comments

Comments
 (0)