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
268 changes: 266 additions & 2 deletions packages/react-aria-components/test/ComboBox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {ListLayout} from 'react-stately/useVirtualizerState';
import {Popover} from '../src/Popover';
import React, {useState} from 'react';
import {Text} from '../src/Text';
import {useAsyncList} from 'react-stately/useAsyncList';
import {User} from '@react-aria/test-utils';
import userEvent from '@testing-library/user-event';
import {Virtualizer} from '../src/Virtualizer';
Expand Down Expand Up @@ -857,11 +858,11 @@ describe('ComboBox', () => {
act(() => {getByTestId('form').checkValidity();});
expect(combobox).toHaveAttribute('aria-describedby');
expect(container.querySelector('.react-aria-ComboBox')).toHaveAttribute('data-invalid');

await comboboxTester.open();
let options = comboboxTester.options();
await user.click(options[0]);

act(() => combobox.blur());
expect(combobox).not.toHaveAttribute('required');
expect(combobox.validity.valid).toBe(true);
Expand Down Expand Up @@ -949,4 +950,267 @@ describe('ComboBox', () => {
rerender(<TestComboBox isReadOnly />);
expect(input.closest('.react-aria-ComboBox')).toHaveAttribute('data-readonly');
});

it('should re-open the menu when controlled items go from empty to non-empty controlled items', async () => {
let onOpenChange = jest.fn();
let onInputChange = jest.fn().mockReturnValueOnce(true).mockReturnValue(false);

function ControlledComboBox() {
let [items, setItems] = useState([{id: 1, name: 'Luke Skywalker'}]);
return (
<ComboBox
items={items}
onInputChange={() => {
if (onInputChange()) {
setItems([]);
} else {
setItems([{id: 1, name: 'Luke Skywalker'}]);
}
}}
onOpenChange={onOpenChange}>
<Label>SW Characters</Label>
<Input />
<Button>{'<'}</Button>
<Popover>
<ListBox>
{(item) => {
return <ListBoxItem id={item.id}>{item.name}</ListBoxItem>;
}}
</ListBox>
</Popover>
</ComboBox>
);
}

let {container, queryByRole} = render(<ControlledComboBox />);
let comboboxTester = testUtilUser.createTester('ComboBox', {root: container});
await user.tab();
await user.keyboard('{ArrowDown}');
expect(onOpenChange).toHaveBeenCalledTimes(1);
expect(comboboxTester.listbox).toBeVisible();
onOpenChange.mockClear();

await user.keyboard('L');
expect(queryByRole('listbox')).toBeNull();

await user.keyboard('{Backspace}');
expect(comboboxTester.listbox).toBeVisible();
});

it('should re-open the menu with useAsyncList after an empty async result then backspace', async () => {
const ASYNC_DELAY_MS = 50;

function itemsForFilterText(filterText) {
if (filterText === 'luka') {
return [];
}
return [{id: 1, name: 'Luke Skywalker'}];
}

function AsyncComboBox() {
let list = useAsyncList({
getKey: (item) => item.id,
async load({filterText}) {
let rows = itemsForFilterText(filterText);
await new Promise((resolve) => setTimeout(resolve, ASYNC_DELAY_MS));
return {items: rows};
}
});

return (
<ComboBox items={list.items} inputValue={list.filterText} onInputChange={list.setFilterText}>
<Label>SW Characters</Label>
<Input />
<Button>{'<'}</Button>
<Popover>
<ListBox>
{(item) => <ListBoxItem id={item.id}>{item.name}</ListBoxItem>}
</ListBox>
</Popover>
</ComboBox>
);
}

let {container, queryByRole} = render(<AsyncComboBox />);
let comboboxTester = testUtilUser.createTester('ComboBox', {root: container});

await act(async () => {
jest.runAllTimers();
});

await user.tab();
await user.keyboard('{ArrowDown}');
await act(async () => {
jest.runAllTimers();
});
expect(comboboxTester.listbox).toBeVisible();
expect(
within(comboboxTester.listbox).getByRole('option', {name: 'Luke Skywalker'})
).toBeInTheDocument();

await user.keyboard('luka');
await act(async () => {
jest.runAllTimers();
});
expect(queryByRole('listbox')).toBeNull();

await user.keyboard('{Backspace}');
expect(queryByRole('listbox')).toBeNull();
await act(async () => {
jest.runAllTimers();
});
expect(comboboxTester.listbox).toBeVisible();
expect(
within(comboboxTester.listbox).getByRole('option', {name: 'Luke Skywalker'})
).toBeInTheDocument();
});

it('should re-open the menu when using Collection pattern (items on ListBox, not ComboBox)', async () => {
const ASYNC_DELAY_MS = 50;

function itemsForFilterText(filterText) {
if (filterText === 'luka') {
return [];
}
return [{id: 1, name: 'Luke Skywalker'}];
}

function CollectionComboBox() {
let list = useAsyncList({
getKey: (item) => item.id,
async load({filterText}) {
let rows = itemsForFilterText(filterText);
await new Promise((resolve) => setTimeout(resolve, ASYNC_DELAY_MS));
return {items: rows};
}
});

return (
<ComboBox inputValue={list.filterText} onInputChange={list.setFilterText}>
<Label>SW Characters</Label>
<Input />
<Button>{'<'}</Button>
<Popover>
<ListBox items={list.items}>
{(item) => <ListBoxItem id={item.id}>{item.name}</ListBoxItem>}
</ListBox>
</Popover>
</ComboBox>
);
}

let {container, queryByRole} = render(<CollectionComboBox />);
let comboboxTester = testUtilUser.createTester('ComboBox', {root: container});

await act(async () => {
jest.runAllTimers();
});

await user.tab();
await user.keyboard('{ArrowDown}');
await act(async () => {
jest.runAllTimers();
});
expect(comboboxTester.listbox).toBeVisible();
expect(
within(comboboxTester.listbox).getByRole('option', {name: 'Luke Skywalker'})
).toBeInTheDocument();

await user.keyboard('luka');
await act(async () => {
jest.runAllTimers();
});
expect(queryByRole('listbox')).toBeNull();

await user.keyboard('{Backspace}');
expect(queryByRole('listbox')).toBeNull();
await act(async () => {
jest.runAllTimers();
});
expect(comboboxTester.listbox).toBeVisible();
expect(
within(comboboxTester.listbox).getByRole('option', {name: 'Luke Skywalker'})
).toBeInTheDocument();
});

it('should still close the menu when uncontrolled items are empty', async () => {
let onOpenChange = jest.fn();

let items = [{id: 1, name: 'Luke Skywalker'}];
function ControlledComboBox() {
return (
<ComboBox
defaultItems={items}
onOpenChange={onOpenChange}>
<Label>SW Characters</Label>
<Input />
<Button>{'<'}</Button>
<Popover>
<ListBox>
{(item) => {
return <ListBoxItem id={item.id}>{item.name}</ListBoxItem>;
}}
</ListBox>
</Popover>
</ComboBox>
);
}

let {container, queryByRole} = render(<ControlledComboBox />);
let comboboxTester = testUtilUser.createTester('ComboBox', {root: container});
await user.tab();
await user.keyboard('{ArrowDown}');
expect(onOpenChange).toHaveBeenCalledTimes(1);
expect(comboboxTester.listbox).toBeVisible();
onOpenChange.mockClear();

await user.keyboard('Z');
expect(queryByRole('listbox')).toBeNull();
});

it('should not re-open after user dismisses with Escape (revert) controlled items', async () => {
let onOpenChange = jest.fn();
let onInputChange = jest.fn().mockReturnValueOnce(true).mockReturnValue(false);

function ControlledComboBox() {
let [items, setItems] = useState([{id: 1, name: 'Luke Skywalker'}]);
return (
<ComboBox
items={items}
onInputChange={() => {
if (onInputChange()) {
setItems([]);
} else {
setItems([{id: 1, name: 'Luke Skywalker'}]);
}
}}
onOpenChange={onOpenChange}>
<Label>SW Characters</Label>
<Input />
<Button>{'<'}</Button>
<Popover>
<ListBox>
{(item) => {
return <ListBoxItem id={item.id}>{item.name}</ListBoxItem>;
}}
</ListBox>
</Popover>
</ComboBox>
);
}

let {container, queryByRole} = render(<ControlledComboBox />);
let comboboxTester = testUtilUser.createTester('ComboBox', {root: container});
await user.tab();
await user.keyboard('{ArrowDown}');
expect(onOpenChange).toHaveBeenCalledTimes(1);
expect(comboboxTester.listbox).toBeVisible();
onOpenChange.mockClear();

await user.keyboard('L');
expect(queryByRole('listbox')).toBeNull();

await user.keyboard('{Escape}');
expect(queryByRole('listbox')).toBeNull();
});
});
19 changes: 19 additions & 0 deletions packages/react-stately/src/combobox/useComboBoxState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export function useComboBoxState<T extends object, M extends SelectionMode = 'si
let [showAllItems, setShowAllItems] = useState(false);
let [isFocused, setFocusedState] = useState(false);
let [focusStrategy, setFocusStrategy] = useState<FocusStrategy | null>(null);
let closedDueToEmpty = useRef(false);

let defaultValue = useMemo(() => {
return props.defaultValue !== undefined ? props.defaultValue : (selectionMode === 'single' ? props.defaultSelectedKey ?? null : []) as ValueType<M>;
Expand Down Expand Up @@ -359,9 +360,25 @@ export function useComboBoxState<T extends object, M extends SelectionMode = 'si
triggerState.isOpen &&
filteredCollection.size === 0
) {
closedDueToEmpty.current = true;
closeMenu();
}

// Re-open the menu when items become non-empty after being auto-closed due to
// an empty collection (e.g. async load completed with results after a previous empty response).
// This works for both controlled items on ComboBox and Collection patterns
// (where items are provided on the ListBox rather than the ComboBox).
if (
isFocused &&
closedDueToEmpty.current &&
filteredCollection.size > 0 &&
!triggerState.isOpen &&
menuTrigger !== 'manual'
) {
closedDueToEmpty.current = false;
open(null, 'input');
}

// Close when an item is selected.
if (
displayValue != null &&
Expand Down Expand Up @@ -418,6 +435,7 @@ export function useComboBoxState<T extends object, M extends SelectionMode = 'si

// Revert input value and close menu
let revert = () => {
closedDueToEmpty.current = false;
if (allowsCustomValue && selectedKey == null) {
commitCustomValue();
} else {
Expand Down Expand Up @@ -457,6 +475,7 @@ export function useComboBoxState<T extends object, M extends SelectionMode = 'si
};

const commitValue = () => {
closedDueToEmpty.current = false;
if (allowsCustomValue) {
const itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : '';
(inputValue === itemText) ? commitSelection() : commitCustomValue();
Expand Down
Loading
Loading