diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index b4563ba..b519b5a 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -20,6 +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)) ## [1.7.17] - 2023-08-17 diff --git a/packages/@headlessui-react/package.json b/packages/@headlessui-react/package.json index dcddbf8..7e6b359 100644 --- a/packages/@headlessui-react/package.json +++ b/packages/@headlessui-react/package.json @@ -51,6 +51,7 @@ "snapshot-diff": "^0.8.1" }, "dependencies": { + "@tanstack/react-virtual": "^3.0.0-beta.60", "client-only": "^0.0.1" } } diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index 0986824..55ddcf1 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -35,6 +35,7 @@ import { mouseLeave, mouseMove, press, + rawClick, shift, type, word, @@ -45,6 +46,12 @@ import { Combobox } from './combobox' let NOOP = () => {} +global.ResizeObserver = class FakeResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + jest.mock('../../hooks/use-id') beforeAll(() => { @@ -1207,6 +1214,57 @@ describe('Rendering', () => { assertActiveComboboxOption(options[2]) }) + it('should guarantee the order of options based on `order` when performing actions', async () => { + function Example({ hide = false }) { + return ( + <> + console.log(x)}> + + Trigger + + + Option 1 + + {!hide && ( + + Option 2 + + )} + + Option 3 + + + + + ) + } + + let { rerender } = render() + + // Open the Combobox + await click(getByText('Trigger')) + + rerender() // Remove Combobox.Option 2 + rerender() // Re-add Combobox.Option 2 + + assertComboboxList({ state: ComboboxState.Visible }) + + let options = getComboboxOptions() + + // Verify that the first combobox option is active + assertActiveComboboxOption(options[0]) + + await press(Keys.ArrowDown) + + // Verify that the second combobox option is active + assertActiveComboboxOption(options[1]) + + await press(Keys.ArrowDown) + + // Verify that the third combobox option is active + assertActiveComboboxOption(options[2]) + }) + describe('Uncontrolled', () => { it('should be possible to use in an uncontrolled way', async () => { let handleSubmission = jest.fn() @@ -1749,7 +1807,7 @@ describe('Composition', () => { }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - await click(getComboboxButton()) + await rawClick(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, @@ -1760,7 +1818,7 @@ describe('Composition', () => { textContent: JSON.stringify({ active: true, selected: false, disabled: false }), }) - await click(getComboboxButton()) + await rawClick(getComboboxButton()) // Verify that we tracked the `mounts` and `unmounts` in the correct order expect(orderFn.mock.calls).toEqual([ @@ -1774,1024 +1832,1081 @@ describe('Composition', () => { ) }) -describe('Keyboard interactions', () => { - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - await focus(getComboboxButton()) - - // Open combobox - await press(Keys.Enter) - - // Verify we moved focus to the input field - assertActiveElement(getComboboxInput()) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option, { selected: false })) - - assertActiveComboboxOption(options[0]) - assertNoSelectedComboboxOption() - }) - ) - - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Try to focus the button - await focus(getComboboxButton()) - - // Try to open the combobox - await press(Keys.Enter) - - // Verify it is still closed - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - }) - ) - - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - await focus(getComboboxButton()) - - // Open combobox - await press(Keys.Enter) - - // Verify we moved focus to the input field - assertActiveElement(getComboboxInput()) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - 'should be possible to open the combobox with Enter, and focus the selected option (when using the `hidden` render strategy)', - suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - Option A - Option B - Option C - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleHidden, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleHidden }) - - // Focus the button - await focus(getComboboxButton()) - - // Open combobox - await press(Keys.Enter) - - // Verify we moved focus to the input field - assertActiveElement(getComboboxInput()) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - let options = getComboboxOptions() - - // Hover over Option A - await mouseMove(options[0]) - - // Verify that Option A is active - assertActiveComboboxOption(options[0]) - - // Verify that Option B is still selected - assertComboboxOption(options[1], { selected: true }) - - // Close/Hide the combobox - await press(Keys.Escape) - - // Re-open the combobox - await click(getComboboxButton()) - - // Verify we have combobox options - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - 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) => ( - - {myOption.name} +describe.each([{ virtual: true }, { virtual: false }])( + 'Keyboard interactions %s', + ({ virtual }) => { + 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 + + + + ) - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + await focus(getComboboxButton()) + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option, { selected: false })) + + assertActiveComboboxOption(options[0]) + assertNoSelectedComboboxOption() }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + ) - // Focus the button - await focus(getComboboxButton()) - - // Open combobox - await press(Keys.Enter) - - // Verify we moved focus to the input field - assertActiveElement(getComboboxInput()) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - 'should have no active combobox option when there are no combobox options at all', - suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - ) - - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - await focus(getComboboxButton()) - - // Open combobox - await press(Keys.Enter) - - // Verify we moved focus to the input field - assertActiveElement(getComboboxInput()) - - assertComboboxList({ state: ComboboxState.Visible }) - assertActiveElement(getComboboxInput()) - - assertNoActiveComboboxOption() - }) - ) - }) - - describe('`Space` key', () => { - it( - 'should be possible to open the combobox with Space', - suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - Option A - Option B - Option C - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - await focus(getComboboxButton()) - - // Open combobox - await press(Keys.Space) - - // Verify we moved focus to the input field - assertActiveElement(getComboboxInput()) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[0]) - }) - ) - - it( - 'should not be possible to open the combobox with Space when the button is disabled', - suppressConsoleLogs(async () => { - render( - console.log(x)} disabled> - - Trigger - - Option A - Option B - Option C - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - await focus(getComboboxButton()) - - // Try to open the combobox - await press(Keys.Space) - - // Verify it is still closed - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - }) - ) - - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ - state: ComboboxState.InvisibleUnmounted, - }) - - // Focus the button - await focus(getComboboxButton()) - - // Open combobox - await press(Keys.Space) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - 'should have no active combobox option when there are no combobox options at all', - suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - ) - - assertComboboxList({ - state: ComboboxState.InvisibleUnmounted, - }) - - // Focus the button - await focus(getComboboxButton()) - - // Open combobox - await press(Keys.Space) - assertComboboxList({ state: ComboboxState.Visible }) - assertActiveElement(getComboboxInput()) - - assertNoActiveComboboxOption() - }) - ) - - it( - '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({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ - state: ComboboxState.InvisibleUnmounted, - }) - - // Focus the button - await focus(getComboboxButton()) - - // Open combobox - await press(Keys.Space) - - assertNoActiveComboboxOption() - }) - ) - }) - - describe('`Escape` key', () => { - it( - 'should be possible to close an open combobox with Escape', - suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - Option A - Option B - Option C - - - ) - - // Open combobox - await click(getComboboxButton()) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Re-focus the button - await focus(getComboboxButton()) - assertActiveElement(getComboboxButton()) - - // Close combobox - await press(Keys.Escape) - - // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Verify the input is focused again - assertActiveElement(getComboboxInput()) - }) - ) - - it( - 'should not propagate the Escape event when the combobox is open', - suppressConsoleLogs(async () => { - let handleKeyDown = jest.fn() - render( -
- console.log(x)}> + 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 + + Option A + + + Option B + + + Option C + -
- ) + ) - // Open combobox - await click(getComboboxButton()) + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - // Close combobox - await press(Keys.Escape) + // Try to focus the button + await focus(getComboboxButton()) - // We should never see the Escape event - expect(handleKeyDown).toHaveBeenCalledTimes(0) - }) - ) + // Try to open the combobox + await press(Keys.Enter) - it( - 'should propagate the Escape event when the combobox is closed', - suppressConsoleLogs(async () => { - let handleKeyDown = jest.fn() - render( -
- console.log(x)}> + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + 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 + + Option A + + + Option B + + + Option C + -
- ) + ) - // Focus the input field - await focus(getComboboxInput()) + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - // Close combobox - await press(Keys.Escape) + // Focus the button + await focus(getComboboxButton()) - // We should never see the Escape event - expect(handleKeyDown).toHaveBeenCalledTimes(1) - }) - ) - }) + // Open combobox + await press(Keys.Enter) - describe('`ArrowDown` key', () => { - it( - 'should be possible to open the combobox with ArrowDown', - suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - Option A - Option B - Option C - - - ) + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + ) - // Focus the button - await focus(getComboboxButton()) + it( + 'should be possible to open the combobox with Enter, and focus the selected option (when using the `hidden` render strategy)', + suppressConsoleLogs(async () => { + if (virtual) return // Incompatible with virtual rendering - // Open combobox - await press(Keys.ArrowDown) + render( + console.log(x)}> + + Trigger + + + Option A + + + Option B + + + Option C + + + + ) - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + assertComboboxButton({ + state: ComboboxState.InvisibleHidden, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleHidden }) + + // Focus the button + await focus(getComboboxButton()) + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + let options = getComboboxOptions() + + // Hover over Option A + await mouseMove(options[0]) + + // Verify that Option A is active + assertActiveComboboxOption(options[0]) + + // Verify that Option B is still selected + assertComboboxOption(options[1], { selected: true }) + + // Close/Hide the combobox + await press(Keys.Escape) + + // Re-open the combobox + await click(getComboboxButton()) + + // Verify we have combobox options + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() + ) - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) + 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} + + ))} + + + ) - // Verify that the first combobox option is active - assertActiveComboboxOption(options[0]) - }) - ) + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - 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 - - - ) + // Focus the button + await focus(getComboboxButton()) - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + ) - // Focus the button - await focus(getComboboxButton()) + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + render( + console.log(x)}> + + Trigger + + + ) - // Try to open the combobox - await press(Keys.ArrowDown) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - // Verify it is still closed - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + // Focus the button + await focus(getComboboxButton()) + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - }) - ) - - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - await focus(getComboboxButton()) - - // Open combobox - await press(Keys.ArrowDown) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - 'should have no active combobox option when there are no combobox options at all', - suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - ) - - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - await focus(getComboboxButton()) - - // Open combobox - await press(Keys.ArrowDown) - assertComboboxList({ state: ComboboxState.Visible }) - assertActiveElement(getComboboxInput()) - - assertNoActiveComboboxOption() - }) - ) - }) - - describe('`ArrowUp` key', () => { - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - await focus(getComboboxButton()) - - // Open combobox - await press(Keys.ArrowUp) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - - // ! ALERT: The LAST option should now be active - assertActiveComboboxOption(options[2]) - }) - ) - - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - await focus(getComboboxButton()) - - // Try to open the combobox - await press(Keys.ArrowUp) - - // Verify it is still closed - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - }) - ) - - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - await focus(getComboboxButton()) - - // Open combobox - await press(Keys.ArrowUp) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - 'should have no active combobox option when there are no combobox options at all', - suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - ) - - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - await focus(getComboboxButton()) - - // Open combobox - await press(Keys.ArrowUp) - assertComboboxList({ state: ComboboxState.Visible }) - assertActiveElement(getComboboxInput()) - - assertNoActiveComboboxOption() - }) - ) - - it( - '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({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - await focus(getComboboxButton()) - - // Open combobox - await press(Keys.ArrowUp) - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[0]) - }) - ) - }) - }) - - 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 - > - 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', () => { + describe('`Space` key', () => { + it( + 'should be possible to open the combobox with Space', + suppressConsoleLogs(async () => { + render( + console.log(x)}> + + Trigger + + + Option A + + + Option B + + + Option C + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + await focus(getComboboxButton()) + + // Open combobox + await press(Keys.Space) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + }) + ) + + 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 + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + await focus(getComboboxButton()) + + // Try to open the combobox + await press(Keys.Space) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + 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 + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ + state: ComboboxState.InvisibleUnmounted, + }) + + // Focus the button + await focus(getComboboxButton()) + + // Open combobox + await press(Keys.Space) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + render( + console.log(x)}> + + Trigger + + + ) + + assertComboboxList({ + state: ComboboxState.InvisibleUnmounted, + }) + + // Focus the button + await focus(getComboboxButton()) + + // Open combobox + await press(Keys.Space) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + '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({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ + state: ComboboxState.InvisibleUnmounted, + }) + + // Focus the button + await focus(getComboboxButton()) + + // Open combobox + await press(Keys.Space) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`Escape` key', () => { + it( + 'should be possible to close an open combobox with Escape', + suppressConsoleLogs(async () => { + render( + console.log(x)}> + + Trigger + + + Option A + + + Option B + + + Option C + + + + ) + + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Re-focus the button + await focus(getComboboxButton()) + assertActiveElement(getComboboxButton()) + + // Close combobox + await press(Keys.Escape) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify the input is focused again + assertActiveElement(getComboboxInput()) + }) + ) + + it( + 'should not propagate the Escape event when the combobox is open', + suppressConsoleLogs(async () => { + let handleKeyDown = jest.fn() + render( +
+ console.log(x)}> + + Trigger + + + Option A + + + Option B + + + Option C + + + +
+ ) + + // Open combobox + await click(getComboboxButton()) + + // Close combobox + await press(Keys.Escape) + + // We should never see the Escape event + expect(handleKeyDown).toHaveBeenCalledTimes(0) + }) + ) + + it( + 'should propagate the Escape event when the combobox is closed', + suppressConsoleLogs(async () => { + let handleKeyDown = jest.fn() + render( +
+ console.log(x)}> + + Trigger + + + Option A + + + Option B + + + Option C + + + +
+ ) + + // Focus the input field + await focus(getComboboxInput()) + + // Close combobox + await press(Keys.Escape) + + // We should never see the Escape event + expect(handleKeyDown).toHaveBeenCalledTimes(1) + }) + ) + }) + + describe('`ArrowDown` key', () => { + it( + 'should be possible to open the combobox with ArrowDown', + suppressConsoleLogs(async () => { + render( + console.log(x)}> + + Trigger + + + Option A + + + Option B + + + Option C + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + await focus(getComboboxButton()) + + // Open combobox + await press(Keys.ArrowDown) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + + // Verify that the first combobox option is active + assertActiveComboboxOption(options[0]) + }) + ) + + 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 + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + await focus(getComboboxButton()) + + // Try to open the combobox + await press(Keys.ArrowDown) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + 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 + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + await focus(getComboboxButton()) + + // Open combobox + await press(Keys.ArrowDown) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + render( + console.log(x)}> + + Trigger + + + ) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + await focus(getComboboxButton()) + + // Open combobox + await press(Keys.ArrowDown) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`ArrowUp` key', () => { + 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 + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + await focus(getComboboxButton()) + + // Open combobox + await press(Keys.ArrowUp) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + + // ! ALERT: The LAST option should now be active + assertActiveComboboxOption(options[2]) + }) + ) + + 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 + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + await focus(getComboboxButton()) + + // Try to open the combobox + await press(Keys.ArrowUp) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + 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 + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + await focus(getComboboxButton()) + + // Open combobox + await press(Keys.ArrowUp) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + render( + console.log(x)}> + + Trigger + + + ) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + await focus(getComboboxButton()) + + // Open combobox + await press(Keys.ArrowUp) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + '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({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + await focus(getComboboxButton()) + + // Open combobox + await press(Keys.ArrowUp) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + }) + ) + }) + }) + + describe('`Backspace` key', () => { it( - 'should be possible to close the combobox with Enter and choose the active combobox option', + '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(undefined) + let [value, setValue] = useState('bob') + let [, setQuery] = useState('') return ( { setValue(value) handleChange(value) }} + nullable > - + setQuery(event.target.value)} /> Trigger - Option A - Option B - Option C + + Alice + + + Bob + + + Charlie + ) @@ -2814,1847 +2936,2099 @@ describe('Keyboard interactions', () => { render() - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - // Open combobox await click(getComboboxButton()) - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) + let options: ReturnType - // Activate the first combobox option - let options = getComboboxOptions() - await mouseMove(options[0]) + // Bob should be active + options = getComboboxOptions() + expect(getComboboxInput()).toHaveValue('bob') + assertActiveComboboxOption(options[1]) - // Choose option, and close combobox - await press(Keys.Enter) - - // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Verify we got the change event - expect(handleChange).toHaveBeenCalledTimes(1) - expect(handleChange).toHaveBeenCalledWith('a') - - // Verify the button is focused again assertActiveElement(getComboboxInput()) - // Open combobox again - await click(getComboboxButton()) + // Delete a character + await press(Keys.Backspace) + expect(getComboboxInput()?.value).toBe('bo') + assertActiveComboboxOption(options[1]) - // Verify the active option is the previously selected one - assertActiveComboboxOption(getComboboxOptions()[0]) - }) - ) - - it( - 'should submit the form on `Enter`', - suppressConsoleLogs(async () => { - let submits = jest.fn() - - function Example() { - let [value, setValue] = useState('b') - - return ( -
{ - // JSDom doesn't automatically submit the form but if we can - // catch an `Enter` event, we can assume it was a submit. - if (event.key === 'Enter') event.currentTarget.submit() - }} - onSubmit={(event) => { - event.preventDefault() - submits([...new FormData(event.currentTarget).entries()]) - }} - > - - - Trigger - - Option A - Option B - Option C - - - - -
- ) - } - - render() - - // Focus the input field - await focus(getComboboxInput()) - assertActiveElement(getComboboxInput()) - - // Press enter (which should submit the form) - await press(Keys.Enter) - - // Verify the form was submitted - expect(submits).toHaveBeenCalledTimes(1) - expect(submits).toHaveBeenCalledWith([['option', 'b']]) - }) - ) - - it( - 'should submit the form on `Enter` (when no submit button was found)', - suppressConsoleLogs(async () => { - let submits = jest.fn() - - function Example() { - let [value, setValue] = useState('b') - - return ( -
{ - // JSDom doesn't automatically submit the form but if we can - // catch an `Enter` event, we can assume it was a submit. - if (event.key === 'Enter') event.currentTarget.submit() - }} - onSubmit={(event) => { - event.preventDefault() - submits([...new FormData(event.currentTarget).entries()]) - }} - > - - - Trigger - - Option A - Option B - Option C - - -
- ) - } - - render() - - // Focus the input field - await focus(getComboboxInput()) - assertActiveElement(getComboboxInput()) - - // Press enter (which should submit the form) - await press(Keys.Enter) - - // Verify the form was submitted - expect(submits).toHaveBeenCalledTimes(1) - expect(submits).toHaveBeenCalledWith([['option', 'b']]) - }) - ) - }) - - describe('`Tab` key', () => { - it( - 'pressing Tab should select the active item and move to the next DOM node', - suppressConsoleLogs(async () => { - function Example() { - let [value, setValue] = useState(undefined) - - return ( - <> - - - - Trigger - - Option A - Option B - Option C - - - - - ) - } - - render() - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Open combobox - await click(getComboboxButton()) - - // Select the 2nd option - await press(Keys.ArrowDown) - - // Tab to the next DOM node - await press(Keys.Tab) - - // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // That the selected value was the highlighted one + // Delete a character + await press(Keys.Backspace) expect(getComboboxInput()?.value).toBe('b') - - // And focus has moved to the next element - assertActiveElement(document.querySelector('#after-combobox')) - }) - ) - - it( - 'pressing Shift+Tab should select the active item and move to the previous DOM node', - suppressConsoleLogs(async () => { - function Example() { - let [value, setValue] = useState(undefined) - - return ( - <> - - - - Trigger - - Option A - Option B - Option C - - - - - ) - } - - render() - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Open combobox - await click(getComboboxButton()) - - // Select the 2nd option - await press(Keys.ArrowDown) - - // Tab to the next DOM node - await press(shift(Keys.Tab)) - - // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // That the selected value was the highlighted one - expect(getComboboxInput()?.value).toBe('b') - - // And focus has moved to the next element - assertActiveElement(document.querySelector('#before-combobox')) - }) - ) - }) - - describe('`Escape` key', () => { - it( - 'should be possible to close an open combobox with Escape', - suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - Option A - Option B - Option C - - - ) - - // Open combobox - await click(getComboboxButton()) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Close combobox - await press(Keys.Escape) - - // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Verify the button is focused again - assertActiveElement(getComboboxInput()) - }) - ) - - it( - 'should bubble escape when using `static` on Combobox.Options', - suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - Option A - Option B - Option C - - - ) - - let spy = jest.fn() - - window.addEventListener( - 'keydown', - (evt) => { - if (evt.key === 'Escape') { - spy() - } - }, - { capture: true } - ) - - window.addEventListener('keydown', (evt) => { - if (evt.key === 'Escape') { - spy() - } - }) - - // Open combobox - await click(getComboboxButton()) - - // Verify the input is focused - assertActiveElement(getComboboxInput()) - - // Close combobox - await press(Keys.Escape) - - // Verify the input is still focused - assertActiveElement(getComboboxInput()) - - // The external event handler should've been called twice - // Once in the capture phase and once in the bubble phase - expect(spy).toHaveBeenCalledTimes(2) - }) - ) - - it( - 'should bubble escape when not using Combobox.Options at all', - suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - ) - - let spy = jest.fn() - - window.addEventListener( - 'keydown', - (evt) => { - if (evt.key === 'Escape') { - spy() - } - }, - { capture: true } - ) - - window.addEventListener('keydown', (evt) => { - if (evt.key === 'Escape') { - spy() - } - }) - - // Open combobox - await click(getComboboxButton()) - - // Verify the input is focused - assertActiveElement(getComboboxInput()) - - // Close combobox - await press(Keys.Escape) - - // Verify the input is still focused - assertActiveElement(getComboboxInput()) - - // The external event handler should've been called twice - // Once in the capture phase and once in the bubble phase - expect(spy).toHaveBeenCalledTimes(2) - }) - ) - - 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 - - - ) - - // Open combobox - await click(getComboboxButton()) - - // Verify the input has the selected value - expect(getComboboxInput()?.value).toBe('option-b') - - // Override the input by typing something - await type(word('test'), getComboboxInput()) - expect(getComboboxInput()?.value).toBe('test') - - // Close combobox - await press(Keys.Escape) - - // Verify the input is reset correctly - expect(getComboboxInput()?.value).toBe('option-b') - }) - ) - }) - - describe('`ArrowDown` key', () => { - it( - 'should be possible to open the combobox with ArrowDown', - suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - Option A - Option B - Option C - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - await focus(getComboboxInput()) - - // Open combobox - await press(Keys.ArrowDown) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - - // Verify that the first combobox option is active - assertActiveComboboxOption(options[0]) - }) - ) - - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - await focus(getComboboxInput()) - - // Try to open the combobox - await press(Keys.ArrowDown) - - // Verify it is still closed - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - }) - ) - - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - await focus(getComboboxInput()) - - // Open combobox - await press(Keys.ArrowDown) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - 'should have no active combobox option when there are no combobox options at all', - suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - ) - - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - await focus(getComboboxInput()) - - // Open combobox - await press(Keys.ArrowDown) - assertComboboxList({ state: ComboboxState.Visible }) - assertActiveElement(getComboboxInput()) - - assertNoActiveComboboxOption() - }) - ) - - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Open combobox - await click(getComboboxButton()) - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[0]) - - // We should be able to go down once - await press(Keys.ArrowDown) assertActiveComboboxOption(options[1]) - // We should be able to go down again - await press(Keys.ArrowDown) - assertActiveComboboxOption(options[2]) + // Delete a character + await press(Keys.Backspace) + expect(getComboboxInput()?.value).toBe('') - // We should NOT be able to go down again (because last option). - // Current implementation won't go around. - await press(Keys.ArrowDown) - assertActiveComboboxOption(options[2]) - }) - ) - - it( - '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({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Open combobox - await click(getComboboxButton()) - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[1]) - - // We should be able to go down once - await press(Keys.ArrowDown) - assertActiveComboboxOption(options[2]) - }) - ) - - it( - '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({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Open combobox - await click(getComboboxButton()) - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[2]) - - // Open combobox - await press(Keys.ArrowDown) - assertActiveComboboxOption(options[2]) - }) - ) - - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Open combobox - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // Verify that we are on the first option - assertActiveComboboxOption(options[0]) - - // Go down once - await press(Keys.ArrowDown) - - // We should be on the next item - assertActiveComboboxOption(options[1]) - }) - ) - }) - - describe('`ArrowUp` key', () => { - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - await focus(getComboboxInput()) - - // Open combobox - await press(Keys.ArrowUp) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - - // ! ALERT: The LAST option should now be active - assertActiveComboboxOption(options[2]) - }) - ) - - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - await focus(getComboboxInput()) - - // Try to open the combobox - await press(Keys.ArrowUp) - - // Verify it is still closed - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - }) - ) - - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - await focus(getComboboxInput()) - - // Open combobox - await press(Keys.ArrowUp) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - 'should have no active combobox option when there are no combobox options at all', - suppressConsoleLogs(async () => { - render( - console.log(x)}> - - Trigger - - - ) - - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - await focus(getComboboxInput()) - - // Open combobox - await press(Keys.ArrowUp) - assertComboboxList({ state: ComboboxState.Visible }) - assertActiveElement(getComboboxInput()) - - assertNoActiveComboboxOption() - }) - ) - - it( - '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({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - await focus(getComboboxInput()) - - // Open combobox - await press(Keys.ArrowUp) - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[0]) - }) - ) - - it( - '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({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Open combobox - await click(getComboboxButton()) - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[2]) - - // Going up or down should select the single available option - await press(Keys.ArrowUp) - - // We should not be able to go up (because those are disabled) - await press(Keys.ArrowUp) - assertActiveComboboxOption(options[2]) - - // We should not be able to go down (because this is the last option) - await press(Keys.ArrowDown) - assertActiveComboboxOption(options[2]) - }) - ) - - 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 - - - ) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - await focus(getComboboxInput()) - - // Open combobox - await press(Keys.ArrowUp) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[2]) - - // We should be able to go down once - await press(Keys.ArrowUp) - assertActiveComboboxOption(options[1]) - - // We should be able to go down again - await press(Keys.ArrowUp) - assertActiveComboboxOption(options[0]) - - // We should NOT be able to go up again (because first option). Current implementation won't go around. - await press(Keys.ArrowUp) - assertActiveComboboxOption(options[0]) - }) - ) - }) - - describe('`End` key', () => { - 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 - - - ) - - // Open combobox - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We should be on the first non-disabled option - assertActiveComboboxOption(options[0]) - - // We should be able to go to the last option - await press(Keys.End) - assertActiveComboboxOption(options[2]) - }) - ) - - it( - '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 - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We should be on the first non-disabled option - assertActiveComboboxOption(options[0]) - - // We should be able to go to the last non-disabled option - await press(Keys.End) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - '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 - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We should be on the first non-disabled option - assertActiveComboboxOption(options[0]) - - // We should not be able to go to the end (no-op) - await press(Keys.End) - - assertActiveComboboxOption(options[0]) - }) - ) - - it( - '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 - await click(getComboboxButton()) - - // We opened via click, we don't have an active option - assertNoActiveComboboxOption() - - // We should not be able to go to the end - await press(Keys.End) - - assertNoActiveComboboxOption() - }) - ) - }) - - describe('`PageDown` key', () => { - 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 - - - ) - - // Open combobox - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We should be on the first option - assertActiveComboboxOption(options[0]) - - // We should be able to go to the last option - await press(Keys.PageDown) - assertActiveComboboxOption(options[2]) - }) - ) - - it( - '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 - await click(getComboboxButton()) - - // Open combobox - await press(Keys.Space) - - let options = getComboboxOptions() - - // We should be on the first non-disabled option - assertActiveComboboxOption(options[0]) - - // We should be able to go to the last non-disabled option - await press(Keys.PageDown) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - '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 - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We should be on the first non-disabled option - assertActiveComboboxOption(options[0]) - - // We should not be able to go to the end - await press(Keys.PageDown) - - assertActiveComboboxOption(options[0]) - }) - ) - - it( - '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 - await click(getComboboxButton()) - - // We opened via click, we don't have an active option - assertNoActiveComboboxOption() - - // We should not be able to go to the end - await press(Keys.PageDown) - - assertNoActiveComboboxOption() - }) - ) - }) - - describe('`Home` key', () => { - 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 - - - ) - - // Focus the input - await focus(getComboboxInput()) - - // Open combobox - await press(Keys.ArrowUp) - - let options = getComboboxOptions() - - // We should be on the last option - assertActiveComboboxOption(options[2]) - - // We should be able to go to the first option - await press(Keys.Home) - assertActiveComboboxOption(options[0]) - }) - ) - - it( - '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 - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We should be on the first non-disabled option - assertActiveComboboxOption(options[2]) - - // We should not be able to go to the end - await press(Keys.Home) - - // We should be on the first non-disabled option - assertActiveComboboxOption(options[2]) - }) - ) - - it( - '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 - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We should be on the last option - assertActiveComboboxOption(options[3]) - - // We should not be able to go to the end - await press(Keys.Home) - - assertActiveComboboxOption(options[3]) - }) - ) - - it( - '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 - await click(getComboboxButton()) - - // We opened via click, we don't have an active option - assertNoActiveComboboxOption() - - // We should not be able to go to the end - await press(Keys.Home) - - assertNoActiveComboboxOption() - }) - ) - }) - - describe('`PageUp` key', () => { - 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 - - - ) - - // Focus the input - await focus(getComboboxInput()) - - // Open combobox - await press(Keys.ArrowUp) - - let options = getComboboxOptions() - - // We should be on the last option - assertActiveComboboxOption(options[2]) - - // We should be able to go to the first option - await press(Keys.PageUp) - assertActiveComboboxOption(options[0]) - }) - ) - - it( - '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 - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We opened via click, we default to the first non-disabled option - assertActiveComboboxOption(options[2]) - - // We should not be able to go to the end (no-op — already there) - await press(Keys.PageUp) - - assertActiveComboboxOption(options[2]) - }) - ) - - it( - '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 - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We opened via click, we default to the first non-disabled option - assertActiveComboboxOption(options[3]) - - // We should not be able to go to the end (no-op — already there) - await press(Keys.PageUp) - - assertActiveComboboxOption(options[3]) - }) - ) - - it( - '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 - await click(getComboboxButton()) - - // We opened via click, we don't have an active option - assertNoActiveComboboxOption() - - // We should not be able to go to the end - await press(Keys.PageUp) - - assertNoActiveComboboxOption() - }) - ) - }) - - describe('`Any` key aka search', () => { - function Example(props: { people: { value: string; name: string; disabled: boolean }[] }) { - let [value, setValue] = useState(undefined) - let [query, setQuery] = useState('') - let filteredPeople = - query === '' - ? props.people - : props.people.filter((person) => - person.name.toLowerCase().includes(query.toLowerCase()) - ) - - return ( - - setQuery(event.target.value)} /> - Trigger - - {filteredPeople.map((person) => ( - - {person.name} - - ))} - - - ) - } - - it( - 'should be possible to type a full word that has a perfect match', - suppressConsoleLogs(async () => { - render( - - ) - - // Open combobox - await click(getComboboxButton()) - - // Verify we moved focus to the input field - assertActiveElement(getComboboxInput()) - let options: ReturnType - - // We should be able to go to the second option - await type(word('bob')) - await press(Keys.Home) - - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('bob') - assertActiveComboboxOption(options[0]) - - // We should be able to go to the first option - await type(word('alice')) - await press(Keys.Home) - - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('alice') - assertActiveComboboxOption(options[0]) - - // We should be able to go to the last option - await type(word('charlie')) - await press(Keys.Home) - - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('charlie') - assertActiveComboboxOption(options[0]) - }) - ) - - it( - 'should be possible to type a partial of a word', - suppressConsoleLogs(async () => { - render( - - ) - - // Open combobox - await click(getComboboxButton()) - - let options: ReturnType - - // We should be able to go to the second option - await type(word('bo')) - await press(Keys.Home) - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('bob') - assertActiveComboboxOption(options[0]) - - // We should be able to go to the first option - await type(word('ali')) - await press(Keys.Home) - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('alice') - assertActiveComboboxOption(options[0]) - - // We should be able to go to the last option - await type(word('char')) - await press(Keys.Home) - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('charlie') - assertActiveComboboxOption(options[0]) - }) - ) - - it( - 'should be possible to type words with spaces', - suppressConsoleLogs(async () => { - render( - - ) - - // Open combobox - await click(getComboboxButton()) - - let options: ReturnType - - // We should be able to go to the second option - await type(word('bob t')) - await press(Keys.Home) - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('bob the builder') - assertActiveComboboxOption(options[0]) - - // We should be able to go to the first option - await type(word('alice j')) - await press(Keys.Home) - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('alice jones') - assertActiveComboboxOption(options[0]) - - // We should be able to go to the last option - await type(word('charlie b')) - await press(Keys.Home) - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('charlie bit me') - assertActiveComboboxOption(options[0]) - }) - ) - - it( - 'should not be possible to search and activate a disabled option', - suppressConsoleLogs(async () => { - render( - - ) - - // Open combobox - await click(getComboboxButton()) - - // We should not be able to go to the disabled option - await type(word('bo')) - await press(Keys.Home) - - assertNoActiveComboboxOption() + // Verify that we don't have an selected option anymore since we are in `nullable` mode + assertNotActiveComboboxOption(options[1]) assertNoSelectedComboboxOption() - }) - ) - it( - 'should maintain activeIndex and activeOption when filtering', - suppressConsoleLogs(async () => { - render( - - ) - - // Open combobox - await click(getComboboxButton()) - - let options: ReturnType - - options = getComboboxOptions() - expect(options[0]).toHaveTextContent('person a') - assertActiveComboboxOption(options[0]) - - await press(Keys.ArrowDown) - - // Person B should be active - options = getComboboxOptions() - expect(options[1]).toHaveTextContent('person b') - assertActiveComboboxOption(options[1]) - - // Filter more, remove `person a` - await type(word('person b')) - options = getComboboxOptions() - expect(options[0]).toHaveTextContent('person b') - assertActiveComboboxOption(options[0]) - - // Filter less, insert `person a` before `person b` - await type(word('person')) - options = getComboboxOptions() - expect(options[1]).toHaveTextContent('person b') - assertActiveComboboxOption(options[1]) + // Verify that we saw the `null` change coming in + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith(null) }) ) }) - }) -}) -describe('Mouse interactions', () => { + 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) + + return ( + { + setValue(value) + handleChange(value) + }} + > + + Trigger + + + Option A + + + Option B + + + Option C + + + + ) + } + + render() + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + + // Activate the first combobox option + let options = getComboboxOptions() + await mouseMove(options[0]) + + // Choose option, and close combobox + await press(Keys.Enter) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify we got the change event + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith('a') + + // Verify the button is focused again + assertActiveElement(getComboboxInput()) + + // Open combobox again + await click(getComboboxButton()) + + // Verify the active option is the previously selected one + assertActiveComboboxOption(getComboboxOptions()[0]) + }) + ) + + it( + 'should submit the form on `Enter`', + suppressConsoleLogs(async () => { + let submits = jest.fn() + + function Example() { + let [value, setValue] = useState('b') + + return ( +
{ + // JSDom doesn't automatically submit the form but if we can + // catch an `Enter` event, we can assume it was a submit. + if (event.key === 'Enter') event.currentTarget.submit() + }} + onSubmit={(event) => { + event.preventDefault() + submits([...new FormData(event.currentTarget).entries()]) + }} + > + + + Trigger + + + Option A + + + Option B + + + Option C + + + + + +
+ ) + } + + render() + + // Focus the input field + await focus(getComboboxInput()) + assertActiveElement(getComboboxInput()) + + // Press enter (which should submit the form) + await press(Keys.Enter) + + // Verify the form was submitted + expect(submits).toHaveBeenCalledTimes(1) + expect(submits).toHaveBeenCalledWith([['option', 'b']]) + }) + ) + + it( + 'should submit the form on `Enter` (when no submit button was found)', + suppressConsoleLogs(async () => { + let submits = jest.fn() + + function Example() { + let [value, setValue] = useState('b') + + return ( +
{ + // JSDom doesn't automatically submit the form but if we can + // catch an `Enter` event, we can assume it was a submit. + if (event.key === 'Enter') event.currentTarget.submit() + }} + onSubmit={(event) => { + event.preventDefault() + submits([...new FormData(event.currentTarget).entries()]) + }} + > + + + Trigger + + + Option A + + + Option B + + + Option C + + + +
+ ) + } + + render() + + // Focus the input field + await focus(getComboboxInput()) + assertActiveElement(getComboboxInput()) + + // Press enter (which should submit the form) + await press(Keys.Enter) + + // Verify the form was submitted + expect(submits).toHaveBeenCalledTimes(1) + expect(submits).toHaveBeenCalledWith([['option', 'b']]) + }) + ) + }) + + describe('`Tab` key', () => { + it( + 'pressing Tab should select the active item and move to the next DOM node', + suppressConsoleLogs(async () => { + function Example() { + let [value, setValue] = useState(undefined) + + return ( + <> + + + + Trigger + + + Option A + + + Option B + + + Option C + + + + + + ) + } + + render() + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Select the 2nd option + await press(Keys.ArrowDown) + + // Tab to the next DOM node + await press(Keys.Tab) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // That the selected value was the highlighted one + expect(getComboboxInput()?.value).toBe('b') + + // And focus has moved to the next element + assertActiveElement(document.querySelector('#after-combobox')) + }) + ) + + it( + 'pressing Shift+Tab should select the active item and move to the previous DOM node', + suppressConsoleLogs(async () => { + function Example() { + let [value, setValue] = useState(undefined) + + return ( + <> + + + + Trigger + + + Option A + + + Option B + + + Option C + + + + + + ) + } + + render() + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Select the 2nd option + await press(Keys.ArrowDown) + + // Tab to the next DOM node + await press(shift(Keys.Tab)) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // That the selected value was the highlighted one + expect(getComboboxInput()?.value).toBe('b') + + // And focus has moved to the next element + assertActiveElement(document.querySelector('#before-combobox')) + }) + ) + }) + + describe('`Escape` key', () => { + it( + 'should be possible to close an open combobox with Escape', + suppressConsoleLogs(async () => { + render( + console.log(x)}> + + Trigger + + + Option A + + + Option B + + + Option C + + + + ) + + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Close combobox + await press(Keys.Escape) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify the button is focused again + assertActiveElement(getComboboxInput()) + }) + ) + + it( + 'should bubble escape when using `static` on Combobox.Options', + suppressConsoleLogs(async () => { + if (virtual) return // Incompatible with virtual rendering + + render( + console.log(x)}> + + Trigger + + + Option A + + + Option B + + + Option C + + + + ) + + let spy = jest.fn() + + window.addEventListener( + 'keydown', + (evt) => { + if (evt.key === 'Escape') { + spy() + } + }, + { capture: true } + ) + + window.addEventListener('keydown', (evt) => { + if (evt.key === 'Escape') { + spy() + } + }) + + // Open combobox + await click(getComboboxButton()) + + // Verify the input is focused + assertActiveElement(getComboboxInput()) + + // Close combobox + await press(Keys.Escape) + + // Verify the input is still focused + assertActiveElement(getComboboxInput()) + + // The external event handler should've been called twice + // Once in the capture phase and once in the bubble phase + expect(spy).toHaveBeenCalledTimes(2) + }) + ) + + it( + 'should bubble escape when not using Combobox.Options at all', + suppressConsoleLogs(async () => { + render( + console.log(x)}> + + Trigger + + ) + + let spy = jest.fn() + + window.addEventListener( + 'keydown', + (evt) => { + if (evt.key === 'Escape') { + spy() + } + }, + { capture: true } + ) + + window.addEventListener('keydown', (evt) => { + if (evt.key === 'Escape') { + spy() + } + }) + + // Open combobox + await click(getComboboxButton()) + + // Verify the input is focused + assertActiveElement(getComboboxInput()) + + // Close combobox + await press(Keys.Escape) + + // Verify the input is still focused + assertActiveElement(getComboboxInput()) + + // The external event handler should've been called twice + // Once in the capture phase and once in the bubble phase + expect(spy).toHaveBeenCalledTimes(2) + }) + ) + + 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 + + + + ) + + // Open combobox + await click(getComboboxButton()) + + // Verify the input has the selected value + expect(getComboboxInput()?.value).toBe('option-b') + + // Override the input by typing something + await type(word('test'), getComboboxInput()) + expect(getComboboxInput()?.value).toBe('test') + + // Close combobox + await press(Keys.Escape) + + // Verify the input is reset correctly + expect(getComboboxInput()?.value).toBe('option-b') + }) + ) + }) + + describe('`ArrowDown` key', () => { + it( + 'should be possible to open the combobox with ArrowDown', + suppressConsoleLogs(async () => { + render( + console.log(x)}> + + Trigger + + + Option A + + + Option B + + + Option C + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + await focus(getComboboxInput()) + + // Open combobox + await press(Keys.ArrowDown) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + + // Verify that the first combobox option is active + assertActiveComboboxOption(options[0]) + }) + ) + + 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 + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + await focus(getComboboxInput()) + + // Try to open the combobox + await press(Keys.ArrowDown) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + 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 + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + await focus(getComboboxInput()) + + // Open combobox + await press(Keys.ArrowDown) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + render( + console.log(x)}> + + Trigger + + + ) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + await focus(getComboboxInput()) + + // Open combobox + await press(Keys.ArrowDown) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + 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 + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + + // We should be able to go down once + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[1]) + + // We should be able to go down again + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + + // We should NOT be able to go down again (because last option). + // Current implementation won't go around. + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + '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({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[1]) + + // We should be able to go down once + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + '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({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[2]) + + // Open combobox + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + }) + ) + + 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 + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // Verify that we are on the first option + assertActiveComboboxOption(options[0]) + + // Go down once + await press(Keys.ArrowDown) + + // We should be on the next item + assertActiveComboboxOption(options[1]) + }) + ) + }) + + describe('`ArrowUp` key', () => { + 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 + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + await focus(getComboboxInput()) + + // Open combobox + await press(Keys.ArrowUp) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + + // ! ALERT: The LAST option should now be active + assertActiveComboboxOption(options[2]) + }) + ) + + 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 + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + await focus(getComboboxInput()) + + // Try to open the combobox + await press(Keys.ArrowUp) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + 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 + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + await focus(getComboboxInput()) + + // Open combobox + await press(Keys.ArrowUp) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + render( + console.log(x)}> + + Trigger + + + ) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + await focus(getComboboxInput()) + + // Open combobox + await press(Keys.ArrowUp) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + '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({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + await focus(getComboboxInput()) + + // Open combobox + await press(Keys.ArrowUp) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + }) + ) + + it( + '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({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[2]) + + // Going up or down should select the single available option + await press(Keys.ArrowUp) + + // We should not be able to go up (because those are disabled) + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[2]) + + // We should not be able to go down (because this is the last option) + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + }) + ) + + 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 + + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + await focus(getComboboxInput()) + + // Open combobox + await press(Keys.ArrowUp) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[2]) + + // We should be able to go down once + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[1]) + + // We should be able to go down again + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[0]) + + // We should NOT be able to go up again (because first option). Current implementation won't go around. + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[0]) + }) + ) + }) + + describe('`End` key', () => { + 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 + + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await press(Keys.End) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + '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 + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last non-disabled option + await press(Keys.End) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + '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 + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[0]) + + // We should not be able to go to the end (no-op) + await press(Keys.End) + + assertActiveComboboxOption(options[0]) + }) + ) + + it( + '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 + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.End) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`PageDown` key', () => { + 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 + + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the first option + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await press(Keys.PageDown) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + '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 + await click(getComboboxButton()) + + // Open combobox + await press(Keys.Space) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last non-disabled option + await press(Keys.PageDown) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + '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 + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[0]) + + // We should not be able to go to the end + await press(Keys.PageDown) + + assertActiveComboboxOption(options[0]) + }) + ) + + it( + '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 + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.PageDown) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`Home` key', () => { + 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 + + + + ) + + // Focus the input + await focus(getComboboxInput()) + + // Open combobox + await press(Keys.ArrowUp) + + let options = getComboboxOptions() + + // We should be on the last option + assertActiveComboboxOption(options[2]) + + // We should be able to go to the first option + await press(Keys.Home) + assertActiveComboboxOption(options[0]) + }) + ) + + it( + '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 + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[2]) + + // We should not be able to go to the end + await press(Keys.Home) + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[2]) + }) + ) + + it( + '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 + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the last option + assertActiveComboboxOption(options[3]) + + // We should not be able to go to the end + await press(Keys.Home) + + assertActiveComboboxOption(options[3]) + }) + ) + + it( + '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 + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.Home) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`PageUp` key', () => { + 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 + + + + ) + + // Focus the input + await focus(getComboboxInput()) + + // Open combobox + await press(Keys.ArrowUp) + + let options = getComboboxOptions() + + // We should be on the last option + assertActiveComboboxOption(options[2]) + + // We should be able to go to the first option + await press(Keys.PageUp) + assertActiveComboboxOption(options[0]) + }) + ) + + it( + '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 + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We opened via click, we default to the first non-disabled option + assertActiveComboboxOption(options[2]) + + // We should not be able to go to the end (no-op — already there) + await press(Keys.PageUp) + + assertActiveComboboxOption(options[2]) + }) + ) + + it( + '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 + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We opened via click, we default to the first non-disabled option + assertActiveComboboxOption(options[3]) + + // We should not be able to go to the end (no-op — already there) + await press(Keys.PageUp) + + assertActiveComboboxOption(options[3]) + }) + ) + + it( + '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 + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.PageUp) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`Any` key aka search', () => { + function Example(props: { people: { value: string; name: string; disabled: boolean }[] }) { + let [value, setValue] = useState(undefined) + let [query, setQuery] = useState('') + let filteredPeople = + query === '' + ? props.people + : props.people.filter((person) => + person.name.toLowerCase().includes(query.toLowerCase()) + ) + + return ( + + setQuery(event.target.value)} /> + Trigger + + {filteredPeople.map((person, idx) => ( + + {person.name} + + ))} + + + ) + } + + it( + 'should be possible to type a full word that has a perfect match', + suppressConsoleLogs(async () => { + render( + + ) + + // Open combobox + await click(getComboboxButton()) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + let options: ReturnType + + // We should be able to go to the second option + await type(word('bob')) + await press(Keys.Home) + + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('bob') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the first option + await type(word('alice')) + await press(Keys.Home) + + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('alice') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await type(word('charlie')) + await press(Keys.Home) + + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('charlie') + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should be possible to type a partial of a word', + suppressConsoleLogs(async () => { + render( + + ) + + // Open combobox + await click(getComboboxButton()) + + let options: ReturnType + + // We should be able to go to the second option + await type(word('bo')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('bob') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the first option + await type(word('ali')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('alice') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await type(word('char')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('charlie') + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should be possible to type words with spaces', + suppressConsoleLogs(async () => { + render( + + ) + + // Open combobox + await click(getComboboxButton()) + + let options: ReturnType + + // We should be able to go to the second option + await type(word('bob t')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('bob the builder') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the first option + await type(word('alice j')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('alice jones') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await type(word('charlie b')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('charlie bit me') + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should not be possible to search and activate a disabled option', + suppressConsoleLogs(async () => { + render( + + ) + + // Open combobox + await click(getComboboxButton()) + + // We should not be able to go to the disabled option + await type(word('bo')) + await press(Keys.Home) + + assertNoActiveComboboxOption() + assertNoSelectedComboboxOption() + }) + ) + + it( + 'should maintain activeIndex and activeOption when filtering', + suppressConsoleLogs(async () => { + render( + + ) + + // Open combobox + await click(getComboboxButton()) + + let options: ReturnType + + options = getComboboxOptions() + expect(options[0]).toHaveTextContent('person a') + assertActiveComboboxOption(options[0]) + + await press(Keys.ArrowDown) + + // Person B should be active + options = getComboboxOptions() + expect(options[1]).toHaveTextContent('person b') + assertActiveComboboxOption(options[1]) + + // Filter more, remove `person a` + await type(word('person b')) + options = getComboboxOptions() + expect(options[0]).toHaveTextContent('person b') + assertActiveComboboxOption(options[0]) + + // Filter less, insert `person a` before `person b` + await type(word('person')) + options = getComboboxOptions() + expect(options[1]).toHaveTextContent('person b') + assertActiveComboboxOption(options[1]) + }) + ) + }) + }) + } +) + +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)}> + console.log(x)}> Label Trigger - Option A - Option B - Option C + + Option A + + + Option B + + + Option C + ) @@ -4674,14 +5048,20 @@ describe('Mouse interactions', () => { 'should not focus the Combobox.Input when we right click the Combobox.Label', suppressConsoleLogs(async () => { render( - console.log(x)}> + console.log(x)}> Label Trigger - Option A - Option B - Option C + + Option A + + + Option B + + + Option C + ) @@ -4701,13 +5081,19 @@ describe('Mouse interactions', () => { '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 + + Option A + + + Option B + + + Option C + ) @@ -4741,13 +5127,19 @@ describe('Mouse interactions', () => { '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 + + Option A + + + Option B + + + Option C + ) @@ -4774,13 +5166,19 @@ describe('Mouse interactions', () => { '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 + + Option A + + + Option B + + + Option C + ) @@ -4807,13 +5205,19 @@ describe('Mouse interactions', () => { 'should be possible to close a combobox on click with immediate mode enabled', suppressConsoleLogs(async () => { render( - + Trigger - Option A - Option B - Option C + + Option A + + + Option B + + + Option C + ) @@ -4838,13 +5242,19 @@ describe('Mouse interactions', () => { 'should be possible to close a focused combobox on click with immediate mode enabled', suppressConsoleLogs(async () => { render( - + Trigger - Option A - Option B - Option C + + Option A + + + Option B + + + Option C + ) @@ -4871,13 +5281,19 @@ describe('Mouse interactions', () => { 'should be possible to open the combobox on click', suppressConsoleLogs(async () => { render( - console.log(x)}> + console.log(x)}> Trigger - Option A - Option B - Option C + + Option A + + + Option B + + + Option C + ) @@ -4911,13 +5327,19 @@ describe('Mouse interactions', () => { 'should not be possible to open the combobox on right click', suppressConsoleLogs(async () => { render( - console.log(x)}> + console.log(x)}> Trigger - Item A - Item B - Item C + + Item A + + + Item B + + + Item C + ) @@ -4940,13 +5362,19 @@ describe('Mouse interactions', () => { 'should not be possible to open the combobox on click when the button is disabled', suppressConsoleLogs(async () => { render( - console.log(x)} disabled> + console.log(x)} disabled> Trigger - Option A - Option B - Option C + + Option A + + + Option B + + + Option C + ) @@ -4973,13 +5401,19 @@ describe('Mouse interactions', () => { 'should be possible to open the combobox on click, and focus the selected option', suppressConsoleLogs(async () => { render( - console.log(x)}> + console.log(x)}> Trigger - Option A - Option B - Option C + + Option A + + + Option B + + + Option C + ) @@ -5016,13 +5450,19 @@ describe('Mouse interactions', () => { 'should be possible to close a combobox on click', suppressConsoleLogs(async () => { render( - console.log(x)}> + console.log(x)}> Trigger - Option A - Option B - Option C + + Option A + + + Option B + + + Option C + ) @@ -5046,13 +5486,19 @@ describe('Mouse interactions', () => { 'should be a no-op when we click outside of a closed combobox', suppressConsoleLogs(async () => { render( - console.log(x)}> + console.log(x)}> Trigger - alice - bob - charlie + + alice + + + bob + + + charlie + ) @@ -5075,13 +5521,19 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { render( <> - console.log(x)}> + console.log(x)}> Trigger - alice - bob - charlie + + alice + + + bob + + + charlie +
@@ -5111,23 +5563,35 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { render(
- console.log(x)}> + console.log(x)}> Trigger - alice - bob - charlie + + alice + + + bob + + + charlie + - console.log(x)}> + console.log(x)}> Trigger - alice - bob - charlie + + alice + + + bob + + + charlie +
@@ -5156,13 +5620,19 @@ describe('Mouse interactions', () => { '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)}> + console.log(x)}> Trigger - alice - bob - charlie + + alice + + + bob + + + charlie + ) @@ -5189,13 +5659,19 @@ describe('Mouse interactions', () => { let focusFn = jest.fn() render(
- console.log(x)}> + x}> Trigger - alice - bob - charlie + + alice + + + bob + + + charlie + @@ -5229,13 +5705,19 @@ describe('Mouse interactions', () => { 'should be possible to hover an option and make it active', suppressConsoleLogs(async () => { render( - console.log(x)}> + console.log(x)}> Trigger - alice - bob - charlie + + alice + + + bob + + + charlie + ) @@ -5261,14 +5743,22 @@ describe('Mouse interactions', () => { it( 'should be possible to hover an option and make it active when using `static`', suppressConsoleLogs(async () => { + if (virtual) return // Incompatible with virtual rendering + render( - console.log(x)}> + console.log(x)}> Trigger - alice - bob - charlie + + alice + + + bob + + + charlie + ) @@ -5292,13 +5782,19 @@ describe('Mouse interactions', () => { 'should make a combobox option active when you move the mouse over it', suppressConsoleLogs(async () => { render( - console.log(x)}> + console.log(x)}> Trigger - alice - bob - charlie + + alice + + + bob + + + charlie + ) @@ -5317,13 +5813,19 @@ describe('Mouse interactions', () => { 'should be a no-op when we move the mouse and the combobox option is already active', suppressConsoleLogs(async () => { render( - console.log(x)}> + console.log(x)}> Trigger - alice - bob - charlie + + alice + + + bob + + + charlie + ) @@ -5348,15 +5850,19 @@ describe('Mouse interactions', () => { 'should be a no-op when we move the mouse and the combobox option is disabled', suppressConsoleLogs(async () => { render( - console.log(x)}> + console.log(x)}> Trigger - alice - + + alice + + bob - charlie + + charlie + ) @@ -5375,15 +5881,19 @@ describe('Mouse interactions', () => { 'should not be possible to hover an option that is disabled', suppressConsoleLogs(async () => { render( - console.log(x)}> + console.log(x)}> Trigger - alice - + + alice + + bob - charlie + + charlie + ) @@ -5405,13 +5915,19 @@ describe('Mouse interactions', () => { 'should be possible to mouse leave an option and make it inactive', suppressConsoleLogs(async () => { render( - console.log(x)}> + console.log(x)}> Trigger - alice - bob - charlie + + alice + + + bob + + + charlie + ) @@ -5448,15 +5964,19 @@ describe('Mouse interactions', () => { 'should be possible to mouse leave a disabled option and be a no-op', suppressConsoleLogs(async () => { render( - console.log(x)}> + console.log(x)}> Trigger - alice - + + alice + + bob - charlie + + charlie + ) @@ -5493,9 +6013,15 @@ describe('Mouse interactions', () => { Trigger - alice - bob - charlie + + alice + + + bob + + + charlie + ) @@ -5531,13 +6057,19 @@ describe('Mouse interactions', () => { 'should be possible to click a combobox option, which closes the combobox with immediate mode enabled', suppressConsoleLogs(async () => { render( - + Trigger - Option A - Option B - Option C + + Option A + + + Option B + + + Option C + ) @@ -5574,11 +6106,15 @@ describe('Mouse interactions', () => { Trigger - alice - + + alice + + bob - charlie + + charlie + ) @@ -5620,13 +6156,19 @@ describe('Mouse interactions', () => { let [value, setValue] = useState(undefined) return ( - + Trigger - alice - bob - charlie + + alice + + + bob + + + charlie + ) @@ -5654,15 +6196,19 @@ describe('Mouse interactions', () => { 'should not be possible to focus a combobox option which is disabled', suppressConsoleLogs(async () => { render( - console.log(x)}> + console.log(x)}> Trigger - alice - + + alice + + bob - charlie + + charlie + ) @@ -5684,13 +6230,19 @@ describe('Mouse interactions', () => { 'should be possible to hold the last active option', suppressConsoleLogs(async () => { render( - console.log(x)}> + console.log(x)}> Trigger - Option A - Option B - Option C + + Option A + + + Option B + + + Option C + ) @@ -5740,13 +6292,19 @@ describe('Mouse interactions', () => { return ( <> - + Trigger - alice - bob - charlie + + alice + + + bob + + + charlie + @@ -5782,13 +6340,19 @@ describe('Mouse interactions', () => { return ( <> - + Trigger - alice - bob - charlie + + alice + + + bob + + + charlie + @@ -5827,13 +6391,19 @@ describe('Mouse interactions', () => { return ( <> - + Trigger - alice - bob - charlie + + alice + + + bob + + + charlie + @@ -5878,7 +6448,7 @@ describe('Mouse interactions', () => { return ( <> - + person?.name} @@ -5886,7 +6456,7 @@ describe('Mouse interactions', () => { Trigger {people.map((person) => ( - + {person.name} ))} diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 93d80f8..1f107ce 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -1,3 +1,4 @@ +import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual' import React, { createContext, createRef, @@ -8,6 +9,7 @@ import React, { useMemo, useReducer, useRef, + type CSSProperties, type ElementType, type FocusEvent as ReactFocusEvent, type KeyboardEvent as ReactKeyboardEvent, @@ -49,7 +51,6 @@ import { type PropsForFeatures, type RefProp, } from '../../utils/render' - import { Keys } from '../keyboard' enum ComboboxState { @@ -69,10 +70,11 @@ enum ActivationTrigger { } type ComboboxOptionDataRef = MutableRefObject<{ - textValue?: string disabled: boolean value: T domRef: MutableRefObject + order: number | null + onVirtualRangeUpdate: (virtualizer: Virtualizer) => void }> interface StateDefinition { @@ -107,10 +109,13 @@ function adjustOrderedState( let currentActiveOption = state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null - let sortedOptions = sortByDomNode( - adjustment(state.options.slice()), - (option) => option.dataRef.current.domRef.current - ) + let list = adjustment(state.options.slice()) + let sortedOptions = + list.length > 0 && list[0].dataRef.current.order !== null + ? // Prefer sorting based on the `order` + list.sort((a, z) => a.dataRef.current.order! - z.dataRef.current.order!) + : // Fallback to much slower DOM order + sortByDomNode(list, (option) => option.dataRef.current.domRef.current) // If we inserted an option before the current active option then the active option index // would be wrong. To fix this, we will re-lookup the correct index. @@ -203,17 +208,29 @@ let reducers: { resolveId: (item) => item.id, resolveDisabled: (item) => item.dataRef.current.disabled, }) + let activationTrigger = action.trigger ?? ActivationTrigger.Other + + if ( + state.activeOptionIndex === activeOptionIndex && + state.activationTrigger === activationTrigger + ) { + return state + } return { ...state, ...adjustedState, activeOptionIndex, - activationTrigger: action.trigger ?? ActivationTrigger.Other, + activationTrigger, } }, [ActionTypes.RegisterOption]: (state, action) => { let option = { id: action.id, dataRef: action.dataRef } - let adjustedState = adjustOrderedState(state, (options) => [...options, option]) + + let adjustedState = adjustOrderedState(state, (options) => { + options.push(option) + return options + }) // Check if we need to make the newly registered option active. if (state.activeOptionIndex === null) { @@ -286,6 +303,69 @@ function useActions(component: string) { } type _Actions = ReturnType +let VirtualContext = createContext | null>(null) + +function VirtualProvider(props: React.PropsWithChildren<{}>) { + 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] + + let styles = window.getComputedStyle(el) + + return [ + parseFloat(styles.paddingBlockStart || styles.paddingTop), + parseFloat(styles.paddingBlockEnd || styles.paddingBottom), + ] + }, [data.optionsRef.current]) + + let virtualizer = useVirtualizer({ + scrollPaddingStart: paddingStart, + scrollPaddingEnd: paddingEnd, + count: data.options.length, + estimateSize() { + return measuredHeight + }, + 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) + } + }, + }) + + return ( + +
+ {props.children} +
+
+ ) +} + let ComboboxDataContext = createContext< | ({ value: unknown @@ -299,6 +379,8 @@ let ComboboxDataContext = createContext< isSelected(value: unknown): boolean __demoMode: boolean + virtual: boolean + optionsPropsRef: MutableRefObject<{ static: boolean hold: boolean @@ -393,6 +475,7 @@ export type ComboboxProps< form?: string name?: string immediate?: boolean + virtual?: boolean } function ComboboxFn( @@ -428,6 +511,7 @@ function ComboboxFn( @@ -474,7 +558,6 @@ function ComboboxFn( () => ({ ...state, @@ -488,6 +571,7 @@ function ComboboxFn( if (data.comboboxState === ComboboxState.Closed) { actions.openCombobox() } + return d.nextFrame(() => data.inputRef.current?.focus({ preventScroll: true })) case Keys.ArrowUp: @@ -1377,6 +1462,13 @@ function OptionsFn( ref: optionsRef, } + // Map the children in a scrollable container when virtualization is enabled + if (data.virtual && data.comboboxState === ComboboxState.Open) { + Object.assign(theirProps, { + children: {theirProps.children}, + }) + } + return render({ ourProps, theirProps, @@ -1405,6 +1497,7 @@ export type ComboboxOptionProps = Props< { disabled?: boolean value: TType + order?: number } > @@ -1419,41 +1512,57 @@ function OptionFn< id = `headlessui-combobox-option-${internalId}`, disabled = false, value, + order = null, ...theirProps } = props + 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 [, rerender] = useReducer((v) => !v, true) let selected = data.isSelected(value) let internalOptionRef = useRef(null) let bag = useLatestValue['current']>({ disabled, value, domRef: internalOptionRef, - textValue: internalOptionRef.current?.textContent?.toLowerCase(), + order, + onVirtualRangeUpdate: rerender, }) - let optionRef = useSyncRefs(ref, internalOptionRef) + let virtualizer = useContext(VirtualContext) + let optionRef = useSyncRefs( + ref, + internalOptionRef, + virtualizer ? virtualizer.measureElement : null + ) let select = useEvent(() => actions.selectOption(id)) useIsoMorphicEffect(() => actions.registerOption(id, bag), [bag, id]) - let enableScrollIntoView = useRef(data.__demoMode ? false : true) + let enableScrollIntoView = useRef(data.virtual || data.__demoMode ? false : true) useIsoMorphicEffect(() => { + if (!data.virtual) return if (!data.__demoMode) return let d = disposables() d.requestAnimationFrame(() => { enableScrollIntoView.current = true }) return d.dispose - }, []) + }, [data.virtual, data.__demoMode]) useIsoMorphicEffect(() => { + if (!enableScrollIntoView.current) return if (data.comboboxState !== ComboboxState.Open) return if (!active) return - if (!enableScrollIntoView.current) return if (data.activationTrigger === ActivationTrigger.Pointer) return let d = disposables() d.requestAnimationFrame(() => { @@ -1522,6 +1631,43 @@ 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, @@ -1532,6 +1678,9 @@ 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, @@ -1543,6 +1692,21 @@ 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/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx index e0c9e3e..737445a 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx @@ -30,6 +30,7 @@ import { mouseLeave, mouseMove, press, + rawClick, shift, type, word, @@ -1353,7 +1354,7 @@ describe('Composition', () => { }) assertListbox({ state: ListboxState.InvisibleUnmounted }) - await click(getListboxButton()) + await rawClick(getListboxButton()) assertListboxButton({ state: ListboxState.Visible, @@ -1364,7 +1365,7 @@ describe('Composition', () => { textContent: JSON.stringify({ active: false, selected: false, disabled: false }), }) - await click(getListboxButton()) + await rawClick(getListboxButton()) // Verify that we tracked the `mounts` and `unmounts` in the correct order expect(orderFn.mock.calls).toEqual([ diff --git a/packages/@headlessui-react/src/components/menu/menu.test.tsx b/packages/@headlessui-react/src/components/menu/menu.test.tsx index 6f461c9..7f32d8c 100644 --- a/packages/@headlessui-react/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.test.tsx @@ -24,6 +24,7 @@ import { mouseLeave, mouseMove, press, + rawClick, shift, type, word, @@ -646,7 +647,7 @@ describe('Composition', () => { }) assertMenu({ state: MenuState.InvisibleUnmounted }) - await click(getMenuButton()) + await rawClick(getMenuButton()) assertMenuButton({ state: MenuState.Visible, @@ -657,7 +658,7 @@ describe('Composition', () => { textContent: JSON.stringify({ active: false, disabled: false }), }) - await click(getMenuButton()) + await rawClick(getMenuButton()) // Verify that we tracked the `mounts` and `unmounts` in the correct order expect(orderFn.mock.calls).toEqual([ @@ -700,7 +701,7 @@ describe('Composition', () => { }) assertMenu({ state: MenuState.InvisibleUnmounted }) - await click(getMenuButton()) + await rawClick(getMenuButton()) assertMenuButton({ state: MenuState.Visible, @@ -711,7 +712,7 @@ describe('Composition', () => { textContent: JSON.stringify({ active: false, disabled: false }), }) - await click(getMenuButton()) + await rawClick(getMenuButton()) // Verify that we tracked the `mounts` and `unmounts` in the correct order expect(orderFn.mock.calls).toEqual([ diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts index bef8231..ab4c3e0 100644 --- a/packages/@headlessui-react/src/test-utils/interactions.ts +++ b/packages/@headlessui-react/src/test-utils/interactions.ts @@ -1,4 +1,4 @@ -import { fireEvent } from '@testing-library/react' +import { act, fireEvent } from '@testing-library/react' import { pointer } from './fake-pointer' function nextFrame(cb: Function): void { @@ -227,6 +227,13 @@ export enum MouseButton { export async function click( element: Document | Element | Window | Node | null, button = MouseButton.Left +) { + return act(() => rawClick(element, button)) +} + +export async function rawClick( + element: Document | Element | Window | Node | null, + button = MouseButton.Left ) { try { if (element === null) return expect(element).not.toBe(null) diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index adae643..83338b2 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -20,6 +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)) ## [1.7.16] - 2023-08-17 diff --git a/packages/@headlessui-vue/package.json b/packages/@headlessui-vue/package.json index da8294b..34d15e8 100644 --- a/packages/@headlessui-vue/package.json +++ b/packages/@headlessui-vue/package.json @@ -44,5 +44,8 @@ "@testing-library/vue": "^5.8.2", "@vue/test-utils": "^2.0.0-rc.18", "vue": "^3.2.29" + }, + "dependencies": { + "@tanstack/vue-virtual": "^3.0.0-beta.60" } } diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts index 7f8a9dd..52a07ba 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts @@ -51,6 +51,12 @@ import { ComboboxOptions, } from './combobox' +global.ResizeObserver = class FakeResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + jest.mock('../../hooks/use-id') beforeAll(() => { @@ -1306,6 +1312,58 @@ describe('Rendering', () => { assertActiveComboboxOption(options[2]) }) + it('should guarantee the order of options based on `order` when performing actions', async () => { + let props = reactive({ hide: false }) + + renderTemplate({ + template: html` + + + Trigger + + Option 1 + Option 2 + Option 3 + + + `, + setup() { + return { + value: ref(null), + get hide() { + return props.hide + }, + } + }, + }) + + // Open the combobox + await click(getByText('Trigger')) + + props.hide = true + await nextFrame() + + props.hide = false + await nextFrame() + + assertComboboxList({ state: ComboboxState.Visible }) + + let options = getComboboxOptions() + + // Verify that the first combobox option is active + assertActiveComboboxOption(options[0]) + + await press(Keys.ArrowDown) + + // Verify that the second combobox option is active + assertActiveComboboxOption(options[1]) + + await press(Keys.ArrowDown) + + // Verify that the third combobox option is active + assertActiveComboboxOption(options[2]) + }) + describe('Uncontrolled', () => { it('should be possible to use in an uncontrolled way', async () => { let handleSubmission = jest.fn() @@ -1906,3060 +1964,3437 @@ describe('Composition', () => { ) }) -describe('Keyboard interactions', () => { - 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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.Enter) - - // Verify we moved focus to the input field - assertActiveElement(getComboboxInput()) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option, { selected: false })) - - assertActiveComboboxOption(options[0]) - assertNoSelectedComboboxOption() - }) - ) - - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Try to focus the button - getComboboxButton()?.focus() - - // Try to open the combobox - await press(Keys.Enter) - - // Verify it is still closed - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - }) - ) - - it( - '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') }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.Enter) - - // Verify we moved focus to the input field - assertActiveElement(getComboboxInput()) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - 'should be possible to open the combobox with Enter, and focus the selected option (when using the `hidden` render strategy)', - suppressConsoleLogs(async () => { - renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref('b') }), - }) - - await new Promise(nextTick) - - assertComboboxButton({ - state: ComboboxState.InvisibleHidden, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleHidden }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.Enter) - - // Verify we moved focus to the input field - assertActiveElement(getComboboxInput()) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - let options = getComboboxOptions() - - // Hover over Option A - await mouseMove(options[0]) - - // Verify that Option A is active - assertActiveComboboxOption(options[0]) - - // Verify that Option B is still selected - assertComboboxOption(options[1], { selected: true }) - - // Close/Hide the combobox - await press(Keys.Escape) - - // Re-open the combobox - await click(getComboboxButton()) - - // Verify we have combobox options - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - '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 } - }, - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.Enter) - - // Verify we moved focus to the input field - assertActiveElement(getComboboxInput()) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - 'should have no active combobox option when there are no combobox options at all', - suppressConsoleLogs(async () => { - renderTemplate({ - template: html` - - - Trigger - - - `, - setup: () => ({ value: ref(null) }), - }) - - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.Enter) - - // Verify we moved focus to the input field - assertActiveElement(getComboboxInput()) - - assertComboboxList({ state: ComboboxState.Visible }) - assertActiveElement(getComboboxInput()) - - assertNoActiveComboboxOption() - }) - ) - }) - - describe('`Space` key', () => { - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.Space) - - // Verify we moved focus to the input field - assertActiveElement(getComboboxInput()) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[0]) - }) - ) - - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - getComboboxButton()?.focus() - - // Try to open the combobox - await press(Keys.Space) - - // Verify it is still closed - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - }) - ) - - it( - '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') }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ - state: ComboboxState.InvisibleUnmounted, - }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.Space) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - 'should have no active combobox option when there are no combobox options at all', - suppressConsoleLogs(async () => { - renderTemplate({ - template: html` - - - Trigger - - - `, - setup: () => ({ value: ref(null) }), - }) - - assertComboboxList({ - state: ComboboxState.InvisibleUnmounted, - }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.Space) - assertComboboxList({ state: ComboboxState.Visible }) - assertActiveElement(getComboboxInput()) - - assertNoActiveComboboxOption() - }) - ) - - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ - state: ComboboxState.InvisibleUnmounted, - }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.Space) - - assertNoActiveComboboxOption() - }) - ) - }) - - describe('`Escape` key', () => { - it( - '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) }), - }) - - // Open combobox - await click(getComboboxButton()) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Re-focus the button - getComboboxButton()?.focus() - assertActiveElement(getComboboxButton()) - - // Close combobox - await press(Keys.Escape) - - // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Verify the input is focused again - assertActiveElement(getComboboxInput()) - }) - ) - - it( - 'should not propagate the Escape event when the combobox is open', - suppressConsoleLogs(async () => { - let handleKeyDown = jest.fn() - renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null) }), - }) - - window.addEventListener('keydown', handleKeyDown) - - // Open combobox - await click(getComboboxButton()) - - // Close combobox - await press(Keys.Escape) - - // We should never see the Escape event - expect(handleKeyDown).toHaveBeenCalledTimes(0) - - window.removeEventListener('keydown', handleKeyDown) - }) - ) - - it( - 'should propagate the Escape event when the combobox is closed', - suppressConsoleLogs(async () => { - let handleKeyDown = jest.fn() - renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null) }), - }) - - window.addEventListener('keydown', handleKeyDown) - - // Focus the input field - await focus(getComboboxInput()) - - // Close combobox - await press(Keys.Escape) - - // We should never see the Escape event - expect(handleKeyDown).toHaveBeenCalledTimes(1) - - window.removeEventListener('keydown', handleKeyDown) - }) - ) - }) - - describe('`ArrowDown` key', () => { - it( - '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') }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.ArrowDown) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - - // Verify that the first combobox option is active - assertActiveComboboxOption(options[0]) - }) - ) - - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - getComboboxButton()?.focus() - - // Try to open the combobox - await press(Keys.ArrowDown) - - // Verify it is still closed - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - }) - ) - - it( - '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') }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.ArrowDown) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - 'should have no active combobox option when there are no combobox options at all', - suppressConsoleLogs(async () => { - renderTemplate({ - template: html` - - - Trigger - - - `, - setup: () => ({ value: ref(null) }), - }) - - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.ArrowDown) - assertComboboxList({ state: ComboboxState.Visible }) - assertActiveElement(getComboboxInput()) - - assertNoActiveComboboxOption() - }) - ) - }) - - describe('`ArrowUp` key', () => { - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.ArrowUp) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - - // ! ALERT: The LAST option should now be active - assertActiveComboboxOption(options[2]) - }) - ) - - 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 () => { - renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - getComboboxButton()?.focus() - - // Try to open the combobox - await press(Keys.ArrowUp) - - // Verify it is still closed - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - }) - ) - - it( - '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') }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.ArrowUp) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - 'should have no active combobox option when there are no combobox options at all', - suppressConsoleLogs(async () => { - renderTemplate({ - template: html` - - - Trigger - - - `, - setup: () => ({ value: ref(null) }), - }) - - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.ArrowUp) - assertComboboxList({ state: ComboboxState.Visible }) - assertActiveElement(getComboboxInput()) - - assertNoActiveComboboxOption() - }) - ) - - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the button - getComboboxButton()?.focus() - - // Open combobox - await press(Keys.ArrowUp) - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[0]) - }) - ) - }) - }) - - 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() - renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup() { - let value = ref(null) - watch([value], () => handleChange(value.value)) - return { value } - }, - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Open combobox - await click(getComboboxButton()) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - - // Activate the first combobox option - let options = getComboboxOptions() - await mouseMove(options[0]) - - // Choose option, and close combobox - await press(Keys.Enter) - - // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Verify we got the change event - expect(handleChange).toHaveBeenCalledTimes(1) - expect(handleChange).toHaveBeenCalledWith('a') - - // Verify the button is focused again - assertActiveElement(getComboboxInput()) - - // Open combobox again - await click(getComboboxButton()) - - // Verify the active option is the previously selected one - assertActiveComboboxOption(getComboboxOptions()[0]) - }) - ) - - it( - 'should submit the form on `Enter`', - suppressConsoleLogs(async () => { - let submits = jest.fn() - - renderTemplate({ - template: html` -
- +describe.each([{ virtual: true }, { virtual: false }])( + 'Keyboard interactions %s', + ({ virtual }) => { + 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 + Option A + Option B + Option C + `, + setup: () => ({ value: ref(null), virtual }), + h, + }) - - - `, - setup() { - let value = ref('b') - return { - value, - 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. - if (event.key === 'Enter') (event.currentTarget as HTMLFormElement).submit() - }, - handleSubmit(event: SubmitEvent) { - event.preventDefault() - submits([...new FormData(event.currentTarget as HTMLFormElement).entries()]) - }, - } - }, + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option, { selected: false })) + + assertActiveComboboxOption(options[0]) + assertNoSelectedComboboxOption() }) + ) - // Focus the input field - getComboboxInput()?.focus() - assertActiveElement(getComboboxInput()) - - // Press enter (which should submit the form) - await press(Keys.Enter) - - // Verify the form was submitted - expect(submits).toHaveBeenCalledTimes(1) - expect(submits).toHaveBeenCalledWith([['option', 'b']]) - }) - ) - - it( - 'should submit the form on `Enter` (when no submit button was found)', - suppressConsoleLogs(async () => { - let submits = jest.fn() - - renderTemplate({ - template: html` -
- + it( + '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 + Option A + Option B + Option C - - `, - setup() { - let value = ref('b') - return { - value, - 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. - if (event.key === 'Enter') (event.currentTarget as HTMLFormElement).submit() - }, - handleSubmit(event: SubmitEvent) { - event.preventDefault() - submits([...new FormData(event.currentTarget as HTMLFormElement).entries()]) - }, - } - }, + `, + setup: () => ({ value: ref(null), virtual }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Try to focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.Enter) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) + ) - // Focus the input field - getComboboxInput()?.focus() - assertActiveElement(getComboboxInput()) + it( + '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 }), + }) - // Press enter (which should submit the form) - await press(Keys.Enter) + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - // Verify the form was submitted - expect(submits).toHaveBeenCalledTimes(1) - expect(submits).toHaveBeenCalledWith([['option', 'b']]) - }) - ) + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be possible to open the combobox with Enter, and focus the selected option (when using the `hidden` render strategy)', + suppressConsoleLogs(async () => { + if (virtual) return // Incompatible with virtual rendering + + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b'), virtual }), + }) + + await new Promise(nextTick) + + assertComboboxButton({ + state: ComboboxState.InvisibleHidden, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleHidden }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + let options = getComboboxOptions() + + // Hover over Option A + await mouseMove(options[0]) + + // Verify that Option A is active + assertActiveComboboxOption(options[0]) + + // Verify that Option B is still selected + assertComboboxOption(options[1], { selected: true }) + + // Close/Hide the combobox + await press(Keys.Escape) + + // Re-open the combobox + await click(getComboboxButton()) + + // Verify we have combobox options + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + '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 } + }, + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + '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 }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`Space` key', () => { + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Space) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.Space) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ + state: ComboboxState.InvisibleUnmounted, + }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Space) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + '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 }), + }) + + assertComboboxList({ + state: ComboboxState.InvisibleUnmounted, + }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Space) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ + state: ComboboxState.InvisibleUnmounted, + }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Space) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`Escape` key', () => { + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Re-focus the button + getComboboxButton()?.focus() + assertActiveElement(getComboboxButton()) + + // Close combobox + await press(Keys.Escape) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify the input is focused again + assertActiveElement(getComboboxInput()) + }) + ) + + it( + 'should not propagate the Escape event when the combobox is open', + suppressConsoleLogs(async () => { + let handleKeyDown = jest.fn() + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null), virtual }), + }) + + window.addEventListener('keydown', handleKeyDown) + + // Open combobox + await click(getComboboxButton()) + + // Close combobox + await press(Keys.Escape) + + // We should never see the Escape event + expect(handleKeyDown).toHaveBeenCalledTimes(0) + + window.removeEventListener('keydown', handleKeyDown) + }) + ) + + it( + 'should propagate the Escape event when the combobox is closed', + suppressConsoleLogs(async () => { + let handleKeyDown = jest.fn() + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null), virtual }), + }) + + window.addEventListener('keydown', handleKeyDown) + + // Focus the input field + await focus(getComboboxInput()) + + // Close combobox + await press(Keys.Escape) + + // We should never see the Escape event + expect(handleKeyDown).toHaveBeenCalledTimes(1) + + window.removeEventListener('keydown', handleKeyDown) + }) + ) + }) + + describe('`ArrowDown` key', () => { + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + + // Verify that the first combobox option is active + assertActiveComboboxOption(options[0]) + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.ArrowDown) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + '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 }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`ArrowUp` key', () => { + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + + // ! ALERT: The LAST option should now be active + assertActiveComboboxOption(options[2]) + }) + ) + + 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 () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null), virtual }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.ArrowUp) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + '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 }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + }) + ) + }) }) - describe('`Tab` key', () => { - it( - 'pressing Tab should select the active item and move to the next DOM node', - suppressConsoleLogs(async () => { - renderTemplate({ - template: html` - - - - Trigger - - Option A - Option B - Option C - - - - `, - setup: () => ({ value: ref(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() + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup() { + let value = ref(null) + watch([value], () => handleChange(value.value)) + return { value, virtual } + }, + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + + // Activate the first combobox option + let options = getComboboxOptions() + await mouseMove(options[0]) + + // Choose option, and close combobox + await press(Keys.Enter) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify we got the change event + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith('a') + + // Verify the button is focused again + assertActiveElement(getComboboxInput()) + + // Open combobox again + await click(getComboboxButton()) + + // Verify the active option is the previously selected one + assertActiveComboboxOption(getComboboxOptions()[0]) }) + ) - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + it( + 'should submit the form on `Enter`', + suppressConsoleLogs(async () => { + let submits = jest.fn() + + renderTemplate({ + template: html` +
+ + + Trigger + + Option A + Option B + Option C + + + + +
+ `, + setup() { + let value = ref('b') + return { + value, + 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. + if (event.key === 'Enter') (event.currentTarget as HTMLFormElement).submit() + }, + handleSubmit(event: SubmitEvent) { + event.preventDefault() + submits([...new FormData(event.currentTarget as HTMLFormElement).entries()]) + }, + virtual, + } + }, + }) + + // Focus the input field + getComboboxInput()?.focus() + assertActiveElement(getComboboxInput()) + + // Press enter (which should submit the form) + await press(Keys.Enter) + + // Verify the form was submitted + expect(submits).toHaveBeenCalledTimes(1) + expect(submits).toHaveBeenCalledWith([['option', 'b']]) }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + ) - // Open combobox - await click(getComboboxButton()) + it( + 'should submit the form on `Enter` (when no submit button was found)', + suppressConsoleLogs(async () => { + let submits = jest.fn() - // Select the 2nd option - await press(Keys.ArrowDown) + renderTemplate({ + template: html` +
+ + + Trigger + + Option A + Option B + Option C + + +
+ `, + setup() { + let value = ref('b') + return { + value, + 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. + if (event.key === 'Enter') (event.currentTarget as HTMLFormElement).submit() + }, + handleSubmit(event: SubmitEvent) { + event.preventDefault() + submits([...new FormData(event.currentTarget as HTMLFormElement).entries()]) + }, + virtual, + } + }, + }) - // Tab to the next DOM node - await press(Keys.Tab) + // Focus the input field + getComboboxInput()?.focus() + assertActiveElement(getComboboxInput()) - // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + // Press enter (which should submit the form) + await press(Keys.Enter) - // That the selected value was the highlighted one - expect(getComboboxInput()?.value).toBe('b') - - // And focus has moved to the next element - assertActiveElement(document.querySelector('#after-combobox')) - }) - ) - - it( - 'pressing Shift+Tab should select the active item and move to the previous DOM node', - suppressConsoleLogs(async () => { - renderTemplate({ - template: html` - - - - Trigger - - Option A - Option B - Option C - - - - `, - setup: () => ({ value: ref(null) }), + // Verify the form was submitted + expect(submits).toHaveBeenCalledTimes(1) + expect(submits).toHaveBeenCalledWith([['option', 'b']]) }) + ) + }) - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, + describe('`Tab` key', () => { + it( + 'pressing Tab should select the active item and move to the next DOM node', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + + Trigger + + Option A + Option B + Option C + + + + `, + setup: () => ({ value: ref(null), virtual }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Select the 2nd option + await press(Keys.ArrowDown) + + // Tab to the next DOM node + await press(Keys.Tab) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // That the selected value was the highlighted one + expect(getComboboxInput()?.value).toBe('b') + + // And focus has moved to the next element + assertActiveElement(document.querySelector('#after-combobox')) }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + ) - // Open combobox - await click(getComboboxButton()) + it( + 'pressing Shift+Tab should select the active item and move to the previous DOM node', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + + Trigger + + Option A + Option B + Option C + + + + `, + setup: () => ({ value: ref(null), virtual }), + }) - // Select the 2nd option - await press(Keys.ArrowDown) + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - // Tab to the next DOM node - await press(shift(Keys.Tab)) + // Open combobox + await click(getComboboxButton()) - // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + // Select the 2nd option + await press(Keys.ArrowDown) - // That the selected value was the highlighted one - expect(getComboboxInput()?.value).toBe('b') + // Tab to the next DOM node + await press(shift(Keys.Tab)) - // And focus has moved to the next element - assertActiveElement(document.querySelector('#before-combobox')) - }) - ) - }) + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - describe('`Escape` key', () => { - it( - '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) }), + // That the selected value was the highlighted one + expect(getComboboxInput()?.value).toBe('b') + + // And focus has moved to the next element + assertActiveElement(document.querySelector('#before-combobox')) }) + ) + }) - // Open combobox - await click(getComboboxButton()) + describe('`Escape` key', () => { + it( + '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 }), + }) - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Close combobox + await press(Keys.Escape) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify the button is focused again + assertActiveElement(getComboboxInput()) }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() + ) - // Close combobox - await press(Keys.Escape) + it( + 'should bubble escape when using `static` on Combobox.Options', + suppressConsoleLogs(async () => { + if (virtual) return // Incompatible with virtual rendering - // Verify it is closed - assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null), virtual }), + }) - // Verify the button is focused again - assertActiveElement(getComboboxInput()) - }) - ) + let spy = jest.fn() - it( - 'should bubble escape when using `static` on Combobox.Options', - suppressConsoleLogs(async () => { - renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null) }), - }) + window.addEventListener( + 'keydown', + (evt) => { + if (evt.key === 'Escape') { + spy() + } + }, + { capture: true } + ) - let spy = jest.fn() - - window.addEventListener( - 'keydown', - (evt) => { + window.addEventListener('keydown', (evt) => { if (evt.key === 'Escape') { spy() } - }, - { capture: true } - ) + }) - window.addEventListener('keydown', (evt) => { - if (evt.key === 'Escape') { - spy() - } + // Open combobox + await click(getComboboxButton()) + + // Verify the input is focused + assertActiveElement(getComboboxInput()) + + // Close combobox + await press(Keys.Escape) + + // Verify the input is still focused + assertActiveElement(getComboboxInput()) + + // The external event handler should've been called twice + // Once in the capture phase and once in the bubble phase + expect(spy).toHaveBeenCalledTimes(2) }) + ) - // Open combobox - await click(getComboboxButton()) + it( + 'should bubble escape when not using Combobox.Options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + `, + setup: () => ({ value: ref(null), virtual }), + }) - // Verify the input is focused - assertActiveElement(getComboboxInput()) + let spy = jest.fn() - // Close combobox - await press(Keys.Escape) + window.addEventListener( + 'keydown', + (evt) => { + if (evt.key === 'Escape') { + spy() + } + }, + { capture: true } + ) - // Verify the input is still focused - assertActiveElement(getComboboxInput()) - - // The external event handler should've been called twice - // Once in the capture phase and once in the bubble phase - expect(spy).toHaveBeenCalledTimes(2) - }) - ) - - it( - 'should bubble escape when not using Combobox.Options at all', - suppressConsoleLogs(async () => { - renderTemplate({ - template: html` - - - Trigger - - `, - setup: () => ({ value: ref(null) }), - }) - - let spy = jest.fn() - - window.addEventListener( - 'keydown', - (evt) => { + window.addEventListener('keydown', (evt) => { if (evt.key === 'Escape') { spy() } + }) + + // Open combobox + await click(getComboboxButton()) + + // Verify the input is focused + assertActiveElement(getComboboxInput()) + + // Close combobox + await press(Keys.Escape) + + // Verify the input is still focused + assertActiveElement(getComboboxInput()) + + // The external event handler should've been called twice + // Once in the capture phase and once in the bubble phase + expect(spy).toHaveBeenCalledTimes(2) + }) + ) + + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + // Verify the input has the selected value + expect(getComboboxInput()?.value).toBe('option-b') + + // Override the input by typing something + await type(word('test'), getComboboxInput()) + expect(getComboboxInput()?.value).toBe('test') + + // Close combobox + await press(Keys.Escape) + + // Verify the input is reset correctly + expect(getComboboxInput()?.value).toBe('option-b') + }) + ) + }) + + describe('`ArrowDown` key', () => { + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + + // Verify that the first combobox option is active + assertActiveComboboxOption(options[0]) + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Try to open the combobox + await press(Keys.ArrowDown) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + '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 }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + + // We should be able to go down once + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[1]) + + // We should be able to go down again + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + + // We should NOT be able to go down again (because last option). + // Current implementation won't go around. + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[1]) + + // We should be able to go down once + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[2]) + + // Open combobox + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // Verify that we are on the first option + assertActiveComboboxOption(options[0]) + + // Go down once + await press(Keys.ArrowDown) + + // We should be on the next item + assertActiveComboboxOption(options[1]) + }) + ) + }) + + describe('`ArrowUp` key', () => { + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + + // ! ALERT: The LAST option should now be active + assertActiveComboboxOption(options[2]) + }) + ) + + 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 () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null), virtual }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Try to open the combobox + await press(Keys.ArrowUp) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + '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 }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[2]) + + // Going up or down should select the single available option + await press(Keys.ArrowUp) + + // We should not be able to go up (because those are disabled) + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[2]) + + // We should not be able to go down (because this is the last option) + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + '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 }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option) => assertComboboxOption(option)) + assertActiveComboboxOption(options[2]) + + // We should be able to go down once + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[1]) + + // We should be able to go down again + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[0]) + + // We should NOT be able to go up again (because first option). Current implementation won't go around. + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[0]) + }) + ) + }) + + describe('`End` key', () => { + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await press(Keys.End) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last non-disabled option + await press(Keys.End) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[0]) + + // We should not be able to go to the end (no-op) + await press(Keys.End) + + assertActiveComboboxOption(options[0]) + }) + ) + + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.End) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`PageDown` key', () => { + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the first option + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await press(Keys.PageDown) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + // Open combobox + await press(Keys.Space) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last non-disabled option + await press(Keys.PageDown) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[0]) + + // We should not be able to go to the end + await press(Keys.PageDown) + + assertActiveComboboxOption(options[0]) + }) + ) + + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.PageDown) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`Home` key', () => { + it( + '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 }), + }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + let options = getComboboxOptions() + + // We should be on the last option + assertActiveComboboxOption(options[2]) + + // We should be able to go to the first option + await press(Keys.Home) + assertActiveComboboxOption(options[0]) + }) + ) + + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[2]) + + // We should not be able to go to the end + await press(Keys.Home) + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[2]) + }) + ) + + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the last option + assertActiveComboboxOption(options[3]) + + // We should not be able to go to the end + await press(Keys.Home) + + assertActiveComboboxOption(options[3]) + }) + ) + + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.Home) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`PageUp` key', () => { + it( + '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 }), + }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + let options = getComboboxOptions() + + // We should be on the last option + assertActiveComboboxOption(options[2]) + + // We should be able to go to the first option + await press(Keys.PageUp) + assertActiveComboboxOption(options[0]) + }) + ) + + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We opened via click, we default to the first non-disabled option + assertActiveComboboxOption(options[2]) + + // We should not be able to go to the end (no-op — already there) + await press(Keys.PageUp) + + assertActiveComboboxOption(options[2]) + }) + ) + + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We opened via click, we default to the first non-disabled option + assertActiveComboboxOption(options[3]) + + // We should not be able to go to the end (no-op — already there) + await press(Keys.PageUp) + + assertActiveComboboxOption(options[3]) + }) + ) + + it( + '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 }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.PageUp) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`Backspace` key', () => { + it( + 'should reset the value when the last character is removed, when in `nullable` mode', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() + renderTemplate({ + template: html` + + + Trigger + + Alice + Bob + Charlie + + + `, + setup: () => { + let value = ref('bob') + watch([value], () => handleChange(value.value)) + return { value, virtual } + }, + }) + + // 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', () => { + let Example = defineComponent({ + components: getDefaultComponents(), + + template: html` + + + Trigger + + + {{ person.name }} + + + + `, + + props: { + people: { + type: Array as PropType<{ value: string; name: string; disabled: boolean }[]>, + required: true, }, - { capture: true } - ) - - window.addEventListener('keydown', (evt) => { - if (evt.key === 'Escape') { - spy() - } - }) - - // Open combobox - await click(getComboboxButton()) - - // Verify the input is focused - assertActiveElement(getComboboxInput()) - - // Close combobox - await press(Keys.Escape) - - // Verify the input is still focused - assertActiveElement(getComboboxInput()) - - // The external event handler should've been called twice - // Once in the capture phase and once in the bubble phase - expect(spy).toHaveBeenCalledTimes(2) - }) - ) - - it( - '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') }), - }) - - // Open combobox - await click(getComboboxButton()) - - // Verify the input has the selected value - expect(getComboboxInput()?.value).toBe('option-b') - - // Override the input by typing something - await type(word('test'), getComboboxInput()) - expect(getComboboxInput()?.value).toBe('test') - - // Close combobox - await press(Keys.Escape) - - // Verify the input is reset correctly - expect(getComboboxInput()?.value).toBe('option-b') - }) - ) - }) - - describe('`ArrowDown` key', () => { - it( - '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') }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - getComboboxInput()?.focus() - - // Open combobox - await press(Keys.ArrowDown) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - - // Verify that the first combobox option is active - assertActiveComboboxOption(options[0]) - }) - ) - - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - getComboboxInput()?.focus() - - // Try to open the combobox - await press(Keys.ArrowDown) - - // Verify it is still closed - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - }) - ) - - it( - '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') }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - getComboboxInput()?.focus() - - // Open combobox - await press(Keys.ArrowDown) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - 'should have no active combobox option when there are no combobox options at all', - suppressConsoleLogs(async () => { - renderTemplate({ - template: html` - - - Trigger - - - `, - setup: () => ({ value: ref(null) }), - }) - - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - getComboboxInput()?.focus() - - // Open combobox - await press(Keys.ArrowDown) - assertComboboxList({ state: ComboboxState.Visible }) - assertActiveElement(getComboboxInput()) - - assertNoActiveComboboxOption() - }) - ) - - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Open combobox - await click(getComboboxButton()) - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[0]) - - // We should be able to go down once - await press(Keys.ArrowDown) - assertActiveComboboxOption(options[1]) - - // We should be able to go down again - await press(Keys.ArrowDown) - assertActiveComboboxOption(options[2]) - - // We should NOT be able to go down again (because last option). - // Current implementation won't go around. - await press(Keys.ArrowDown) - assertActiveComboboxOption(options[2]) - }) - ) - - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Open combobox - await click(getComboboxButton()) - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[1]) - - // We should be able to go down once - await press(Keys.ArrowDown) - assertActiveComboboxOption(options[2]) - }) - ) - - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Open combobox - await click(getComboboxButton()) - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[2]) - - // Open combobox - await press(Keys.ArrowDown) - assertActiveComboboxOption(options[2]) - }) - ) - - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Open combobox - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // Verify that we are on the first option - assertActiveComboboxOption(options[0]) - - // Go down once - await press(Keys.ArrowDown) - - // We should be on the next item - assertActiveComboboxOption(options[1]) - }) - ) - }) - - describe('`ArrowUp` key', () => { - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - getComboboxInput()?.focus() - - // Open combobox - await press(Keys.ArrowUp) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - - // ! ALERT: The LAST option should now be active - assertActiveComboboxOption(options[2]) - }) - ) - - 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 () => { - renderTemplate({ - template: html` - - - Trigger - - Option A - Option B - Option C - - - `, - setup: () => ({ value: ref(null) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - getComboboxInput()?.focus() - - // Try to open the combobox - await press(Keys.ArrowUp) - - // Verify it is still closed - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - }) - ) - - it( - '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') }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - getComboboxInput()?.focus() - - // Open combobox - await press(Keys.ArrowUp) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) - - // Verify that the second combobox option is active (because it is already selected) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - 'should have no active combobox option when there are no combobox options at all', - suppressConsoleLogs(async () => { - renderTemplate({ - template: html` - - - Trigger - - - `, - setup: () => ({ value: ref(null) }), - }) - - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - getComboboxInput()?.focus() - - // Open combobox - await press(Keys.ArrowUp) - assertComboboxList({ state: ComboboxState.Visible }) - assertActiveElement(getComboboxInput()) - - assertNoActiveComboboxOption() - }) - ) - - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - getComboboxInput()?.focus() - - // Open combobox - await press(Keys.ArrowUp) - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[0]) - }) - ) - - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Open combobox - await click(getComboboxButton()) - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[2]) - - // Going up or down should select the single available option - await press(Keys.ArrowUp) - - // We should not be able to go up (because those are disabled) - await press(Keys.ArrowUp) - assertActiveComboboxOption(options[2]) - - // We should not be able to go down (because this is the last option) - await press(Keys.ArrowDown) - assertActiveComboboxOption(options[2]) - }) - ) - - it( - '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) }), - }) - - assertComboboxButton({ - state: ComboboxState.InvisibleUnmounted, - attributes: { id: 'headlessui-combobox-button-2' }, - }) - assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) - - // Focus the input - getComboboxInput()?.focus() - - // Open combobox - await press(Keys.ArrowUp) - - // Verify it is visible - assertComboboxButton({ state: ComboboxState.Visible }) - assertComboboxList({ - state: ComboboxState.Visible, - attributes: { id: 'headlessui-combobox-options-3' }, - }) - assertActiveElement(getComboboxInput()) - assertComboboxButtonLinkedWithCombobox() - - // Verify we have combobox options - let options = getComboboxOptions() - expect(options).toHaveLength(3) - options.forEach((option) => assertComboboxOption(option)) - assertActiveComboboxOption(options[2]) - - // We should be able to go down once - await press(Keys.ArrowUp) - assertActiveComboboxOption(options[1]) - - // We should be able to go down again - await press(Keys.ArrowUp) - assertActiveComboboxOption(options[0]) - - // We should NOT be able to go up again (because first option). Current implementation won't go around. - await press(Keys.ArrowUp) - assertActiveComboboxOption(options[0]) - }) - ) - }) - - describe('`End` key', () => { - it( - '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) }), - }) - - // Open combobox - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We should be on the first non-disabled option - assertActiveComboboxOption(options[0]) - - // We should be able to go to the last option - await press(Keys.End) - assertActiveComboboxOption(options[2]) - }) - ) - - it( - '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) }), - }) - - // Open combobox - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We should be on the first non-disabled option - assertActiveComboboxOption(options[0]) - - // We should be able to go to the last non-disabled option - await press(Keys.End) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - '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) }), - }) - - // Open combobox - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We should be on the first non-disabled option - assertActiveComboboxOption(options[0]) - - // We should not be able to go to the end (no-op) - await press(Keys.End) - - assertActiveComboboxOption(options[0]) - }) - ) - - it( - '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) }), - }) - - // Open combobox - await click(getComboboxButton()) - - // We opened via click, we don't have an active option - assertNoActiveComboboxOption() - - // We should not be able to go to the end - await press(Keys.End) - - assertNoActiveComboboxOption() - }) - ) - }) - - describe('`PageDown` key', () => { - it( - '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) }), - }) - - // Open combobox - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We should be on the first option - assertActiveComboboxOption(options[0]) - - // We should be able to go to the last option - await press(Keys.PageDown) - assertActiveComboboxOption(options[2]) - }) - ) - - it( - '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) }), - }) - - // Open combobox - await click(getComboboxButton()) - - // Open combobox - await press(Keys.Space) - - let options = getComboboxOptions() - - // We should be on the first non-disabled option - assertActiveComboboxOption(options[0]) - - // We should be able to go to the last non-disabled option - await press(Keys.PageDown) - assertActiveComboboxOption(options[1]) - }) - ) - - it( - '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) }), - }) - - // Open combobox - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We should be on the first non-disabled option - assertActiveComboboxOption(options[0]) - - // We should not be able to go to the end - await press(Keys.PageDown) - - assertActiveComboboxOption(options[0]) - }) - ) - - it( - '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) }), - }) - - // Open combobox - await click(getComboboxButton()) - - // We opened via click, we don't have an active option - assertNoActiveComboboxOption() - - // We should not be able to go to the end - await press(Keys.PageDown) - - assertNoActiveComboboxOption() - }) - ) - }) - - describe('`Home` key', () => { - it( - '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) }), - }) - - // Focus the input - getComboboxInput()?.focus() - - // Open combobox - await press(Keys.ArrowUp) - - let options = getComboboxOptions() - - // We should be on the last option - assertActiveComboboxOption(options[2]) - - // We should be able to go to the first option - await press(Keys.Home) - assertActiveComboboxOption(options[0]) - }) - ) - - it( - '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) }), - }) - - // Open combobox - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We should be on the first non-disabled option - assertActiveComboboxOption(options[2]) - - // We should not be able to go to the end - await press(Keys.Home) - - // We should be on the first non-disabled option - assertActiveComboboxOption(options[2]) - }) - ) - - it( - '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) }), - }) - - // Open combobox - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We should be on the last option - assertActiveComboboxOption(options[3]) - - // We should not be able to go to the end - await press(Keys.Home) - - assertActiveComboboxOption(options[3]) - }) - ) - - it( - '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) }), - }) - - // Open combobox - await click(getComboboxButton()) - - // We opened via click, we don't have an active option - assertNoActiveComboboxOption() - - // We should not be able to go to the end - await press(Keys.Home) - - assertNoActiveComboboxOption() - }) - ) - }) - - describe('`PageUp` key', () => { - it( - '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) }), - }) - - // Focus the input - getComboboxInput()?.focus() - - // Open combobox - await press(Keys.ArrowUp) - - let options = getComboboxOptions() - - // We should be on the last option - assertActiveComboboxOption(options[2]) - - // We should be able to go to the first option - await press(Keys.PageUp) - assertActiveComboboxOption(options[0]) - }) - ) - - it( - '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) }), - }) - - // Open combobox - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We opened via click, we default to the first non-disabled option - assertActiveComboboxOption(options[2]) - - // We should not be able to go to the end (no-op — already there) - await press(Keys.PageUp) - - assertActiveComboboxOption(options[2]) - }) - ) - - it( - '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) }), - }) - - // Open combobox - await click(getComboboxButton()) - - let options = getComboboxOptions() - - // We opened via click, we default to the first non-disabled option - assertActiveComboboxOption(options[3]) - - // We should not be able to go to the end (no-op — already there) - await press(Keys.PageUp) - - assertActiveComboboxOption(options[3]) - }) - ) - - it( - '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) }), - }) - - // Open combobox - await click(getComboboxButton()) - - // We opened via click, we don't have an active option - assertNoActiveComboboxOption() - - // We should not be able to go to the end - await press(Keys.PageUp) - - assertNoActiveComboboxOption() - }) - ) - }) - - describe('`Backspace` key', () => { - it( - 'should reset the value when the last character is removed, when in `nullable` mode', - suppressConsoleLogs(async () => { - let handleChange = jest.fn() - renderTemplate({ - template: html` - - - Trigger - - Alice - Bob - Charlie - - - `, - setup: () => { - let value = ref('bob') - watch([value], () => handleChange(value.value)) - return { value } - }, - }) - - // 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', () => { - let Example = defineComponent({ - components: getDefaultComponents(), - - template: html` - - - Trigger - - - {{ person.name }} - - - - `, - - props: { - people: { - type: Array as PropType<{ value: string; name: string; disabled: boolean }[]>, - required: true, }, - }, - setup(props) { - let value = ref(null) - let query = ref('') - let filteredPeople = computed(() => { - return query.value === '' - ? props.people - : props.people.filter((person) => - person.name.toLowerCase().includes(query.value.toLowerCase()) - ) + setup(props) { + let value = ref(null) + let query = ref('') + let filteredPeople = computed(() => { + return query.value === '' + ? props.people + : props.people.filter((person) => + person.name.toLowerCase().includes(query.value.toLowerCase()) + ) + }) + + return { + value, + query, + filteredPeople, + setQuery: (event: Event & { target: HTMLInputElement }) => { + query.value = event.target.value + }, + virtual, + } + }, + }) + + it( + 'should be possible to type a full word that has a perfect match', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { Example }, + template: html` + + `, + }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + let options: ReturnType + + // We should be able to go to the second option + await type(word('bob')) + await press(Keys.Home) + + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('bob') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the first option + await type(word('alice')) + await press(Keys.Home) + + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('alice') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await type(word('charlie')) + await press(Keys.Home) + + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('charlie') + assertActiveComboboxOption(options[0]) }) + ) - return { - value, - query, - filteredPeople, - setQuery: (event: Event & { target: HTMLInputElement }) => { + it( + 'should be possible to type a partial of a word', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { Example }, + template: html` + + `, + }) + + // Open combobox + await click(getComboboxButton()) + + let options: ReturnType + + // We should be able to go to the second option + await type(word('bo')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('bob') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the first option + await type(word('ali')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('alice') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await type(word('char')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('charlie') + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should be possible to type words with spaces', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { Example }, + template: html` + + `, + }) + + // Open combobox + await click(getComboboxButton()) + + let options: ReturnType + + // We should be able to go to the second option + await type(word('bob t')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('bob the builder') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the first option + await type(word('alice j')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('alice jones') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await type(word('charlie b')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('charlie bit me') + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should not be possible to search and activate a disabled option', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { Example }, + template: html` + + `, + }) + + // Open combobox + await click(getComboboxButton()) + + // We should not be able to go to the disabled option + await type(word('bo')) + await press(Keys.Home) + + assertNoActiveComboboxOption() + assertNoSelectedComboboxOption() + }) + ) + + it( + 'should maintain activeIndex and activeOption when filtering', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { Example }, + template: html` + + `, + }) + + // Open combobox + await click(getComboboxButton()) + + let options: ReturnType + + await press(Keys.ArrowDown) + + // Person B should be active + options = getComboboxOptions() + expect(options[1]).toHaveTextContent('person b') + assertActiveComboboxOption(options[1]) + + // Filter more, remove `person a` + await type(word('person b')) + options = getComboboxOptions() + expect(options[0]).toHaveTextContent('person b') + assertActiveComboboxOption(options[0]) + + // Filter less, insert `person a` before `person b` + await type(word('person')) + options = getComboboxOptions() + expect(options[1]).toHaveTextContent('person b') + assertActiveComboboxOption(options[1]) + }) + ) + }) + }) + + it( + 'should sync the active index properly', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + {{ activeIndex }} + + {{ option }} + + + `, + setup: () => { + let value = ref(null) + let options = ref(['Option A', 'Option B', 'Option C', 'Option D']) + + let query = ref('') + let filteredOptions = computed(() => { + return query.value === '' + ? options.value + : options.value.filter((option) => option.includes(query.value)) + }) + + function filter(event: Event & { target: HTMLInputElement }) { query.value = event.target.value - }, - } - }, + } + + return { value, options: filteredOptions, filter, virtual } + }, + }) + + // Open combobox + await click(getComboboxButton()) + + let activeIndexEl = document.querySelector('[data-test="idx"]') + function activeIndex() { + return Number(activeIndexEl?.innerHTML) + } + + expect(activeIndex()).toEqual(0) + + let options: ReturnType + + await focus(getComboboxInput()) + await type(word('Option B')) + + // Option B should be active + options = getComboboxOptions() + expect(options[activeIndex()]).toHaveTextContent('Option B') + assertActiveComboboxOption(options[activeIndex()]) + await press(Keys.Enter) + + // Reveal all options again + await type(word('Option')) + + // Option B should still be active + options = getComboboxOptions() + expect(options[activeIndex()]).toHaveTextContent('Option B') + assertActiveComboboxOption(options[activeIndex()]) }) + ) + } +) - it( - 'should be possible to type a full word that has a perfect match', - suppressConsoleLogs(async () => { - renderTemplate({ - components: { Example }, - template: html` - - `, - }) - - // Open combobox - await click(getComboboxButton()) - - // Verify we moved focus to the input field - assertActiveElement(getComboboxInput()) - let options: ReturnType - - // We should be able to go to the second option - await type(word('bob')) - await press(Keys.Home) - - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('bob') - assertActiveComboboxOption(options[0]) - - // We should be able to go to the first option - await type(word('alice')) - await press(Keys.Home) - - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('alice') - assertActiveComboboxOption(options[0]) - - // We should be able to go to the last option - await type(word('charlie')) - await press(Keys.Home) - - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('charlie') - assertActiveComboboxOption(options[0]) - }) - ) - - it( - 'should be possible to type a partial of a word', - suppressConsoleLogs(async () => { - renderTemplate({ - components: { Example }, - template: html` - - `, - }) - - // Open combobox - await click(getComboboxButton()) - - let options: ReturnType - - // We should be able to go to the second option - await type(word('bo')) - await press(Keys.Home) - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('bob') - assertActiveComboboxOption(options[0]) - - // We should be able to go to the first option - await type(word('ali')) - await press(Keys.Home) - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('alice') - assertActiveComboboxOption(options[0]) - - // We should be able to go to the last option - await type(word('char')) - await press(Keys.Home) - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('charlie') - assertActiveComboboxOption(options[0]) - }) - ) - - it( - 'should be possible to type words with spaces', - suppressConsoleLogs(async () => { - renderTemplate({ - components: { Example }, - template: html` - - `, - }) - - // Open combobox - await click(getComboboxButton()) - - let options: ReturnType - - // We should be able to go to the second option - await type(word('bob t')) - await press(Keys.Home) - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('bob the builder') - assertActiveComboboxOption(options[0]) - - // We should be able to go to the first option - await type(word('alice j')) - await press(Keys.Home) - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('alice jones') - assertActiveComboboxOption(options[0]) - - // We should be able to go to the last option - await type(word('charlie b')) - await press(Keys.Home) - options = getComboboxOptions() - expect(options).toHaveLength(1) - expect(options[0]).toHaveTextContent('charlie bit me') - assertActiveComboboxOption(options[0]) - }) - ) - - it( - 'should not be possible to search and activate a disabled option', - suppressConsoleLogs(async () => { - renderTemplate({ - components: { Example }, - template: html` - - `, - }) - - // Open combobox - await click(getComboboxButton()) - - // We should not be able to go to the disabled option - await type(word('bo')) - await press(Keys.Home) - - assertNoActiveComboboxOption() - assertNoSelectedComboboxOption() - }) - ) - - it( - 'should maintain activeIndex and activeOption when filtering', - suppressConsoleLogs(async () => { - renderTemplate({ - components: { Example }, - template: html` - - `, - }) - - // Open combobox - await click(getComboboxButton()) - - let options: ReturnType - - await press(Keys.ArrowDown) - - // Person B should be active - options = getComboboxOptions() - expect(options[1]).toHaveTextContent('person b') - assertActiveComboboxOption(options[1]) - - // Filter more, remove `person a` - await type(word('person b')) - options = getComboboxOptions() - expect(options[0]).toHaveTextContent('person b') - assertActiveComboboxOption(options[0]) - - // Filter less, insert `person a` before `person b` - await type(word('person')) - options = getComboboxOptions() - expect(options[1]).toHaveTextContent('person b') - assertActiveComboboxOption(options[1]) - }) - ) - }) - }) - - it( - 'should sync the active index properly', - suppressConsoleLogs(async () => { - renderTemplate({ - template: html` - - - Trigger - {{ activeIndex }} - - {{ option }} - - - `, - setup: () => { - let value = ref(null) - let options = ref(['Option A', 'Option B', 'Option C', 'Option D']) - - let query = ref('') - let filteredOptions = computed(() => { - return query.value === '' - ? options.value - : options.value.filter((option) => option.includes(query.value)) - }) - - function filter(event: Event & { target: HTMLInputElement }) { - query.value = event.target.value - } - - return { value, options: filteredOptions, filter } - }, - }) - - // Open combobox - await click(getComboboxButton()) - - let activeIndexEl = document.querySelector('[data-test="idx"]') - function activeIndex() { - return Number(activeIndexEl?.innerHTML) - } - - expect(activeIndex()).toEqual(0) - - let options: ReturnType - - await focus(getComboboxInput()) - await type(word('Option B')) - - // Option B should be active - options = getComboboxOptions() - expect(options[0]).toHaveTextContent('Option B') - assertActiveComboboxOption(options[0]) - - expect(activeIndex()).toEqual(0) - - // Reveal all options again - await type(word('Option')) - - // Option B should still be active - options = getComboboxOptions() - expect(options[1]).toHaveTextContent('Option B') - assertActiveComboboxOption(options[1]) - - expect(activeIndex()).toEqual(1) - }) - ) -}) - -describe('Mouse interactions', () => { +describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', ({ virtual }) => { it( 'should focus the ComboboxButton when we click the ComboboxLabel', suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Label Trigger - Option A - Option B - Option C + Option A + Option B + Option C `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Ensure the button is not focused yet @@ -4978,18 +5413,18 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Label Trigger - Option A - Option B - Option C + Option A + Option B + Option C `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Ensure the button is not focused yet @@ -5008,17 +5443,17 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - Option A - Option B - Option C + Option A + Option B + Option C `, - setup: () => ({ value: ref('test') }), + setup: () => ({ value: ref('test'), virtual }), }) assertComboboxButton({ @@ -5054,17 +5489,17 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - Option A - Option B - Option C + Option A + Option B + Option C `, - setup: () => ({ value: ref('test') }), + setup: () => ({ value: ref('test'), virtual }), }) assertComboboxButton({ @@ -5090,17 +5525,17 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - Option A - Option B - Option C + Option A + Option B + Option C `, - setup: () => ({ value: ref('test') }), + setup: () => ({ value: ref('test'), virtual }), }) assertComboboxButton({ @@ -5126,17 +5561,17 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - Option A - Option B - Option C + Option A + Option B + Option C `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Open combobox @@ -5160,17 +5595,17 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - Option A - Option B - Option C + Option A + Option B + Option C `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) @@ -5196,17 +5631,17 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - Option A - Option B - Option C + Option A + Option B + Option C `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) assertComboboxButton({ @@ -5239,17 +5674,17 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - Option A - Option B - Option C + Option A + Option B + Option C `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) assertComboboxButton({ @@ -5271,17 +5706,17 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - Option A - Option B - Option C + Option A + Option B + Option C `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) assertComboboxButton({ @@ -5307,17 +5742,17 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - Option A - Option B - Option C + Option A + Option B + Option C `, - setup: () => ({ value: ref('b') }), + setup: () => ({ value: ref('b'), virtual }), }) assertComboboxButton({ @@ -5353,17 +5788,17 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - Option A - Option B - Option C + Option A + Option B + Option C `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Open combobox @@ -5386,17 +5821,19 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Verify that the window is closed @@ -5417,18 +5854,20 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie
after
`, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Open combobox @@ -5453,28 +5892,36 @@ describe('Mouse interactions', () => { renderTemplate({ template: html`
- + Trigger - alice - bob - charlie + alice + bob + charlie - + Trigger - alice - bob - charlie + alice + bob + charlie
`, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) let [button1, button2] = getComboboxButtons() @@ -5501,17 +5948,19 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Open combobox @@ -5537,13 +5986,17 @@ describe('Mouse interactions', () => { renderTemplate({ template: html`
- + Trigger - alice - bob - charlie + alice + bob + charlie @@ -5552,7 +6005,7 @@ describe('Mouse interactions', () => {
`, - setup: () => ({ value: ref('test'), focusFn }), + setup: () => ({ value: ref('test'), focusFn, virtual }), }) // Click the combobox button @@ -5580,17 +6033,19 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Open combobox @@ -5614,22 +6069,29 @@ describe('Mouse interactions', () => { it( 'should be possible to hover an option and make it active when using `static`', suppressConsoleLogs(async () => { + if (virtual) return // Incompatible with virtual rendering + renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) + await nextTick() + let options = getComboboxOptions() + // We should be able to go to the second option await mouseMove(options[1]) assertActiveComboboxOption(options[1]) @@ -5649,17 +6111,19 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Open combobox @@ -5677,17 +6141,19 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Open combobox @@ -5711,17 +6177,21 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + + bob + + charlie `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Open combobox @@ -5739,17 +6209,21 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + + bob + + charlie `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Open combobox @@ -5770,17 +6244,19 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie `, - setup: () => ({ value: ref('bob') }), + setup: () => ({ value: ref('bob'), virtual }), }) // Open combobox @@ -5816,17 +6292,21 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + + bob + + charlie `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Open combobox @@ -5849,20 +6329,22 @@ describe('Mouse interactions', () => { let handleChange = jest.fn() renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie `, setup() { let value = ref(null) watch([value], () => handleChange(value.value)) - return { value } + return { value, virtual } }, }) @@ -5895,17 +6377,19 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Open combobox by focusing input @@ -5928,20 +6412,24 @@ describe('Mouse interactions', () => { let handleChange = jest.fn() renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + + bob + + charlie `, setup() { let value = ref(null) watch([value], () => handleChange(value.value)) - return { value } + return { value, virtual } }, }) @@ -5977,17 +6465,19 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Open combobox @@ -6011,17 +6501,21 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) // Open combobox @@ -6042,17 +6536,17 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - Option A - Option B - Option C + Option A + Option B + Option C `, - setup: () => ({ value: ref(null) }), + setup: () => ({ value: ref(null), virtual }), }) assertComboboxButton({ @@ -6097,18 +6591,20 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie `, - setup: () => ({ value: ref('bob') }), + setup: () => ({ value: ref('bob'), virtual }), }) // Open combobox @@ -6134,18 +6630,20 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - alice - bob - charlie + alice + bob + charlie `, - setup: () => ({ value: ref('bob') }), + setup: () => ({ value: ref('bob'), virtual }), }) // Open combobox @@ -6176,11 +6674,15 @@ describe('Mouse interactions', () => { suppressConsoleLogs(async () => { renderTemplate({ template: html` - + Trigger - {{ person.name }} @@ -6197,6 +6699,7 @@ describe('Mouse interactions', () => { return { people, value: ref(people[1]), + virtual, } }, }) diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index 0e25d11..0ba0ae1 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -1,3 +1,5 @@ +import type { Virtualizer } from '@tanstack/virtual-core' +import { useVirtualizer } from '@tanstack/vue-virtual' import { computed, defineComponent, @@ -8,15 +10,20 @@ import { onMounted, onUnmounted, 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' @@ -62,6 +69,8 @@ type ComboboxOptionData = { disabled: boolean value: unknown domRef: Ref + order: Ref + onVirtualRangeUpdate: (virtualizer: Virtualizer) => void } type StateDefinition = { // State @@ -72,6 +81,7 @@ type StateDefinition = { mode: ComputedRef nullable: ComputedRef immediate: ComputedRef + virtual: ComputedRef compare: (a: unknown, z: unknown) => boolean @@ -84,6 +94,7 @@ type StateDefinition = { disabled: Ref options: Ref<{ id: string; dataRef: ComputedRef }[]> + indexes: Ref> activeOptionIndex: Ref activationTrigger: Ref @@ -116,6 +127,82 @@ function useComboboxContext(component: string) { // --- +let VirtualContext = Symbol('VirtualContext') as InjectionKey> | null> + +let VirtualProvider = defineComponent({ + name: 'VirtualProvider', + 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 } + + let styles = window.getComputedStyle(el) + + return { + start: parseFloat(styles.paddingBlockStart || styles.paddingTop), + end: parseFloat(styles.paddingBlockEnd || styles.paddingBottom), + } + }) + + let virtualizer = useVirtualizer( + computed(() => { + return { + scrollPaddingStart: padding.value.start, + scrollPaddingEnd: padding.value.end, + count: api.options.value.length, + estimateSize() { + return measuredHeight.value + }, + 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) + } + }, + } + }) + ) + + provide(VirtualContext, api.virtual.value ? virtualizer : null) + + return () => [ + h( + 'div', + { + style: { + position: 'relative', + width: '100%', + height: `${virtualizer.value.getTotalSize()}px`, + }, + }, + slots.default?.() + ), + ] + }, +}) + +// --- + export let Combobox = defineComponent({ name: 'Combobox', emits: { 'update:modelValue': (_value: any) => true }, @@ -140,6 +227,7 @@ export let Combobox = defineComponent({ nullable: { type: Boolean, default: false }, multiple: { type: [Boolean], default: false }, immediate: { type: [Boolean], default: false }, + virtual: { type: [Boolean], default: false }, }, inheritAttrs: false, setup(props, { slots, attrs, emit }) { @@ -155,12 +243,19 @@ 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 @@ -169,9 +264,14 @@ export let Combobox = defineComponent({ let currentActiveOption = activeOptionIndex.value !== null ? options.value[activeOptionIndex.value] : null - let sortedOptions = sortByDomNode(adjustment(options.value.slice()), (option) => - dom(option.dataRef.domRef) - ) + let list = adjustment(options.value.slice()) + + let sortedOptions = + list.length > 0 && list[0].dataRef.order.value !== null + ? // Prefer sorting based on the `order` + list.sort((a, z) => a.dataRef.order.value! - z.dataRef.order.value!) + : // Fallback to much slower DOM order + sortByDomNode(list, (option) => dom(option.dataRef.domRef)) // If we inserted an option before the current active option then the active option index // would be wrong. To fix this, we will re-lookup the correct index. @@ -224,12 +324,14 @@ export let Combobox = defineComponent({ defaultValue: computed(() => props.defaultValue), nullable, immediate: computed(() => props.immediate), + virtual: computed(() => props.virtual), inputRef, labelRef, buttonRef, optionsRef, disabled: computed(() => props.disabled), options, + indexes, change(value: unknown) { theirOnChange(value as typeof props.modelValue) }, @@ -241,7 +343,7 @@ export let Combobox = defineComponent({ ) { let localActiveOptionIndex = options.value.findIndex((option) => !option.dataRef.disabled) if (localActiveOptionIndex !== -1) { - activeOptionIndex.value = localActiveOptionIndex + return localActiveOptionIndex } } @@ -333,6 +435,7 @@ export let Combobox = defineComponent({ activeOptionIndex.value = nextActiveOptionIndex activationTrigger.value = trigger ?? ActivationTrigger.Other options.value = adjustedState.options + recalculateIndexes() }) }, selectOption(id: string) { @@ -389,7 +492,10 @@ export let Combobox = defineComponent({ registerOption(id: string, dataRef: ComboboxOptionData) { if (orderOptionsRaf) cancelAnimationFrame(orderOptionsRaf) - let option = { id, dataRef } + let option = reactive({ id, dataRef }) as unknown as { + id: typeof id + dataRef: typeof dataRef + } let adjustedState = adjustOrderedState((options) => { options.push(option) @@ -415,13 +521,16 @@ 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))) { orderOptionsRaf = requestAnimationFrame(() => { let adjustedState = adjustOrderedState() options.value = adjustedState.options + activeOptionIndex.value = adjustedState.activeOptionIndex + recalculateIndexes() }) } }, @@ -451,6 +560,7 @@ export let Combobox = defineComponent({ options.value = adjustedState.options activeOptionIndex.value = adjustedState.activeOptionIndex activationTrigger.value = ActivationTrigger.Other + recalculateIndexes() }, } @@ -533,13 +643,14 @@ export let Combobox = defineComponent({ theirProps: { ...attrs, ...omit(theirProps, [ - 'modelValue', - 'defaultValue', - 'nullable', - 'multiple', - 'immediate', - 'onUpdate:modelValue', 'by', + 'defaultValue', + 'immediate', + 'modelValue', + 'multiple', + 'nullable', + 'onUpdate:modelValue', + 'virtual', ]), }, ourProps: {}, @@ -1164,7 +1275,13 @@ export let ComboboxOptions = defineComponent({ theirProps, slot, attrs, - slots, + slots: + api.virtual.value && api.comboboxState.value === ComboboxStates.Open + ? { + ...slots, + default: () => [h(VirtualProvider, {}, slots.default)], + } + : slots, features: Features.RenderStrategy | Features.Static, visible: visible.value, name: 'ComboboxOptions', @@ -1183,6 +1300,7 @@ export let ComboboxOption = defineComponent({ >, }, disabled: { type: Boolean, default: false }, + order: { type: [Number], default: null }, }, setup(props, { slots, attrs, expose }) { let api = useComboboxContext('ComboboxOption') @@ -1191,6 +1309,14 @@ 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 @@ -1207,18 +1333,29 @@ export let ComboboxOption = defineComponent({ }) ) + let virtualizer = inject(VirtualContext, null) let dataRef = computed(() => ({ disabled: props.disabled, value: props.value, domRef: internalOptionRef, + order: computed(() => props.order), + onVirtualRangeUpdate: () => {}, })) onMounted(() => api.registerOption(id, dataRef)) onUnmounted(() => api.unregisterOption(id)) + watchEffect(() => { + let el = dom(internalOptionRef) + if (!el) return + + virtualizer?.value.measureElement(el) + }) + watchEffect(() => { if (api.comboboxState.value !== ComboboxStates.Open) return if (!active.value) return + if (api.virtual.value) return if (api.activationTrigger.value === ActivationTrigger.Pointer) return nextTick(() => dom(internalOptionRef)?.scrollIntoView?.({ block: 'nearest' })) }) @@ -1274,7 +1411,53 @@ export let ComboboxOption = defineComponent({ 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 = { @@ -1287,6 +1470,9 @@ 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, @@ -1298,7 +1484,22 @@ export let ComboboxOption = defineComponent({ onMouseleave: handleLeave, } - let theirProps = props + 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']) return render({ ourProps, diff --git a/packages/playground-react/data.ts b/packages/playground-react/data.ts index 7df0810..43b3654 100644 --- a/packages/playground-react/data.ts +++ b/packages/playground-react/data.ts @@ -249,3 +249,6 @@ export let countries = [ 'Zimbabwe', 'Åland Islands', ] + +// @ts-expect-error +export let timezones: string[] = Intl.supportedValuesOf('timeZone') diff --git a/packages/playground-react/package.json b/packages/playground-react/package.json index 14de382..e1eca05 100644 --- a/packages/playground-react/package.json +++ b/packages/playground-react/package.json @@ -28,7 +28,7 @@ "react-dom": "^18.0.0", "react-flatpickr": "^3.10.9", "react-hot-toast": "2.3.0", - "tailwindcss": "^0.0.0-insiders.9faf109" + "tailwindcss": "^3.3.3" }, "devDependencies": { "@floating-ui/react": "^0.24.8" diff --git a/packages/playground-react/pages/combobox/combobox-virtualized.tsx b/packages/playground-react/pages/combobox/combobox-virtualized.tsx new file mode 100644 index 0000000..6e295fc --- /dev/null +++ b/packages/playground-react/pages/combobox/combobox-virtualized.tsx @@ -0,0 +1,124 @@ +import { Combobox } from '@headlessui/react' +import { useState } from 'react' + +import { Button } from '../../components/button' +import { timezones as allTimezones } from '../../data' +import { classNames } from '../../utils/class-names' + +export default function Home() { + return ( +
+ + +
+ ) +} + +function Example({ virtual = true, initial }: { virtual?: boolean; initial: string }) { + let [query, setQuery] = useState('') + let [activeTimezone, setActiveTimezone] = useState(initial) + + let timezones = + query === '' + ? allTimezones + : allTimezones.filter((timezone) => timezone.toLowerCase().includes(query.toLowerCase())) + + return ( +
+
+
Selected timezone: {activeTimezone}
+
+ { + setActiveTimezone(value) + setQuery('') + }} + as="div" + > + + Timezone {virtual ? `(virtual)` : ''} + + +
+ + setQuery(e.target.value)} + className="border-none px-3 py-1 outline-none" + /> + + + + + + + + + +
+ + {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/package.json b/packages/playground-vue/package.json index bd8118a..490f7a7 100644 --- a/packages/playground-vue/package.json +++ b/packages/playground-vue/package.json @@ -22,7 +22,7 @@ "@tailwindcss/typography": "^0.5.2", "autoprefixer": "^10.4.7", "postcss": "^8.4.14", - "tailwindcss": "^0.0.0-insiders.9faf109", + "tailwindcss": "^3.3.3", "vue": "^3.2.27", "vue-flatpickr-component": "^9.0.5", "vue-router": "^4.0.0" diff --git a/packages/playground-vue/src/components/combobox/_virtual-example.vue b/packages/playground-vue/src/components/combobox/_virtual-example.vue new file mode 100644 index 0000000..57d1029 --- /dev/null +++ b/packages/playground-vue/src/components/combobox/_virtual-example.vue @@ -0,0 +1,111 @@ + + + diff --git a/packages/playground-vue/src/components/combobox/combobox-virtualized.vue b/packages/playground-vue/src/components/combobox/combobox-virtualized.vue new file mode 100644 index 0000000..811fd38 --- /dev/null +++ b/packages/playground-vue/src/components/combobox/combobox-virtualized.vue @@ -0,0 +1,10 @@ + + + diff --git a/packages/playground-vue/src/data.ts b/packages/playground-vue/src/data.ts index 7df0810..43b3654 100644 --- a/packages/playground-vue/src/data.ts +++ b/packages/playground-vue/src/data.ts @@ -249,3 +249,6 @@ export let countries = [ 'Zimbabwe', 'Åland Islands', ] + +// @ts-expect-error +export let timezones: string[] = Intl.supportedValuesOf('timeZone') diff --git a/yarn.lock b/yarn.lock index fdc7ba1..f8b6e7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -932,66 +932,6 @@ dependencies: mini-svg-data-uri "^1.2.3" -"@tailwindcss/oxide-darwin-arm64@0.0.0-insiders.9faf109": - version "0.0.0-insiders.9faf109" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-0.0.0-insiders.9faf109.tgz#a44f63ca1e8f1fc1355ab4e65d54a3249dc31196" - integrity sha512-SnJBw4j8uZddhXEhfsQHXUgpECVLn+icCXDTLh58cRPekKZ9JTj/uYyFYbnZHcMyIHiAKx1mQhoCP0FDXCUiWA== - -"@tailwindcss/oxide-darwin-x64@0.0.0-insiders.9faf109": - version "0.0.0-insiders.9faf109" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-0.0.0-insiders.9faf109.tgz#9146d714997e485292515011152319108862b923" - integrity sha512-lZvruT3X8tBQ/m/ShvGUxoMSaqfRh1jXoqKX6oZjuqRuTNPgG0hV2u02VDst+kL3WsZ+NoNuzDYk5jYQNUIV9A== - -"@tailwindcss/oxide-freebsd-x64@0.0.0-insiders.9faf109": - version "0.0.0-insiders.9faf109" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-0.0.0-insiders.9faf109.tgz#de09732e1aefa3c1046b2d0a022091f92685aa0b" - integrity sha512-FGh/kwk2oVYJXed+GCmVkqv0COYdNwYBeW5rLVumHT8S5yyZo7xQtEto9ehKjfHuhuN49rD5asgtNK2/pbCN0Q== - -"@tailwindcss/oxide-linux-arm-gnueabihf@0.0.0-insiders.9faf109": - version "0.0.0-insiders.9faf109" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-0.0.0-insiders.9faf109.tgz#8db1007aec8c08b150262d3c4733cce238bafb68" - integrity sha512-7rN2EuKTfZpAajhWRf+Qe0YgnomRpBYFwjL8KV8RvZ9z3rRlO1hG72JKLEegCoCH6atTqKO1c8oGRMZMhPQFDg== - -"@tailwindcss/oxide-linux-arm64-gnu@0.0.0-insiders.9faf109": - version "0.0.0-insiders.9faf109" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-0.0.0-insiders.9faf109.tgz#f23a8b1a6f1e8fd2cdd71c612f39e2a14bf7e7ec" - integrity sha512-4pczdi52hqp2Hm9zO0OtMQyAr6JWrKNIoMF/UQQ2H61iBfaNmvaqWdpo4OVx2MjFq983nvLr8/fLC06cJp/zpA== - -"@tailwindcss/oxide-linux-arm64-musl@0.0.0-insiders.9faf109": - version "0.0.0-insiders.9faf109" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-0.0.0-insiders.9faf109.tgz#d0c155b72e88dd9556e04cc7162123009a1132af" - integrity sha512-OHGesCnEx8sMvle3mNLHVveDXCNIM2nlFyBxoHb7Xj58bmbgwDBXYI0GDl/KxhV+zYhGJxJm1K5+7XbUBXfgYw== - -"@tailwindcss/oxide-linux-x64-gnu@0.0.0-insiders.9faf109": - version "0.0.0-insiders.9faf109" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-0.0.0-insiders.9faf109.tgz#02b1d2470c020494f8cf547bca236779bb23ab4e" - integrity sha512-cSmIxJrPsns31RcWHah2XxQs1BBfyWy4Q7hTKen8aIGfYhjY9jhXVt0jeMm64ZOmmWzirV9y4Qkt4nt9ULSU2w== - -"@tailwindcss/oxide-linux-x64-musl@0.0.0-insiders.9faf109": - version "0.0.0-insiders.9faf109" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-0.0.0-insiders.9faf109.tgz#3771a21c87c511944d8bb341e0a6abd60f477f4b" - integrity sha512-zvNLNfmPtoXQnEsVnRh+dkeJCMqa6/XVYG3eSlejSzzKcz9+ZM6XXl+LBkm15a9gGYvew8jfQ9IsohIexErCbw== - -"@tailwindcss/oxide-win32-x64-msvc@0.0.0-insiders.9faf109": - version "0.0.0-insiders.9faf109" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-0.0.0-insiders.9faf109.tgz#df51683c2d82cd743e3503e83de270e84b54ab8e" - integrity sha512-VVIGzP3WB/xfOqf9QJRvtvs2uWqq3VAxYjdR6ilIxP6tn3cbJdOjqN7w9c8dhzgHAulEmBgW1Ea1roXeZKd39g== - -"@tailwindcss/oxide@0.0.0-insiders.9faf109": - version "0.0.0-insiders.9faf109" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide/-/oxide-0.0.0-insiders.9faf109.tgz#031ec5a8d731e58b4782918d3a9d9a4315e69fe7" - integrity sha512-MsYO1lUlUigkiQCcqhS+V2OdseN59fn1odJpraX8Nqk70Xfz/6U2JS0mcXsCOYrtPTt0u6zTSxTojEc8MYPlaA== - optionalDependencies: - "@tailwindcss/oxide-darwin-arm64" "0.0.0-insiders.9faf109" - "@tailwindcss/oxide-darwin-x64" "0.0.0-insiders.9faf109" - "@tailwindcss/oxide-freebsd-x64" "0.0.0-insiders.9faf109" - "@tailwindcss/oxide-linux-arm-gnueabihf" "0.0.0-insiders.9faf109" - "@tailwindcss/oxide-linux-arm64-gnu" "0.0.0-insiders.9faf109" - "@tailwindcss/oxide-linux-arm64-musl" "0.0.0-insiders.9faf109" - "@tailwindcss/oxide-linux-x64-gnu" "0.0.0-insiders.9faf109" - "@tailwindcss/oxide-linux-x64-musl" "0.0.0-insiders.9faf109" - "@tailwindcss/oxide-win32-x64-msvc" "0.0.0-insiders.9faf109" - "@tailwindcss/typography@^0.5.2": version "0.5.2" resolved "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.2.tgz" @@ -1001,6 +941,25 @@ lodash.isplainobject "^4.0.6" lodash.merge "^4.6.2" +"@tanstack/react-virtual@^3.0.0-beta.60": + version "3.0.0-beta.60" + resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.0.0-beta.60.tgz#2b37c0d72997a54f7927f6b159a77311429fec1e" + integrity sha512-F0wL9+byp7lf/tH6U5LW0ZjBqs+hrMXJrj5xcIGcklI0pggvjzMNW9DdIBcyltPNr6hmHQ0wt8FDGe1n1ZAThA== + dependencies: + "@tanstack/virtual-core" "3.0.0-beta.60" + +"@tanstack/virtual-core@3.0.0-beta.60": + version "3.0.0-beta.60" + resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.60.tgz#fcac07cb182d41929208899062de8c9510cf42ed" + integrity sha512-QlCdhsV1+JIf0c0U6ge6SQmpwsyAT0oQaOSZk50AtEeAyQl9tQrd6qCHAslxQpgphrfe945abvKG8uYvw3hIGA== + +"@tanstack/vue-virtual@^3.0.0-beta.60": + version "3.0.0-beta.60" + resolved "https://registry.yarnpkg.com/@tanstack/vue-virtual/-/vue-virtual-3.0.0-beta.60.tgz#f32c41f1b5dfacc40f8d427874947a24f71aba60" + integrity sha512-sJdNB4IAHzM8a4rEozQlp7RjXJ/0nFf9tIaJNfJ1mCygORCmoJBBoepvkVSgzPLxJROQNNNm2sSlp+2d+R15rw== + dependencies: + "@tanstack/virtual-core" "3.0.0-beta.60" + "@testing-library/dom@^7.26.6": version "7.31.2" resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-7.31.2.tgz" @@ -1680,16 +1639,6 @@ browserslist@^4.20.3: node-releases "^2.0.3" picocolors "^1.0.0" -browserslist@^4.21.10: - version "4.21.10" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.10.tgz#dbbac576628c13d3b2231332cb2ec5a46e015bb0" - integrity sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ== - dependencies: - caniuse-lite "^1.0.30001517" - electron-to-chromium "^1.4.477" - node-releases "^2.0.13" - update-browserslist-db "^1.0.11" - bser@2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz" @@ -1760,11 +1709,6 @@ caniuse-lite@^1.0.30001332, caniuse-lite@^1.0.30001335: resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001341.tgz" integrity sha512-2SodVrFFtvGENGCv0ChVJIDQ0KPaS1cg7/qtfMaICgeMolDdo/Z2OD32F0Aq9yl6F4YFwGPBS5AaPqNYiW4PoA== -caniuse-lite@^1.0.30001517: - version "1.0.30001532" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001532.tgz#c6a4d5d2da6d2b967f0ee5e12e7f680db6ad2fca" - integrity sha512-FbDFnNat3nMnrROzqrsg314zhqN5LGQ1kyyMk2opcrwGbVGpHRhgCWtAgD5YJUqNAiQ+dklreil/c3Qf1dfCTw== - capture-exit@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz" @@ -2161,11 +2105,6 @@ delayed-stream@~1.0.0: resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= -detect-libc@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== - detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz" @@ -2242,11 +2181,6 @@ electron-to-chromium@^1.4.17: resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.49.tgz" integrity sha512-k/0t1TRfonHIp8TJKfjBu2cKj8MqYTiEpOhci+q7CVEE5xnCQnx1pTa+V8b/sdhe4S3PR4p4iceEQWhGrKQORQ== -electron-to-chromium@^1.4.477: - version "1.4.513" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.513.tgz#41a50bf749aa7d8058ffbf7a131fc3327a7b1675" - integrity sha512-cOB0xcInjm+E5qIssHeXJ29BaUyWpMyFKT5RB3bsLENDheCja0wMkHJyiPl0NBE/VzDI7JDuNEQWhe6RitEUcw== - emittery@^0.7.1: version "0.7.2" resolved "https://registry.npmjs.org/emittery/-/emittery-0.7.2.tgz" @@ -2647,17 +2581,6 @@ fast-glob@^3.2.12: merge2 "^1.3.0" micromatch "^4.0.4" -fast-glob@^3.3.1: - version "3.3.1" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4" - integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg== - dependencies: - "@nodelib/fs.stat" "^2.0.2" - "@nodelib/fs.walk" "^1.2.3" - glob-parent "^5.1.2" - merge2 "^1.3.0" - micromatch "^4.0.4" - fast-json-stable-stringify@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" @@ -3828,7 +3751,7 @@ jest@26: import-local "^3.0.2" jest-cli "^26.6.3" -jiti@^1.19.3: +jiti@^1.18.2: version "1.20.0" resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.20.0.tgz#2d823b5852ee8963585c8dd8b7992ffc1ae83b42" integrity sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA== @@ -3953,68 +3876,6 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" -lightningcss-darwin-arm64@1.21.8: - version "1.21.8" - resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.21.8.tgz#b4ea8d5133236bff361623ce8c30639a1b024240" - integrity sha512-BOMoGfcgkk2f4ltzsJqmkjiqRtlZUK+UdwhR+P6VgIsnpQBV3G01mlL6GzYxYqxq+6/3/n/D+4oy2NeknmADZw== - -lightningcss-darwin-x64@1.21.8: - version "1.21.8" - resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.21.8.tgz#81f4671cf9c245bb25a6536c01ddac76973fd283" - integrity sha512-YhF64mcVDPKKufL4aNFBnVH7uvzE0bW3YUsPXdP4yUcT/8IXChypOZ/PE1pmt2RlbmsyVuuIIeZU4zTyZe5Amw== - -lightningcss-freebsd-x64@1.21.8: - version "1.21.8" - resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.21.8.tgz#d1b18c5a1b894e1332b23870afdbe23d07f22614" - integrity sha512-CV6A/vTG2Ryd3YpChEgfWWv4TXCAETo9TcHSNx0IP0dnKcnDEiAko4PIKhCqZL11IGdN1ZLBCVPw+vw5ZYwzfA== - -lightningcss-linux-arm-gnueabihf@1.21.8: - version "1.21.8" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.21.8.tgz#523366a683d3545d3a36c133079ff6af0a3d95c0" - integrity sha512-9PMbqh8n/Xq0F4/j2NR/hHM2HRDiFXFSF0iOvV67pNWKJkHIO6mR8jBw/88Aro5Ye/ILsX5OuWsxIVJDFv0NXA== - -lightningcss-linux-arm64-gnu@1.21.8: - version "1.21.8" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.21.8.tgz#6a74eff0680dd0759591962a3b92353f9b2bf49a" - integrity sha512-JTM/TuMMllkzaXV7/eDjG4IJKLlCl+RfYZwtsVmC82gc0QX0O37csGAcY2OGleiuA4DnEo/Qea5WoFfZUNC6zg== - -lightningcss-linux-arm64-musl@1.21.8: - version "1.21.8" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.21.8.tgz#98c74b70d99e08efb3cc6dacd0c57d516a15c2e7" - integrity sha512-01gWShXrgoIb8urzShpn1RWtZuaSyKSzF2hfO+flzlTPoACqcO3rgcu/3af4Cw54e8vKzL5hPRo4kROmgaOMLg== - -lightningcss-linux-x64-gnu@1.21.8: - version "1.21.8" - resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.21.8.tgz#96c691c0852eaae9b6a15d238b7bdd9fbfc3cc85" - integrity sha512-yVB5vYJjJb/Aku0V9QaGYIntvK/1TJOlNB9GmkNpXX5bSSP2pYW4lWW97jxFMHO908M0zjEt1qyOLMyqojHL+Q== - -lightningcss-linux-x64-musl@1.21.8: - version "1.21.8" - resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.21.8.tgz#19787f71eeabdcec34af6e74509a2902548d45f9" - integrity sha512-TYi+KNtBVK0+FZvxTX/d5XJb+tw3Jq+2Rr9hW359wp1afsi1Vkg+uVGgbn+m2dipa5XwpCseQq81ylMlXuyfPw== - -lightningcss-win32-x64-msvc@1.21.8: - version "1.21.8" - resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.21.8.tgz#eb10b607b464bd19c966de0065c95ff47e6acb1b" - integrity sha512-mww+kqbPx0/C44l2LEloECtRUuOFDjq9ftp+EHTPiCp2t+avy0sh8MaFwGsrKkj2XfZhaRhi4CPVKBoqF1Qlwg== - -lightningcss@^1.21.7: - version "1.21.8" - resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.21.8.tgz#a02e4a8979208ffb61d7c6deebb75c4abce0b5d6" - integrity sha512-jEqaL7m/ZckZJjlMAfycr1Kpz7f93k6n7KGF5SJjuPSm6DWI6h3ayLZmgRHgy1OfrwoCed6h4C/gHYPOd1OFMA== - dependencies: - detect-libc "^1.0.3" - optionalDependencies: - lightningcss-darwin-arm64 "1.21.8" - lightningcss-darwin-x64 "1.21.8" - lightningcss-freebsd-x64 "1.21.8" - lightningcss-linux-arm-gnueabihf "1.21.8" - lightningcss-linux-arm64-gnu "1.21.8" - lightningcss-linux-arm64-musl "1.21.8" - lightningcss-linux-x64-gnu "1.21.8" - lightningcss-linux-x64-musl "1.21.8" - lightningcss-win32-x64-msvc "1.21.8" - lilconfig@2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.4.tgz" @@ -4395,11 +4256,6 @@ node-releases@^2.0.1: resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz" integrity sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA== -node-releases@^2.0.13: - version "2.0.13" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d" - integrity sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ== - node-releases@^2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.4.tgz" @@ -4823,14 +4679,6 @@ postcss-selector-parser@^6.0.11: cssesc "^3.0.0" util-deprecate "^1.0.2" -postcss-selector-parser@^6.0.12: - version "6.0.13" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz#d05d8d76b1e8e173257ef9d60b706a8e5e99bf1b" - integrity sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ== - dependencies: - cssesc "^3.0.0" - util-deprecate "^1.0.2" - postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" @@ -4881,7 +4729,7 @@ postcss@^8.4.16: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.4.28: +postcss@^8.4.23: version "8.4.29" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.29.tgz#33bc121cf3b3688d4ddef50be869b2a54185a1dd" integrity sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw== @@ -5161,7 +5009,7 @@ resolve@^1.10.0, resolve@^1.18.1: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -resolve@^1.22.4: +resolve@^1.22.2: version "1.22.4" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.4.tgz#1dc40df46554cdaf8948a486a10f6ba1e2026c34" integrity sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg== @@ -5667,7 +5515,7 @@ styled-jsx@5.0.1: resolved "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.0.1.tgz" integrity sha512-+PIZ/6Uk40mphiQJJI1202b+/dYeTVd9ZnMPR80pgiWbjIwvN2zIp4r9et0BgqBuShh48I0gttPlAXA7WVvBxw== -sucrase@^3.34.0: +sucrase@^3.32.0: version "3.34.0" resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.34.0.tgz#1e0e2d8fcf07f8b9c3569067d92fbd8690fb576f" integrity sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw== @@ -5722,38 +5570,6 @@ tabbable@^6.0.1: resolved "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz" integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew== -tailwindcss@^0.0.0-insiders.9faf109: - version "0.0.0-insiders.9faf109" - resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-0.0.0-insiders.9faf109.tgz#d0dfe2f0a5013dae9eab576a9503878bad1d12f4" - integrity sha512-WJ++yMXHE9TvwxU8Pl7Cw9tBQtCmtaH72XOwfGfhpI0pT9NPYzO3I+8PT+SNAwAwy/CMPYUn9Nm8UoZ8EHC3lw== - dependencies: - "@alloc/quick-lru" "^5.2.0" - "@tailwindcss/oxide" "0.0.0-insiders.9faf109" - arg "^5.0.2" - browserslist "^4.21.10" - chokidar "^3.5.3" - didyoumean "^1.2.2" - dlv "^1.1.3" - fast-glob "^3.3.1" - glob-parent "^6.0.2" - is-glob "^4.0.3" - jiti "^1.19.3" - lightningcss "^1.21.7" - lilconfig "^2.1.0" - micromatch "^4.0.5" - normalize-path "^3.0.0" - object-hash "^3.0.0" - picocolors "^1.0.0" - postcss "^8.4.28" - postcss-import "^15.1.0" - postcss-js "^4.0.1" - postcss-load-config "^4.0.1" - postcss-nested "^6.0.1" - postcss-selector-parser "^6.0.12" - postcss-value-parser "^4.2.0" - resolve "^1.22.4" - sucrase "^3.34.0" - tailwindcss@^3.2.7: version "3.2.7" resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.7.tgz" @@ -5783,6 +5599,34 @@ tailwindcss@^3.2.7: quick-lru "^5.1.1" resolve "^1.22.1" +tailwindcss@^3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.3.tgz#90da807393a2859189e48e9e7000e6880a736daf" + integrity sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w== + dependencies: + "@alloc/quick-lru" "^5.2.0" + arg "^5.0.2" + chokidar "^3.5.3" + didyoumean "^1.2.2" + dlv "^1.1.3" + fast-glob "^3.2.12" + glob-parent "^6.0.2" + is-glob "^4.0.3" + jiti "^1.18.2" + lilconfig "^2.1.0" + micromatch "^4.0.5" + normalize-path "^3.0.0" + object-hash "^3.0.0" + picocolors "^1.0.0" + postcss "^8.4.23" + postcss-import "^15.1.0" + postcss-js "^4.0.1" + postcss-load-config "^4.0.1" + postcss-nested "^6.0.1" + postcss-selector-parser "^6.0.11" + resolve "^1.22.2" + sucrase "^3.32.0" + terminal-link@^2.0.0: version "2.1.1" resolved "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz" @@ -5969,14 +5813,6 @@ unset-value@^1.0.0: has-value "^0.3.1" isobject "^3.0.0" -update-browserslist-db@^1.0.11: - version "1.0.11" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz#9a2a641ad2907ae7b3616506f4b977851db5b940" - integrity sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA== - dependencies: - escalade "^3.1.1" - picocolors "^1.0.0" - urix@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz" @@ -6222,9 +6058,9 @@ yaml@^1.10.0, yaml@^1.10.2: integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== yaml@^2.1.1: - version "2.3.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.2.tgz#f522db4313c671a0ca963a75670f1c12ea909144" - integrity sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg== + version "2.3.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b" + integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ== yargs-parser@^18.1.2: version "18.1.3"