Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
21 changes: 14 additions & 7 deletions packages/@react-aria/autocomplete/src/useAutocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,11 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, 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);
}
Expand Down Expand Up @@ -189,7 +193,7 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, 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
Expand Down Expand Up @@ -274,9 +278,11 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, 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;
}
}
}

Expand Down Expand Up @@ -366,8 +372,9 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, 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);
});
}
};
Expand Down
71 changes: 69 additions & 2 deletions packages/react-aria-components/test/Autocomplete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<Autocomplete inputValue={inputValue} onInputChange={onInputChange}>
<SearchField aria-label="Search">
<Input aria-label="Search" placeholder="Search..." />
<Button>X</Button>
</SearchField>
<ListBox selectionMode="multiple">
<Collection items={options} dependencies={[inputValue]}>
{(option) => (
<ListBoxItem id={option.value}>{option.value}</ListBoxItem>
)}
</Collection>
<ListBoxLoadMoreItem onLoadMore={() => {}} isLoading={false}>
<div>Loading...</div>
</ListBoxLoadMoreItem>
</ListBox>
</Autocomplete>
);
}
let {getByRole} = render(
<ControlledItemsFilter />
);

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