Skip to content

Commit ee5c451

Browse files
authored
fix: Properly allow user to keyboard edit the Autocomplete input field when collection becomes empty (#8861)
aria-activedescendat sticks around in some cases, will still need to be properly fixed but this at least makes sure the user doesnt get stuck
1 parent 6c17b7c commit ee5c451

File tree

2 files changed

+83
-9
lines changed

2 files changed

+83
-9
lines changed

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

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,11 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
124124
queuedActiveDescendant.current = target.id;
125125
state.setFocusedNodeId(target.id);
126126
}
127-
} else {
127+
} else if (queuedActiveDescendant.current && !document.getElementById(queuedActiveDescendant.current)) {
128+
// If we recieve a focus event refocusing the collection, either we have newly refocused the input and are waiting for the
129+
// wrapped collection to refocus the previously focused node if any OR
130+
// we are in a state where we've filtered to such a point that there aren't any matching items in the collection to focus.
131+
// In this case we want to clear tracked item if any and clear active descendant
128132
queuedActiveDescendant.current = null;
129133
state.setFocusedNodeId(null);
130134
}
@@ -189,7 +193,7 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
189193
// copy paste/backspacing/undo/redo for screen reader announcements
190194
if (lastInputType.current === 'insertText' && !disableAutoFocusFirst) {
191195
focusFirstItem();
192-
} else if (lastInputType.current.includes('insert') || lastInputType.current.includes('delete') || lastInputType.current.includes('history')) {
196+
} else if (lastInputType.current && (lastInputType.current.includes('insert') || lastInputType.current.includes('delete') || lastInputType.current.includes('history'))) {
193197
clearVirtualFocus(true);
194198

195199
// If onChange was triggered before the timeout actually updated the activedescendant, we need to fire
@@ -274,9 +278,11 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
274278
) || false;
275279
} else {
276280
let item = document.getElementById(focusedNodeId);
277-
shouldPerformDefaultAction = item?.dispatchEvent(
278-
new KeyboardEvent(e.nativeEvent.type, e.nativeEvent)
279-
) || false;
281+
if (item) {
282+
shouldPerformDefaultAction = item?.dispatchEvent(
283+
new KeyboardEvent(e.nativeEvent.type, e.nativeEvent)
284+
) || false;
285+
}
280286
}
281287
}
282288

@@ -366,8 +372,9 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
366372
if (curFocusedNode) {
367373
let target = e.target;
368374
queueMicrotask(() => {
369-
dispatchVirtualBlur(target, curFocusedNode);
370-
dispatchVirtualFocus(curFocusedNode, target);
375+
// instead of focusing the last focused node, just focus the collection instead and have the collection handle what item to focus via useSelectableCollection/Item
376+
dispatchVirtualBlur(target, collectionRef.current);
377+
dispatchVirtualFocus(collectionRef.current!, target);
371378
});
372379
}
373380
};

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

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212

1313
import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal';
1414
import {AriaAutocompleteTests} from './AriaAutocomplete.test-util';
15-
import {Autocomplete, Breadcrumb, Breadcrumbs, Button, Cell, Column, Dialog, DialogTrigger, GridList, GridListItem, Header, Input, Label, ListBox, ListBoxItem, ListBoxSection, Menu, MenuItem, MenuSection, Popover, Row, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Tab, Table, TableBody, TableHeader, TabList, TabPanel, Tabs, Tag, TagGroup, TagList, Text, TextField, Tree, TreeItem, TreeItemContent} from '..';
16-
import React, {ReactNode} from 'react';
15+
import {Autocomplete, Breadcrumb, Breadcrumbs, Button, Cell, Collection, Column, Dialog, DialogTrigger, GridList, GridListItem, Header, Input, Label, ListBox, ListBoxItem, ListBoxLoadMoreItem, ListBoxSection, Menu, MenuItem, MenuSection, Popover, Row, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Tab, Table, TableBody, TableHeader, TabList, TabPanel, Tabs, Tag, TagGroup, TagList, Text, TextField, Tree, TreeItem, TreeItemContent} from '..';
16+
import React, {ReactNode, useEffect, useState} from 'react';
1717
import {useAsyncList} from 'react-stately';
1818
import {useFilter} from '@react-aria/i18n';
1919
import userEvent from '@testing-library/user-event';
@@ -957,6 +957,73 @@ describe('Autocomplete', () => {
957957
expect(within(sections[0]).getByText('Baz')).toBeTruthy();
958958
expect(within(sections[1]).getByText('Copy')).toBeTruthy();
959959
});
960+
961+
962+
it('shouldnt prevent default on keyboard interactions if somehow the active descendant doesnt exist in the DOM', async () => {
963+
let defaultOptions = [
964+
{value: 'one'},
965+
{value: 'two'},
966+
{value: 'three'},
967+
{value: 'four'},
968+
{value: 'five'}
969+
];
970+
function ControlledItemsFilter() {
971+
const [options, setOptions] = useState(defaultOptions);
972+
const [inputValue, onInputChange] = useState('');
973+
974+
useEffect(() => {
975+
setOptions(
976+
defaultOptions.filter(({value}) => value.includes(inputValue))
977+
);
978+
}, [inputValue]);
979+
980+
return (
981+
<Autocomplete inputValue={inputValue} onInputChange={onInputChange}>
982+
<SearchField aria-label="Search">
983+
<Input aria-label="Search" placeholder="Search..." />
984+
<Button>X</Button>
985+
</SearchField>
986+
<ListBox selectionMode="multiple">
987+
<Collection items={options} dependencies={[inputValue]}>
988+
{(option) => (
989+
<ListBoxItem id={option.value}>{option.value}</ListBoxItem>
990+
)}
991+
</Collection>
992+
<ListBoxLoadMoreItem onLoadMore={() => {}} isLoading={false}>
993+
<div>Loading...</div>
994+
</ListBoxLoadMoreItem>
995+
</ListBox>
996+
</Autocomplete>
997+
);
998+
}
999+
let {getByRole} = render(
1000+
<ControlledItemsFilter />
1001+
);
1002+
1003+
let input = getByRole('searchbox');
1004+
await user.tab();
1005+
expect(document.activeElement).toBe(input);
1006+
await user.keyboard('o');
1007+
act(() => jest.runAllTimers());
1008+
let listbox = getByRole('listbox');
1009+
let options = within(listbox).getAllByRole('option');
1010+
expect(options).toHaveLength(3);
1011+
expect(input).toHaveAttribute('aria-activedescendant', options[0].id);
1012+
1013+
await user.keyboard('o');
1014+
act(() => jest.runAllTimers());
1015+
options = within(listbox).queryAllByRole('option');
1016+
expect(options).toHaveLength(0);
1017+
// TODO: this is strange, still need to investigate. Ideally this would be removed
1018+
// but the collection in this configuration doesn't seem to update in time, so
1019+
// useSelectableCollection doesn't properly resend virtual focus to the input
1020+
expect(input).toHaveAttribute('aria-activedescendant');
1021+
1022+
await user.keyboard('{Backspace}');
1023+
act(() => jest.runAllTimers());
1024+
options = within(listbox).getAllByRole('option');
1025+
expect(options).toHaveLength(3);
1026+
});
9601027
});
9611028

9621029
AriaAutocompleteTests({

0 commit comments

Comments
 (0)