diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index 93145bacfed..92dc68e5e07 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -24,6 +24,7 @@ import {ListLayout} from 'react-stately/private/layout/ListLayout'; 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'; @@ -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); @@ -937,4 +938,199 @@ describe('ComboBox', () => { expect(comboboxTester.combobox).toHaveFocus(); expect(onOpenChange).toHaveBeenCalledTimes(1); }); + + 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 ( + { + if (onInputChange()) { + setItems([]); + } else { + setItems([{id: 1, name: 'Luke Skywalker'}]); + } + }} + onOpenChange={onOpenChange}> + + + + + + {(item) => { + return {item.name}; + }} + + + + ); + } + + let {container, queryByRole} = render(); + 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 ( + + + + + + + {(item) => {item.name}} + + + + ); + } + + let {container, queryByRole} = render(); + 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 ( + + + + + + + {(item) => { + return {item.name}; + }} + + + + ); + } + + let {container, queryByRole} = render(); + 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 ( + { + if (onInputChange()) { + setItems([]); + } else { + setItems([{id: 1, name: 'Luke Skywalker'}]); + } + }} + onOpenChange={onOpenChange}> + + + + + + {(item) => { + return {item.name}; + }} + + + + ); + } + + let {container, queryByRole} = render(); + 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(); + }); }); diff --git a/packages/react-stately/src/combobox/useComboBoxState.ts b/packages/react-stately/src/combobox/useComboBoxState.ts index 02f6bd0116c..8c8ce560b1e 100644 --- a/packages/react-stately/src/combobox/useComboBoxState.ts +++ b/packages/react-stately/src/combobox/useComboBoxState.ts @@ -167,6 +167,7 @@ export function useComboBoxState; @@ -359,9 +360,25 @@ export function useComboBoxState { + closedDueToEmptyControlled.current = false; if (allowsCustomValue) { const itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''; (inputValue === itemText) ? commitSelection() : commitCustomValue(); diff --git a/packages/react-stately/test/combobox/useComboBoxState.test.js b/packages/react-stately/test/combobox/useComboBoxState.test.js index 0f6894144a9..07906d256ec 100644 --- a/packages/react-stately/test/combobox/useComboBoxState.test.js +++ b/packages/react-stately/test/combobox/useComboBoxState.test.js @@ -305,4 +305,84 @@ describe('useComboBoxState tests', function () { expect(result.current.collection.size).toEqual(2); }); }); + + describe('controlled items (async loading)', function () { + it('should re-open the menu when controlled items go from empty to non-empty', function () { + let onOpenChange = jest.fn(); + let initialProps = { + items: [{id: 1, name: 'Luke Skywalker'}], + children: (props) => {props.name}, + onOpenChange + }; + + let {result, rerender} = renderHook((props) => useComboBoxState(props), {initialProps}); + + // Focus and open the menu by setting input value + act(() => {result.current.setFocused(true);}); + act(() => {result.current.open(null, 'input');}); + expect(result.current.isOpen).toBe(true); + + // Simulate async load returning empty results (e.g. user typed "luka") + rerender({...initialProps, items: []}); + // Menu closes on empty collection + expect(result.current.isOpen).toBe(false); + + // Simulate async load returning results again (e.g. user backspaced to "luk") + rerender({...initialProps, items: [{id: 1, name: 'Luke Skywalker'}]}); + // Menu should re-open because items were controlled and the close was due to empty collection + expect(result.current.isOpen).toBe(true); + expect(result.current.collection.size).toEqual(1); + expect(onOpenChange).toHaveBeenLastCalledWith(true, 'input'); + }); + + it('should not re-open after user dismisses with Escape (revert)', function () { + let onOpenChange = jest.fn(); + let initialProps = { + items: [{id: 1, name: 'Luke Skywalker'}], + children: (props) => {props.name}, + onOpenChange + }; + + let {result, rerender} = renderHook((props) => useComboBoxState(props), {initialProps}); + + // Focus and open + act(() => {result.current.setFocused(true);}); + act(() => {result.current.open(null, 'input');}); + expect(result.current.isOpen).toBe(true); + + // Async returns empty, menu auto-closes + rerender({...initialProps, items: []}); + expect(result.current.isOpen).toBe(false); + + // User presses Escape (revert) while menu is closed + act(() => {result.current.revert();}); + + // Async returns items — menu should NOT re-open because user explicitly dismissed + rerender({...initialProps, items: [{id: 1, name: 'Luke Skywalker'}]}); + expect(result.current.isOpen).toBe(false); + }); + + it('should still close the menu when uncontrolled items are empty', function () { + let onOpenChange = jest.fn(); + let contains = (a, b) => a.toLowerCase().includes(b.toLowerCase()); + let initialProps = { + defaultItems: [{id: 1, name: 'Luke Skywalker'}], + children: (props) => {props.name}, + onOpenChange, + defaultFilter: contains + }; + + let {result} = renderHook((props) => useComboBoxState(props), {initialProps}); + + // Focus and open + act(() => {result.current.setFocused(true);}); + act(() => {result.current.open(null, 'input');}); + expect(result.current.isOpen).toBe(true); + + // Type something that filters to zero results + act(() => {result.current.setInputValue('zzz');}); + // Menu should close because items are uncontrolled and filtered to empty + expect(result.current.isOpen).toBe(false); + }); + }); });