diff --git a/packages/@adobe/react-spectrum/test/combobox/ComboBox.test.js b/packages/@adobe/react-spectrum/test/combobox/ComboBox.test.js index 9c1ea49af91..9d40dd2bfc4 100644 --- a/packages/@adobe/react-spectrum/test/combobox/ComboBox.test.js +++ b/packages/@adobe/react-spectrum/test/combobox/ComboBox.test.js @@ -1496,6 +1496,131 @@ describe('ComboBox', function () { expect(items).toHaveLength(1); expect(combobox).not.toHaveAttribute('aria-activedescendant'); }); + + it("doesn't focus the first item when filtering by default", async function () { + let {getByRole} = renderComboBox(); + + let combobox = getByRole('combobox'); + act(() => { + combobox.focus(); + }); + await user.keyboard('o'); + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(2); + expect(combobox).not.toHaveAttribute('aria-activedescendant'); + }); + + it('focuses the first item when filtering if autoFocusFirst is true', async function () { + let {getByRole} = renderComboBox({autoFocusFirst: true}); + + let combobox = getByRole('combobox'); + act(() => { + combobox.focus(); + }); + await user.keyboard('o'); + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(2); + expect(items[0]).toHaveTextContent('One'); + expect(combobox).toHaveAttribute('aria-activedescendant', items[0].id); + + // Narrowing the filter further should keep moving focus to the new first match. + await user.keyboard('ne'); + act(() => { + jest.runAllTimers(); + }); + + items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + expect(items[0]).toHaveTextContent('One'); + expect(combobox).toHaveAttribute('aria-activedescendant', items[0].id); + }); + + it('still focuses the first item if the menu was already open before typing', async function () { + let {getByRole} = renderComboBox({autoFocusFirst: true}); + + let button = getByRole('button'); + await user.click(button); + act(() => { + jest.runAllTimers(); + }); + + let combobox = getByRole('combobox'); + let listbox = getByRole('listbox'); + await user.keyboard('o'); + act(() => { + jest.runAllTimers(); + }); + + let items = within(listbox).getAllByRole('option'); + expect(combobox).toHaveAttribute('aria-activedescendant', items[0].id); + }); + + it('does not get stuck on a stale selection when re-searching after selecting an item', async function () { + let {getByRole} = renderComboBox({autoFocusFirst: true}); + + let combobox = getByRole('combobox'); + act(() => { + combobox.focus(); + }); + await user.keyboard('One'); + act(() => { + jest.runAllTimers(); + }); + await user.keyboard('[Enter]'); + act(() => { + jest.runAllTimers(); + }); + + await user.clear(combobox); + await user.keyboard('T'); + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(items[0]).toHaveTextContent('Two'); + expect(combobox).toHaveAttribute('aria-activedescendant', items[0].id); + }); + + it('clears the focused option when the input is cleared back to empty', async function () { + let {getByRole} = renderComboBox({autoFocusFirst: true}); + + let combobox = getByRole('combobox'); + act(() => { + combobox.focus(); + }); + await user.keyboard('One'); + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(combobox).toHaveAttribute('aria-activedescendant', items[0].id); + + await user.clear(combobox); + act(() => { + jest.runAllTimers(); + }); + + // Clearing returns to the unfiltered list, which should not be treated as "a filter was + // performed," so no option should be focused until the user navigates explicitly. + listbox = getByRole('listbox'); + items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(3); + expect(combobox).not.toHaveAttribute('aria-activedescendant'); + }); }); describe('blur', function () { diff --git a/packages/react-stately/src/combobox/useComboBoxState.ts b/packages/react-stately/src/combobox/useComboBoxState.ts index af0d29deded..6b829845ce0 100644 --- a/packages/react-stately/src/combobox/useComboBoxState.ts +++ b/packages/react-stately/src/combobox/useComboBoxState.ts @@ -115,6 +115,13 @@ export interface ComboBoxProps * @default 'input' */ menuTrigger?: MenuTriggerAction; + /** + * Whether to automatically focus the first item in the ComboBox's listbox when the + * user filters the list by typing into the input. + * + * @default false + */ + autoFocusFirst?: boolean; } export interface ComboBoxState @@ -200,7 +207,8 @@ export function useComboBoxState( allowsEmptyCollection = false, allowsCustomValue, shouldCloseOnBlur = true, - selectionMode = 'single' as SelectionMode + selectionMode = 'single' as SelectionMode, + autoFocusFirst = false } = props; let [showAllItems, setShowAllItems] = useState(false); @@ -409,6 +417,7 @@ export function useComboBoxState( let lastSelectedKeyText = useRef( selectedKey != null ? (collection.getItem(selectedKey)?.textValue ?? '') : '' ); + let shouldAutoFocusFirst = autoFocusFirst && inputValue !== ''; // intentional omit dependency array, want this to happen on every render // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { @@ -422,7 +431,7 @@ export function useComboBoxState( menuTrigger !== 'manual' ) { // oxlint-disable-next-line react/react-compiler - open(null, 'input'); + open(shouldAutoFocusFirst ? 'first' : null, 'input'); } // Close the menu if the collection is empty. Don't close menu if filtered collection size is 0 @@ -447,7 +456,9 @@ export function useComboBoxState( // Clear focused key when input value changes and display filtered collection again. if (inputValue !== lastValue) { - selectionManager.setFocusedKey(null); + selectionManager.setFocusedKey( + shouldAutoFocusFirst ? filteredCollection.getFirstKey() : null + ); setShowAllItems(false); // Set value to null when the user clears the input.