diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index b569b4b6c46..7f895a676b1 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -124,7 +124,11 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut queuedActiveDescendant.current = target.id; state.setFocusedNodeId(target.id); } - } else { + } else if (queuedActiveDescendant.current && !document.getElementById(queuedActiveDescendant.current)) { + // If we recieve a focus event refocusing the collection, either we have newly refocused the input and are waiting for the + // wrapped collection to refocus the previously focused node if any OR + // 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. + // In this case we want to clear tracked item if any and clear active descendant queuedActiveDescendant.current = null; state.setFocusedNodeId(null); } @@ -189,7 +193,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut // copy paste/backspacing/undo/redo for screen reader announcements if (lastInputType.current === 'insertText' && !disableAutoFocusFirst) { focusFirstItem(); - } else if (lastInputType.current.includes('insert') || lastInputType.current.includes('delete') || lastInputType.current.includes('history')) { + } else if (lastInputType.current && (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 @@ -274,9 +278,11 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut ) || false; } else { let item = document.getElementById(focusedNodeId); - shouldPerformDefaultAction = item?.dispatchEvent( - new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) - ) || false; + if (item) { + shouldPerformDefaultAction = item?.dispatchEvent( + new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) + ) || false; + } } } @@ -366,8 +372,9 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut if (curFocusedNode) { let target = e.target; queueMicrotask(() => { - dispatchVirtualBlur(target, curFocusedNode); - dispatchVirtualFocus(curFocusedNode, target); + // instead of focusing the last focused node, just focus the collection instead and have the collection handle what item to focus via useSelectableCollection/Item + dispatchVirtualBlur(target, collectionRef.current); + dispatchVirtualFocus(collectionRef.current!, target); }); } }; diff --git a/packages/react-aria-components/test/Autocomplete.test.tsx b/packages/react-aria-components/test/Autocomplete.test.tsx index 1322e3940a1..20eaeaaf0a3 100644 --- a/packages/react-aria-components/test/Autocomplete.test.tsx +++ b/packages/react-aria-components/test/Autocomplete.test.tsx @@ -12,8 +12,8 @@ import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {AriaAutocompleteTests} from './AriaAutocomplete.test-util'; -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 '..'; -import React, {ReactNode} from 'react'; +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 '..'; +import React, {ReactNode, useEffect, useState} from 'react'; import {useAsyncList} from 'react-stately'; import {useFilter} from '@react-aria/i18n'; import userEvent from '@testing-library/user-event'; @@ -957,6 +957,73 @@ describe('Autocomplete', () => { expect(within(sections[0]).getByText('Baz')).toBeTruthy(); expect(within(sections[1]).getByText('Copy')).toBeTruthy(); }); + + + it('shouldnt prevent default on keyboard interactions if somehow the active descendant doesnt exist in the DOM', async () => { + let defaultOptions = [ + {value: 'one'}, + {value: 'two'}, + {value: 'three'}, + {value: 'four'}, + {value: 'five'} + ]; + function ControlledItemsFilter() { + const [options, setOptions] = useState(defaultOptions); + const [inputValue, onInputChange] = useState(''); + + useEffect(() => { + setOptions( + defaultOptions.filter(({value}) => value.includes(inputValue)) + ); + }, [inputValue]); + + return ( + + + + + + + + {(option) => ( + {option.value} + )} + + {}} isLoading={false}> +
Loading...
+
+
+
+ ); + } + let {getByRole} = render( + + ); + + let input = getByRole('searchbox'); + await user.tab(); + expect(document.activeElement).toBe(input); + await user.keyboard('o'); + act(() => jest.runAllTimers()); + let listbox = getByRole('listbox'); + let options = within(listbox).getAllByRole('option'); + expect(options).toHaveLength(3); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + + await user.keyboard('o'); + act(() => jest.runAllTimers()); + options = within(listbox).queryAllByRole('option'); + expect(options).toHaveLength(0); + // TODO: this is strange, still need to investigate. Ideally this would be removed + // but the collection in this configuration doesn't seem to update in time, so + // useSelectableCollection doesn't properly resend virtual focus to the input + expect(input).toHaveAttribute('aria-activedescendant'); + + await user.keyboard('{Backspace}'); + act(() => jest.runAllTimers()); + options = within(listbox).getAllByRole('option'); + expect(options).toHaveLength(3); + }); }); AriaAutocompleteTests({