diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index d19180552cd..ac2a9aafa74 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -113,10 +113,10 @@ export function useComboBox(props: AriaComboBoxProps, state: ComboBoxState state.close(); break; case 'ArrowDown': - state.open('first'); + state.open('first', 'manual'); break; case 'ArrowUp': - state.open('last'); + state.open('last', 'manual'); break; case 'ArrowLeft': case 'ArrowRight': @@ -165,14 +165,14 @@ export function useComboBox(props: AriaComboBoxProps, state: ComboBoxState if (e.pointerType === 'touch') { // Focus the input field in case it isn't focused yet inputRef.current.focus(); - state.toggle(); + state.toggle(null, 'manual'); } }; let onPressStart = (e: PressEvent) => { if (e.pointerType !== 'touch') { inputRef.current.focus(); - state.toggle((e.pointerType === 'keyboard' || e.pointerType === 'virtual') ? 'first' : null); + state.toggle((e.pointerType === 'keyboard' || e.pointerType === 'virtual') ? 'first' : null, 'manual'); } }; @@ -211,7 +211,7 @@ export function useComboBox(props: AriaComboBoxProps, state: ComboBoxState if (touch.clientX === centerX && touch.clientY === centerY) { e.preventDefault(); inputRef.current.focus(); - state.toggle(); + state.toggle(null, 'manual'); lastEventTime.current = e.timeStamp; } diff --git a/packages/@react-aria/combobox/test/useComboBox.test.js b/packages/@react-aria/combobox/test/useComboBox.test.js index ce03ef5b50e..b49d50be529 100644 --- a/packages/@react-aria/combobox/test/useComboBox.test.js +++ b/packages/@react-aria/combobox/test/useComboBox.test.js @@ -21,8 +21,8 @@ import {useSingleSelectListState} from '@react-stately/list'; describe('useComboBox', function () { let preventDefault = jest.fn(); let stopPropagation = jest.fn(); - let event = (key) => ({ - key, + let event = (e) => ({ + ...e, preventDefault, stopPropagation }); @@ -89,11 +89,53 @@ describe('useComboBox', function () { let {result, rerender} = renderHook((props) => useComboBox(props, useComboBoxState(props)), {initialProps: props}); let {inputProps} = result.current; - inputProps.onKeyDown(event('Enter')); + inputProps.onKeyDown(event({key: 'Enter'})); expect(preventDefault).toHaveBeenCalledTimes(1); rerender({...props, isOpen: false}); - result.current.inputProps.onKeyDown(event('Enter')); + result.current.inputProps.onKeyDown(event({key: 'Enter'})); expect(preventDefault).toHaveBeenCalledTimes(1); }); + + it('calls open and toggle with the expected parameters when arrow down/up/trigger button is pressed', function () { + let openSpy = jest.fn(); + let toggleSpy = jest.fn(); + let props = { + label: 'test label', + popoverRef: React.createRef(), + buttonRef: React.createRef(), + inputRef: { + current: { + contains: jest.fn(), + focus: jest.fn() + } + }, + listBoxRef: React.createRef(), + layout: mockLayout, + isOpen: false + }; + + let {result: state} = renderHook((props) => useComboBoxState(props), {initialProps: props}); + state.current.open = openSpy; + state.current.toggle = toggleSpy; + + let {result} = renderHook((props) => useComboBox(props, state.current), {initialProps: props}); + let {inputProps, buttonProps} = result.current; + inputProps.onKeyDown(event({key: 'ArrowDown'})); + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy).toHaveBeenLastCalledWith('first', 'manual'); + expect(toggleSpy).toHaveBeenCalledTimes(0); + inputProps.onKeyDown(event({key: 'ArrowUp'})); + expect(openSpy).toHaveBeenCalledTimes(2); + expect(openSpy).toHaveBeenLastCalledWith('last', 'manual'); + expect(toggleSpy).toHaveBeenCalledTimes(0); + buttonProps.onPress(event({pointerType: 'touch'})); + expect(openSpy).toHaveBeenCalledTimes(2); + expect(toggleSpy).toHaveBeenCalledTimes(1); + expect(toggleSpy).toHaveBeenLastCalledWith(null, 'manual'); + buttonProps.onPressStart(event({pointerType: 'mouse'})); + expect(openSpy).toHaveBeenCalledTimes(2); + expect(toggleSpy).toHaveBeenCalledTimes(2); + expect(toggleSpy).toHaveBeenLastCalledWith(null, 'manual'); + }); }); diff --git a/packages/@react-spectrum/combobox/stories/ComboBox.stories.tsx b/packages/@react-spectrum/combobox/stories/ComboBox.stories.tsx index e138f9f04dc..c42470cd846 100644 --- a/packages/@react-spectrum/combobox/stories/ComboBox.stories.tsx +++ b/packages/@react-spectrum/combobox/stories/ComboBox.stories.tsx @@ -87,6 +87,12 @@ storiesOf('ComboBox', module) ) ) + .add( + 'with mapped items (defaultItem and items undef)', + () => ( + + ) + ) .add( 'with sections', () => ( @@ -496,19 +502,39 @@ function ListDataExample() { let {contains} = useFilter({sensitivity: 'base'}); let list = useListData({ initialItems: items, + initialFilterText: 'Snake', filter(item, text) { return contains(item.name, text); } }); + let [showAll, setShowAll] = useState(false); + return ( - - {item => {item.name}} - + + { + if (reason === 'manual' && open) { + setShowAll(true); + } + }} + label="ComboBox (show all on open)" + items={showAll ? items : list.items} + inputValue={list.filterText} + onInputChange={(value) => { + setShowAll(false); + list.setFilterText(value); + }}> + {item => {item.name}} + + + {item => {item.name}} + + ); } @@ -893,3 +919,32 @@ function render(props = {}) { ); } + +function ComboBoxWithMap(props) { + let [items, setItems] = React.useState([ + {name: 'The first item', id: 'one'}, + {name: 'The second item', id: 'two'}, + {name: 'The third item', id: 'three'} + ]); + + let onClick = () => { + setItems([ + {name: 'The first item new text', id: 'one'}, + {name: 'The second item new text', id: 'two'}, + {name: 'The third item new text', id: 'three'} + ]); + }; + + return ( + + + + {items.map((item) => ( + + {item.name} + + ))} + + + ); +} diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index e185f7e3803..d1871eeb2a7 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -22,6 +22,8 @@ import themeLight from '@adobe/spectrum-css-temp/vars/spectrum-light-unique.css' import {triggerPress} from '@react-spectrum/test-utils'; import {typeText} from '@react-spectrum/test-utils'; import {useAsyncList} from '@react-stately/data'; +import {useFilter} from '@react-aria/i18n'; +import {useListData} from '@react-stately/data'; import userEvent from '@testing-library/user-event'; let theme = { @@ -257,6 +259,25 @@ function AllControlledComboBox(props) { ); } +function ControlledItemsComboBox(props) { + let {contains} = useFilter({sensitivity: 'base'}); + let list = useListData({ + initialItems: items, + initialFilterText: props.defaultInputValue, + filter(item, text) { + return contains(item.name, text); + } + }); + + return ( + + + {(item) => {item.name}} + + + ); +} + let initialFilterItems = [ {name: 'Aardvark', id: '1'}, {name: 'Kangaroo', id: '2'}, @@ -515,11 +536,10 @@ describe('ComboBox', function () { combobox.focus(); jest.runAllTimers(); }); - expect(onOpenChange).toHaveBeenLastCalledWith(true); let listbox = getByRole('listbox'); expect(onOpenChange).toBeCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(true, 'focus'); testComboBoxOpen(combobox, button, listbox); }); }); @@ -548,7 +568,7 @@ describe('ComboBox', function () { expect(listbox).toBeVisible(); expect(onOpenChange).toBeCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onOpenChange).toHaveBeenCalledWith(false, undefined); expect(combobox).toHaveAttribute('aria-expanded', 'true'); expect(combobox).toHaveAttribute('aria-controls'); @@ -573,7 +593,7 @@ describe('ComboBox', function () { expect(listbox).not.toBeInTheDocument(); expect(onOpenChange).toBeCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onOpenChange).toHaveBeenCalledWith(false, undefined); expect(combobox).toHaveAttribute('aria-expanded', 'false'); expect(combobox).not.toHaveAttribute('aria-controls'); @@ -635,7 +655,7 @@ describe('ComboBox', function () { jest.runAllTimers(); }); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(true, 'manual'); let listbox = getByRole('listbox'); testComboBoxOpen(combobox, button, listbox); @@ -700,7 +720,7 @@ describe('ComboBox', function () { expect(combobox).not.toHaveAttribute('aria-activedescendant'); }); - it('does\'t render the menu if there are\'t any items to show', function () { + it('shows all items', function () { let {getByRole} = renderComboBox({defaultInputValue: 'gibberish'}); let button = getByRole('button'); @@ -713,8 +733,10 @@ describe('ComboBox', function () { jest.runAllTimers(); }); - expect(() => getByRole('listbox')).toThrow(); - expect(onOpenChange).not.toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(true, 'manual'); + + let listbox = getByRole('listbox'); + testComboBoxOpen(combobox, button, listbox); }); }); @@ -736,7 +758,7 @@ describe('ComboBox', function () { let listbox = getByRole('listbox'); expect(onOpenChange).toHaveBeenCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(true, 'manual'); testComboBoxOpen(combobox, button, listbox, 0); }); @@ -757,7 +779,7 @@ describe('ComboBox', function () { let listbox = getByRole('listbox'); expect(onOpenChange).toHaveBeenCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(true, 'manual'); testComboBoxOpen(combobox, button, listbox, 2); }); @@ -776,7 +798,7 @@ describe('ComboBox', function () { }); expect(onOpenChange).toHaveBeenCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(true, 'input'); let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); @@ -806,7 +828,7 @@ describe('ComboBox', function () { }); expect(onOpenChange).toHaveBeenCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(true, 'input'); let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); @@ -831,7 +853,7 @@ describe('ComboBox', function () { act(() => jest.runAllTimers()); expect(onOpenChange).toHaveBeenCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(true, 'input'); let listbox = getByRole('listbox'); let items = within(listbox).getAllByRole('option'); @@ -867,7 +889,7 @@ describe('ComboBox', function () { }); expect(onOpenChange).toHaveBeenCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(true, 'manual'); let listbox = getByRole('listbox'); expect(listbox).toBeTruthy(); @@ -968,13 +990,8 @@ describe('ComboBox', function () { jest.runAllTimers(); }); - let listbox = getByRole('listbox'); - - let items = within(listbox).getAllByRole('option'); - expect(document.activeElement).toBe(combobox); expect(combobox).not.toHaveAttribute('aria-activedescendant'); - expect(items[0]).toHaveTextContent('Two'); }); it('keeps the menu open if the user clears the input field if menuTrigger = focus', function () { @@ -1188,7 +1205,7 @@ describe('ComboBox', function () { expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onSelectionChange).toHaveBeenCalledWith(null); expect(onOpenChange).toHaveBeenCalledTimes(2); - expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); }); it('defers closing menu to user handlers if allowsCustomValue=true, no item is focused, and isOpen is controlled', function () { @@ -1347,7 +1364,184 @@ describe('ComboBox', function () { expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onSelectionChange).toHaveBeenCalledWith('2'); expect(onOpenChange).toHaveBeenCalledTimes(1); - expect(onOpenChange).toHaveBeenLastCalledWith(false); + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); + }); + + describe.each` + Name | Component + ${'uncontrolled items (defaultItems)'} | ${ControlledKeyComboBox} + ${'uncontrolled items (static items)'} | ${ExampleComboBox} + ${'controlled items'} | ${ControlledItemsComboBox} + `('$Name ComboBox', ({Name, Component}) => { + it('displays all items when opened via trigger button', function () { + let {getByRole} = render(); + let button = getByRole('button'); + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + if (Name.includes('uncontrolled')) { + expect(items).toHaveLength(3); + } else { + expect(items).toHaveLength(1); + } + }); + + it('displays all items when opened via arrow keys', function () { + let {getByRole} = render(); + let combobox = getByRole('combobox'); + + act(() => { + fireEvent.keyDown(combobox, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(combobox, {key: 'ArrowDown', code: 40, charCode: 40}); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + if (Name.includes('uncontrolled')) { + expect(items).toHaveLength(3); + } else { + expect(items).toHaveLength(1); + } + + act(() => { + fireEvent.keyDown(combobox, {key: 'Escape', code: 27, charCode: 27}); + fireEvent.keyUp(combobox, {key: 'Escape', code: 27, charCode: 27}); + jest.runAllTimers(); + }); + + expect(() => getByRole('listbox')).toThrow(); + act(() => { + fireEvent.keyDown(combobox, {key: 'ArrowUp', code: 38, charCode: 38}); + fireEvent.keyUp(combobox, {key: 'ArrowDown', code: 38, charCode: 38}); + jest.runAllTimers(); + }); + + listbox = getByRole('listbox'); + items = within(listbox).getAllByRole('option'); + if (Name.includes('uncontrolled')) { + expect(items).toHaveLength(3); + } else { + expect(items).toHaveLength(1); + } + }); + + it('displays all items when opened via menuTrigger=focus', function () { + let {getByRole} = render(); + let combobox = getByRole('combobox'); + + act(() => { + combobox.focus(); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + if (Name.includes('uncontrolled')) { + expect(items).toHaveLength(3); + } else { + expect(items).toHaveLength(1); + } + }); + + it('displays filtered list when input value is changed', function () { + let {getByRole} = render(); + let combobox = getByRole('combobox'); + let button = getByRole('button'); + act(() => { + combobox.focus(); + triggerPress(button); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + if (Name.includes('uncontrolled')) { + expect(items).toHaveLength(3); + } else { + expect(items).toHaveLength(1); + } + + act(() => { + typeText(combobox, 'o'); + jest.runAllTimers(); + }); + + items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + + // Arrow keys will only navigate through menu if open, won't show full list + act(() => { + // Not sure why, test blows up for controlled items combobox when trying to fire arrow down here + if (Name.includes('uncontrolled')) { + fireEvent.keyDown(combobox, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(combobox, {key: 'ArrowDown', code: 40, charCode: 40}); + jest.runAllTimers(); + } + }); + + items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + + act(() => { + typeText(combobox, 'blah'); + jest.runAllTimers(); + }); + + expect(() => getByRole('listbox')).toThrow(); + combobox = getByRole('combobox'); + act(() => { + // Not sure why, test blows up for controlled items combobox when trying to fire arrow down here + if (Name.includes('uncontrolled')) { + fireEvent.keyDown(combobox, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(combobox, {key: 'ArrowDown', code: 40, charCode: 40}); + jest.runAllTimers(); + } + }); + + if (Name.includes('uncontrolled')) { + listbox = getByRole('listbox'); + items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(3); + } + }); + + // separate test since controlled items case above blows up + it('controlled items combobox doesn\'t display all items when menu is opened', function () { + let {getByRole} = render(); + let combobox = getByRole('combobox'); + let button = getByRole('button'); + + act(() => { + combobox.focus(); + triggerPress(button); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + + act(() => { + fireEvent.keyDown(combobox, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(combobox, {key: 'ArrowDown', code: 40, charCode: 40}); + jest.runAllTimers(); + }); + + items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + + act(() => { + typeText(combobox, 'blah'); + jest.runAllTimers(); + }); + + expect(() => getByRole('listbox')).toThrow(); + }); }); }); @@ -1539,7 +1733,7 @@ describe('ComboBox', function () { expect(getByRole('listbox')).toBeVisible(); expect(onOpenChange).toHaveBeenCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(true, 'input'); typeText(combobox, 'x'); @@ -1551,7 +1745,7 @@ describe('ComboBox', function () { expect(() => getByRole('listbox')).toThrow(); expect(onOpenChange).toHaveBeenCalledTimes(2); - expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); }); it('should clear the focused item when typing', function () { @@ -1614,7 +1808,7 @@ describe('ComboBox', function () { }); expect(document.activeElement).toBe(secondaryButton); - expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); expect(onSelectionChange).toHaveBeenCalledWith('1'); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onInputChange).toHaveBeenCalledWith('Bulbasaur'); @@ -1651,7 +1845,7 @@ describe('ComboBox', function () { }); // ComboBox value should reset to the selected key value and menu should be closed - expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); expect(onInputChange).toHaveBeenLastCalledWith('Squirtle'); expect(combobox.value).toBe('Squirtle'); expect(onSelectionChange).toHaveBeenCalledTimes(0); @@ -1682,7 +1876,7 @@ describe('ComboBox', function () { jest.runAllTimers(); }); - expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onSelectionChange).toHaveBeenCalledWith(null); @@ -1800,7 +1994,7 @@ describe('ComboBox', function () { }); expect(document.activeElement).toBe(combobox); - expect(onOpenChange).toHaveBeenLastCalledWith(true); + expect(onOpenChange).toHaveBeenLastCalledWith(true, 'manual'); let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); let items = within(listbox).getAllByRole('option'); @@ -1812,7 +2006,7 @@ describe('ComboBox', function () { expect(onInputChange).not.toHaveBeenCalled(); expect(onSelectionChange).not.toHaveBeenCalled(); - expect(onOpenChange).toHaveBeenLastCalledWith(true); + expect(onOpenChange).toHaveBeenLastCalledWith(true, 'manual'); expect(combobox.value).toBe(''); expect(document.activeElement).toBe(combobox); expect(listbox).toBeVisible(); @@ -2052,8 +2246,8 @@ describe('ComboBox', function () { let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); let items = within(listbox).getAllByRole('option'); - expect(items[0]).toHaveTextContent('Two'); - expect(items[0]).toHaveAttribute('aria-selected', 'true'); + expect(items[1]).toHaveTextContent('Two'); + expect(items[1]).toHaveAttribute('aria-selected', 'true'); act(() => { fireEvent.change(combobox, {target: {value: ''}}); @@ -2090,8 +2284,8 @@ describe('ComboBox', function () { let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); let items = within(listbox).getAllByRole('option'); - expect(items[0]).toHaveTextContent('Two'); - expect(items[0]).toHaveAttribute('aria-selected', 'true'); + expect(items[1]).toHaveTextContent('Two'); + expect(items[1]).toHaveAttribute('aria-selected', 'true'); act(() => { fireEvent.change(combobox, {target: {value: ''}}); @@ -2157,12 +2351,12 @@ describe('ComboBox', function () { let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); let items = within(listbox).getAllByRole('option'); - expect(items).toHaveLength(2); - expect(items[0]).toHaveTextContent('Two'); - expect(items[0]).toHaveAttribute('aria-selected', 'true'); + expect(items).toHaveLength(3); + expect(items[1]).toHaveTextContent('Two'); + expect(items[1]).toHaveAttribute('aria-selected', 'true'); act(() => { - userEvent.click(items[1]); + userEvent.click(items[2]); jest.runAllTimers(); }); @@ -2176,8 +2370,8 @@ describe('ComboBox', function () { expect(listbox).toBeVisible(); items = within(listbox).getAllByRole('option'); - expect(items).toHaveLength(2); - expect(items[0]).toHaveAttribute('aria-selected', 'true'); + expect(items).toHaveLength(3); + expect(items[1]).toHaveAttribute('aria-selected', 'true'); }); it('updates when selectedKey and inputValue change', function () { @@ -2219,10 +2413,10 @@ describe('ComboBox', function () { let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); let items = within(listbox).getAllByRole('option'); - expect(items).toHaveLength(2); + expect(items).toHaveLength(3); act(() => { - userEvent.click(items[1]); + userEvent.click(items[2]); rerender(); }); @@ -2269,7 +2463,7 @@ describe('ComboBox', function () { listbox = getByRole('listbox'); expect(listbox).toBeVisible(); expect(onOpenChange).toHaveBeenCalledTimes(1); - expect(onOpenChange).toHaveBeenLastCalledWith(false); + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); }); it('calls onOpenChange when clicking on a selected item if selectedKey is controlled but open state isn\'t ', function () { @@ -2286,18 +2480,18 @@ describe('ComboBox', function () { let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); let items = within(listbox).getAllByRole('option'); - expect(items).toHaveLength(1); + expect(items).toHaveLength(3); expect(onOpenChange).toHaveBeenCalledTimes(1); - expect(onOpenChange).toHaveBeenLastCalledWith(true); + expect(onOpenChange).toHaveBeenLastCalledWith(true, 'manual'); act(() => { - userEvent.click(items[0]); + userEvent.click(items[1]); jest.runAllTimers(); }); expect(() => getByRole('listbox')).toThrow(); expect(onOpenChange).toHaveBeenCalledTimes(2); - expect(onOpenChange).toHaveBeenLastCalledWith(false); + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); }); }); @@ -2318,9 +2512,9 @@ describe('ComboBox', function () { let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); let items = within(listbox).getAllByRole('option'); - expect(items).toHaveLength(1); - expect(items[0]).toHaveTextContent('Two'); - expect(items[0]).toHaveAttribute('aria-selected', 'true'); + expect(items).toHaveLength(3); + expect(items[1]).toHaveTextContent('Two'); + expect(items[1]).toHaveAttribute('aria-selected', 'true'); act(() => { fireEvent.change(combobox, {target: {value: 'Th'}}); @@ -2364,7 +2558,7 @@ describe('ComboBox', function () { }); describe('controlled by inputValue', function () { - it('updates selectedKey but not not inputValue', function () { + it('updates selectedKey but not inputValue', function () { let {getByRole} = renderComboBox({defaultSelectedKey: '3', inputValue: 'T'}); let combobox = getByRole('combobox'); let button = getByRole('button'); @@ -2379,10 +2573,11 @@ describe('ComboBox', function () { let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); let items = within(listbox).getAllByRole('option'); - expect(items).toHaveLength(2); - expect(items[0]).toHaveTextContent('Two'); - expect(items[1]).toHaveTextContent('Three'); - expect(items[1]).toHaveAttribute('aria-selected', 'true'); + expect(items).toHaveLength(3); + expect(items[0]).toHaveTextContent('One'); + expect(items[1]).toHaveTextContent('Two'); + expect(items[2]).toHaveTextContent('Three'); + expect(items[2]).toHaveAttribute('aria-selected', 'true'); typeText(combobox, 'w'); @@ -2395,7 +2590,7 @@ describe('ComboBox', function () { expect(onSelectionChange).not.toHaveBeenCalled(); act(() => { - userEvent.click(items[0]); + userEvent.click(items[1]); jest.runAllTimers(); }); @@ -2571,7 +2766,7 @@ describe('ComboBox', function () { if (!Name.includes('open') && !Name.includes('all')) { // Check that onOpenChange is firing appropriately for the comboboxes w/o user defined onOpenChange handlers expect(onOpenChange).toBeCalledTimes(4); - expect(onOpenChange).toHaveBeenLastCalledWith(false); + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); } act(() => { @@ -2623,7 +2818,7 @@ describe('ComboBox', function () { if (!Name.includes('open') && !Name.includes('all')) { // Check that onOpenChange is firing appropriately for the comboboxes w/o user defined onOpenChange handlers expect(onOpenChange).toBeCalledTimes(2); - expect(onOpenChange).toHaveBeenLastCalledWith(false); + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); } let button = getByRole('button'); @@ -2688,7 +2883,7 @@ describe('ComboBox', function () { if (!Name.includes('open') && !Name.includes('all')) { // Check that onOpenChange is firing appropriately for the comboboxes w/o user defined onOpenChange handlers expect(onOpenChange).toBeCalledTimes(4); - expect(onOpenChange).toHaveBeenLastCalledWith(false); + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); } }); @@ -2734,7 +2929,7 @@ describe('ComboBox', function () { if (!Name.includes('open') && !Name.includes('all')) { // Check that onOpenChange is firing appropriately for the comboboxes w/o user defined onOpenChange handlers expect(onOpenChange).toBeCalledTimes(2); - expect(onOpenChange).toHaveBeenLastCalledWith(false); + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); } }); }); @@ -2835,9 +3030,9 @@ describe('ComboBox', function () { let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); let items = within(listbox).getAllByRole('option'); - expect(items).toHaveLength(1); - expect(items[0]).toHaveTextContent('Two'); - expect(items[0]).toHaveAttribute('aria-selected', 'true'); + expect(items).toHaveLength(3); + expect(items[1]).toHaveTextContent('Two'); + expect(items[1]).toHaveAttribute('aria-selected', 'true'); }); it('should keep defaultInputValue if it doesn\'t match defaultSelectedKey', function () { @@ -2879,9 +3074,9 @@ describe('ComboBox', function () { let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); let items = within(listbox).getAllByRole('option'); - expect(items).toHaveLength(1); - expect(items[0]).toHaveTextContent('Two'); - expect(items[0]).toHaveAttribute('aria-selected', 'false'); + expect(items).toHaveLength(3); + expect(items[1]).toHaveTextContent('Two'); + expect(items[1]).toHaveAttribute('aria-selected', 'false'); }); it('defaultSelectedKey should set input value', function () { @@ -2899,9 +3094,9 @@ describe('ComboBox', function () { let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); let items = within(listbox).getAllByRole('option'); - expect(items).toHaveLength(1); - expect(items[0]).toHaveTextContent('Two'); - expect(items[0]).toHaveAttribute('aria-selected', 'true'); + expect(items).toHaveLength(3); + expect(items[1]).toHaveTextContent('Two'); + expect(items[1]).toHaveAttribute('aria-selected', 'true'); }); it('should close the menu if user clicks on a already selected item', function () { @@ -2919,17 +3114,17 @@ describe('ComboBox', function () { let listbox = getByRole('listbox'); expect(listbox).toBeVisible(); let items = within(listbox).getAllByRole('option'); - expect(items).toHaveLength(1); + expect(items).toHaveLength(3); expect(onOpenChange).toHaveBeenCalledTimes(1); act(() => { - triggerPress(items[0]); + triggerPress(items[1]); jest.runAllTimers(); }); expect(() => getByRole('listbox')).toThrow(); expect(onOpenChange).toHaveBeenCalledTimes(2); - expect(onOpenChange).toHaveBeenLastCalledWith(false); + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); }); }); @@ -3011,13 +3206,12 @@ describe('ComboBox', function () { items = within(listbox).getAllByRole('option'); groups = within(listbox).getAllByRole('group'); - expect(items).toHaveLength(1); - expect(groups).toHaveLength(1); - expect(items[0]).toHaveTextContent('Four'); - expect(items[0]).toHaveAttribute('aria-selected', 'true'); - expect(groups[0]).toContainElement(items[0]); - expect(groups[0]).toHaveAttribute('aria-labelledby', getByText('Section Two').id); - expect(() => getByText('Section One')).toThrow(); + expect(items).toHaveLength(6); + expect(groups).toHaveLength(2); + expect(items[3]).toHaveTextContent('Four'); + expect(items[3]).toHaveAttribute('aria-selected', 'true'); + expect(groups[1]).toContainElement(items[3]); + expect(groups[1]).toHaveAttribute('aria-labelledby', getByText('Section Two').id); }); it('sections are not valid selectable values', function () { @@ -3551,7 +3745,7 @@ describe('ComboBox', function () { jest.runAllTimers(); }); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(true, undefined); expect(onOpenChange).toHaveBeenCalledTimes(1); let tray = getByTestId('tray'); @@ -3571,7 +3765,7 @@ describe('ComboBox', function () { expect(onInputChange).toHaveBeenCalledTimes(1); expect(onSelectionChange).toHaveBeenCalledWith('2'); expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onOpenChange).toHaveBeenCalledWith(false, undefined); expect(onOpenChange).toHaveBeenCalledTimes(2); expect(() => getByTestId('tray')).toThrow(); expect(button).toHaveAttribute('aria-labelledby', `${getByText('Test').id} ${getByText('Two').id}`); @@ -3613,7 +3807,7 @@ describe('ComboBox', function () { jest.runAllTimers(); }); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(true, undefined); expect(onOpenChange).toHaveBeenCalledTimes(1); testComboBoxTrayOpen(trayInput, tray, listbox, 2); @@ -3628,7 +3822,7 @@ describe('ComboBox', function () { expect(onInputChange).toHaveBeenCalledTimes(1); expect(onSelectionChange).toHaveBeenCalledWith('3'); expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onOpenChange).toHaveBeenCalledWith(false, undefined); expect(onOpenChange).toHaveBeenCalledTimes(2); expect(() => getByTestId('tray')).toThrow(); expect(button).toHaveAttribute('aria-labelledby', `${getByText('Test').id} ${getByText('Three').id}`); @@ -3689,7 +3883,7 @@ describe('ComboBox', function () { jest.runAllTimers(); }); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(true, undefined); expect(onOpenChange).toHaveBeenCalledTimes(1); let tray = getByTestId('tray'); @@ -3712,7 +3906,7 @@ describe('ComboBox', function () { jest.runAllTimers(); }); - expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onOpenChange).toHaveBeenCalledWith(false, undefined); expect(onOpenChange).toHaveBeenCalledTimes(2); expect(() => getByTestId('tray')).toThrow(); expect(document.activeElement).toBe(button); @@ -4253,10 +4447,12 @@ describe('ComboBox', function () { }); it('should announce when navigating into a section with a single item', function () { - let {getByRole} = renderSectionComboBox({inputValue: 'Two'}); + let {getByRole} = renderSectionComboBox({defaultInputValue: 'Tw'}); let combobox = getByRole('combobox'); act(() => { + typeText(combobox, 'o'); + jest.runAllTimers(); fireEvent.keyDown(combobox, {key: 'ArrowDown'}); fireEvent.keyUp(combobox, {key: 'ArrowDown'}); jest.runAllTimers(); @@ -4270,6 +4466,10 @@ describe('ComboBox', function () { let combobox = getByRole('combobox'); act(() => { + typeText(combobox, 'o'); + jest.runAllTimers(); + fireEvent.change(combobox, {target: {value: 'Two'}}); + jest.runAllTimers(); fireEvent.keyDown(combobox, {key: 'ArrowDown'}); fireEvent.keyUp(combobox, {key: 'ArrowDown'}); jest.runAllTimers(); diff --git a/packages/@react-stately/combobox/src/useComboBoxState.ts b/packages/@react-stately/combobox/src/useComboBoxState.ts index a1e34c2a735..be1e9951a9d 100644 --- a/packages/@react-stately/combobox/src/useComboBoxState.ts +++ b/packages/@react-stately/combobox/src/useComboBoxState.ts @@ -11,7 +11,7 @@ */ import {Collection, FocusStrategy, Node} from '@react-types/shared'; -import {ComboBoxProps} from '@react-types/combobox'; +import {ComboBoxProps, MenuTriggerAction} from '@react-types/combobox'; import {ListCollection, useSingleSelectListState} from '@react-stately/list'; import {SelectState} from '@react-stately/select'; import {useControlledState} from '@react-stately/utils'; @@ -24,7 +24,11 @@ export interface ComboBoxState extends SelectState { /** Sets the value of the combo box input. */ setInputValue(value: string): void, /** Selects the currently focused item and updates the input value. */ - commit(): void + commit(): void, + /** Opens the menu. */ + open(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void, + /** Toggles the menu. */ + toggle(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void } type FilterFn = (textValue: string, inputValue: string) => boolean; @@ -51,6 +55,7 @@ export function useComboBoxState(props: ComboBoxStateProps) shouldCloseOnBlur = true } = props; + let [showAllItems, setShowAllItems] = useState(false); let [isFocused, setFocusedState] = useState(false); let [inputValue, setInputValue] = useControlledState( props.inputValue, @@ -79,6 +84,8 @@ export function useComboBoxState(props: ComboBoxStateProps) items: props.items ?? props.defaultItems }); + // Preserve original collection so we can show all items on demand + let originalCollection = collection; let filteredCollection = useMemo(() => ( // No default filter if items are controlled. props.items != null || !defaultFilter @@ -86,20 +93,48 @@ export function useComboBoxState(props: ComboBoxStateProps) : filterCollection(collection, inputValue, defaultFilter) ), [collection, inputValue, defaultFilter, props.items]); - let triggerState = useMenuTriggerState(props); - let open = (focusStrategy?: FocusStrategy) => { + // Track what action is attempting to open the menu + let menuOpenTrigger = useRef('focus' as MenuTriggerAction); + let onOpenChange = (open: boolean) => { + if (props.onOpenChange) { + props.onOpenChange(open, open ? menuOpenTrigger.current : undefined); + } + }; + + let triggerState = useMenuTriggerState({...props, onOpenChange}); + let open = (focusStrategy?: FocusStrategy, trigger?: MenuTriggerAction) => { + let displayAllItems = (trigger === 'manual' || (trigger === 'focus' && menuTrigger === 'focus')); // Prevent open operations from triggering if there is nothing to display - if (allowsEmptyCollection || filteredCollection.size > 0) { + // Also prevent open operations from triggering if items are uncontrolled but defaultItems is empty, even if displayAllItems is true. + // This is to prevent comboboxes with empty defaultItems from opening but allow controlled items comboboxes to open even if the inital list is empty (assumption is user will provide swap the empty list with a base list via onOpenChange returning `menuTrigger` manual) + if (allowsEmptyCollection || filteredCollection.size > 0 || (displayAllItems && originalCollection.size > 0) || props.items) { + if (displayAllItems && !triggerState.isOpen && props.items === undefined) { + // Show all items if menu is manually opened. Only care about this if items are undefined + setShowAllItems(true); + } + + menuOpenTrigger.current = trigger; triggerState.open(focusStrategy); } }; - let toggle = (focusStrategy?: FocusStrategy) => { + let toggle = (focusStrategy?: FocusStrategy, trigger?: MenuTriggerAction) => { + let displayAllItems = (trigger === 'manual' || (trigger === 'focus' && menuTrigger === 'focus')); // If the menu is closed and there is nothing to display, early return so toggle isn't called to prevent extraneous onOpenChange - if (!(allowsEmptyCollection || filteredCollection.size > 0) && !triggerState.isOpen) { + if (!(allowsEmptyCollection || filteredCollection.size > 0 || (displayAllItems && originalCollection.size > 0) || props.items) && !triggerState.isOpen) { return; } + if (displayAllItems && !triggerState.isOpen && props.items === undefined) { + // Show all items if menu is toggled open. Only care about this if items are undefined + setShowAllItems(true); + } + + // Only update the menuOpenTrigger if menu is currently closed + if (!triggerState.isOpen) { + menuOpenTrigger.current = trigger; + } + triggerState.toggle(focusStrategy); }; @@ -112,6 +147,8 @@ export function useComboBoxState(props: ComboBoxStateProps) let isInitialRender = useRef(true); let lastSelectedKey = useRef(props.selectedKey ?? props.defaultSelectedKey ?? null); + // intentional omit dependency array, want this to happen on every render + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { // If open state or inputValue is uncontrolled, open and close automatically when the input value changes, // the input is if focused, and there are items in the collection or allowEmptyCollection is true. @@ -123,11 +160,13 @@ export function useComboBoxState(props: ComboBoxStateProps) menuTrigger !== 'manual' && (props.isOpen === undefined || props.inputValue === undefined) ) { - open(); + open(null, 'input'); } // Close the menu if the collection is empty and either open state or items are uncontrolled. + // Don't close menu if filtered collection size is 0 but we are currently showing all items via button press (only applies to uncontrolled items) if ( + !showAllItems && !allowsEmptyCollection && triggerState.isOpen && filteredCollection.size === 0 && @@ -145,9 +184,10 @@ export function useComboBoxState(props: ComboBoxStateProps) triggerState.close(); } - // Clear focused key when input value changes. + // Clear focused key when input value changes and display filtered collection again. if (inputValue !== lastValue.current) { selectionManager.setFocusedKey(null); + setShowAllItems(false); // Set selectedKey to null when the user clears the input. // If controlled, this is the application developer's responsibility. @@ -252,7 +292,7 @@ export function useComboBoxState(props: ComboBoxStateProps) let setFocused = (isFocused: boolean) => { if (isFocused) { if (menuTrigger === 'focus') { - open(); + open(null, 'focus'); } } else if (shouldCloseOnBlur) { let itemText = collection.getItem(selectedKey)?.textValue ?? ''; @@ -277,7 +317,7 @@ export function useComboBoxState(props: ComboBoxStateProps) isFocused, setFocused, selectedItem, - collection: filteredCollection, + collection: showAllItems ? originalCollection : filteredCollection, inputValue, setInputValue, commit @@ -297,7 +337,7 @@ function filterNodes(nodes: Iterable>, inputValue: string, filter: Fi filteredNode.push({...node, childNodes: filtered}); } } else if (node.type !== 'section' && filter(node.textValue, inputValue)) { - filteredNode.push(node); + filteredNode.push({...node}); } } return filteredNode; diff --git a/packages/@react-stately/combobox/test/useComboBoxState.test.js b/packages/@react-stately/combobox/test/useComboBoxState.test.js index d842f1aea16..fcf7990f01a 100644 --- a/packages/@react-stately/combobox/test/useComboBoxState.test.js +++ b/packages/@react-stately/combobox/test/useComboBoxState.test.js @@ -33,11 +33,11 @@ describe('useComboBoxState tests', function () { result.current.open(); }); expect(result.current.isOpen).toBe(true); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(true, undefined); act(() => result.current.close()); expect(result.current.isOpen).toBe(false); - expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onOpenChange).toHaveBeenCalledWith(false, undefined); }); it('should be set open by default if defaultOpen is true and isFocused is true', function () { @@ -50,7 +50,7 @@ describe('useComboBoxState tests', function () { act(() => result.current.close()); expect(result.current.isOpen).toBe(false); - expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onOpenChange).toHaveBeenCalledWith(false, undefined); }); it('open should be a controllable value', function () { @@ -62,7 +62,7 @@ describe('useComboBoxState tests', function () { act(() => result.current.close()); expect(result.current.isOpen).toBe(true); - expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onOpenChange).toHaveBeenCalledWith(false, undefined); rerender({...defaultProps, isOpen: false}); @@ -70,7 +70,65 @@ describe('useComboBoxState tests', function () { act(() => result.current.open()); expect(result.current.isOpen).toBe(false); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(true, undefined); + }); + + it('onOpenChange should return the reason that open was called', function () { + let initialProps = defaultProps; + let {result} = renderHook((props) => useComboBoxState(props), {initialProps}); + + act(() => { + result.current.open(undefined, 'focus'); + }); + expect(result.current.isOpen).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(true, 'focus'); + + act(() => result.current.close()); + expect(result.current.isOpen).toBe(false); + expect(onOpenChange).toHaveBeenCalledWith(false, undefined); + + act(() => { + result.current.open(undefined, 'input'); + }); + expect(result.current.isOpen).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(true, 'input'); + + act(() => result.current.close()); + + act(() => { + result.current.open(undefined, 'manual'); + }); + expect(result.current.isOpen).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(true, 'manual'); + }); + + it('onOpenChange should return the reason that toggle was called when opening', function () { + let initialProps = defaultProps; + let {result} = renderHook((props) => useComboBoxState(props), {initialProps}); + + act(() => { + result.current.toggle(undefined, 'focus'); + }); + expect(result.current.isOpen).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(true, 'focus'); + + act(() => result.current.toggle(undefined, 'focus')); + expect(result.current.isOpen).toBe(false); + expect(onOpenChange).toHaveBeenCalledWith(false, undefined); + + act(() => { + result.current.toggle(undefined, 'input'); + }); + expect(result.current.isOpen).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(true, 'input'); + + act(() => result.current.close()); + + act(() => { + result.current.toggle(undefined, 'manual'); + }); + expect(result.current.isOpen).toBe(true); + expect(onOpenChange).toHaveBeenCalledWith(true, 'manual'); }); }); diff --git a/packages/@react-types/combobox/src/index.d.ts b/packages/@react-types/combobox/src/index.d.ts index 10b9eb1a5c0..455585dd545 100644 --- a/packages/@react-types/combobox/src/index.d.ts +++ b/packages/@react-types/combobox/src/index.d.ts @@ -12,6 +12,8 @@ import {AsyncLoadable, CollectionBase, DOMProps, FocusableProps, InputBase, LoadingState, SingleSelection, SpectrumLabelableProps, StyleProps, TextInputBase, Validation} from '@react-types/shared'; +export type MenuTriggerAction = 'focus' | 'input' | 'manual'; + export interface ComboBoxProps extends CollectionBase, SingleSelection, InputBase, TextInputBase, DOMProps, Validation, FocusableProps { /** The list of ComboBox items (uncontrolled). */ defaultItems?: Iterable, @@ -21,8 +23,8 @@ export interface ComboBoxProps extends CollectionBase, SingleSelection, In isOpen?: boolean, /** Sets the default open state of the menu. */ defaultOpen?: boolean, - /** Method that is called when the open state of the menu changes. */ - onOpenChange?: (isOpen: boolean) => void, + /** Method that is called when the open state of the menu changes. Returns the new open state and the action that caused the opening of the menu. */ + onOpenChange?: (isOpen: boolean, menuTrigger?: MenuTriggerAction) => void, /** The value of the ComboBox input (controlled). */ inputValue?: string, /** The default value of the ComboBox input (uncontrolled). */ @@ -40,7 +42,7 @@ export interface ComboBoxProps extends CollectionBase, SingleSelection, In * The interaction required to display the ComboBox menu. * @default 'input' */ - menuTrigger?: 'focus' | 'input' | 'manual', + menuTrigger?: MenuTriggerAction, /** * Whether the menu should automatically flip direction when space is limited. * @default true