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 (
-
- )
- }
-
- 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 (
-
- )
- }
-
- 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 (
+
+ )
+ }
+
+ 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 (
+
+ )
+ }
+
+ 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)}>
LabelTrigger
- 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)}>
LabelTrigger
- 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
+
- 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`
-
- `,
- 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`
-
- `,
- 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`
+
+ `,
+ 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`
+
+ `,
+ 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`
-
+ LabelTrigger
- 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`
-
+ LabelTrigger
- 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