diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index de0f4915e..521b00855 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `immediate` prop to `` for immediately opening the Combobox when the `input` receives focus ([#2686](https://github.com/tailwindlabs/headlessui/pull/2686)) -- Add `virtual` prop to `Combobox` component ([#2740](https://github.com/tailwindlabs/headlessui/pull/2740)) +- Add `virtual` prop to `Combobox` component ([#2779](https://github.com/tailwindlabs/headlessui/pull/2779)) ## [1.7.17] - 2023-08-17 diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index 55ddcf19a..7a9bb90e5 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -1835,28 +1835,84 @@ describe('Composition', () => { describe.each([{ virtual: true }, { virtual: false }])( 'Keyboard interactions %s', ({ virtual }) => { + let data = ['Option A', 'Option B', 'Option C'] + function MyCombobox({ + options = data.slice() as T[], + useComboboxOptions = true, + comboboxProps = {}, + inputProps = {}, + buttonProps = {}, + optionProps = {}, + }: { + options?: T[] + useComboboxOptions?: boolean + comboboxProps?: Record + inputProps?: Record + buttonProps?: Record + optionProps?: Record + }) { + function isDisabled(option: T): boolean { + return typeof option === 'string' + ? false + : typeof option === 'object' && + option !== null && + 'disabled' in option && + typeof option.disabled === 'boolean' + ? option?.disabled ?? false + : false + } + if (virtual) { + return ( + + + Trigger + {useComboboxOptions && ( + + {({ option }) => { + return + }} + + )} + + ) + } + + return ( + + + Trigger + {useComboboxOptions && ( + + {options.map((option, idx) => { + return ( + + ) + })} + + )} + + ) + } + describe('Button', () => { describe('`Enter` key', () => { it( 'should be possible to open the combobox with Enter', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -1895,28 +1951,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should not be possible to open the combobox with Enter when the button is disabled', suppressConsoleLogs(async () => { - render( - console.log(x)} - disabled - > - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -1942,23 +1977,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to open the combobox with Enter, and focus the selected option', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -2000,19 +2019,13 @@ describe.each([{ virtual: true }, { virtual: false }])( if (virtual) return // Incompatible with virtual rendering render( - console.log(x)}> + console.log(x)}> Trigger - - Option A - - - Option B - - - Option C - + Option A + Option B + Option C ) @@ -2070,29 +2083,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to open the combobox with Enter, and focus the selected option (with a list of objects)', suppressConsoleLogs(async () => { - let myOptions = [ - { id: 'a', name: 'Option A' }, - { id: 'b', name: 'Option B' }, - { id: 'c', name: 'Option C' }, - ] - let selectedOption = myOptions[1] - render( - console.log(x)}> - - Trigger - - {myOptions.map((myOption, idx) => ( - - {myOption.name} - - ))} - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -2131,13 +2122,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - ) + render() assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -2162,23 +2147,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to open the combobox with Space', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -2215,28 +2184,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should not be possible to open the combobox with Space when the button is disabled', suppressConsoleLogs(async () => { - render( - console.log(x)} - disabled - virtual={virtual} - > - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -2262,23 +2210,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to open the combobox with Space, and focus the selected option', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -2316,13 +2248,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - ) + render() assertComboboxList({ state: ComboboxState.InvisibleUnmounted, @@ -2344,21 +2270,13 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option upon Space key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - + ) assertComboboxButton({ @@ -2384,23 +2302,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to close an open combobox with Escape', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() // Open combobox await click(getComboboxButton()) @@ -2436,21 +2338,7 @@ describe.each([{ virtual: true }, { virtual: false }])( let handleKeyDown = jest.fn() render(
- console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - +
) @@ -2471,21 +2359,7 @@ describe.each([{ virtual: true }, { virtual: false }])( let handleKeyDown = jest.fn() render(
- console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - +
) @@ -2505,23 +2379,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to open the combobox with ArrowDown', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -2557,28 +2415,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should not be possible to open the combobox with ArrowDown when the button is disabled', suppressConsoleLogs(async () => { - render( - console.log(x)} - disabled - > - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -2604,23 +2441,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to open the combobox with ArrowDown, and focus the selected option', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -2656,13 +2477,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - ) + render() assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -2683,23 +2498,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to open the combobox with ArrowUp and the last option should be active', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -2735,28 +2534,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should not be possible to open the combobox with ArrowUp and the last option should be active when the button is disabled', suppressConsoleLogs(async () => { - render( - console.log(x)} - disabled - > - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -2782,23 +2560,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to open the combobox with ArrowUp, and focus the selected option', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -2834,13 +2596,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - ) + render() assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -2860,21 +2616,13 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowUp to navigate the combobox options and jump to the first non-disabled one', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - + ) assertComboboxButton({ @@ -2899,112 +2647,26 @@ describe.each([{ virtual: true }, { virtual: false }])( }) }) - describe('`Backspace` key', () => { - it( - 'should reset the value when the last character is removed, when in `nullable` mode', - suppressConsoleLogs(async () => { - let handleChange = jest.fn() - function Example() { - let [value, setValue] = useState('bob') - let [, setQuery] = useState('') + describe('Input', () => { + describe('`Enter` key', () => { + it( + 'should be possible to close the combobox with Enter and choose the active combobox option', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() - return ( - { - setValue(value) - handleChange(value) - }} - nullable - > - setQuery(event.target.value)} /> - Trigger - - - Alice - - - Bob - - - Charlie - - - - ) - } - - render() - - // Open combobox - await click(getComboboxButton()) - - let options: ReturnType - - // Bob should be active - options = getComboboxOptions() - expect(getComboboxInput()).toHaveValue('bob') - assertActiveComboboxOption(options[1]) - - assertActiveElement(getComboboxInput()) - - // Delete a character - await press(Keys.Backspace) - expect(getComboboxInput()?.value).toBe('bo') - assertActiveComboboxOption(options[1]) - - // Delete a character - await press(Keys.Backspace) - expect(getComboboxInput()?.value).toBe('b') - assertActiveComboboxOption(options[1]) - - // Delete a character - await press(Keys.Backspace) - expect(getComboboxInput()?.value).toBe('') - - // Verify that we don't have an selected option anymore since we are in `nullable` mode - assertNotActiveComboboxOption(options[1]) - assertNoSelectedComboboxOption() - - // Verify that we saw the `null` change coming in - expect(handleChange).toHaveBeenCalledTimes(1) - expect(handleChange).toHaveBeenCalledWith(null) - }) - ) - }) - - describe('Input', () => { - describe('`Enter` key', () => { - it( - 'should be possible to close the combobox with Enter and choose the active combobox option', - suppressConsoleLogs(async () => { - let handleChange = jest.fn() - - function Example() { - let [value, setValue] = useState(undefined) + function Example() { + let [value, setValue] = useState(undefined) return ( - { - setValue(value) - handleChange(value) + - - Trigger - - - Option A - - - Option B - - - Option C - - - + /> ) } @@ -3035,7 +2697,7 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify we got the change event expect(handleChange).toHaveBeenCalledTimes(1) - expect(handleChange).toHaveBeenCalledWith('a') + expect(handleChange).toHaveBeenCalledWith('Option A') // Verify the button is focused again assertActiveElement(getComboboxInput()) @@ -3068,22 +2730,7 @@ describe.each([{ virtual: true }, { virtual: false }])( submits([...new FormData(event.currentTarget).entries()]) }} > - - - Trigger - - - Option A - - - Option B - - - Option C - - - - + ) @@ -3124,21 +2771,7 @@ describe.each([{ virtual: true }, { virtual: false }])( submits([...new FormData(event.currentTarget).entries()]) }} > - - - Trigger - - - Option A - - - Option B - - - Option C - - - + ) } @@ -3169,21 +2802,7 @@ describe.each([{ virtual: true }, { virtual: false }])( return ( <> - - - Trigger - - - Option A - - - Option B - - - Option C - - - + ) @@ -3211,7 +2830,7 @@ describe.each([{ virtual: true }, { virtual: false }])( assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // That the selected value was the highlighted one - expect(getComboboxInput()?.value).toBe('b') + expect(getComboboxInput()?.value).toBe('Option B') // And focus has moved to the next element assertActiveElement(document.querySelector('#after-combobox')) @@ -3227,21 +2846,7 @@ describe.each([{ virtual: true }, { virtual: false }])( return ( <> - - - Trigger - - - Option A - - - Option B - - - Option C - - - + ) @@ -3269,7 +2874,7 @@ describe.each([{ virtual: true }, { virtual: false }])( assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // That the selected value was the highlighted one - expect(getComboboxInput()?.value).toBe('b') + expect(getComboboxInput()?.value).toBe('Option B') // And focus has moved to the next element assertActiveElement(document.querySelector('#before-combobox')) @@ -3281,23 +2886,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to close an open combobox with Escape', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() // Open combobox await click(getComboboxButton()) @@ -3329,19 +2918,13 @@ describe.each([{ virtual: true }, { virtual: false }])( if (virtual) return // Incompatible with virtual rendering render( - console.log(x)}> + console.log(x)}> Trigger - - Option A - - - Option B - - - Option C - + Option A + Option B + Option C ) @@ -3385,12 +2968,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should bubble escape when not using Combobox.Options at all', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - ) + render() let spy = jest.fn() @@ -3431,29 +3009,13 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should sync the input field correctly and reset it when pressing Escape', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() // Open combobox await click(getComboboxButton()) // Verify the input has the selected value - expect(getComboboxInput()?.value).toBe('option-b') + expect(getComboboxInput()?.value).toBe('Option B') // Override the input by typing something await type(word('test'), getComboboxInput()) @@ -3463,7 +3025,7 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.Escape) // Verify the input is reset correctly - expect(getComboboxInput()?.value).toBe('option-b') + expect(getComboboxInput()?.value).toBe('Option B') }) ) }) @@ -3472,23 +3034,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to open the combobox with ArrowDown', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -3524,28 +3070,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should not be possible to open the combobox with ArrowDown when the button is disabled', suppressConsoleLogs(async () => { - render( - console.log(x)} - disabled - > - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -3571,23 +3096,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to open the combobox with ArrowDown, and focus the selected option', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -3623,13 +3132,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - ) + render() assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -3648,23 +3151,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to use ArrowDown to navigate the combobox options', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -3700,21 +3187,13 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowDown to navigate the combobox options and skip the first disabled one', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - + ) assertComboboxButton({ @@ -3742,21 +3221,13 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowDown to navigate the combobox options and jump to the first non-disabled one', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - + ) assertComboboxButton({ @@ -3783,23 +3254,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to go to the next item if no value is set', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -3828,23 +3283,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to open the combobox with ArrowUp and the last option should be active', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -3880,28 +3319,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should not be possible to open the combobox with ArrowUp and the last option should be active when the button is disabled', suppressConsoleLogs(async () => { - render( - console.log(x)} - disabled - > - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -3927,23 +3345,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to open the combobox with ArrowUp, and focus the selected option', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -3979,13 +3381,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - ) + render() assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -4005,21 +3401,13 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowUp to navigate the combobox options and jump to the first non-disabled one', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - + ) assertComboboxButton({ @@ -4038,6 +3426,7 @@ describe.each([{ virtual: true }, { virtual: false }])( let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) }) ) @@ -4046,21 +3435,13 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should not be possible to navigate up or down if there is only a single non-disabled option', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - + ) assertComboboxButton({ @@ -4094,23 +3475,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to use ArrowUp to navigate the combobox options', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -4158,23 +3523,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to use the End key to go to the last combobox option', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() // Open combobox await click(getComboboxButton()) @@ -4194,24 +3543,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the End key to go to the last non disabled combobox option', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - + ) // Open combobox @@ -4232,24 +3571,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the End key to go to the first combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - + ) // Open combobox @@ -4271,24 +3600,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option upon End key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - + ) // Open combobox @@ -4309,23 +3628,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to use the PageDown key to go to the last combobox option', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() // Open combobox await click(getComboboxButton()) @@ -4345,24 +3648,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageDown key to go to the last non disabled combobox option', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - + ) // Open combobox @@ -4386,24 +3679,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageDown key to go to the first combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - + ) // Open combobox @@ -4425,24 +3708,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option upon PageDown key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - + ) // Open combobox @@ -4463,23 +3736,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to use the Home key to go to the first combobox option', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() // Focus the input await focus(getComboboxInput()) @@ -4502,24 +3759,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the Home key to go to the first non disabled combobox option', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - + ) // Open combobox @@ -4542,24 +3789,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the Home key to go to the last combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - + ) // Open combobox @@ -4581,24 +3818,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option upon Home key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - + ) // Open combobox @@ -4619,23 +3846,7 @@ describe.each([{ virtual: true }, { virtual: false }])( it( 'should be possible to use the PageUp key to go to the first combobox option', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() // Focus the input await focus(getComboboxInput()) @@ -4658,24 +3869,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageUp key to go to the first non disabled combobox option', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - + ) // Open combobox @@ -4697,24 +3898,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageUp key to go to the last combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - + ) // Open combobox @@ -4736,24 +3927,14 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option upon PageUp key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - + ) // Open combobox @@ -4770,9 +3951,106 @@ describe.each([{ virtual: true }, { virtual: false }])( ) }) + describe('`Backspace` key', () => { + it( + 'should reset the value when the last character is removed, when in `nullable` mode', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() + function Example() { + let [value, setValue] = useState('bob') + let [, setQuery] = useState('') + + // return ( + // { + // setValue(value) + // handleChange(value) + // }, + // nullable: true, + // }} + // inputProps={{ + // onChange: (event: any) => setQuery(event.target.value), + // }} + // /> + // ) + + return ( + { + setValue(value) + handleChange(value) + }} + nullable + > + setQuery(event.target.value)} /> + Trigger + + + Alice + + + Bob + + + Charlie + + + + ) + } + + render() + + // Open combobox + await click(getComboboxButton()) + + let options: ReturnType + + // Bob should be active + options = getComboboxOptions() + expect(getComboboxInput()).toHaveValue('bob') + assertActiveComboboxOption(options[1]) + + assertActiveElement(getComboboxInput()) + + // Delete a character + await press(Keys.Backspace) + expect(getComboboxInput()?.value).toBe('bo') + assertActiveComboboxOption(options[1]) + + // Delete a character + await press(Keys.Backspace) + expect(getComboboxInput()?.value).toBe('b') + assertActiveComboboxOption(options[1]) + + // Delete a character + await press(Keys.Backspace) + expect(getComboboxInput()?.value).toBe('') + + // Verify that we don't have an selected option anymore since we are in `nullable` mode + assertNotActiveComboboxOption(options[1]) + assertNoSelectedComboboxOption() + + // Verify that we saw the `null` change coming in + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith(null) + }) + ) + }) + describe('`Any` key aka search', () => { function Example(props: { people: { value: string; name: string; disabled: boolean }[] }) { - let [value, setValue] = useState(undefined) + let [value, setValue] = useState< + { value: string; name: string; disabled: boolean } | undefined + >(undefined) let [query, setQuery] = useState('') let filteredPeople = query === '' @@ -4781,18 +4059,39 @@ describe.each([{ virtual: true }, { virtual: false }])( person.name.toLowerCase().includes(query.toLowerCase()) ) + if (virtual) { + return ( + person?.disabled ?? false, + }} + value={value} + by="value" + onChange={(value) => setValue(value)} + > + setQuery(event.target.value)} /> + Trigger + + {({ option }: { option: NonNullable }) => { + return ( + + {option.name} + + ) + }} + + + ) + } + return ( - + setQuery(event.target.value)} /> Trigger - {filteredPeople.map((person, idx) => ( - + {filteredPeople.map((person) => ( + {person.name} ))} @@ -5011,27 +4310,79 @@ describe.each([{ virtual: true }, { virtual: false }])( ) describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', ({ virtual }) => { - it( - 'should focus the Combobox.Input when we click the Combobox.Label', - suppressConsoleLogs(async () => { - render( - console.log(x)}> - Label - - Trigger - - - Option A - - - Option B - - - Option C - + let data = ['Option A', 'Option B', 'Option C'] + function MyCombobox({ + options = data.slice() as T[], + label = true, + comboboxProps = {}, + inputProps = {}, + buttonProps = {}, + optionProps = {}, + optionsProps = {}, + }: { + options?: T[] + label?: boolean + comboboxProps?: Record + inputProps?: Record + buttonProps?: Record + optionProps?: Record + optionsProps?: Record + }) { + function isDisabled(option: T): boolean { + return typeof option === 'string' + ? false + : typeof option === 'object' && option !== null && 'disabled' in option + ? (option?.disabled as unknown as boolean | undefined) ?? false + : false + } + if (virtual) { + return ( + + {label && Label} + + Trigger + + {({ option }) => { + return + }} ) + } + + return ( + + {label && Label} + + Trigger + + {options.map((option, idx) => { + return ( + + ) + })} + + + ) + } + + it( + 'should focus the Combobox.Input when we click the Combobox.Label', + suppressConsoleLogs(async () => { + render() // Ensure the button is not focused yet assertActiveElement(document.body) @@ -5047,24 +4398,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', it( 'should not focus the Combobox.Input when we right click the Combobox.Label', suppressConsoleLogs(async () => { - render( - console.log(x)}> - Label - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() // Ensure the button is not focused yet assertActiveElement(document.body) @@ -5080,23 +4414,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', it( 'should be possible to open the combobox by focusing the input with immediate mode enabled', suppressConsoleLogs(async () => { - render( - - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -5126,27 +4444,11 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', it( 'should not be possible to open the combobox by focusing the input with immediate mode disabled', suppressConsoleLogs(async () => { - render( - - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: { id: 'headlessui-combobox-button-3' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -5156,7 +4458,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', // Verify it is invisible assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: { id: 'headlessui-combobox-button-3' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) @@ -5165,27 +4467,11 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', it( 'should not be possible to open the combobox by focusing the input with immediate mode enabled when button is disabled', suppressConsoleLogs(async () => { - render( - - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: { id: 'headlessui-combobox-button-3' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -5195,7 +4481,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', // Verify it is invisible assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: { id: 'headlessui-combobox-button-3' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) @@ -5204,23 +4490,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', it( 'should be possible to close a combobox on click with immediate mode enabled', suppressConsoleLogs(async () => { - render( - - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() // Open combobox await click(getComboboxButton()) @@ -5241,23 +4511,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', it( 'should be possible to close a focused combobox on click with immediate mode enabled', suppressConsoleLogs(async () => { - render( - - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) // Open combobox by focusing input @@ -5280,23 +4534,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', it( 'should be possible to open the combobox on click', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -5326,23 +4564,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', it( 'should not be possible to open the combobox on right click', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Item A - - - Item B - - - Item C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -5351,33 +4573,17 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Try to open the combobox - await click(getComboboxButton(), MouseButton.Right) - - // Verify it is still closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - }) - ) - - it( - 'should not be possible to open the combobox on click when the button is disabled', - suppressConsoleLogs(async () => { - render( - console.log(x)} disabled> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + await click(getComboboxButton(), MouseButton.Right) + + // Verify it is still closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should not be possible to open the combobox on click when the button is disabled', + suppressConsoleLogs(async () => { + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -5400,23 +4606,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', it( 'should be possible to open the combobox on click, and focus the selected option', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, @@ -5449,23 +4639,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', it( 'should be possible to close a combobox on click', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - Option A - - - Option B - - - Option C - - - - ) + render() // Open combobox await click(getComboboxButton()) @@ -5485,23 +4659,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', it( 'should be a no-op when we click outside of a closed combobox', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - alice - - - bob - - - charlie - - - - ) + render() // Verify that the window is closed assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -5521,21 +4679,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', suppressConsoleLogs(async () => { render( <> - console.log(x)}> - - Trigger - - - alice - - - bob - - - charlie - - - +
after
@@ -5563,37 +4707,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', suppressConsoleLogs(async () => { render(
- console.log(x)}> - - Trigger - - - alice - - - bob - - - charlie - - - - - console.log(x)}> - - Trigger - - - alice - - - bob - - - charlie - - - + +
) @@ -5619,23 +4734,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', it( 'should be possible to click outside of the combobox which should close the combobox (even if we press the combobox button)', suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - alice - - - bob - - - charlie - - - - ) + render() // Open combobox await click(getComboboxButton()) @@ -5659,21 +4758,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', let focusFn = jest.fn() render(
- x}> - - Trigger - - - alice - - - bob - - - charlie - - - + ) @@ -6318,7 +5220,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', await click(getComboboxButton()) // Verify the input has the selected value - expect(getComboboxInput()?.value).toBe('bob') + expect(getComboboxInput()?.value).toBe('Option B') // Override the input by typing something await type(word('test'), getComboboxInput()) @@ -6340,21 +5242,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', return ( <> - - - Trigger - - - alice - - - bob - - - charlie - - - + ) @@ -6391,21 +5279,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', return ( <> - - - Trigger - - - alice - - - bob - - - charlie - - - + ) @@ -6448,20 +5322,13 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', return ( <> - - person?.name} - /> - Trigger - - {people.map((person) => ( - - {person.name} - - ))} - - + person?.name, + }} + /> ) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 1f107ce83..05cc76dc2 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -9,7 +9,7 @@ import React, { useMemo, useReducer, useRef, - type CSSProperties, + useState, type ElementType, type FocusEvent as ReactFocusEvent, type KeyboardEvent as ReactKeyboardEvent, @@ -74,13 +74,14 @@ type ComboboxOptionDataRef = MutableRefObject<{ value: T domRef: MutableRefObject order: number | null - onVirtualRangeUpdate: (virtualizer: Virtualizer) => void }> interface StateDefinition { dataRef: MutableRefObject<_Data | null> labelId: string | null + virtual: { options: T[]; disabled: (value: unknown) => boolean } | null + comboboxState: ComboboxState options: { id: string; dataRef: ComboboxOptionDataRef }[] @@ -100,6 +101,8 @@ enum ActionTypes { RegisterLabel, SetActivationTrigger, + + UpdateVirtualOptions, } function adjustOrderedState( @@ -137,16 +140,25 @@ function adjustOrderedState( type Actions = | { type: ActionTypes.CloseCombobox } | { type: ActionTypes.OpenCombobox } - | { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string; trigger?: ActivationTrigger } + | { + type: ActionTypes.GoToOption + focus: Focus.Specific + idx: number + trigger?: ActivationTrigger + } | { type: ActionTypes.GoToOption focus: Exclude trigger?: ActivationTrigger } - | { type: ActionTypes.RegisterOption; id: string; dataRef: ComboboxOptionDataRef } + | { + type: ActionTypes.RegisterOption + payload: { id: string; dataRef: ComboboxOptionDataRef } + } | { type: ActionTypes.RegisterLabel; id: string | null } | { type: ActionTypes.UnregisterOption; id: string } | { type: ActionTypes.SetActivationTrigger; trigger: ActivationTrigger } + | { type: ActionTypes.UpdateVirtualOptions; options: T[] } let reducers: { [P in ActionTypes]: ( @@ -157,6 +169,7 @@ let reducers: { [ActionTypes.CloseCombobox](state) { if (state.dataRef.current?.disabled) return state if (state.comboboxState === ComboboxState.Closed) return state + return { ...state, activeOptionIndex: null, comboboxState: ComboboxState.Closed } }, [ActionTypes.OpenCombobox](state) { @@ -164,18 +177,18 @@ let reducers: { if (state.comboboxState === ComboboxState.Open) return state // Check if we have a selected value that we can make active - let activeOptionIndex = state.activeOptionIndex - - if (state.dataRef.current) { - let { isSelected } = state.dataRef.current - let optionIdx = state.options.findIndex((option) => isSelected(option.dataRef.current.value)) - - if (optionIdx !== -1) { - activeOptionIndex = optionIdx + if (state.dataRef.current?.value) { + let idx = state.dataRef.current.calculateIndex(state.dataRef.current.value) + if (idx !== -1) { + return { + ...state, + activeOptionIndex: idx, + comboboxState: ComboboxState.Open, + } } } - return { ...state, comboboxState: ComboboxState.Open, activeOptionIndex } + return { ...state, comboboxState: ComboboxState.Open } }, [ActionTypes.GoToOption](state, action) { if (state.dataRef.current?.disabled) return state @@ -187,6 +200,38 @@ let reducers: { return state } + if (state.virtual) { + let activeOptionIndex = + action.focus === Focus.Specific + ? action.idx + : calculateActiveIndex(action, { + resolveItems: () => state.virtual!.options, + resolveActiveIndex: () => + state.activeOptionIndex ?? + state.virtual!.options.findIndex((option) => !state.virtual!.disabled(option)) ?? + null, + resolveDisabled: state.virtual!.disabled, + resolveId() { + throw new Error('Function not implemented.') + }, + }) + + let activationTrigger = action.trigger ?? ActivationTrigger.Other + + if ( + state.activeOptionIndex === activeOptionIndex && + state.activationTrigger === activationTrigger + ) { + return state + } + + return { + ...state, + activeOptionIndex, + activationTrigger, + } + } + let adjustedState = adjustOrderedState(state) // It's possible that the activeOptionIndex is set to `null` internally, but @@ -202,12 +247,15 @@ let reducers: { } } - let activeOptionIndex = calculateActiveIndex(action, { - resolveItems: () => adjustedState.options, - resolveActiveIndex: () => adjustedState.activeOptionIndex, - resolveId: (item) => item.id, - resolveDisabled: (item) => item.dataRef.current.disabled, - }) + let activeOptionIndex = + action.focus === Focus.Specific + ? action.idx + : calculateActiveIndex(action, { + resolveItems: () => adjustedState.options, + resolveActiveIndex: () => adjustedState.activeOptionIndex, + resolveId: (item) => item.id, + resolveDisabled: (item) => item.dataRef.current.disabled, + }) let activationTrigger = action.trigger ?? ActivationTrigger.Other if ( @@ -225,7 +273,14 @@ let reducers: { } }, [ActionTypes.RegisterOption]: (state, action) => { - let option = { id: action.id, dataRef: action.dataRef } + if (state.dataRef.current?.virtual) { + return { + ...state, + options: [...state.options, action.payload], + } + } + + let option = action.payload let adjustedState = adjustOrderedState(state, (options) => { options.push(option) @@ -234,7 +289,7 @@ let reducers: { // Check if we need to make the newly registered option active. if (state.activeOptionIndex === null) { - if (state.dataRef.current?.isSelected(action.dataRef.current.value)) { + if (state.dataRef.current?.isSelected(action.payload.dataRef.current.value)) { adjustedState.activeOptionIndex = adjustedState.options.indexOf(option) } } @@ -252,8 +307,15 @@ let reducers: { return nextState }, [ActionTypes.UnregisterOption]: (state, action) => { + if (state.dataRef.current?.virtual) { + return { + ...state, + options: state.options.filter((option) => option.id !== action.id), + } + } + let adjustedState = adjustOrderedState(state, (options) => { - let idx = options.findIndex((a) => a.id === action.id) + let idx = options.findIndex((option) => option.id === action.id) if (idx !== -1) options.splice(idx, 1) return options }) @@ -265,17 +327,46 @@ let reducers: { } }, [ActionTypes.RegisterLabel]: (state, action) => { + if (state.labelId === action.id) { + return state + } + return { ...state, labelId: action.id, } }, [ActionTypes.SetActivationTrigger]: (state, action) => { + if (state.activationTrigger === action.trigger) { + return state + } + return { ...state, activationTrigger: action.trigger, } }, + [ActionTypes.UpdateVirtualOptions]: (state, action) => { + if (state.virtual?.options === action.options) { + return state + } + + let adjustedActiveOptionIndex = state.activeOptionIndex + if (state.activeOptionIndex !== null) { + let idx = action.options.indexOf(state.virtual!.options[state.activeOptionIndex]) + if (idx !== -1) { + adjustedActiveOptionIndex = idx + } else { + adjustedActiveOptionIndex = null + } + } + + return { + ...state, + activeOptionIndex: adjustedActiveOptionIndex, + virtual: Object.assign({}, state.virtual, { options: action.options }), + } + }, } let ComboboxActionsContext = createContext<{ @@ -283,9 +374,8 @@ let ComboboxActionsContext = createContext<{ closeCombobox(): void registerOption(id: string, dataRef: ComboboxOptionDataRef): () => void registerLabel(id: string): () => void - goToOption(focus: Focus.Specific, id: string, trigger?: ActivationTrigger): void - goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger): void - selectOption(id: string): void + goToOption(focus: Focus.Specific, idx: number, trigger?: ActivationTrigger): void + goToOption(focus: Focus, idx?: number, trigger?: ActivationTrigger): void selectActiveOption(): void setActivationTrigger(trigger: ActivationTrigger): void onChange(value: unknown): void @@ -305,16 +395,11 @@ type _Actions = ReturnType let VirtualContext = createContext | null>(null) -function VirtualProvider(props: React.PropsWithChildren<{}>) { +function VirtualProvider(props: { + children: (data: { option: unknown; open: boolean }) => React.ReactElement +}) { let data = useData('VirtualProvider') - let firstAvailableOption = data.options.find((option) => option.dataRef.current.domRef.current) - let measuredHeight = useMemo(() => { - let height = - firstAvailableOption?.dataRef.current.domRef.current?.getBoundingClientRect().height - return height ?? 40 - }, [firstAvailableOption]) - let [paddingStart, paddingEnd] = useMemo(() => { let el = data.optionsRef.current if (!el) return [0, 0] @@ -330,27 +415,21 @@ function VirtualProvider(props: React.PropsWithChildren<{}>) { let virtualizer = useVirtualizer({ scrollPaddingStart: paddingStart, scrollPaddingEnd: paddingEnd, - count: data.options.length, + count: data.virtual!.options.length, estimateSize() { - return measuredHeight + return 40 }, getScrollElement() { return (data.optionsRef.current ?? null) as HTMLElement | null }, overscan: 12, - onChange(event) { - let list = event.getVirtualItems() - if (list.length === 0) return - - let min = list[0].index - let max = list[list.length - 1].index + 1 - - for (let option of data.options.slice(min, max)) { - option.dataRef.current.onVirtualRangeUpdate(event) - } - }, }) + let [baseKey, setBaseKey] = useState(0) + useIsoMorphicEffect(() => { + setBaseKey((v) => v + 1) + }, [data.virtual?.options]) + return (
) { width: '100%', height: `${virtualizer.getTotalSize()}px`, }} + ref={(el) => { + if (!el) { + return + } + + // Scroll to the active index + { + // Ignore this when we are in a test environment + if (typeof process !== 'undefined' && process.env.JEST_WORKER_ID !== undefined) { + return + } + + // Do not scroll when the mouse/pointer is being used + if (data.activationTrigger === ActivationTrigger.Pointer) { + return + } + + if ( + data.activeOptionIndex !== null && + data.virtual!.options.length > data.activeOptionIndex + ) { + virtualizer.scrollToIndex(data.activeOptionIndex) + } + } + }} > - {props.children} + {virtualizer.getVirtualItems().map((item) => { + return ( + + {React.cloneElement( + props.children?.({ + option: data.virtual!.options[item.index], + open: data.comboboxState === ComboboxState.Open, + }), + { + key: `${baseKey}-${item.key}`, + 'data-index': item.index, + 'aria-setsize': data.virtual!.options.length, + 'aria-posinset': item.index + 1, + style: { + position: 'absolute', + top: 0, + left: 0, + transform: `translateY(${item.start}px)`, + overflowAnchor: 'none', + }, + } + )} + + ) + })}
) @@ -375,11 +503,14 @@ let ComboboxDataContext = createContext< activeOptionIndex: number | null nullable: boolean immediate: boolean + + virtual: { options: unknown[]; disabled: (value: unknown) => boolean } | null + calculateIndex(value: unknown): number compare(a: unknown, z: unknown): boolean isSelected(value: unknown): boolean - __demoMode: boolean + isActive(value: unknown): boolean - virtual: boolean + __demoMode: boolean optionsPropsRef: MutableRefObject<{ static: boolean @@ -475,7 +606,10 @@ export type ComboboxProps< form?: string name?: string immediate?: boolean - virtual?: boolean + virtual?: { + options: TValue[] + disabled?: (value: TValue) => boolean + } | null } function ComboboxFn( @@ -505,13 +639,13 @@ function ComboboxFn a === z, + by = null, disabled = false, __demoMode = false, nullable = false, multiple = false, immediate = false, - virtual = false, + virtual = null, ...theirProps } = props let [value = multiple ? [] : undefined, theirOnChange] = useControllable( @@ -524,6 +658,9 @@ function ComboboxFn false) } + : null, activeOptionIndex: null, activationTrigger: ActivationTrigger.Other, labelId: null, @@ -546,18 +683,36 @@ function ComboboxFn a === z) ) + let calculateIndex = useEvent((value: TValue) => { + if (virtual) { + if (by === null) { + return virtual.options.indexOf(value) + } else { + return virtual.options.findIndex((other) => compare(other, value)) + } + } else { + // @ts-expect-error + return state.options.findIndex((other) => compare(other.dataRef.current.value, value)) + } + }) + let isSelected: (value: TValue) => boolean = useCallback( - (compareValue) => + (other) => match(data.mode, { [ValueMode.Multi]: () => - (value as EnsureArray).some((option) => compare(option, compareValue)), - [ValueMode.Single]: () => compare(value as TValue, compareValue), + (value as EnsureArray).some((option) => compare(option, other)), + [ValueMode.Single]: () => compare(value as TValue, other), }), [value] ) + + let isActive = useEvent((other: TValue) => { + return state.activeOptionIndex === calculateIndex(other) + }) + let data = useMemo<_Data>( () => ({ ...state, @@ -571,16 +726,26 @@ function ComboboxFn 0 + (virtual ? virtual.options.length > 0 : state.options.length > 0) ) { - let localActiveOptionIndex = state.options.findIndex( - (option) => !option.dataRef.current.disabled - ) + if (virtual) { + let localActiveOptionIndex = virtual.options.findIndex( + (option) => !(virtual?.disabled?.(option) ?? false) + ) + + if (localActiveOptionIndex !== -1) { + return localActiveOptionIndex + } + } + + let localActiveOptionIndex = state.options.findIndex((option) => { + return !option.dataRef.current.disabled + }) if (localActiveOptionIndex !== -1) { return localActiveOptionIndex @@ -589,24 +754,20 @@ function ComboboxFn { - let currentActiveOption = - data.activeOptionIndex !== null ? data.options[data.activeOptionIndex] : null - if (lastActiveOption.current !== currentActiveOption) { - lastActiveOption.current = currentActiveOption - } - }) + useIsoMorphicEffect(() => { + if (!virtual) return + dispatch({ type: ActionTypes.UpdateVirtualOptions, options: virtual.options }) + }, [virtual, virtual?.options]) useIsoMorphicEffect(() => { state.dataRef.current = data @@ -627,28 +788,27 @@ function ComboboxFn { - let option = data.options.find((item) => item.id === id) - if (!option) return - - onChange(option.dataRef.current.value) - }) - let selectActiveOption = useEvent(() => { - if (data.activeOptionIndex !== null) { - let { dataRef, id } = data.options[data.activeOptionIndex] - onChange(dataRef.current.value) + if (data.activeOptionIndex === null) return - // It could happen that the `activeOptionIndex` stored in state is actually null, - // but we are getting the fallback active option back instead. - actions.goToOption(Focus.Specific, id) + if (data.virtual) { + onChange(data.virtual.options[data.activeOptionIndex]) + } else { + let { dataRef } = data.options[data.activeOptionIndex] + onChange(dataRef.current.value) } + + // It could happen that the `activeOptionIndex` stored in state is actually null, but we are + // getting the fallback active option back instead. + actions.goToOption(Focus.Specific, data.activeOptionIndex) }) let openCombobox = useEvent(() => { @@ -661,18 +821,18 @@ function ComboboxFn { + let goToOption = useEvent((focus, idx, trigger) => { defaultToFirstOption.current = false if (focus === Focus.Specific) { - return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id: id!, trigger }) + return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, idx: idx!, trigger }) } return dispatch({ type: ActionTypes.GoToOption, focus, trigger }) }) let registerOption = useEvent((id, dataRef) => { - dispatch({ type: ActionTypes.RegisterOption, id, dataRef }) + dispatch({ type: ActionTypes.RegisterOption, payload: { id, dataRef } }) return () => { // When we are unregistering the currently active option, then we also have to make sure to // reset the `defaultToFirstOption` flag, so that visually something is selected and the next @@ -683,7 +843,7 @@ function ComboboxFn { - actions.goToOption(Focus.Next) - }, - [ComboboxState.Closed]: () => { - actions.openCombobox() - }, + [ComboboxState.Open]: () => actions.goToOption(Focus.Next), + [ComboboxState.Closed]: () => actions.openCombobox(), }) case Keys.ArrowUp: @@ -1021,9 +1176,7 @@ function InputFn< event.preventDefault() event.stopPropagation() return match(data.comboboxState, { - [ComboboxState.Open]: () => { - actions.goToOption(Focus.Previous) - }, + [ComboboxState.Open]: () => actions.goToOption(Focus.Previous), [ComboboxState.Closed]: () => { actions.openCombobox() d.nextFrame(() => { @@ -1199,7 +1352,18 @@ function InputFn< 'aria-controls': data.optionsRef.current?.id, 'aria-expanded': data.comboboxState === ComboboxState.Open, 'aria-activedescendant': - data.activeOptionIndex === null ? undefined : data.options[data.activeOptionIndex]?.id, + data.activeOptionIndex === null + ? undefined + : data.virtual + ? data.options.find( + (option) => + !data.virtual?.disabled(option.dataRef.current.value) && + data.compare( + option.dataRef.current.value, + data.virtual!.options[data.activeOptionIndex!] + ) + )?.id + : data.options[data.activeOptionIndex]?.id, 'aria-labelledby': labelledby, 'aria-autocomplete': 'list', defaultValue: @@ -1392,6 +1556,7 @@ function LabelFn( let DEFAULT_OPTIONS_TAG = 'ul' as const interface OptionsRenderPropArg { open: boolean + option: unknown } type OptionsPropsWeControl = 'aria-labelledby' | 'aria-multiselectable' | 'role' | 'tabIndex' @@ -1451,7 +1616,7 @@ function OptionsFn( ) let slot = useMemo( - () => ({ open: data.comboboxState === ComboboxState.Open }), + () => ({ open: data.comboboxState === ComboboxState.Open, option: undefined }), [data] ) let ourProps = { @@ -1465,6 +1630,7 @@ function OptionsFn( // Map the children in a scrollable container when virtualization is enabled if (data.virtual && data.comboboxState === ComboboxState.Open) { Object.assign(theirProps, { + // @ts-expect-error The `children` prop now is a callback function that receives `{ option }`. children: {theirProps.children}, }) } @@ -1519,25 +1685,22 @@ function OptionFn< let data = useData('Combobox.Option') let actions = useActions('Combobox.Option') - let active = - data.activeOptionIndex !== null ? data.options[data.activeOptionIndex].id === id : false - - if (order === null && data.virtual) { - throw new Error( - `The \`order\` prop on is required when using .` - ) - } + let active = data.virtual + ? data.activeOptionIndex === data.calculateIndex(value) + : data.activeOptionIndex === null + ? false + : data.options[data.activeOptionIndex]?.id === id - let [, rerender] = useReducer((v) => !v, true) let selected = data.isSelected(value) let internalOptionRef = useRef(null) + let bag = useLatestValue['current']>({ disabled, value, domRef: internalOptionRef, order, - onVirtualRangeUpdate: rerender, }) + let virtualizer = useContext(VirtualContext) let optionRef = useSyncRefs( ref, @@ -1545,7 +1708,7 @@ function OptionFn< virtualizer ? virtualizer.measureElement : null ) - let select = useEvent(() => actions.selectOption(id)) + let select = useEvent(() => actions.onChange(value)) useIsoMorphicEffect(() => actions.registerOption(id, bag), [bag, id]) let enableScrollIntoView = useRef(data.virtual || data.__demoMode ? false : true) @@ -1578,7 +1741,7 @@ function OptionFn< ]) let handleClick = useEvent((event: { preventDefault: Function }) => { - if (disabled) return event.preventDefault() + if (disabled || data.virtual?.disabled(value)) return event.preventDefault() select() // We want to make sure that we don't accidentally trigger the virtual keyboard. @@ -1603,8 +1766,11 @@ function OptionFn< }) let handleFocus = useEvent(() => { - if (disabled) return actions.goToOption(Focus.Nothing) - actions.goToOption(Focus.Specific, id) + if (disabled || data.virtual?.disabled(value)) { + return actions.goToOption(Focus.Nothing) + } + let idx = data.calculateIndex(value) + actions.goToOption(Focus.Specific, idx) }) let pointer = useTrackedPointer() @@ -1613,14 +1779,15 @@ function OptionFn< let handleMove = useEvent((evt) => { if (!pointer.wasMoved(evt)) return - if (disabled) return + if (disabled || data.virtual?.disabled(value)) return if (active) return - actions.goToOption(Focus.Specific, id, ActivationTrigger.Pointer) + let idx = data.calculateIndex(value) + actions.goToOption(Focus.Specific, idx, ActivationTrigger.Pointer) }) let handleLeave = useEvent((evt) => { if (!pointer.wasMoved(evt)) return - if (disabled) return + if (disabled || data.virtual?.disabled(value)) return if (!active) return if (data.optionsPropsRef.current.hold) return actions.goToOption(Focus.Nothing) @@ -1631,43 +1798,6 @@ function OptionFn< [active, selected, disabled] ) - let virtualIdx = useMemo(() => { - if (!data.virtual) return -1 - return data.options.findIndex((o) => o.id === id) ?? 0 - }, [virtualizer, data.options, id]) - - let virtualItem = - virtualIdx === -1 - ? undefined - : (virtualizer?.getVirtualItems() ?? []).find((item) => item.index === virtualIdx) - - let d = useDisposables() - let shouldScroll = - virtualizer && data.activationTrigger !== ActivationTrigger.Pointer && data.virtual && active - - useEffect(() => { - if (!shouldScroll) return - - // Try scrolling to the item - virtualizer!.scrollToIndex(virtualIdx) - - // Ensure we scrolled to the correct location - ;(function ensureScrolledCorrectly() { - if (virtualizer?.isScrolling) { - d.requestAnimationFrame(ensureScrolledCorrectly) - return - } - - virtualizer!.scrollToIndex(virtualIdx) - })() - - return d.dispose - }, [active, virtualizer, virtualIdx, shouldScroll]) - - if (data.virtual && !virtualItem) { - return null - } - let ourProps = { id, ref: optionRef, @@ -1678,9 +1808,6 @@ function OptionFn< // multi-select,but Voice-Over disagrees. So we use aria-checked instead for // both single and multi-select. 'aria-selected': selected, - 'data-index': virtualizer && virtualIdx !== -1 ? virtualIdx : undefined, - 'aria-setsize': virtualizer ? data.options.length : undefined, - 'aria-posinset': virtualizer && virtualIdx !== -1 ? virtualIdx + 1 : undefined, disabled: undefined, // Never forward the `disabled` prop onClick: handleClick, onFocus: handleFocus, @@ -1692,21 +1819,6 @@ function OptionFn< onMouseLeave: handleLeave, } - if (virtualItem) { - let localOurProps = ourProps as typeof ourProps & { style: CSSProperties } - - localOurProps.style = { - ...localOurProps.style, - position: 'absolute', - top: 0, - left: 0, - transform: `translateY(${virtualItem.start}px)`, - } - - // Technically unnecessary - ourProps = localOurProps - } - return render({ ourProps, theirProps, diff --git a/packages/@headlessui-react/src/utils/calculate-active-index.ts b/packages/@headlessui-react/src/utils/calculate-active-index.ts index 16ed66ffb..bed4d6a44 100644 --- a/packages/@headlessui-react/src/utils/calculate-active-index.ts +++ b/packages/@headlessui-react/src/utils/calculate-active-index.ts @@ -27,8 +27,8 @@ export function calculateActiveIndex( resolvers: { resolveItems(): TItem[] resolveActiveIndex(): number | null - resolveId(item: TItem): string - resolveDisabled(item: TItem): boolean + resolveId(item: TItem, index: number, items: TItem[]): string + resolveDisabled(item: TItem, index: number, items: TItem[]): boolean } ) { let items = resolvers.resolveItems() @@ -37,48 +37,56 @@ export function calculateActiveIndex( let currentActiveIndex = resolvers.resolveActiveIndex() let activeIndex = currentActiveIndex ?? -1 - let nextActiveIndex = (() => { - switch (action.focus) { - case Focus.First: - return items.findIndex((item) => !resolvers.resolveDisabled(item)) - - case Focus.Previous: { - let idx = items - .slice() - .reverse() - .findIndex((item, idx, all) => { - if (activeIndex !== -1 && all.length - idx - 1 >= activeIndex) return false - return !resolvers.resolveDisabled(item) - }) - if (idx === -1) return idx - return items.length - 1 - idx + switch (action.focus) { + case Focus.First: { + for (let i = 0; i < items.length; ++i) { + if (!resolvers.resolveDisabled(items[i], i, items)) { + return i + } } + return currentActiveIndex + } - case Focus.Next: - return items.findIndex((item, idx) => { - if (idx <= activeIndex) return false - return !resolvers.resolveDisabled(item) - }) - - case Focus.Last: { - let idx = items - .slice() - .reverse() - .findIndex((item) => !resolvers.resolveDisabled(item)) - if (idx === -1) return idx - return items.length - 1 - idx + case Focus.Previous: { + for (let i = activeIndex - 1; i >= 0; --i) { + if (!resolvers.resolveDisabled(items[i], i, items)) { + return i + } } + return currentActiveIndex + } - case Focus.Specific: - return items.findIndex((item) => resolvers.resolveId(item) === action.id) + case Focus.Next: { + for (let i = activeIndex + 1; i < items.length; ++i) { + if (!resolvers.resolveDisabled(items[i], i, items)) { + return i + } + } + return currentActiveIndex + } - case Focus.Nothing: - return null + case Focus.Last: { + for (let i = items.length - 1; i >= 0; --i) { + if (!resolvers.resolveDisabled(items[i], i, items)) { + return i + } + } + return currentActiveIndex + } - default: - assertNever(action) + case Focus.Specific: { + for (let i = 0; i < items.length; ++i) { + if (resolvers.resolveId(items[i], i, items) === action.id) { + return i + } + } + return currentActiveIndex } - })() - return nextActiveIndex === -1 ? currentActiveIndex : nextActiveIndex + case Focus.Nothing: + return null + + default: + assertNever(action) + } } diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index 83338b2e7..b6c9894d4 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `immediate` prop to `` for immediately opening the Combobox when the `input` receives focus ([#2686](https://github.com/tailwindlabs/headlessui/pull/2686)) -- Add `virtual` prop to `Combobox` component ([#2740](https://github.com/tailwindlabs/headlessui/pull/2740)) +- Add `virtual` prop to `Combobox` component ([#2779](https://github.com/tailwindlabs/headlessui/pull/2779)) ## [1.7.16] - 2023-08-17 diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts index 52a07ba59..d7349f263 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts @@ -1967,31 +1967,79 @@ describe('Composition', () => { describe.each([{ virtual: true }, { virtual: false }])( 'Keyboard interactions %s', ({ virtual }) => { + let data = ['Option A', 'Option B', 'Option C'] + let MyCombobox = defineComponent({ + components: getDefaultComponents(), + template: html` + + + Trigger + + + {{ option?.children ?? option }} + + + + + + + Trigger + + + {{ option?.children ?? option }} + + + + `, + + props: { + options: { default: data.slice() }, + useComboboxOptions: { default: true }, + comboboxProps: {}, + inputProps: { default: {} }, + buttonProps: { default: {} }, + optionProps: { default: {} }, + }, + + setup(props) { + // @ts-expect-error + let { value = 'test', update, ...comboboxProps } = props.comboboxProps ?? {} + function isDisabled(option: any) { + return typeof option === 'string' + ? false + : typeof option === 'object' && option !== null && 'disabled' in option + ? option?.disabled ?? false + : false + } + + let model = ref(value) + watch([model], () => update?.(model.value)) + + return { + value: model, + comboboxProps, + isDisabled, + virtual: computed(() => { + return virtual ? { options: props.options, disabled: isDisabled } : null + }), + } + }, + }) + describe('Button', () => { describe('`Enter` key', () => { it( 'should be possible to open the Combobox with Enter', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), - h, + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -2032,24 +2080,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should not be possible to open the combobox with Enter when the button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -2077,24 +2109,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with Enter, and focus the selected option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref('b'), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -2138,19 +2154,13 @@ describe.each([{ virtual: true }, { virtual: false }])( renderTemplate({ template: html` - + Trigger - Option A - Option B - Option C + Option A + Option B + Option C `, @@ -2213,31 +2223,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with Enter, and focus the selected option (with a list of objects)', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - {{ option.name }} - - - `, - setup: () => { - let options = [ - { id: 'a', name: 'Option A' }, - { id: 'b', name: 'Option B' }, - { id: 'c', name: 'Option C' }, - ] - let value = ref(options[1]) - - return { value, options, virtual } - }, + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -2278,14 +2265,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -2312,24 +2293,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with Space', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -2368,24 +2333,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should not be possible to open the combobox with Space when the button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -2413,24 +2362,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with Space, and focus the selected option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref('b'), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -2470,14 +2403,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxList({ @@ -2500,24 +2427,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option upon Space key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - Option A - - - Option B - - - Option C - - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'alice', children: 'alice', disabled: true }, + { value: 'bob', children: 'bob', disabled: true }, + { value: 'charlie', children: 'charlie', disabled: true }, + ], + }), }) assertComboboxButton({ @@ -2544,24 +2462,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to close an open combobox with Escape', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Open combobox @@ -2597,24 +2499,8 @@ describe.each([{ virtual: true }, { virtual: false }])( suppressConsoleLogs(async () => { let handleKeyDown = jest.fn() renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) window.addEventListener('keydown', handleKeyDown) @@ -2637,24 +2523,8 @@ describe.each([{ virtual: true }, { virtual: false }])( suppressConsoleLogs(async () => { let handleKeyDown = jest.fn() renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) window.addEventListener('keydown', handleKeyDown) @@ -2678,24 +2548,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowDown', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref('test'), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -2733,24 +2587,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should not be possible to open the combobox with ArrowDown when the button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -2778,24 +2616,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowDown, and focus the selected option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref('b'), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -2833,14 +2655,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -2863,24 +2679,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowUp and the last option should be active', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -2918,24 +2718,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should not be possible to open the combobox with ArrowUp and the last option should be active when the button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -2963,24 +2747,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowUp, and focus the selected option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref('b'), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -3018,14 +2786,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -3046,24 +2808,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowUp to navigate the combobox options and jump to the first non-disabled one', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - - Option B - - - Option C - - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'alice', children: 'alice', disabled: false }, + { value: 'bob', children: 'bob', disabled: true }, + { value: 'charlie', children: 'charlie', disabled: true }, + ], + }), }) assertComboboxButton({ @@ -3095,27 +2848,17 @@ describe.each([{ virtual: true }, { virtual: false }])( suppressConsoleLogs(async () => { let handleChange = jest.fn() renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup() { - let value = ref(null) - watch([value], () => handleChange(value.value)) - return { value, virtual } + components: { MyCombobox }, + template: html``, + setup: () => { + let model = ref(null) + return { + value: model, + update: (value: any) => { + model.value = value + handleChange(value) + }, + } }, }) @@ -3144,7 +2887,7 @@ describe.each([{ virtual: true }, { virtual: false }])( // Verify we got the change event expect(handleChange).toHaveBeenCalledTimes(1) - expect(handleChange).toHaveBeenCalledWith('a') + expect(handleChange).toHaveBeenCalledWith('Option A') // Verify the button is focused again assertActiveElement(getComboboxInput()) @@ -3163,24 +2906,10 @@ describe.each([{ virtual: true }, { virtual: false }])( let submits = jest.fn() renderTemplate({ + components: { MyCombobox }, template: html`
- - - Trigger - - Option A - Option B - Option C - - - + `, @@ -3188,6 +2917,9 @@ describe.each([{ virtual: true }, { virtual: false }])( let value = ref('b') return { value, + update(newValue: any) { + value.value = newValue + }, handleKeyUp(event: KeyboardEvent) { // JSDom doesn't automatically submit the form but if we can // catch an `Enter` event, we can assume it was a submit. @@ -3221,29 +2953,19 @@ describe.each([{ virtual: true }, { virtual: false }])( let submits = jest.fn() renderTemplate({ + components: { MyCombobox }, template: html`
- - - Trigger - - Option A - Option B - Option C - - + `, setup() { let value = ref('b') return { value, + update(newValue: any) { + value.value = newValue + }, handleKeyUp(event: KeyboardEvent) { // JSDom doesn't automatically submit the form but if we can // catch an `Enter` event, we can assume it was a submit. @@ -3277,26 +2999,21 @@ describe.each([{ virtual: true }, { virtual: false }])( 'pressing Tab should select the active item and move to the next DOM node', suppressConsoleLogs(async () => { renderTemplate({ + components: { MyCombobox }, template: html` - - - Trigger - - Option A - Option B - Option C - - + `, - setup: () => ({ value: ref(null), virtual }), + setup: () => { + let value = ref(null) + return { + value, + update(newValue: any) { + value.value = newValue + }, + } + }, }) assertComboboxButton({ @@ -3319,7 +3036,7 @@ describe.each([{ virtual: true }, { virtual: false }])( assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // That the selected value was the highlighted one - expect(getComboboxInput()?.value).toBe('b') + expect(getComboboxInput()?.value).toBe('Option B') // And focus has moved to the next element assertActiveElement(document.querySelector('#after-combobox')) @@ -3330,26 +3047,21 @@ describe.each([{ virtual: true }, { virtual: false }])( 'pressing Shift+Tab should select the active item and move to the previous DOM node', suppressConsoleLogs(async () => { renderTemplate({ + components: { MyCombobox }, template: html` - - - Trigger - - Option A - Option B - Option C - - + `, - setup: () => ({ value: ref(null), virtual }), + setup: () => { + let value = ref(null) + return { + value, + update(newValue: any) { + value.value = newValue + }, + } + }, }) assertComboboxButton({ @@ -3372,7 +3084,7 @@ describe.each([{ virtual: true }, { virtual: false }])( assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // That the selected value was the highlighted one - expect(getComboboxInput()?.value).toBe('b') + expect(getComboboxInput()?.value).toBe('Option B') // And focus has moved to the next element assertActiveElement(document.querySelector('#before-combobox')) @@ -3385,24 +3097,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to close an open combobox with Escape', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Open combobox @@ -3436,19 +3132,13 @@ describe.each([{ virtual: true }, { virtual: false }])( renderTemplate({ template: html` - + Trigger - Option A - Option B - Option C + Option A + Option B + Option C `, @@ -3495,13 +3185,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should bubble escape when not using Combobox.Options at all', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) let spy = jest.fn() @@ -3544,31 +3229,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should sync the input field correctly and reset it when pressing Escape', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref('option-b'), virtual }), + components: { MyCombobox }, + template: html``, }) // Open combobox await click(getComboboxButton()) // Verify the input has the selected value - expect(getComboboxInput()?.value).toBe('option-b') + expect(getComboboxInput()?.value).toBe('Option B') // Override the input by typing something await type(word('test'), getComboboxInput()) @@ -3578,7 +3247,7 @@ describe.each([{ virtual: true }, { virtual: false }])( await press(Keys.Escape) // Verify the input is reset correctly - expect(getComboboxInput()?.value).toBe('option-b') + expect(getComboboxInput()?.value).toBe('Option B') }) ) }) @@ -3588,24 +3257,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowDown', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref('test'), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -3643,24 +3296,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should not be possible to open the combobox with ArrowDown when the button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -3688,24 +3325,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowDown, and focus the selected option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref('b'), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -3743,14 +3364,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -3771,24 +3386,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowDown to navigate the combobox options', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -3825,26 +3424,19 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowDown to navigate the combobox options and skip the first disabled one', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - Option A - - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: true }, + { value: 'b', children: 'Option B', disabled: false }, + { value: 'c', children: 'Option C', disabled: false }, + ], + }), }) + render + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, @@ -3870,24 +3462,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowDown to navigate the combobox options and jump to the first non-disabled one', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - Option A - - - Option B - - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: true }, + { value: 'b', children: 'Option B', disabled: true }, + { value: 'c', children: 'Option C', disabled: false }, + ], + }), }) assertComboboxButton({ @@ -3915,24 +3498,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to go to the next item if no value is set', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -3963,24 +3530,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowUp and the last option should be active', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -4018,24 +3569,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should not be possible to open the combobox with ArrowUp and the last option should be active when the button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -4063,24 +3598,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to open the combobox with ArrowUp, and focus the selected option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref('b'), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -4118,14 +3637,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -4146,24 +3659,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowUp to navigate the combobox options and jump to the first non-disabled one', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - - Option B - - - Option C - - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: false }, + { value: 'b', children: 'Option B', disabled: true }, + { value: 'c', children: 'Option C', disabled: true }, + ], + }), }) assertComboboxButton({ @@ -4190,24 +3694,15 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should not be possible to navigate up or down if there is only a single non-disabled option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - Option A - - - Option B - - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: true }, + { value: 'b', children: 'Option B', disabled: true }, + { value: 'c', children: 'Option C', disabled: false }, + ], + }), }) assertComboboxButton({ @@ -4242,24 +3737,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use ArrowUp to navigate the combobox options', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -4309,24 +3788,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the End key to go to the last combobox option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Open combobox @@ -4347,27 +3810,16 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the End key to go to the last non disabled combobox option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - - Option C - - - Option D - - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: false }, + { value: 'b', children: 'Option B', disabled: false }, + { value: 'c', children: 'Option C', disabled: true }, + { value: 'd', children: 'Option D', disabled: true }, + ], + }), }) // Open combobox @@ -4388,27 +3840,16 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the End key to go to the first combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - - Option B - - - Option C - - - Option D - - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: false }, + { value: 'b', children: 'Option B', disabled: true }, + { value: 'c', children: 'Option C', disabled: true }, + { value: 'd', children: 'Option D', disabled: true }, + ], + }), }) // Open combobox @@ -4430,27 +3871,16 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option upon End key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: true }, + { value: 'b', children: 'Option B', disabled: true }, + { value: 'c', children: 'Option C', disabled: true }, + { value: 'd', children: 'Option D', disabled: true }, + ], + }), }) // Open combobox @@ -4472,24 +3902,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageDown key to go to the last combobox option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Open combobox @@ -4510,27 +3924,16 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageDown key to go to the last non disabled Combobox option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - - Option C - - - Option D - - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: false }, + { value: 'b', children: 'Option B', disabled: false }, + { value: 'c', children: 'Option C', disabled: true }, + { value: 'd', children: 'Option D', disabled: true }, + ], + }), }) // Open combobox @@ -4554,27 +3957,16 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageDown key to go to the first combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - - Option B - - - Option C - - - Option D - - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: false }, + { value: 'b', children: 'Option B', disabled: true }, + { value: 'c', children: 'Option C', disabled: true }, + { value: 'd', children: 'Option D', disabled: true }, + ], + }), }) // Open combobox @@ -4596,27 +3988,16 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option upon PageDown key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: true }, + { value: 'b', children: 'Option B', disabled: true }, + { value: 'c', children: 'Option C', disabled: true }, + { value: 'd', children: 'Option D', disabled: true }, + ], + }), }) // Open combobox @@ -4638,24 +4019,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the Home key to go to the first combobox option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Focus the input @@ -4679,27 +4044,16 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the Home key to go to the first non disabled combobox option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - Option A - - - Option B - - Option C - Option D - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: true }, + { value: 'b', children: 'Option B', disabled: true }, + { value: 'c', children: 'Option C', disabled: false }, + { value: 'd', children: 'Option D', disabled: false }, + ], + }), }) // Open combobox @@ -4722,27 +4076,16 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the Home key to go to the last combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - Option A - - - Option B - - - Option C - - Option D - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: true }, + { value: 'b', children: 'Option B', disabled: true }, + { value: 'c', children: 'Option C', disabled: true }, + { value: 'd', children: 'Option D', disabled: false }, + ], + }), }) // Open combobox @@ -4764,27 +4107,16 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option upon Home key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: true }, + { value: 'b', children: 'Option B', disabled: true }, + { value: 'c', children: 'Option C', disabled: true }, + { value: 'd', children: 'Option D', disabled: true }, + ], + }), }) // Open combobox @@ -4806,24 +4138,8 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageUp key to go to the first combobox option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Focus the input @@ -4847,27 +4163,16 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageUp key to go to the first non disabled combobox option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - Option A - - - Option B - - Option C - Option D - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: true }, + { value: 'b', children: 'Option B', disabled: true }, + { value: 'c', children: 'Option C', disabled: false }, + { value: 'd', children: 'Option D', disabled: false }, + ], + }), }) // Open combobox @@ -4889,27 +4194,16 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to use the PageUp key to go to the last combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - Option A - - - Option B - - - Option C - - Option D - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: true }, + { value: 'b', children: 'Option B', disabled: true }, + { value: 'c', children: 'Option C', disabled: true }, + { value: 'd', children: 'Option D', disabled: false }, + ], + }), }) // Open combobox @@ -4931,27 +4225,16 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should have no active combobox option upon PageUp key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - - Option A - - - Option B - - - Option C - - - Option D - - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'a', children: 'Option A', disabled: true }, + { value: 'b', children: 'Option B', disabled: true }, + { value: 'c', children: 'Option C', disabled: true }, + { value: 'd', children: 'Option D', disabled: true }, + ], + }), }) // Open combobox @@ -4975,7 +4258,7 @@ describe.each([{ virtual: true }, { virtual: false }])( let handleChange = jest.fn() renderTemplate({ template: html` - + Trigger @@ -5036,25 +4319,31 @@ describe.each([{ virtual: true }, { virtual: false }])( }) describe('`Any` key aka search', () => { - let Example = defineComponent({ + let MyCombobox = defineComponent({ components: getDefaultComponents(), template: html` - + Trigger {{ person.name }} + + + Trigger + + {{ person.name }} + + `, props: { @@ -5082,7 +4371,15 @@ describe.each([{ virtual: true }, { virtual: false }])( setQuery: (event: Event & { target: HTMLInputElement }) => { query.value = event.target.value }, - virtual, + virtual: computed(() => { + return virtual + ? { + options: filteredPeople.value, + disabled: (person: { value: string; name: string; disabled: boolean }) => + person?.disabled ?? false, + } + : null + }), } }, }) @@ -5091,9 +4388,9 @@ describe.each([{ virtual: true }, { virtual: false }])( 'should be possible to type a full word that has a perfect match', suppressConsoleLogs(async () => { renderTemplate({ - components: { Example }, + components: { MyCombobox }, template: html` - { renderTemplate({ - components: { Example }, + components: { MyCombobox }, template: html` - { renderTemplate({ - components: { Example }, + components: { MyCombobox }, template: html` - + Trigger {{ activeIndex }} - {{ option }} @@ -5378,23 +4675,87 @@ describe.each([{ virtual: true }, { virtual: false }])( ) describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', ({ virtual }) => { + let data = ['Option A', 'Option B', 'Option C'] + let MyCombobox = defineComponent({ + components: getDefaultComponents(), + template: html` + + Label + + Trigger + + + {{ option?.children ?? option }} + + + + + + Label + + Trigger + + + {{ option?.children ?? option }} + + + + `, + + props: { + options: { default: data.slice() }, + label: { default: true }, + useComboboxOptions: { default: true }, + comboboxProps: {}, + inputProps: { default: {} }, + buttonProps: { default: {} }, + optionProps: { default: {} }, + optionsProps: { default: {} }, + }, + + setup(props) { + // @ts-expect-error + let { value = 'test', update, ...comboboxProps } = props.comboboxProps ?? {} + function isDisabled(option: any) { + return typeof option === 'string' + ? false + : typeof option === 'object' && option !== null && 'disabled' in option + ? option?.disabled ?? false + : false + } + + let model = ref(value) + watch([model], () => update?.(model.value)) + + return { + value: model, + comboboxProps, + isDisabled, + virtual: computed(() => { + return virtual ? { options: props.options, disabled: isDisabled } : null + }), + } + }, + }) + it( 'should focus the ComboboxButton when we click the ComboboxLabel', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - Label - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Ensure the button is not focused yet @@ -5412,19 +4773,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should not focus the ComboboxInput when we right click the ComboboxLabel', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - Label - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Ensure the button is not focused yet @@ -5442,18 +4792,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to open the combobox by focusing the input with immediate mode enabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref('test'), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -5478,9 +4818,6 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) - - // Verify that the first combobox option is active - assertActiveComboboxOption(options[0]) }) ) @@ -5488,23 +4825,13 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should not be possible to open the combobox by focusing the input with immediate mode disabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref('test'), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: { id: 'headlessui-combobox-button-3' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -5524,23 +4851,13 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should not be possible to open the combobox by focusing the input with immediate mode enabled when button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref('test'), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + attributes: { id: 'headlessui-combobox-button-3' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) @@ -5560,18 +4877,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to close a combobox on click with immediate mode enabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Open combobox @@ -5594,18 +4901,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to close a focused combobox on click with immediate mode enabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) @@ -5630,18 +4927,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to open the combobox on click', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -5673,18 +4960,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should not be possible to open the combobox on right click', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -5705,18 +4982,11 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should not be possible to open the combobox on click when the button is disabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -5741,18 +5011,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to open the combobox on click, and focus the selected option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref('b'), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -5787,18 +5047,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to close a combobox on click', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Open combobox @@ -5820,20 +5070,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be a no-op when we click outside of a closed combobox', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - alice - bob - charlie - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Verify that the window is closed @@ -5853,21 +5091,11 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to click outside of the combobox which should close the combobox', suppressConsoleLogs(async () => { renderTemplate({ + components: { MyCombobox }, template: html` - - - Trigger - - alice - bob - charlie - - -
after
+ +
after
`, - setup: () => ({ value: ref(null), virtual }), }) // Open combobox @@ -5890,38 +5118,13 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to click outside of the combobox on another combobox button which should close the current combobox and open the new combobox', suppressConsoleLogs(async () => { renderTemplate({ + components: { MyCombobox }, template: html`
- - - Trigger - - alice - bob - charlie - - - - - - Trigger - - alice - bob - charlie - - + +
`, - setup: () => ({ value: ref(null), virtual }), }) let [button1, button2] = getComboboxButtons() @@ -5947,20 +5150,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to click outside of the combobox which should close the combobox (even if we press the combobox button)', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - alice - bob - charlie - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Open combobox @@ -5984,28 +5175,18 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', suppressConsoleLogs(async () => { let focusFn = jest.fn() renderTemplate({ + components: { MyCombobox }, template: html`
- - - Trigger - - alice - bob - charlie - - - +
`, - setup: () => ({ value: ref('test'), focusFn, virtual }), + setup: () => ({ + onFocus: focusFn, + }), }) // Click the combobox button @@ -6032,20 +5213,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to hover an option and make it active', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - alice - bob - charlie - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Open combobox @@ -6073,15 +5242,13 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie `, @@ -6110,20 +5277,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should make a combobox option active when you move the mouse over it', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - alice - bob - charlie - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Open combobox @@ -6140,20 +5295,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be a no-op when we move the mouse and the combobox option is already active', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - alice - bob - charlie - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Open combobox @@ -6176,22 +5319,15 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be a no-op when we move the mouse and the combobox option is disabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - alice - - bob - - charlie - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'alice', children: 'alice', disabled: false }, + { value: 'bob', children: 'bob', disabled: true }, + { value: 'charlie', children: 'charlie', disabled: false }, + ], + }), }) // Open combobox @@ -6208,22 +5344,15 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should not be possible to hover an option that is disabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - alice - - bob - - charlie - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'alice', children: 'alice', disabled: false }, + { value: 'bob', children: 'bob', disabled: true }, + { value: 'charlie', children: 'charlie', disabled: false }, + ], + }), }) // Open combobox @@ -6243,20 +5372,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to mouse leave an option and make it inactive', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - alice - bob - charlie - - - `, - setup: () => ({ value: ref('bob'), virtual }), + components: { MyCombobox }, + template: html``, }) // Open combobox @@ -6291,22 +5408,15 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to mouse leave a disabled option and be a no-op', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - alice - - bob - - charlie - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'alice', children: 'alice', disabled: false }, + { value: 'bob', children: 'bob', disabled: true }, + { value: 'charlie', children: 'charlie', disabled: false }, + ], + }), }) // Open combobox @@ -6328,23 +5438,17 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', suppressConsoleLogs(async () => { let handleChange = jest.fn() renderTemplate({ - template: html` - - - Trigger - - alice - bob - charlie - - - `, - setup() { + components: { MyCombobox }, + template: html``, + setup: () => { let value = ref(null) - watch([value], () => handleChange(value.value)) - return { value, virtual } + return { + value, + update(newValue: any) { + value.value = newValue + handleChange(newValue) + }, + } }, }) @@ -6359,7 +5463,7 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', await click(options[1]) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) expect(handleChange).toHaveBeenCalledTimes(1) - expect(handleChange).toHaveBeenCalledWith('bob') + expect(handleChange).toHaveBeenCalledWith('Option B') // Verify the input is focused again assertActiveElement(getComboboxInput()) @@ -6376,20 +5480,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to click a combobox option, which closes the combobox with immediate mode enabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - alice - bob - charlie - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) // Open combobox by focusing input @@ -6411,25 +5503,22 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', suppressConsoleLogs(async () => { let handleChange = jest.fn() renderTemplate({ - template: html` - - - Trigger - - alice - - bob - - charlie - - - `, - setup() { + components: { MyCombobox }, + template: html``, + setup: () => { let value = ref(null) - watch([value], () => handleChange(value.value)) - return { value, virtual } + return { + value, + options: [ + { value: 'alice', children: 'Alice', disabled: false }, + { value: 'bob', children: 'Bob', disabled: true }, + { value: 'charile', children: 'Charlie', disabled: false }, + ], + update(newValue: any) { + value.value = newValue + handleChange(newValue) + }, + } }, }) @@ -6464,20 +5553,17 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible focus a combobox option, so that it becomes active', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - alice - bob - charlie - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => { + let value = ref(null) + return { + value, + update(newValue: any) { + value.value = newValue + }, + } + }, }) // Open combobox @@ -6500,22 +5586,15 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should not be possible to focus a combobox option which is disabled', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - alice - bob - charlie - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, + setup: () => ({ + options: [ + { value: 'alice', disabled: false, children: 'alice' }, + { value: 'bob', disabled: true, children: 'bob' }, + { value: 'charlie', disabled: false, children: 'charlie' }, + ], + }), }) // Open combobox @@ -6535,18 +5614,8 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', 'should be possible to hold the last active option', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null), virtual }), + components: { MyCombobox }, + template: html``, }) assertComboboxButton({ @@ -6591,20 +5660,28 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie + + + + + Trigger + + {{ option }} `, - setup: () => ({ value: ref('bob'), virtual }), + setup: () => ({ + value: ref('bob'), + virtual: computed(() => (virtual ? { options: ['alice', 'bob', 'charlie'] } : null)), + }), }) // Open combobox @@ -6630,36 +5707,47 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', suppressConsoleLogs(async () => { renderTemplate({ template: html` - - + + + Trigger + + {{ person.name }} + + + + Trigger - alice - bob - charlie{{ person.name }} - + `, - setup: () => ({ value: ref('bob'), virtual }), - }) + setup: () => { + let people = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ] + return { + people, + value: ref(people[1]), + virtual: computed(() => (virtual ? { options: people } : null)), + } + }, + }) // Open combobox await click(getComboboxButton()) // Verify the input has the selected value - expect(getComboboxInput()?.value).toBe('bob') + expect(getComboboxInput()?.value).toBe('Bob') // Override the input by typing something - await type(word('alice'), getComboboxInput()) - expect(getComboboxInput()?.value).toBe('alice') - - // Select the option - await press(Keys.ArrowUp) - await press(Keys.Enter) - expect(getComboboxInput()?.value).toBe('alice') + await type(word('test'), getComboboxInput()) + expect(getComboboxInput()?.value).toBe('test') // Reset from outside await click(getByText('reset')) @@ -6672,34 +5760,28 @@ describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', it( 'should sync the input field correctly and reset it when resetting the value from outside (when using displayValue)', suppressConsoleLogs(async () => { + let people = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ] renderTemplate({ + components: { MyCombobox }, template: html` - - - Trigger - - {{ person.name }} - - + `, setup: () => { - let people = [ - { id: 1, name: 'Alice' }, - { id: 2, name: 'Bob' }, - { id: 3, name: 'Charlie' }, - ] - + let value = ref(people[1]) return { - people, - value: ref(people[1]), - virtual, + value, + update(newValue: any) { + value.value = newValue + }, } }, }) diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index 0ba0ae14e..8555c76b5 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -1,6 +1,7 @@ import type { Virtualizer } from '@tanstack/virtual-core' import { useVirtualizer } from '@tanstack/vue-virtual' import { + cloneVNode, computed, defineComponent, Fragment, @@ -12,18 +13,14 @@ import { provide, reactive, ref, - shallowRef, toRaw, watch, watchEffect, - watchPostEffect, type ComputedRef, - type CSSProperties, type InjectionKey, type PropType, type Ref, type UnwrapNestedRefs, - type UnwrapRef, } from 'vue' import { useControllable } from '../../hooks/use-controllable' import { useId } from '../../hooks/use-id' @@ -70,7 +67,6 @@ type ComboboxOptionData = { value: unknown domRef: Ref order: Ref - onVirtualRangeUpdate: (virtualizer: Virtualizer) => void } type StateDefinition = { // State @@ -81,7 +77,14 @@ type StateDefinition = { mode: ComputedRef nullable: ComputedRef immediate: ComputedRef - virtual: ComputedRef + + virtual: ComputedRef<{ + options: unknown[] + disabled: (value: unknown) => boolean + } | null> + calculateIndex(value: unknown): number + isSelected(value: unknown): boolean + isActive(value: unknown): boolean compare: (a: unknown, z: unknown) => boolean @@ -94,7 +97,6 @@ type StateDefinition = { disabled: Ref options: Ref<{ id: string; dataRef: ComputedRef }[]> - indexes: Ref> activeOptionIndex: Ref activationTrigger: Ref @@ -102,12 +104,12 @@ type StateDefinition = { closeCombobox(): void openCombobox(): void setActivationTrigger(trigger: ActivationTrigger): void - goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger): void + goToOption(focus: Focus, idx?: number, trigger?: ActivationTrigger): void change(value: unknown): void selectOption(id: string): void selectActiveOption(): void registerOption(id: string, dataRef: ComputedRef): void - unregisterOption(id: string): void + unregisterOption(id: string, active: boolean): void select(value: unknown): void } @@ -134,14 +136,6 @@ let VirtualProvider = defineComponent({ setup(_, { slots }) { let api = useComboboxContext('VirtualProvider') - let measuredHeight = computed(() => { - let firstAvailableOption = api.options.value.find( - (option) => dom(option.dataRef.value.domRef) !== null - ) - let height = dom(firstAvailableOption?.dataRef.value.domRef)?.getBoundingClientRect().height - return height ?? 40 - }) - let padding = computed(() => { let el = dom(api.optionsRef) if (!el) return { start: 0, end: 0 } @@ -159,45 +153,86 @@ let VirtualProvider = defineComponent({ return { scrollPaddingStart: padding.value.start, scrollPaddingEnd: padding.value.end, - count: api.options.value.length, + count: api.virtual.value!.options.length, estimateSize() { - return measuredHeight.value + return 40 }, getScrollElement() { return dom(api.optionsRef) }, overscan: 12, - onChange(event) { - let list = event.getVirtualItems() - if (list.length === 0) return - - let min = list[0].index - let max = list[list.length - 1].index + 1 - - for (let option of api.options.value.slice(min, max)) { - let dataRef = option.dataRef as unknown as UnwrapRef - dataRef.onVirtualRangeUpdate(event) - } - }, } }) ) + let options = computed(() => api.virtual.value?.options) + let baseKey = ref(0) + watch([options], () => { + baseKey.value += 1 + }) + provide(VirtualContext, api.virtual.value ? virtualizer : null) - return () => [ - h( - 'div', - { - style: { - position: 'relative', - width: '100%', - height: `${virtualizer.value.getTotalSize()}px`, + return () => { + return [ + h( + 'div', + { + style: { + position: 'relative', + width: '100%', + height: `${virtualizer.value.getTotalSize()}px`, + }, + ref: (el) => { + if (!el) { + return + } + + // Scroll to the active index + { + // Ignore this when we are in a test environment + if (typeof process !== 'undefined' && process.env.JEST_WORKER_ID !== undefined) { + return + } + + // Do not scroll when the mouse/pointer is being used + if (api.activationTrigger.value === ActivationTrigger.Pointer) { + return + } + + if ( + api.activeOptionIndex.value !== null && + api.virtual.value!.options.length > api.activeOptionIndex.value + ) { + virtualizer.value.scrollToIndex(api.activeOptionIndex.value) + } + } + }, }, - }, - slots.default?.() - ), - ] + virtualizer.value.getVirtualItems().map((item) => { + return cloneVNode( + slots.default!({ + option: api.virtual.value!.options[item.index], + open: api.comboboxState.value === ComboboxStates.Open, + })![0], + { + key: `${baseKey.value}-${item.index}`, + 'data-index': item.index, + 'aria-setsize': api.virtual.value!.options.length, + 'aria-posinset': item.index + 1, + style: { + position: 'absolute', + top: 0, + left: 0, + transform: `translateY(${item.start}px)`, + overflowAnchor: 'none', + }, + } + ) + }) + ), + ] + } }, }) @@ -209,7 +244,7 @@ export let Combobox = defineComponent({ props: { as: { type: [Object, String], default: 'template' }, disabled: { type: [Boolean], default: false }, - by: { type: [String, Function], default: () => defaultComparator }, + by: { type: [String, Function], nullable: true, default: null }, modelValue: { type: [Object, String, Number, Boolean] as PropType< object | string | number | boolean | null @@ -227,7 +262,13 @@ export let Combobox = defineComponent({ nullable: { type: Boolean, default: false }, multiple: { type: [Boolean], default: false }, immediate: { type: [Boolean], default: false }, - virtual: { type: [Boolean], default: false }, + virtual: { + type: Object as PropType boolean + }>, + default: null, + }, }, inheritAttrs: false, setup(props, { slots, attrs, emit }) { @@ -243,19 +284,12 @@ export let Combobox = defineComponent({ hold: false, }) as StateDefinition['optionsPropsRef'] let options = ref([]) - let indexes = shallowRef>({}) let activeOptionIndex = ref(null) let activationTrigger = ref( ActivationTrigger.Other ) let defaultToFirstOption = ref(false) - // This is not a "computed" ref because we eventually - // want to calculate this only when the length or order can actually change - function recalculateIndexes() { - indexes.value = Object.fromEntries(options.value.map((v, idx) => [v.id, idx])) - } - function adjustOrderedState( adjustment: ( options: UnwrapNestedRefs @@ -310,7 +344,44 @@ export let Combobox = defineComponent({ let goToOptionRaf: ReturnType | null = null let orderOptionsRaf: ReturnType | null = null - let api = { + function onChange(value: unknown) { + return match(mode.value, { + [ValueMode.Single]() { + return theirOnChange?.(value) + }, + [ValueMode.Multi]: () => { + let copy = toRaw(api.value.value as unknown[]).slice() + let raw = toRaw(value) + + let idx = copy.findIndex((value) => api.compare(raw, toRaw(value))) + if (idx === -1) { + copy.push(raw) + } else { + copy.splice(idx, 1) + } + + return theirOnChange?.(copy) + }, + }) + } + + let virtualOptions = computed(() => props.virtual?.options) + watch([virtualOptions], ([newOptions], [oldOptions]) => { + if (!api.virtual.value) return + if (!newOptions) return + if (!oldOptions) return + + if (activeOptionIndex.value !== null) { + let idx = newOptions.indexOf(oldOptions[activeOptionIndex.value]) + if (idx !== -1) { + activeOptionIndex.value = idx + } else { + activeOptionIndex.value = null + } + } + }) + + let api: StateDefinition = { comboboxState, value, mode, @@ -319,19 +390,42 @@ export let Combobox = defineComponent({ let property = props.by as unknown as any return a?.[property] === z?.[property] } + + if (props.by === null) { + return defaultComparator(a, z) + } + return props.by(a, z) }, + calculateIndex(value: any) { + if (api.virtual.value) { + if (props.by === null) { + return api.virtual.value!.options.indexOf(value) + } else { + return api.virtual.value!.options.findIndex((other) => api.compare(other, value)) + } + } else { + return options.value.findIndex((other) => api.compare(other.dataRef.value, value)) + } + }, defaultValue: computed(() => props.defaultValue), nullable, immediate: computed(() => props.immediate), - virtual: computed(() => props.virtual), + virtual: computed(() => { + return props.virtual + ? { + options: props.virtual.options, + disabled: props.virtual.disabled ?? (() => false), + } + : null + }), inputRef, labelRef, buttonRef, optionsRef, disabled: computed(() => props.disabled), + // @ts-expect-error dateRef types are incorrect due to unwrapped or wrapped refs options, - indexes, change(value: unknown) { theirOnChange(value as typeof props.modelValue) }, @@ -339,8 +433,18 @@ export let Combobox = defineComponent({ if ( defaultToFirstOption.value && activeOptionIndex.value === null && - options.value.length > 0 + (api.virtual.value ? api.virtual.value.options.length > 0 : options.value.length > 0) ) { + if (api.virtual.value) { + let localActiveOptionIndex = api.virtual.value.options.findIndex( + (option) => !api.virtual.value?.disabled(option) + ) + + if (localActiveOptionIndex !== -1) { + return localActiveOptionIndex + } + } + let localActiveOptionIndex = options.value.findIndex((option) => !option.dataRef.disabled) if (localActiveOptionIndex !== -1) { return localActiveOptionIndex @@ -365,22 +469,12 @@ export let Combobox = defineComponent({ if (props.disabled) return if (comboboxState.value === ComboboxStates.Open) return - // Check if we have a selected value that we can make active. - let optionIdx = options.value.findIndex((option) => { - let optionValue = toRaw(option.dataRef.value) - let selected = match(mode.value, { - [ValueMode.Single]: () => api.compare(toRaw(api.value.value), toRaw(optionValue)), - [ValueMode.Multi]: () => - (toRaw(api.value.value) as unknown[]).some((value) => - api.compare(toRaw(value), toRaw(optionValue)) - ), - }) - - return selected - }) - - if (optionIdx !== -1) { - activeOptionIndex.value = optionIdx + // Check if we have a selected value that we can make active + if (api.value.value) { + let idx = api.calculateIndex(api.value.value) + if (idx !== -1) { + activeOptionIndex.value = idx + } } comboboxState.value = ComboboxStates.Open @@ -388,7 +482,7 @@ export let Combobox = defineComponent({ setActivationTrigger(trigger: ActivationTrigger) { activationTrigger.value = trigger }, - goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger) { + goToOption(focus: Focus, idx?: number, trigger?: ActivationTrigger) { defaultToFirstOption.value = false if (goToOptionRaf !== null) { @@ -405,6 +499,33 @@ export let Combobox = defineComponent({ return } + if (api.virtual.value) { + activeOptionIndex.value = + focus === Focus.Specific + ? idx! + : calculateActiveIndex( + { focus: focus as Exclude }, + { + resolveItems: () => api.virtual.value!.options, + resolveActiveIndex: () => { + return ( + api.activeOptionIndex.value ?? + api.virtual.value!.options.findIndex( + (option) => !api.virtual.value?.disabled(option) + ) ?? + null + ) + }, + resolveDisabled: (item) => api.virtual.value!.disabled(item), + resolveId() { + throw new Error('Function not implemented.') + }, + } + ) + activationTrigger.value = trigger ?? ActivationTrigger.Other + return + } + let adjustedState = adjustOrderedState() // It's possible that the activeOptionIndex is set to `null` internally, but @@ -420,22 +541,22 @@ export let Combobox = defineComponent({ } } - let nextActiveOptionIndex = calculateActiveIndex( + let nextActiveOptionIndex = focus === Focus.Specific - ? { focus: Focus.Specific, id: id! } - : { focus: focus as Exclude }, - { - resolveItems: () => adjustedState.options, - resolveActiveIndex: () => adjustedState.activeOptionIndex, - resolveId: (option) => option.id, - resolveDisabled: (option) => option.dataRef.disabled, - } - ) + ? idx! + : calculateActiveIndex( + { focus: focus as Exclude }, + { + resolveItems: () => adjustedState.options, + resolveActiveIndex: () => adjustedState.activeOptionIndex, + resolveId: (option) => option.id, + resolveDisabled: (option) => option.dataRef.disabled, + } + ) activeOptionIndex.value = nextActiveOptionIndex activationTrigger.value = trigger ?? ActivationTrigger.Other options.value = adjustedState.options - recalculateIndexes() }) }, selectOption(id: string) { @@ -443,77 +564,44 @@ export let Combobox = defineComponent({ if (!option) return let { dataRef } = option - theirOnChange( - match(mode.value, { - [ValueMode.Single]: () => dataRef.value, - [ValueMode.Multi]: () => { - let copy = toRaw(api.value.value as unknown[]).slice() - let raw = toRaw(dataRef.value) - - let idx = copy.findIndex((value) => api.compare(raw, toRaw(value))) - if (idx === -1) { - copy.push(raw) - } else { - copy.splice(idx, 1) - } - return copy - }, - }) - ) + onChange(dataRef.value) }, selectActiveOption() { if (api.activeOptionIndex.value === null) return - let { dataRef, id } = options.value[api.activeOptionIndex.value] - theirOnChange( - match(mode.value, { - [ValueMode.Single]: () => dataRef.value, - [ValueMode.Multi]: () => { - let copy = toRaw(api.value.value as unknown[]).slice() - let raw = toRaw(dataRef.value) - - let idx = copy.findIndex((value) => api.compare(raw, toRaw(value))) - if (idx === -1) { - copy.push(raw) - } else { - copy.splice(idx, 1) - } - - return copy - }, - }) - ) + if (api.virtual.value) { + onChange(api.virtual.value.options[api.activeOptionIndex.value]) + } else { + let { dataRef } = options.value[api.activeOptionIndex.value] + onChange(dataRef.value) + } // It could happen that the `activeOptionIndex` stored in state is actually null, // but we are getting the fallback active option back instead. - api.goToOption(Focus.Specific, id) + api.goToOption(Focus.Specific, api.activeOptionIndex.value) }, - registerOption(id: string, dataRef: ComboboxOptionData) { - if (orderOptionsRaf) cancelAnimationFrame(orderOptionsRaf) - + registerOption(id: string, dataRef: ComputedRef) { let option = reactive({ id, dataRef }) as unknown as { id: typeof id - dataRef: typeof dataRef + dataRef: typeof dataRef['value'] + } + + if (api.virtual.value) { + options.value.push(option) + return } + if (orderOptionsRaf) cancelAnimationFrame(orderOptionsRaf) + let adjustedState = adjustOrderedState((options) => { options.push(option) return options }) - // Check if we have a selected value that we can make active. + // Check if we need to make the newly registered option active. if (activeOptionIndex.value === null) { - let optionValue = (dataRef.value as any).value - let selected = match(mode.value, { - [ValueMode.Single]: () => api.compare(toRaw(api.value.value), toRaw(optionValue)), - [ValueMode.Multi]: () => - (toRaw(api.value.value) as unknown[]).some((value) => - api.compare(toRaw(value), toRaw(optionValue)) - ), - }) - - if (selected) { + if (api.isSelected(dataRef.value.value)) { adjustedState.activeOptionIndex = adjustedState.options.indexOf(option) } } @@ -521,7 +609,6 @@ export let Combobox = defineComponent({ options.value = adjustedState.options activeOptionIndex.value = adjustedState.activeOptionIndex activationTrigger.value = ActivationTrigger.Other - recalculateIndexes() // If some of the DOM elements aren't ready yet, then we can retry in the next tick. if (adjustedState.options.some((option) => !dom(option.dataRef.domRef))) { @@ -530,11 +617,14 @@ export let Combobox = defineComponent({ options.value = adjustedState.options activeOptionIndex.value = adjustedState.activeOptionIndex - recalculateIndexes() }) } }, - unregisterOption(id: string) { + unregisterOption(id: string, active: boolean) { + if (goToOptionRaf !== null) { + cancelAnimationFrame(goToOptionRaf) + } + // When we are unregistering the currently active option, then we also have to make sure to // reset the `defaultToFirstOption` flag, so that visually something is selected and the // next time you press a key on your keyboard it will go to the proper next or previous @@ -544,15 +634,17 @@ export let Combobox = defineComponent({ // to the very first option seems like a fine default. We _could_ be smarter about this by // going to the previous / next item in list if we know the direction of the keyboard // navigation, but that might be too complex/confusing from an end users perspective. - if ( - api.activeOptionIndex.value !== null && - api.options.value[api.activeOptionIndex.value]?.id === id - ) { + if (active) { defaultToFirstOption.value = true } + if (api.virtual.value) { + options.value = options.value.filter((option) => option.id !== id) + return + } + let adjustedState = adjustOrderedState((options) => { - let idx = options.findIndex((a) => a.id === id) + let idx = options.findIndex((option) => option.id === id) if (idx !== -1) options.splice(idx, 1) return options }) @@ -560,7 +652,18 @@ export let Combobox = defineComponent({ options.value = adjustedState.options activeOptionIndex.value = adjustedState.activeOptionIndex activationTrigger.value = ActivationTrigger.Other - recalculateIndexes() + }, + isSelected(other) { + return match(mode.value, { + [ValueMode.Single]: () => api.compare(toRaw(api.value.value), toRaw(other)), + [ValueMode.Multi]: () => + (toRaw(api.value.value) as unknown[]).some((option) => + api.compare(toRaw(option), toRaw(other)) + ), + }) + }, + isActive(other) { + return activeOptionIndex.value === api.calculateIndex(other) }, } @@ -571,8 +674,8 @@ export let Combobox = defineComponent({ computed(() => comboboxState.value === ComboboxStates.Open) ) - // @ts-expect-error Types of property 'dataRef' are incompatible. provide(ComboboxContext, api) + useOpenClosedProvider( computed(() => match(comboboxState.value, { @@ -582,12 +685,6 @@ export let Combobox = defineComponent({ ) ) - let activeOption = computed(() => - api.activeOptionIndex.value === null - ? null - : (options.value[api.activeOptionIndex.value].dataRef.value as any) - ) - let form = computed(() => dom(inputRef)?.closest('form')) onMounted(() => { watch( @@ -616,7 +713,12 @@ export let Combobox = defineComponent({ open: comboboxState.value === ComboboxStates.Open, disabled, activeIndex: api.activeOptionIndex.value, - activeOption: activeOption.value, + activeOption: + api.activeOptionIndex.value === null + ? null + : api.virtual.value + ? api.virtual.value.options[api.activeOptionIndex.value ?? 0] + : api.options.value[api.activeOptionIndex.value]?.dataRef.value.value ?? null, value: value.value, } @@ -1182,6 +1284,16 @@ export let ComboboxInput = defineComponent({ 'aria-activedescendant': api.activeOptionIndex.value === null ? undefined + : api.virtual.value + ? api.options.value.find((option) => { + return ( + !api.virtual.value!.disabled(option.dataRef.value) && + api.compare( + option.dataRef.value, + api.virtual.value!.options[api.activeOptionIndex.value!] + ) + ) + })?.id : api.options.value[api.activeOptionIndex.value]?.id, 'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id, 'aria-autocomplete': 'list', @@ -1309,29 +1421,15 @@ export let ComboboxOption = defineComponent({ expose({ el: internalOptionRef, $el: internalOptionRef }) - watchEffect(() => { - if (props.order === null && api.virtual.value) { - throw new Error( - `The \`order\` prop on is required when using .` - ) - } - }) - let active = computed(() => { - return api.activeOptionIndex.value !== null - ? api.options.value[api.activeOptionIndex.value].id === id - : false + return api.virtual.value + ? api.activeOptionIndex.value === api.calculateIndex(props.value) + : api.activeOptionIndex.value === null + ? false + : api.options.value[api.activeOptionIndex.value]?.id === id }) - let selected = computed(() => - match(api.mode.value, { - [ValueMode.Single]: () => api.compare(toRaw(api.value.value), toRaw(props.value)), - [ValueMode.Multi]: () => - (toRaw(api.value.value) as unknown[]).some((value) => - api.compare(toRaw(value), toRaw(props.value)) - ), - }) - ) + let selected = computed(() => api.isSelected(props.value)) let virtualizer = inject(VirtualContext, null) let dataRef = computed(() => ({ @@ -1339,11 +1437,10 @@ export let ComboboxOption = defineComponent({ value: props.value, domRef: internalOptionRef, order: computed(() => props.order), - onVirtualRangeUpdate: () => {}, })) onMounted(() => api.registerOption(id, dataRef)) - onUnmounted(() => api.unregisterOption(id)) + onUnmounted(() => api.unregisterOption(id, active.value)) watchEffect(() => { let el = dom(internalOptionRef) @@ -1361,7 +1458,7 @@ export let ComboboxOption = defineComponent({ }) function handleClick(event: MouseEvent) { - if (props.disabled) return event.preventDefault() + if (props.disabled || api.virtual.value?.disabled(props.value)) return event.preventDefault() api.selectOption(id) // We want to make sure that we don't accidentally trigger the virtual keyboard. @@ -1386,8 +1483,11 @@ export let ComboboxOption = defineComponent({ } function handleFocus() { - if (props.disabled) return api.goToOption(Focus.Nothing) - api.goToOption(Focus.Specific, id) + if (props.disabled || api.virtual.value?.disabled(props.value)) { + return api.goToOption(Focus.Nothing) + } + let idx = api.calculateIndex(props.value) + api.goToOption(Focus.Specific, idx) } let pointer = useTrackedPointer() @@ -1398,66 +1498,21 @@ export let ComboboxOption = defineComponent({ function handleMove(evt: PointerEvent) { if (!pointer.wasMoved(evt)) return - if (props.disabled) return + if (props.disabled || api.virtual.value?.disabled(props.value)) return if (active.value) return - api.goToOption(Focus.Specific, id, ActivationTrigger.Pointer) + let idx = api.calculateIndex(props.value) + api.goToOption(Focus.Specific, idx, ActivationTrigger.Pointer) } function handleLeave(evt: PointerEvent) { if (!pointer.wasMoved(evt)) return - if (props.disabled) return + if (props.disabled || api.virtual.value?.disabled(props.value)) return if (!active.value) return if (api.optionsPropsRef.value.hold) return api.goToOption(Focus.Nothing) } - let virtualIdx = computed(() => { - if (!api.virtual.value) return -1 - return api.indexes.value[id] ?? 0 - }) - - let virtualItem = computed(() => { - return virtualIdx.value === -1 - ? undefined - : virtualizer?.value.getVirtualItems().find((item) => item.index === virtualIdx.value) - }) - - let d = disposables() - onUnmounted(() => d.dispose()) - - let shouldScroll = computed(() => { - return ( - virtualizer?.value && - api.activationTrigger.value !== ActivationTrigger.Pointer && - api.virtual.value && - active.value - ) - }) - - watchPostEffect((onCleanup) => { - if (!shouldScroll.value) return - - // Try scrolling to the item - virtualizer!.value.scrollToIndex(virtualIdx.value) - - // Ensure we scrolled to the correct location - ;(function ensureScrolledCorrectly() { - if (virtualizer?.value.isScrolling) { - d.requestAnimationFrame(ensureScrolledCorrectly) - return - } - - virtualizer!.value.scrollToIndex(virtualIdx.value) - })() - - onCleanup(d.dispose) - }) - return () => { - if (api.virtual.value && !virtualItem.value) { - return null - } - let { disabled } = props let slot = { active: active.value, selected: selected.value, disabled } let ourProps = { @@ -1470,9 +1525,6 @@ export let ComboboxOption = defineComponent({ // multi-select,but Voice-Over disagrees. So we use aria-selected instead for // both single and multi-select. 'aria-selected': selected.value, - 'data-index': virtualizer && virtualIdx.value !== -1 ? virtualIdx.value : undefined, - 'aria-setsize': virtualizer ? api.options.value.length : undefined, - 'aria-posinset': virtualizer && virtualIdx.value !== -1 ? virtualIdx.value + 1 : undefined, disabled: undefined, // Never forward the `disabled` prop onClick: handleClick, onFocus: handleFocus, @@ -1484,22 +1536,7 @@ export let ComboboxOption = defineComponent({ onMouseleave: handleLeave, } - if (virtualItem.value) { - let localOurProps = ourProps as typeof ourProps & { style: CSSProperties } - - localOurProps.style = { - ...localOurProps.style, - position: 'absolute', - top: 0, - left: 0, - transform: `translateY(${virtualItem.value!.start}px)`, - } - - // Technically unnecessary - ourProps = localOurProps - } - - let theirProps = omit(props, ['order']) + let theirProps = omit(props, ['order', 'value']) return render({ ourProps, diff --git a/packages/@headlessui-vue/src/utils/calculate-active-index.ts b/packages/@headlessui-vue/src/utils/calculate-active-index.ts index 16ed66ffb..bed4d6a44 100644 --- a/packages/@headlessui-vue/src/utils/calculate-active-index.ts +++ b/packages/@headlessui-vue/src/utils/calculate-active-index.ts @@ -27,8 +27,8 @@ export function calculateActiveIndex( resolvers: { resolveItems(): TItem[] resolveActiveIndex(): number | null - resolveId(item: TItem): string - resolveDisabled(item: TItem): boolean + resolveId(item: TItem, index: number, items: TItem[]): string + resolveDisabled(item: TItem, index: number, items: TItem[]): boolean } ) { let items = resolvers.resolveItems() @@ -37,48 +37,56 @@ export function calculateActiveIndex( let currentActiveIndex = resolvers.resolveActiveIndex() let activeIndex = currentActiveIndex ?? -1 - let nextActiveIndex = (() => { - switch (action.focus) { - case Focus.First: - return items.findIndex((item) => !resolvers.resolveDisabled(item)) - - case Focus.Previous: { - let idx = items - .slice() - .reverse() - .findIndex((item, idx, all) => { - if (activeIndex !== -1 && all.length - idx - 1 >= activeIndex) return false - return !resolvers.resolveDisabled(item) - }) - if (idx === -1) return idx - return items.length - 1 - idx + switch (action.focus) { + case Focus.First: { + for (let i = 0; i < items.length; ++i) { + if (!resolvers.resolveDisabled(items[i], i, items)) { + return i + } } + return currentActiveIndex + } - case Focus.Next: - return items.findIndex((item, idx) => { - if (idx <= activeIndex) return false - return !resolvers.resolveDisabled(item) - }) - - case Focus.Last: { - let idx = items - .slice() - .reverse() - .findIndex((item) => !resolvers.resolveDisabled(item)) - if (idx === -1) return idx - return items.length - 1 - idx + case Focus.Previous: { + for (let i = activeIndex - 1; i >= 0; --i) { + if (!resolvers.resolveDisabled(items[i], i, items)) { + return i + } } + return currentActiveIndex + } - case Focus.Specific: - return items.findIndex((item) => resolvers.resolveId(item) === action.id) + case Focus.Next: { + for (let i = activeIndex + 1; i < items.length; ++i) { + if (!resolvers.resolveDisabled(items[i], i, items)) { + return i + } + } + return currentActiveIndex + } - case Focus.Nothing: - return null + case Focus.Last: { + for (let i = items.length - 1; i >= 0; --i) { + if (!resolvers.resolveDisabled(items[i], i, items)) { + return i + } + } + return currentActiveIndex + } - default: - assertNever(action) + case Focus.Specific: { + for (let i = 0; i < items.length; ++i) { + if (resolvers.resolveId(items[i], i, items) === action.id) { + return i + } + } + return currentActiveIndex } - })() - return nextActiveIndex === -1 ? currentActiveIndex : nextActiveIndex + case Focus.Nothing: + return null + + default: + assertNever(action) + } } diff --git a/packages/playground-react/next.config.js b/packages/playground-react/next.config.js index cff704b71..800d1db69 100644 --- a/packages/playground-react/next.config.js +++ b/packages/playground-react/next.config.js @@ -1,5 +1,5 @@ module.exports = { - reactStrictMode: true, + reactStrictMode: false, devIndicators: { autoPrerender: false, }, diff --git a/packages/playground-react/pages/_app.tsx b/packages/playground-react/pages/_app.tsx index 37cef297e..9ec6e6500 100644 --- a/packages/playground-react/pages/_app.tsx +++ b/packages/playground-react/pages/_app.tsx @@ -150,6 +150,7 @@ function MyApp({ Component, pageProps }) { + (React) diff --git a/packages/playground-react/pages/combobox/combobox-virtualized.tsx b/packages/playground-react/pages/combobox/combobox-virtualized.tsx index 6e295fc85..3d2b7bd6c 100644 --- a/packages/playground-react/pages/combobox/combobox-virtualized.tsx +++ b/packages/playground-react/pages/combobox/combobox-virtualized.tsx @@ -1,27 +1,61 @@ import { Combobox } from '@headlessui/react' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { Button } from '../../components/button' -import { timezones as allTimezones } from '../../data' +import { timezones as _allTimezones } from '../../data' import { classNames } from '../../utils/class-names' export default function Home() { + let [count, setCount] = useState(1_000) + + let list = useMemo(() => { + console.time('Generating list') + let result = [] + + while (result.length < count) { + let batch = Math.floor(result.length / _allTimezones.length) + 1 + result.push(`${_allTimezones[result.length % _allTimezones.length]} #${batch}`) + } + console.timeEnd('Generating list') + + return result + }, [count]) + return ( -
- - +
+ + +
+ + +
) } -function Example({ virtual = true, initial }: { virtual?: boolean; initial: string }) { +let nf = new Intl.NumberFormat('en-US') +function Example({ virtual = true, data, initial }: { virtual?: boolean; data; initial: string }) { let [query, setQuery] = useState('') let [activeTimezone, setActiveTimezone] = useState(initial) let timezones = query === '' - ? allTimezones - : allTimezones.filter((timezone) => timezone.toLowerCase().includes(query.toLowerCase())) + ? data + : data.filter((timezone) => timezone.toLowerCase().includes(query.toLowerCase())) return (
@@ -29,7 +63,7 @@ function Example({ virtual = true, initial }: { virtual?: boolean; initial: stri
Selected timezone: {activeTimezone}
{ @@ -39,7 +73,10 @@ function Example({ virtual = true, initial }: { virtual?: boolean; initial: stri as="div" > - Timezone {virtual ? `(virtual)` : ''} + Timezone{' '} + {virtual + ? `(virtual — ${nf.format(timezones.length)} items)` + : `(${nf.format(timezones.length)} items)`}
@@ -68,52 +105,106 @@ function Example({ virtual = true, initial }: { virtual?: boolean; initial: stri
- - {timezones.map((timezone, idx) => { - return ( - { - return classNames( - 'relative w-full cursor-default select-none py-2 pl-3 pr-9 focus:outline-none', - active ? 'bg-indigo-600 text-white' : 'text-gray-900' - ) - }} - > - {({ active, selected }) => ( - <> - - {timezone} - - {selected && ( + {virtual ? ( + + { + // @ts-expect-error TODO + ({ option, idx }) => { + return ( + { + return classNames( + 'relative w-full cursor-default select-none py-2 pl-3 pr-9 focus:outline-none', + active ? 'bg-indigo-600 text-white' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + + {option} + + {selected && ( + + + + + + )} + + )} + + ) + } + } + + ) : ( + + {timezones.map((timezone, idx) => { + return ( + { + return classNames( + 'relative w-full cursor-default select-none py-2 pl-3 pr-9 focus:outline-none', + active ? 'bg-indigo-600 text-white' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> - - - + {timezone} - )} - - )} - - ) - })} - + {selected && ( + + + + + + )} + + )} + + ) + })} + + )}
diff --git a/packages/playground-vue/src/Layout.vue b/packages/playground-vue/src/Layout.vue index 29614fa26..1dd73b5b8 100644 --- a/packages/playground-vue/src/Layout.vue +++ b/packages/playground-vue/src/Layout.vue @@ -59,6 +59,7 @@ + (Vue)
diff --git a/packages/playground-vue/src/components/combobox/_virtual-example.vue b/packages/playground-vue/src/components/combobox/_virtual-example.vue index 57d102932..9e4c56918 100644 --- a/packages/playground-vue/src/components/combobox/_virtual-example.vue +++ b/packages/playground-vue/src/components/combobox/_virtual-example.vue @@ -5,7 +5,12 @@
- Timezone {{ virtual ? '(virtual)' : '' }} + Timezone + {{ + virtual + ? `(virtual — ${nf.format(timezones.length)} items)` + : `(${nf.format(timezones.length)} items)` + }}
@@ -37,6 +42,7 @@
+ + +
  • + + {{ timezone }} + + + + + + +
  • +
    +
    @@ -83,7 +122,7 @@ diff --git a/packages/playground-vue/src/components/combobox/combobox-virtualized.vue b/packages/playground-vue/src/components/combobox/combobox-virtualized.vue index 811fd3867..a1c8fdc90 100644 --- a/packages/playground-vue/src/components/combobox/combobox-virtualized.vue +++ b/packages/playground-vue/src/components/combobox/combobox-virtualized.vue @@ -1,10 +1,37 @@