Skip to content
Open
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
125 changes: 125 additions & 0 deletions packages/@adobe/react-spectrum/test/combobox/ComboBox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
17 changes: 14 additions & 3 deletions packages/react-stately/src/combobox/useComboBoxState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ export interface ComboBoxProps<T, M extends SelectionMode = 'single'>
* @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<T, M extends SelectionMode = 'single'>
Expand Down Expand Up @@ -200,7 +207,8 @@ export function useComboBoxState<T, M extends SelectionMode = 'single'>(
allowsEmptyCollection = false,
allowsCustomValue,
shouldCloseOnBlur = true,
selectionMode = 'single' as SelectionMode
selectionMode = 'single' as SelectionMode,
autoFocusFirst = false
} = props;

let [showAllItems, setShowAllItems] = useState(false);
Expand Down Expand Up @@ -409,6 +417,7 @@ export function useComboBoxState<T, M extends SelectionMode = 'single'>(
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(() => {
Expand All @@ -422,7 +431,7 @@ export function useComboBoxState<T, M extends SelectionMode = 'single'>(
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
Expand All @@ -447,7 +456,9 @@ export function useComboBoxState<T, M extends SelectionMode = 'single'>(

// 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.
Expand Down