diff --git a/CHANGELOG.md b/CHANGELOG.md
index 44cccf4..46dedd4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improve controlled Tabs behaviour ([#1050](https://github.com/tailwindlabs/headlessui/pull/1050))
- Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051))
+### Added
+
+- Add `Combobox` component ([#1047](https://github.com/tailwindlabs/headlessui/pull/1047))
+
## [Unreleased - @headlessui/vue]
### Fixed
@@ -20,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure correct order when conditionally rendering `MenuItem`, `ListboxOption` and `RadioGroupOption` ([#1045](https://github.com/tailwindlabs/headlessui/pull/1045))
- Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051))
+### Added
+
+- Add `Combobox` component ([#1047](https://github.com/tailwindlabs/headlessui/pull/1047))
+
## [@headlessui/react@v1.4.3] - 2022-01-14
### Fixes
@@ -88,7 +96,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683))
- Expose `close` function from the render prop for `Disclosure`, `Disclosure.Panel`, `Popover` and `Popover.Panel` ([#697](https://github.com/tailwindlabs/headlessui/pull/697))
-
## [@headlessui/vue@v1.4.0] - 2021-07-29
### Added
diff --git a/package.json b/package.json
index b4996f4..7f6b020 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,8 @@
],
"scripts": {
"react": "yarn workspace @headlessui/react",
+ "react-playground": "yarn workspace playground-react dev",
+ "playground-react": "yarn workspace playground-react dev",
"vue": "yarn workspace @headlessui/vue",
"shared": "yarn workspace @headlessui/shared",
"build": "yarn workspaces run build",
diff --git a/packages/@headlessui-react/package.json b/packages/@headlessui-react/package.json
index f9b2721..a638b2d 100644
--- a/packages/@headlessui-react/package.json
+++ b/packages/@headlessui-react/package.json
@@ -26,6 +26,7 @@
"prepublishOnly": "npm run build",
"test": "../../scripts/test.sh",
"build": "../../scripts/build.sh",
+ "watch": "../../scripts/watch.sh",
"lint": "../../scripts/lint.sh"
},
"peerDependencies": {
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx
new file mode 100644
index 0000000..60c6375
--- /dev/null
+++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx
@@ -0,0 +1,4917 @@
+import React, { createElement, useState, useEffect } from 'react'
+import { render } from '@testing-library/react'
+
+import { Combobox } from './combobox'
+import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
+import {
+ click,
+ focus,
+ mouseMove,
+ mouseLeave,
+ press,
+ shift,
+ type,
+ word,
+ Keys,
+ MouseButton,
+} from '../../test-utils/interactions'
+import {
+ assertActiveElement,
+ assertActiveComboboxOption,
+ assertComboboxList,
+ assertComboboxButton,
+ assertComboboxButtonLinkedWithCombobox,
+ assertComboboxButtonLinkedWithComboboxLabel,
+ assertComboboxOption,
+ assertComboboxLabel,
+ assertComboboxLabelLinkedWithCombobox,
+ assertNoActiveComboboxOption,
+ assertNoSelectedComboboxOption,
+ getComboboxInput,
+ getComboboxButton,
+ getComboboxButtons,
+ getComboboxInputs,
+ getComboboxOptions,
+ getComboboxLabel,
+ ComboboxState,
+ getByText,
+ getComboboxes,
+} from '../../test-utils/accessibility-assertions'
+import { Transition } from '../transitions/transition'
+
+let NOOP = () => {}
+
+jest.mock('../../hooks/use-id')
+
+beforeAll(() => {
+ jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
+ jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
+})
+
+afterAll(() => jest.restoreAllMocks())
+
+describe('safeguards', () => {
+ it.each([
+ ['Combobox.Button', Combobox.Button],
+ ['Combobox.Label', Combobox.Label],
+ ['Combobox.Options', Combobox.Options],
+ ['Combobox.Option', Combobox.Option],
+ ])(
+ 'should error when we are using a <%s /> without a parent ',
+ suppressConsoleLogs((name, Component) => {
+ // @ts-expect-error This is fine
+ expect(() => render(createElement(Component))).toThrowError(
+ `<${name} /> is missing a parent component.`
+ )
+ })
+ )
+
+ it(
+ 'should be possible to render a Combobox without crashing',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+ })
+ )
+})
+
+describe('Rendering', () => {
+ describe('Combobox', () => {
+ it(
+ 'should be possible to render a Combobox using a render prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+ {({ open }) => (
+ <>
+
+ Trigger
+ {open && (
+
+ Option A
+ Option B
+ Option C
+
+ )}
+ >
+ )}
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await click(getComboboxButton())
+
+ assertComboboxButton({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.Visible })
+ })
+ )
+
+ it(
+ 'should be possible to disable a Combobox',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await click(getComboboxButton())
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await press(Keys.Enter, getComboboxButton())
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+ })
+ )
+ })
+
+ describe('Combobox.Input', () => {
+ it(
+ 'selecting an option puts the value into Combobox.Input when displayValue is not provided',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ let [value, setValue] = useState(undefined)
+
+ return (
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+ }
+
+ render()
+
+ await click(getComboboxButton())
+
+ assertComboboxList({ state: ComboboxState.Visible })
+
+ await click(getComboboxOptions()[1])
+
+ expect(getComboboxInput()).toHaveValue('b')
+ })
+ )
+
+ it(
+ 'selecting an option puts the display value into Combobox.Input when displayValue is provided',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ let [value, setValue] = useState(undefined)
+
+ return (
+
+ str?.toUpperCase() ?? ''}
+ />
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+ }
+
+ render()
+
+ await click(getComboboxButton())
+
+ assertComboboxList({ state: ComboboxState.Visible })
+
+ await click(getComboboxOptions()[1])
+
+ expect(getComboboxInput()).toHaveValue('B')
+ })
+ )
+ })
+
+ describe('Combobox.Label', () => {
+ it(
+ 'should be possible to render a Combobox.Label using a render prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+ {JSON.stringify}
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-3' },
+ })
+ assertComboboxLabel({
+ attributes: { id: 'headlessui-combobox-label-1' },
+ textContent: JSON.stringify({ open: false, disabled: false }),
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await click(getComboboxButton())
+
+ assertComboboxLabel({
+ attributes: { id: 'headlessui-combobox-label-1' },
+ textContent: JSON.stringify({ open: true, disabled: false }),
+ })
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertComboboxLabelLinkedWithCombobox()
+ assertComboboxButtonLinkedWithComboboxLabel()
+ })
+ )
+
+ it(
+ 'should be possible to render a Combobox.Label using a render prop and an `as` prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+ {JSON.stringify}
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxLabel({
+ attributes: { id: 'headlessui-combobox-label-1' },
+ textContent: JSON.stringify({ open: false, disabled: false }),
+ tag: 'p',
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await click(getComboboxButton())
+ assertComboboxLabel({
+ attributes: { id: 'headlessui-combobox-label-1' },
+ textContent: JSON.stringify({ open: true, disabled: false }),
+ tag: 'p',
+ })
+ assertComboboxList({ state: ComboboxState.Visible })
+ })
+ )
+ })
+
+ describe('Combobox.Button', () => {
+ it(
+ 'should be possible to render a Combobox.Button using a render prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ {JSON.stringify}
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ textContent: JSON.stringify({ open: false, disabled: false }),
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await click(getComboboxButton())
+
+ assertComboboxButton({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ textContent: JSON.stringify({ open: true, disabled: false }),
+ })
+ assertComboboxList({ state: ComboboxState.Visible })
+ })
+ )
+
+ it(
+ 'should be possible to render a Combobox.Button using a render prop and an `as` prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+
+ {JSON.stringify}
+
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ textContent: JSON.stringify({ open: false, disabled: false }),
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await click(getComboboxButton())
+
+ assertComboboxButton({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ textContent: JSON.stringify({ open: true, disabled: false }),
+ })
+ assertComboboxList({ state: ComboboxState.Visible })
+ })
+ )
+
+ it(
+ 'should be possible to render a Combobox.Button and a Combobox.Label and see them linked together',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Label
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ // TODO: Needed to make it similar to vue test implementation?
+ // await new Promise(requestAnimationFrame)
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-3' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+ assertComboboxButtonLinkedWithComboboxLabel()
+ })
+ )
+
+ describe('`type` attribute', () => {
+ it('should set the `type` to "button" by default', async () => {
+ render(
+
+
+ Trigger
+
+ )
+
+ expect(getComboboxButton()).toHaveAttribute('type', 'button')
+ })
+
+ it('should not set the `type` to "button" if it already contains a `type`', async () => {
+ render(
+
+
+ Trigger
+
+ )
+
+ expect(getComboboxButton()).toHaveAttribute('type', 'submit')
+ })
+
+ it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => {
+ let CustomButton = React.forwardRef((props, ref) => (
+
+ ))
+
+ render(
+
+
+ Trigger
+
+ )
+
+ expect(getComboboxButton()).toHaveAttribute('type', 'button')
+ })
+
+ it('should not set the type if the "as" prop is not a "button"', async () => {
+ render(
+
+
+ Trigger
+
+ )
+
+ expect(getComboboxButton()).not.toHaveAttribute('type')
+ })
+
+ it('should not set the `type` to "button" when using the `as` prop which resolves to a "div"', async () => {
+ let CustomButton = React.forwardRef((props, ref) => (
+
+ ))
+
+ render(
+
+
+ Trigger
+
+ )
+
+ expect(getComboboxButton()).not.toHaveAttribute('type')
+ })
+ })
+ })
+
+ describe('Combobox.Options', () => {
+ it(
+ 'should be possible to render Combobox.Options using a render prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ {data => (
+ <>
+ {JSON.stringify(data)}
+ >
+ )}
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await click(getComboboxButton())
+
+ assertComboboxButton({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ textContent: JSON.stringify({ open: true }),
+ })
+ assertActiveElement(getComboboxInput())
+ })
+ )
+
+ it('should be possible to always render the Combobox.Options if we provide it a `static` prop', () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ // Let's verify that the Combobox is already there
+ expect(getComboboxInput()).not.toBe(null)
+ })
+
+ it('should be possible to use a different render strategy for the Combobox.Options', async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxList({ state: ComboboxState.InvisibleHidden })
+
+ // Let's open the Combobox, to see if it is not hidden anymore
+ await click(getComboboxButton())
+
+ assertComboboxList({ state: ComboboxState.Visible })
+ })
+ })
+
+ describe('Combobox.Option', () => {
+ it(
+ 'should be possible to render a Combobox.Option using a render prop',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ {JSON.stringify}
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await click(getComboboxButton())
+
+ assertComboboxButton({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ textContent: JSON.stringify({ active: false, selected: false, disabled: false }),
+ })
+ })
+ )
+ })
+
+ it('should guarantee the order of DOM nodes when performing actions', async () => {
+ function Example({ hide = false }) {
+ return (
+ <>
+
+
+ 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()
+
+ // Focus the first item
+ await press(Keys.ArrowDown)
+
+ // 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('Rendering composition', () => {
+ it(
+ 'should be possible to conditionally render classNames (aka className can be a function?!)',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ JSON.stringify(bag)}>
+ Option A
+
+ JSON.stringify(bag)}>
+ 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 correct classNames
+ expect('' + options[0].classList).toEqual(
+ JSON.stringify({ active: false, selected: false, disabled: false })
+ )
+ expect('' + options[1].classList).toEqual(
+ JSON.stringify({ active: false, selected: false, disabled: true })
+ )
+ expect('' + options[2].classList).toEqual('no-special-treatment')
+
+ // Double check that nothing is active
+ assertNoActiveComboboxOption(getComboboxInput())
+
+ // Make the first option active
+ await press(Keys.ArrowDown)
+
+ // Verify the classNames
+ expect('' + options[0].classList).toEqual(
+ JSON.stringify({ active: true, selected: false, disabled: false })
+ )
+ expect('' + options[1].classList).toEqual(
+ JSON.stringify({ active: false, selected: false, disabled: true })
+ )
+ expect('' + options[2].classList).toEqual('no-special-treatment')
+
+ // Double check that the first option is the active one
+ assertActiveComboboxOption(options[0])
+
+ // Let's go down, this should go to the third option since the second option is disabled!
+ await press(Keys.ArrowDown)
+
+ // Verify the classNames
+ expect('' + options[0].classList).toEqual(
+ JSON.stringify({ active: false, selected: false, disabled: false })
+ )
+ expect('' + options[1].classList).toEqual(
+ JSON.stringify({ active: false, selected: false, disabled: true })
+ )
+ expect('' + options[2].classList).toEqual('no-special-treatment')
+
+ // Double check that the last option is the active one
+ assertActiveComboboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should be possible to swap the Combobox option with a button for example',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ 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 options are buttons now
+ getComboboxOptions().forEach(option => assertComboboxOption(option, { tag: 'button' }))
+ })
+ )
+})
+
+describe('Composition', () => {
+ function Debug({ fn, name }: { fn: (text: string) => void; name: string }) {
+ useEffect(() => {
+ fn(`Mounting - ${name}`)
+ return () => {
+ fn(`Unmounting - ${name}`)
+ }
+ }, [fn, name])
+ return null
+ }
+
+ it(
+ 'should be possible to wrap the Combobox.Options with a Transition component',
+ suppressConsoleLogs(async () => {
+ let orderFn = jest.fn()
+ render(
+
+
+ Trigger
+
+
+
+
+
+ {data => (
+ <>
+ {JSON.stringify(data)}
+
+ >
+ )}
+
+
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await click(getComboboxButton())
+
+ assertComboboxButton({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ textContent: JSON.stringify({ active: false, selected: false, disabled: false }),
+ })
+
+ await click(getComboboxButton())
+
+ // Verify that we tracked the `mounts` and `unmounts` in the correct order
+ expect(orderFn.mock.calls).toEqual([
+ ['Mounting - Combobox'],
+ ['Mounting - Transition'],
+ ['Mounting - Combobox.Option'],
+ ['Unmounting - Transition'],
+ ['Unmounting - Combobox.Option'],
+ ])
+ })
+ )
+})
+
+describe('Keyboard interactions', () => {
+ describe('Button', () => {
+ describe('`Enter` key', () => {
+ it(
+ 'should be possible to open the combobox with Enter',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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 }))
+
+ assertNoActiveComboboxOption()
+ assertNoSelectedComboboxOption()
+ })
+ )
+
+ it(
+ 'should not be possible to open the combobox with Enter when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ 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
+ 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 () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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 () => {
+ let myOptions = [
+ { id: 'a', name: 'Option A' },
+ { id: 'b', name: 'Option B' },
+ { id: 'c', name: 'Option C' },
+ ]
+ let selectedOption = myOptions[1]
+ render(
+
+
+ Trigger
+
+ {myOptions.map(myOption => (
+
+ {myOption.name}
+
+ ))}
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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))
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should not be possible to open the combobox with Space when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+
+ Option A
+
+
+ Option B
+
+
+ Option C
+
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ 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
+ 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())
+ })
+ )
+ })
+
+ describe('`ArrowDown` key', () => {
+ it(
+ 'should be possible to open the combobox with ArrowDown',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should not be possible to open the combobox with ArrowDown when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+
+ )
+
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowDown)
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertActiveElement(getComboboxInput())
+
+ assertNoActiveComboboxOption()
+ })
+ )
+ })
+
+ describe('`ArrowRight` key', () => {
+ it(
+ 'should be possible to open the combobox with ArrowRight',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowRight)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should not be possible to open the combobox with ArrowRight when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Try to open the combobox
+ await press(Keys.ArrowRight)
+
+ // Verify it is still closed
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+ })
+ )
+
+ it(
+ 'should be possible to open the combobox with ArrowRight, and focus the selected option',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowRight)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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(
+
+
+ Trigger
+
+
+ )
+
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowRight)
+ assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' })
+ 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(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+ Option A
+
+ Option B
+
+
+ Option C
+
+
+
+ )
+
+ 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('`ArrowLeft` key', () => {
+ it(
+ 'should be possible to open the combobox with ArrowLeft and the last option should be active',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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 ArrowLeft and the last option should be active when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Try to open the combobox
+ await press(Keys.ArrowLeft)
+
+ // Verify it is still closed
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+ })
+ )
+
+ it(
+ 'should be possible to open the combobox with ArrowLeft, and focus the selected option',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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(
+
+
+ Trigger
+
+
+ )
+
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+ assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' })
+ assertActiveElement(getComboboxInput())
+
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowLeft to navigate the combobox options and jump to the first non-disabled one',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+
+ Option B
+
+
+ Option C
+
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+
+ // 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()
+
+ 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])
+ })
+ )
+ })
+
+ 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)
+ 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)
+ 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(
+
+
+ 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())
+ })
+ )
+ })
+
+ describe('`ArrowDown` key', () => {
+ it(
+ 'should be possible to open the combobox with ArrowDown',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should not be possible to open the combobox with ArrowDown when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ 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))
+ assertNoActiveComboboxOption()
+
+ // We should be able to go down once
+ await press(Keys.ArrowDown)
+ assertActiveComboboxOption(options[0])
+
+ // We should be able to go down again
+ 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(
+
+
+ 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))
+ assertNoActiveComboboxOption()
+
+ // We should be able to go down once
+ await press(Keys.ArrowDown)
+ assertActiveComboboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowDown to navigate the combobox options and jump to the first non-disabled one',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ 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))
+ assertNoActiveComboboxOption()
+
+ // Open combobox
+ await press(Keys.ArrowDown)
+ assertActiveComboboxOption(options[2])
+ })
+ )
+ })
+
+ describe('`ArrowRight` key', () => {
+ it(
+ 'should be possible to open the combobox with ArrowRight',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowRight)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should not be possible to open the combobox with ArrowRight when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Try to open the combobox
+ await press(Keys.ArrowRight)
+
+ // Verify it is still closed
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+ })
+ )
+
+ it(
+ 'should be possible to open the combobox with ArrowRight, and focus the selected option',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowRight)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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(
+
+
+ Trigger
+
+
+ )
+
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowRight)
+ assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' })
+ assertActiveElement(getComboboxInput())
+
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowRight to navigate the combobox options',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Verify we have combobox options
+ let options = getComboboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertComboboxOption(option))
+ assertNoActiveComboboxOption()
+
+ // We should be able to go down once
+ await press(Keys.ArrowRight)
+ assertActiveComboboxOption(options[0])
+
+ // We should be able to go down again
+ await press(Keys.ArrowRight)
+ assertActiveComboboxOption(options[1])
+
+ // We should be able to go down again
+ await press(Keys.ArrowRight)
+ assertActiveComboboxOption(options[2])
+
+ // We should NOT be able to go down again (because last option). Current implementation won't go around.
+ await press(Keys.ArrowRight)
+ assertActiveComboboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowRight to navigate the combobox options and skip the first disabled one',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+
+ Option A
+
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Verify we have combobox options
+ let options = getComboboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertComboboxOption(option))
+ assertNoActiveComboboxOption()
+
+ // We should be able to go down once
+ await press(Keys.ArrowRight)
+ assertActiveComboboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowRight to navigate the combobox options and jump to the first non-disabled one',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+
+ Option A
+
+
+ Option B
+
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Verify we have combobox options
+ let options = getComboboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertComboboxOption(option))
+ assertNoActiveComboboxOption()
+
+ // Open combobox
+ await press(Keys.ArrowRight)
+ assertActiveComboboxOption(options[2])
+ })
+ )
+ })
+
+ describe('`ArrowUp` key', () => {
+ it(
+ 'should be possible to open the combobox with ArrowUp and the last option should be active',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ Trigger
+
+ Option A
+
+ Option B
+
+
+ Option C
+
+
+
+ )
+
+ 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 () => {
+ render(
+
+
+ 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))
+ assertNoActiveComboboxOption()
+
+ // 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(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ 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('`ArrowLeft` key', () => {
+ it(
+ 'should be possible to open the combobox with ArrowLeft and the last option should be active',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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 ArrowLeft and the last option should be active when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Try to open the combobox
+ await press(Keys.ArrowLeft)
+
+ // Verify it is still closed
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+ })
+ )
+
+ it(
+ 'should be possible to open the combobox with ArrowLeft, and focus the selected option',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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(
+
+
+ Trigger
+
+
+ )
+
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+ assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' })
+ assertActiveElement(getComboboxInput())
+
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowLeft to navigate the combobox options and jump to the first non-disabled one',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+
+ Option B
+
+
+ Option C
+
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+
+ // Verify we have combobox options
+ let options = getComboboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertComboboxOption(option))
+ 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(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+
+ // We should have no option selected
+ assertNoActiveComboboxOption()
+
+ // 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(
+
+
+ Trigger
+
+ Option A
+ Option B
+
+ Option C
+
+
+ Option D
+
+
+
+ )
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+
+ // We should have no option selected
+ assertNoActiveComboboxOption()
+
+ // 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(
+
+
+ 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)
+
+ let options = getComboboxOptions()
+ 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(
+
+
+ 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(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+
+ // We should be on the first option
+ assertNoActiveComboboxOption()
+
+ // 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(
+
+
+ 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 have nothing active
+ assertNoActiveComboboxOption()
+
+ // 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(
+
+
+ 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)
+
+ let options = getComboboxOptions()
+ 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(
+
+
+ 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(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ // 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 () => {
+ render(
+
+
+ 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)
+
+ let options = getComboboxOptions()
+
+ // 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(
+
+
+ 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)
+
+ let options = getComboboxOptions()
+ 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(
+
+
+ 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(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ // 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 () => {
+ render(
+
+
+ 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)
+
+ let options = getComboboxOptions()
+
+ // We should be on the first non-disabled option
+ 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(
+
+
+ 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)
+
+ let options = getComboboxOptions()
+ 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(
+
+
+ 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()
+ assertNoSelectedComboboxOption()
+ })
+ )
+
+ it(
+ 'should maintain activeIndex and activeOption when filtering',
+ suppressConsoleLogs(async () => {
+ render(
+
+ )
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options: ReturnType
+
+ await press(Keys.ArrowDown)
+ 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('Mouse interactions', () => {
+ it(
+ 'should focus the Combobox.Input when we click the Combobox.Label',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Label
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ // Ensure the button is not focused yet
+ assertActiveElement(document.body)
+
+ // Focus the label
+ await click(getComboboxLabel())
+
+ // Ensure that the actual button is focused instead
+ assertActiveElement(getComboboxInput())
+ })
+ )
+
+ it(
+ 'should not focus the Combobox.Input when we right click the Combobox.Label',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Label
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ // Ensure the button is not focused yet
+ assertActiveElement(document.body)
+
+ // Focus the label
+ await click(getComboboxLabel(), MouseButton.Right)
+
+ // Ensure that the body is still active
+ assertActiveElement(document.body)
+ })
+ )
+
+ it(
+ 'should be possible to open the combobox on click',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ 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 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))
+ })
+ )
+
+ it(
+ 'should not be possible to open the combobox on right click',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Item A
+ Item B
+ Item C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ // Try to open the combobox
+ await click(getComboboxButton(), MouseButton.Right)
+
+ // Verify it is still closed
+ assertComboboxButton({ state: ComboboxState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should not be possible to open the combobox on click when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ // Try to open the combobox
+ await click(getComboboxButton())
+
+ // 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 on click, and focus the selected option',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ 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 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 close a combobox on click',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ )
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+
+ // Click to close
+ await click(getComboboxButton())
+
+ // Verify it is closed
+ assertComboboxButton({ state: ComboboxState.InvisibleUnmounted })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be a no-op when we click outside of a closed combobox',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ )
+
+ // Verify that the window is closed
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ // Click something that is not related to the combobox
+ await click(document.body)
+
+ // Should still be closed
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to click outside of the combobox which should close the combobox',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ )
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertActiveElement(getComboboxInput())
+
+ // Click something that is not related to the combobox
+ await click(document.body)
+
+ // Should be closed now
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ // Verify the input is focused again
+ assertActiveElement(getComboboxInput())
+ })
+ )
+
+ it(
+ 'should be possible to click outside of the combobox on another combobox button which should close the current combobox and open the new combobox',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+
+ )
+
+ let [button1, button2] = getComboboxButtons()
+
+ // Click the first combobox button
+ await click(button1)
+ expect(getComboboxes()).toHaveLength(1) // Only 1 combobox should be visible
+
+ // Verify that the first input is focused
+ assertActiveElement(getComboboxInputs()[0])
+
+ // Click the second combobox button
+ await click(button2)
+
+ expect(getComboboxes()).toHaveLength(1) // Only 1 combobox should be visible
+
+ // Verify that the first input is focused
+ assertActiveElement(getComboboxInputs()[1])
+ })
+ )
+
+ it(
+ 'should be possible to click outside of the combobox which should close the combobox (even if we press the combobox button)',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ )
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertActiveElement(getComboboxInput())
+
+ // Click the combobox button again
+ await click(getComboboxButton())
+
+ // Should be closed now
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ // Verify the input is focused again
+ assertActiveElement(getComboboxInput())
+ })
+ )
+
+ it(
+ 'should be possible to click outside of the combobox, on an element which is within a focusable element, which closes the combobox',
+ suppressConsoleLogs(async () => {
+ let focusFn = jest.fn()
+ render(
+
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+
+
+
+ )
+
+ // Click the combobox button
+ await click(getComboboxButton())
+
+ // Ensure the combobox is open
+ assertComboboxList({ state: ComboboxState.Visible })
+
+ // Click the span inside the button
+ await click(getByText('Next'))
+
+ // Ensure the combobox is closed
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ // Ensure the outside button is focused
+ assertActiveElement(document.getElementById('btn'))
+
+ // Ensure that the focus button only got focus once (first click)
+ expect(focusFn).toHaveBeenCalledTimes(1)
+ })
+ )
+
+ it(
+ 'should be possible to hover an option and make it active',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ )
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+ // We should be able to go to the second option
+ await mouseMove(options[1])
+ assertActiveComboboxOption(options[1])
+
+ // We should be able to go to the first option
+ await mouseMove(options[0])
+ assertActiveComboboxOption(options[0])
+
+ // We should be able to go to the last option
+ await mouseMove(options[2])
+ assertActiveComboboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should make a combobox option active when you move the mouse over it',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ )
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+ // We should be able to go to the second option
+ await mouseMove(options[1])
+ assertActiveComboboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should be a no-op when we move the mouse and the combobox option is already active',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ )
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+
+ // We should be able to go to the second option
+ await mouseMove(options[1])
+ assertActiveComboboxOption(options[1])
+
+ await mouseMove(options[1])
+
+ // Nothing should be changed
+ assertActiveComboboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should be a no-op when we move the mouse and the combobox option is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ alice
+
+ bob
+
+ charlie
+
+
+ )
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+
+ await mouseMove(options[1])
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should not be possible to hover an option that is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ alice
+
+ bob
+
+ charlie
+
+
+ )
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+
+ // Try to hover over option 1, which is disabled
+ await mouseMove(options[1])
+
+ // We should not have an active option now
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to mouse leave an option and make it inactive',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ )
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+
+ // We should be able to go to the second option
+ await mouseMove(options[1])
+ assertActiveComboboxOption(options[1])
+
+ await mouseLeave(options[1])
+ assertNoActiveComboboxOption()
+
+ // We should be able to go to the first option
+ await mouseMove(options[0])
+ assertActiveComboboxOption(options[0])
+
+ await mouseLeave(options[0])
+ assertNoActiveComboboxOption()
+
+ // We should be able to go to the last option
+ await mouseMove(options[2])
+ assertActiveComboboxOption(options[2])
+
+ await mouseLeave(options[2])
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to mouse leave a disabled option and be a no-op',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ alice
+
+ bob
+
+ charlie
+
+
+ )
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+
+ // Try to hover over option 1, which is disabled
+ await mouseMove(options[1])
+ assertNoActiveComboboxOption()
+
+ await mouseLeave(options[1])
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to click a combobox option, which closes the combobox',
+ suppressConsoleLogs(async () => {
+ let handleChange = jest.fn()
+ function Example() {
+ let [value, setValue] = useState(undefined)
+
+ return (
+ {
+ setValue(value)
+ handleChange(value)
+ }}
+ >
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ )
+ }
+
+ render()
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertActiveElement(getComboboxInput())
+
+ let options = getComboboxOptions()
+
+ // We should be able to click the first option
+ await click(options[1])
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+ expect(handleChange).toHaveBeenCalledTimes(1)
+ expect(handleChange).toHaveBeenCalledWith('bob')
+
+ // Verify the input is focused again
+ assertActiveElement(getComboboxInput())
+
+ // Open combobox again
+ await click(getComboboxButton())
+
+ // Verify the active option is the previously selected one
+ assertActiveComboboxOption(getComboboxOptions()[1])
+ })
+ )
+
+ it(
+ 'should be possible to click a disabled combobox option, which is a no-op',
+ suppressConsoleLogs(async () => {
+ let handleChange = jest.fn()
+ function Example() {
+ let [value, setValue] = useState(undefined)
+
+ return (
+ {
+ setValue(value)
+ handleChange(value)
+ }}
+ >
+
+ Trigger
+
+ alice
+
+ bob
+
+ charlie
+
+
+ )
+ }
+
+ render()
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertActiveElement(getComboboxInput())
+
+ let options = getComboboxOptions()
+
+ // We should be able to click the first option
+ await click(options[1])
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertActiveElement(getComboboxInput())
+ expect(handleChange).toHaveBeenCalledTimes(0)
+
+ // Close the combobox
+ await click(getComboboxButton())
+
+ // Open combobox again
+ await click(getComboboxButton())
+
+ // Verify the active option is non existing
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should be possible focus a combobox option, so that it becomes active',
+ suppressConsoleLogs(async () => {
+ function Example() {
+ let [value, setValue] = useState(undefined)
+
+ return (
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ )
+ }
+
+ render()
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertActiveElement(getComboboxInput())
+
+ let options = getComboboxOptions()
+
+ // Verify that nothing is active yet
+ assertNoActiveComboboxOption()
+
+ // We should be able to focus the first option
+ await focus(options[1])
+ assertActiveComboboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should not be possible to focus a combobox option which is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+
+
+ Trigger
+
+ alice
+
+ bob
+
+ charlie
+
+
+ )
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertActiveElement(getComboboxInput())
+
+ let options = getComboboxOptions()
+
+ // We should not be able to focus the first option
+ await focus(options[1])
+ assertNoActiveComboboxOption()
+ })
+ )
+})
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx
new file mode 100644
index 0000000..d0f327b
--- /dev/null
+++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx
@@ -0,0 +1,924 @@
+import React, {
+ Fragment,
+ createContext,
+ createRef,
+ useCallback,
+ useContext,
+ useMemo,
+ useReducer,
+ useRef,
+
+ // Types
+ Dispatch,
+ ElementType,
+ KeyboardEvent as ReactKeyboardEvent,
+ MouseEvent as ReactMouseEvent,
+ MutableRefObject,
+ Ref,
+ ContextType,
+} from 'react'
+
+import { useDisposables } from '../../hooks/use-disposables'
+import { useId } from '../../hooks/use-id'
+import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
+import { useComputed } from '../../hooks/use-computed'
+import { useSyncRefs } from '../../hooks/use-sync-refs'
+import { Props } from '../../types'
+import { Features, forwardRefWithAs, PropsForFeatures, render } from '../../utils/render'
+import { match } from '../../utils/match'
+import { disposables } from '../../utils/disposables'
+import { Keys } from '../keyboard'
+import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
+import { isDisabledReactIssue7711 } from '../../utils/bugs'
+import { isFocusableElement, FocusableMode } from '../../utils/focus-management'
+import { useWindowEvent } from '../../hooks/use-window-event'
+import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
+import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
+import { useLatestValue } from '../../hooks/use-latest-value'
+
+enum ComboboxStates {
+ Open,
+ Closed,
+}
+
+type ComboboxOptionDataRef = MutableRefObject<{
+ textValue?: string
+ disabled: boolean
+ value: unknown
+}>
+
+interface StateDefinition {
+ comboboxState: ComboboxStates
+
+ orientation: 'horizontal' | 'vertical'
+
+ propsRef: MutableRefObject<{
+ value: unknown
+ onChange(value: unknown): void
+ }>
+ inputPropsRef: MutableRefObject<{
+ displayValue?(item: unknown): string
+ }>
+ labelRef: MutableRefObject
+ inputRef: MutableRefObject
+ buttonRef: MutableRefObject
+ optionsRef: MutableRefObject
+
+ disabled: boolean
+ options: { id: string; dataRef: ComboboxOptionDataRef }[]
+ activeOptionIndex: number | null
+}
+
+enum ActionTypes {
+ OpenCombobox,
+ CloseCombobox,
+
+ SetDisabled,
+ SetOrientation,
+
+ GoToOption,
+
+ RegisterOption,
+ UnregisterOption,
+}
+
+type Actions =
+ | { type: ActionTypes.CloseCombobox }
+ | { type: ActionTypes.OpenCombobox }
+ | { type: ActionTypes.SetDisabled; disabled: boolean }
+ | { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] }
+ | { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string }
+ | { type: ActionTypes.GoToOption; focus: Exclude }
+ | { type: ActionTypes.RegisterOption; id: string; dataRef: ComboboxOptionDataRef }
+ | { type: ActionTypes.UnregisterOption; id: string }
+
+let reducers: {
+ [P in ActionTypes]: (
+ state: StateDefinition,
+ action: Extract
+ ) => StateDefinition
+} = {
+ [ActionTypes.CloseCombobox](state) {
+ if (state.disabled) return state
+ if (state.comboboxState === ComboboxStates.Closed) return state
+ return { ...state, activeOptionIndex: null, comboboxState: ComboboxStates.Closed }
+ },
+ [ActionTypes.OpenCombobox](state) {
+ if (state.disabled) return state
+ if (state.comboboxState === ComboboxStates.Open) return state
+ return { ...state, comboboxState: ComboboxStates.Open }
+ },
+ [ActionTypes.SetDisabled](state, action) {
+ if (state.disabled === action.disabled) return state
+ return { ...state, disabled: action.disabled }
+ },
+ [ActionTypes.SetOrientation](state, action) {
+ if (state.orientation === action.orientation) return state
+ return { ...state, orientation: action.orientation }
+ },
+ [ActionTypes.GoToOption](state, action) {
+ if (state.disabled) return state
+ if (state.comboboxState === ComboboxStates.Closed) return state
+
+ let activeOptionIndex = calculateActiveIndex(action, {
+ resolveItems: () => state.options,
+ resolveActiveIndex: () => state.activeOptionIndex,
+ resolveId: item => item.id,
+ resolveDisabled: item => item.dataRef.current.disabled,
+ })
+
+ if (state.activeOptionIndex === activeOptionIndex) return state
+ return { ...state, activeOptionIndex }
+ },
+ [ActionTypes.RegisterOption]: (state, action) => {
+ let currentActiveOption =
+ state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null
+
+ let orderMap = Array.from(
+ state.optionsRef.current?.querySelectorAll('[id^="headlessui-combobox-option-"]')!
+ ).reduce(
+ (lookup, element, index) => Object.assign(lookup, { [element.id]: index }),
+ {}
+ ) as Record
+
+ let options = [...state.options, { id: action.id, dataRef: action.dataRef }].sort(
+ (a, z) => orderMap[a.id] - orderMap[z.id]
+ )
+
+ return {
+ ...state,
+ options,
+ activeOptionIndex: (() => {
+ if (currentActiveOption === null) return null
+
+ // 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.
+ return options.indexOf(currentActiveOption)
+ })(),
+ }
+ },
+ [ActionTypes.UnregisterOption]: (state, action) => {
+ let nextOptions = state.options.slice()
+ let currentActiveOption =
+ state.activeOptionIndex !== null ? nextOptions[state.activeOptionIndex] : null
+
+ let idx = nextOptions.findIndex(a => a.id === action.id)
+
+ if (idx !== -1) nextOptions.splice(idx, 1)
+
+ return {
+ ...state,
+ options: nextOptions,
+ activeOptionIndex: (() => {
+ if (idx === state.activeOptionIndex) return null
+ if (currentActiveOption === null) return null
+
+ // If we removed the option before the actual active index, then it would be out of sync. To
+ // fix this, we will find the correct (new) index position.
+ return nextOptions.indexOf(currentActiveOption)
+ })(),
+ }
+ },
+}
+
+let ComboboxContext = createContext<[StateDefinition, Dispatch] | null>(null)
+ComboboxContext.displayName = 'ComboboxContext'
+
+function useComboboxContext(component: string) {
+ let context = useContext(ComboboxContext)
+ if (context === null) {
+ let err = new Error(`<${component} /> is missing a parent <${Combobox.name} /> component.`)
+ if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxContext)
+ throw err
+ }
+ return context
+}
+
+let ComboboxActions = createContext<{
+ selectOption(id: string): void
+ selectActiveOption(): void
+} | null>(null)
+ComboboxActions.displayName = 'ComboboxActions'
+
+function useComboboxActions() {
+ let context = useContext(ComboboxActions)
+ if (context === null) {
+ let err = new Error(`ComboboxActions is missing a parent <${Combobox.name} /> component.`)
+ if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxActions)
+ throw err
+ }
+ return context
+}
+
+function stateReducer(state: StateDefinition, action: Actions) {
+ return match(action.type, reducers, state, action)
+}
+
+// ---
+
+let DEFAULT_COMBOBOX_TAG = Fragment
+interface ComboboxRenderPropArg {
+ open: boolean
+ disabled: boolean
+ activeIndex: number | null
+ activeOption: T | null
+}
+
+export function Combobox(
+ props: Props<
+ TTag,
+ ComboboxRenderPropArg,
+ 'value' | 'onChange' | 'disabled' | 'horizontal'
+ > & {
+ value: TType
+ onChange(value: TType): void
+ disabled?: boolean
+ horizontal?: boolean
+ }
+) {
+ let { value, onChange, disabled = false, horizontal = false, ...passThroughProps } = props
+ const orientation = horizontal ? 'horizontal' : 'vertical'
+
+ let reducerBag = useReducer(stateReducer, {
+ comboboxState: ComboboxStates.Closed,
+ propsRef: {
+ current: {
+ value,
+ onChange,
+ },
+ },
+ inputPropsRef: {
+ current: {
+ displayValue: undefined,
+ },
+ },
+ labelRef: createRef(),
+ inputRef: createRef(),
+ buttonRef: createRef(),
+ optionsRef: createRef(),
+ disabled,
+ orientation,
+ options: [],
+ activeOptionIndex: null,
+ } as StateDefinition)
+ let [
+ {
+ comboboxState,
+ options,
+ activeOptionIndex,
+ propsRef,
+ inputPropsRef,
+ optionsRef,
+ inputRef,
+ buttonRef,
+ },
+ dispatch,
+ ] = reducerBag
+
+ useIsoMorphicEffect(() => {
+ propsRef.current.value = value
+ }, [value, propsRef])
+ useIsoMorphicEffect(() => {
+ propsRef.current.onChange = onChange
+ }, [onChange, propsRef])
+
+ useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled])
+ useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetOrientation, orientation }), [
+ orientation,
+ ])
+
+ // Handle outside click
+ useWindowEvent('mousedown', event => {
+ let target = event.target as HTMLElement
+
+ if (comboboxState !== ComboboxStates.Open) return
+
+ if (buttonRef.current?.contains(target)) return
+ if (inputRef.current?.contains(target)) return
+ if (optionsRef.current?.contains(target)) return
+
+ dispatch({ type: ActionTypes.CloseCombobox })
+
+ if (!isFocusableElement(target, FocusableMode.Loose)) {
+ event.preventDefault()
+ inputRef.current?.focus()
+ }
+ })
+
+ let slot = useMemo>(
+ () => ({
+ open: comboboxState === ComboboxStates.Open,
+ disabled,
+ activeIndex: activeOptionIndex,
+ activeOption:
+ activeOptionIndex === null
+ ? null
+ : (options[activeOptionIndex].dataRef.current.value as TType),
+ }),
+ [comboboxState, disabled, options, activeOptionIndex]
+ )
+
+ let syncInputValue = useCallback(() => {
+ if (!inputRef.current) return
+ if (value === undefined) return
+ let displayValue = inputPropsRef.current.displayValue
+
+ if (typeof displayValue === 'function') {
+ inputRef.current.value = displayValue(value)
+ } else if (typeof value === 'string') {
+ inputRef.current.value = value
+ }
+ }, [value, inputRef, inputPropsRef])
+
+ let selectOption = useCallback(
+ (id: string) => {
+ let option = options.find(item => item.id === id)
+ if (!option) return
+
+ let { dataRef } = option
+ propsRef.current.onChange(dataRef.current.value)
+ syncInputValue()
+ },
+ [options, propsRef, inputRef]
+ )
+
+ let selectActiveOption = useCallback(() => {
+ if (activeOptionIndex !== null) {
+ let { dataRef } = options[activeOptionIndex]
+ propsRef.current.onChange(dataRef.current.value)
+ syncInputValue()
+ }
+ }, [activeOptionIndex, options, propsRef, inputRef])
+
+ let actionsBag = useMemo>(
+ () => ({ selectOption, selectActiveOption }),
+ [selectOption, selectActiveOption]
+ )
+
+ useIsoMorphicEffect(() => {
+ if (comboboxState !== ComboboxStates.Closed) {
+ return
+ }
+ syncInputValue()
+ }, [syncInputValue, comboboxState])
+
+ // Ensure that we update the inputRef if the value changes
+ useIsoMorphicEffect(syncInputValue, [syncInputValue])
+
+ return (
+
+
+
+ {render({
+ props: passThroughProps,
+ slot,
+ defaultTag: DEFAULT_COMBOBOX_TAG,
+ name: 'Combobox',
+ })}
+
+
+
+ )
+}
+
+// ---
+
+let DEFAULT_INPUT_TAG = 'input' as const
+interface InputRenderPropArg {
+ open: boolean
+ disabled: boolean
+}
+type InputPropsWeControl =
+ | 'id'
+ | 'role'
+ | 'type'
+ | 'aria-labelledby'
+ | 'aria-expanded'
+ | 'aria-activedescendant'
+ | 'onKeyDown'
+ | 'onChange'
+ | 'displayValue'
+
+let Input = forwardRefWithAs(function Input<
+ TTag extends ElementType = typeof DEFAULT_INPUT_TAG,
+ // TODO: One day we will be able to infer this type from the generic in Combobox itself.
+ // But today is not that day..
+ TType = Parameters[0]['value']
+>(
+ props: Props & {
+ displayValue?(item: TType): string
+ onChange(event: React.ChangeEvent): void
+ },
+ ref: Ref
+) {
+ let { value, onChange, displayValue, ...passThroughProps } = props
+ let [state, dispatch] = useComboboxContext([Combobox.name, Input.name].join('.'))
+ let actions = useComboboxActions()
+
+ let inputRef = useSyncRefs(state.inputRef, ref)
+ let inputPropsRef = state.inputPropsRef
+
+ let id = `headlessui-combobox-input-${useId()}`
+ let d = useDisposables()
+
+ let onChangeRef = useLatestValue(onChange)
+
+ useIsoMorphicEffect(() => {
+ inputPropsRef.current.displayValue = displayValue
+ }, [displayValue, inputPropsRef])
+
+ let handleKeyDown = useCallback(
+ (event: ReactKeyboardEvent) => {
+ switch (event.key) {
+ // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
+
+ case Keys.Enter:
+ event.preventDefault()
+ event.stopPropagation()
+
+ actions.selectActiveOption()
+ dispatch({ type: ActionTypes.CloseCombobox })
+ break
+
+ case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }):
+ event.preventDefault()
+ event.stopPropagation()
+ return match(state.comboboxState, {
+ [ComboboxStates.Open]: () => {
+ return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next })
+ },
+ [ComboboxStates.Closed]: () => {
+ dispatch({ type: ActionTypes.OpenCombobox })
+ // TODO: We can't do this outside next frame because the options aren't rendered yet
+ // But doing this in next frame results in a flicker because the dom mutations are async here
+ // Basically:
+ // Sync -> no option list yet
+ // Next frame -> option list already rendered with selection -> dispatch -> next frame -> now we have the focus on the right element
+
+ // TODO: The spec here is underspecified. There's mention of skipping to the next item when autocomplete has suggested something but nothing regarding a non-autocomplete selection/value
+ d.nextFrame(() => {
+ if (!state.propsRef.current.value) {
+ dispatch({ type: ActionTypes.GoToOption, focus: Focus.First })
+ }
+ })
+ },
+ })
+
+ case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
+ event.preventDefault()
+ event.stopPropagation()
+ return match(state.comboboxState, {
+ [ComboboxStates.Open]: () => {
+ return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Previous })
+ },
+ [ComboboxStates.Closed]: () => {
+ dispatch({ type: ActionTypes.OpenCombobox })
+ d.nextFrame(() => {
+ if (!state.propsRef.current.value) {
+ dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last })
+ }
+ })
+ },
+ })
+
+ case Keys.Home:
+ case Keys.PageUp:
+ event.preventDefault()
+ event.stopPropagation()
+ return dispatch({ type: ActionTypes.GoToOption, focus: Focus.First })
+
+ case Keys.End:
+ case Keys.PageDown:
+ event.preventDefault()
+ event.stopPropagation()
+ return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last })
+
+ case Keys.Escape:
+ event.preventDefault()
+ event.stopPropagation()
+ return dispatch({ type: ActionTypes.CloseCombobox })
+
+ case Keys.Tab:
+ actions.selectActiveOption()
+ dispatch({ type: ActionTypes.CloseCombobox })
+ break
+ }
+ },
+ [d, dispatch, state, actions]
+ )
+
+ let handleChange = useCallback(
+ (event: React.ChangeEvent) => {
+ dispatch({ type: ActionTypes.OpenCombobox })
+ onChangeRef.current(event)
+ },
+ [dispatch, onChangeRef]
+ )
+
+ // TODO: Verify this. The spec says that, for the input/combobox, the lebel is the labelling element when present
+ // Otherwise it's the ID of the non-label element
+ let labelledby = useComputed(() => {
+ if (!state.labelRef.current) return undefined
+ return [state.labelRef.current.id].join(' ')
+ }, [state.labelRef.current])
+
+ let slot = useMemo(
+ () => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }),
+ [state]
+ )
+
+ let propsWeControl = {
+ ref: inputRef,
+ id,
+ role: 'combobox',
+ type: 'text',
+ 'aria-controls': state.optionsRef.current?.id,
+ 'aria-expanded': state.disabled ? undefined : state.comboboxState === ComboboxStates.Open,
+ 'aria-activedescendant':
+ state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id,
+ 'aria-labelledby': labelledby,
+ disabled: state.disabled,
+ onKeyDown: handleKeyDown,
+ onChange: handleChange,
+ }
+
+ return render({
+ props: { ...passThroughProps, ...propsWeControl },
+ slot,
+ defaultTag: DEFAULT_INPUT_TAG,
+ name: 'Combobox.Input',
+ })
+})
+
+// ---
+
+let DEFAULT_BUTTON_TAG = 'button' as const
+interface ButtonRenderPropArg {
+ open: boolean
+ disabled: boolean
+}
+type ButtonPropsWeControl =
+ | 'id'
+ | 'type'
+ | 'tabIndex'
+ | 'aria-haspopup'
+ | 'aria-controls'
+ | 'aria-expanded'
+ | 'aria-labelledby'
+ | 'disabled'
+ | 'onClick'
+ | 'onKeyDown'
+
+let Button = forwardRefWithAs(function Button(
+ props: Props,
+ ref: Ref
+) {
+ let [state, dispatch] = useComboboxContext([Combobox.name, Button.name].join('.'))
+ let actions = useComboboxActions()
+ let buttonRef = useSyncRefs(state.buttonRef, ref)
+
+ let id = `headlessui-combobox-button-${useId()}`
+ let d = useDisposables()
+
+ let handleKeyDown = useCallback(
+ (event: ReactKeyboardEvent) => {
+ switch (event.key) {
+ // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
+
+ case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }):
+ event.preventDefault()
+ event.stopPropagation()
+ if (state.comboboxState === ComboboxStates.Closed) {
+ dispatch({ type: ActionTypes.OpenCombobox })
+ // TODO: We can't do this outside next frame because the options aren't rendered yet
+ // But doing this in next frame results in a flicker because the dom mutations are async here
+ // Basically:
+ // Sync -> no option list yet
+ // Next frame -> option list already rendered with selection -> dispatch -> next frame -> now we have the focus on the right element
+
+ // TODO: The spec here is underspecified. There's mention of skipping to the next item when autocomplete has suggested something but nothing regarding a non-autocomplete selection/value
+ d.nextFrame(() => {
+ if (!state.propsRef.current.value) {
+ dispatch({ type: ActionTypes.GoToOption, focus: Focus.First })
+ }
+ })
+ }
+ return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
+
+ case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
+ event.preventDefault()
+ event.stopPropagation()
+ if (state.comboboxState === ComboboxStates.Closed) {
+ dispatch({ type: ActionTypes.OpenCombobox })
+ d.nextFrame(() => {
+ if (!state.propsRef.current.value) {
+ dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last })
+ }
+ })
+ }
+ return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
+
+ case Keys.Escape:
+ event.preventDefault()
+ event.stopPropagation()
+ dispatch({ type: ActionTypes.CloseCombobox })
+ return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
+ }
+ },
+ [d, dispatch, state, actions]
+ )
+
+ let handleClick = useCallback(
+ (event: ReactMouseEvent) => {
+ if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
+ if (state.comboboxState === ComboboxStates.Open) {
+ dispatch({ type: ActionTypes.CloseCombobox })
+ } else {
+ event.preventDefault()
+ dispatch({ type: ActionTypes.OpenCombobox })
+ }
+
+ d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
+ },
+ [dispatch, d, state]
+ )
+
+ let labelledby = useComputed(() => {
+ if (!state.labelRef.current) return undefined
+ return [state.labelRef.current.id, id].join(' ')
+ }, [state.labelRef.current, id])
+
+ let slot = useMemo(
+ () => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }),
+ [state]
+ )
+ let passthroughProps = props
+ let propsWeControl = {
+ ref: buttonRef,
+ id,
+ type: useResolveButtonType(props, state.buttonRef),
+ tabIndex: -1,
+ 'aria-haspopup': true,
+ 'aria-controls': state.optionsRef.current?.id,
+ 'aria-expanded': state.disabled ? undefined : state.comboboxState === ComboboxStates.Open,
+ 'aria-labelledby': labelledby,
+ disabled: state.disabled,
+ onClick: handleClick,
+ onKeyDown: handleKeyDown,
+ }
+
+ return render({
+ props: { ...passthroughProps, ...propsWeControl },
+ slot,
+ defaultTag: DEFAULT_BUTTON_TAG,
+ name: 'Combobox.Button',
+ })
+})
+
+// ---
+
+let DEFAULT_LABEL_TAG = 'label' as const
+interface LabelRenderPropArg {
+ open: boolean
+ disabled: boolean
+}
+type LabelPropsWeControl = 'id' | 'ref' | 'onClick'
+
+function Label(
+ props: Props
+) {
+ let [state] = useComboboxContext([Combobox.name, Label.name].join('.'))
+ let id = `headlessui-combobox-label-${useId()}`
+
+ let handleClick = useCallback(() => state.inputRef.current?.focus({ preventScroll: true }), [
+ state.inputRef,
+ ])
+
+ let slot = useMemo(
+ () => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }),
+ [state]
+ )
+ let propsWeControl = { ref: state.labelRef, id, onClick: handleClick }
+ return render({
+ props: { ...props, ...propsWeControl },
+ slot,
+ defaultTag: DEFAULT_LABEL_TAG,
+ name: 'Combobox.Label',
+ })
+}
+
+// ---
+
+let DEFAULT_OPTIONS_TAG = 'ul' as const
+interface OptionsRenderPropArg {
+ open: boolean
+}
+type OptionsPropsWeControl =
+ | 'aria-activedescendant'
+ | 'aria-labelledby'
+ | 'aria-orientation'
+ | 'id'
+ | 'onKeyDown'
+ | 'role'
+ | 'tabIndex'
+
+let OptionsRenderFeatures = Features.RenderStrategy | Features.Static
+
+let Options = forwardRefWithAs(function Options<
+ TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG
+>(
+ props: Props &
+ PropsForFeatures,
+ ref: Ref
+) {
+ let [state, dispatch] = useComboboxContext([Combobox.name, Options.name].join('.'))
+ let optionsRef = useSyncRefs(state.optionsRef, ref)
+
+ let id = `headlessui-combobox-options-${useId()}`
+
+ let usesOpenClosedState = useOpenClosed()
+ let visible = (() => {
+ if (usesOpenClosedState !== null) {
+ return usesOpenClosedState === State.Open
+ }
+
+ return state.comboboxState === ComboboxStates.Open
+ })()
+
+ let labelledby = useComputed(() => state.labelRef.current?.id ?? state.buttonRef.current?.id, [
+ state.labelRef.current,
+ state.buttonRef.current,
+ ])
+
+ let handleLeave = useCallback(() => {
+ if (state.comboboxState !== ComboboxStates.Open) return
+ if (state.activeOptionIndex === null) return
+ dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
+ }, [state, dispatch])
+
+ let slot = useMemo(
+ () => ({ open: state.comboboxState === ComboboxStates.Open }),
+ [state]
+ )
+ let propsWeControl = {
+ 'aria-activedescendant':
+ state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id,
+ 'aria-labelledby': labelledby,
+ 'aria-orientation': state.orientation,
+ role: 'listbox',
+ id,
+ ref: optionsRef,
+ onPointerLeave: handleLeave,
+ onMouseLeave: handleLeave,
+ }
+ let passthroughProps = props
+
+ return render({
+ props: { ...passthroughProps, ...propsWeControl },
+ slot,
+ defaultTag: DEFAULT_OPTIONS_TAG,
+ features: OptionsRenderFeatures,
+ visible,
+ name: 'Combobox.Options',
+ })
+})
+
+// ---
+
+let DEFAULT_OPTION_TAG = 'li' as const
+interface OptionRenderPropArg {
+ active: boolean
+ selected: boolean
+ disabled: boolean
+}
+type ComboboxOptionPropsWeControl =
+ | 'id'
+ | 'role'
+ | 'tabIndex'
+ | 'aria-disabled'
+ | 'aria-selected'
+ | 'onPointerLeave'
+ | 'onMouseLeave'
+ | 'onPointerMove'
+ | 'onMouseMove'
+
+function Option<
+ TTag extends ElementType = typeof DEFAULT_OPTION_TAG,
+ // TODO: One day we will be able to infer this type from the generic in Combobox itself.
+ // But today is not that day..
+ TType = Parameters[0]['value']
+>(
+ props: Props & {
+ disabled?: boolean
+ value: TType
+ }
+) {
+ let { disabled = false, value, ...passthroughProps } = props
+ let [state, dispatch] = useComboboxContext([Combobox.name, Option.name].join('.'))
+ let actions = useComboboxActions()
+ let id = `headlessui-combobox-option-${useId()}`
+ let active =
+ state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false
+ let selected = state.propsRef.current.value === value
+ let bag = useRef({ disabled, value })
+
+ useIsoMorphicEffect(() => {
+ bag.current.disabled = disabled
+ }, [bag, disabled])
+ useIsoMorphicEffect(() => {
+ bag.current.value = value
+ }, [bag, value])
+ useIsoMorphicEffect(() => {
+ bag.current.textValue = document.getElementById(id)?.textContent?.toLowerCase()
+ }, [bag, id])
+
+ let select = useCallback(() => actions.selectOption(id), [actions, id])
+
+ useIsoMorphicEffect(() => {
+ dispatch({ type: ActionTypes.RegisterOption, id, dataRef: bag })
+ return () => dispatch({ type: ActionTypes.UnregisterOption, id })
+ }, [bag, id])
+
+ useIsoMorphicEffect(() => {
+ if (state.comboboxState !== ComboboxStates.Open) return
+ if (!selected) return
+ dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
+ }, [state.comboboxState])
+
+ useIsoMorphicEffect(() => {
+ if (state.comboboxState !== ComboboxStates.Open) return
+ if (!active) return
+ let d = disposables()
+ d.nextFrame(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }))
+ return d.dispose
+ }, [id, active, state.comboboxState])
+
+ let handleClick = useCallback(
+ (event: { preventDefault: Function }) => {
+ if (disabled) return event.preventDefault()
+ select()
+ dispatch({ type: ActionTypes.CloseCombobox })
+ disposables().nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
+ },
+ [dispatch, state.inputRef, disabled, select]
+ )
+
+ let handleFocus = useCallback(() => {
+ if (disabled) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
+ dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
+ }, [disabled, id, dispatch])
+
+ let handleMove = useCallback(() => {
+ if (disabled) return
+ if (active) return
+ dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
+ }, [disabled, active, id, dispatch])
+
+ let handleLeave = useCallback(() => {
+ if (disabled) return
+ if (!active) return
+ dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
+ }, [disabled, active, dispatch])
+
+ let slot = useMemo(() => ({ active, selected, disabled }), [
+ active,
+ selected,
+ disabled,
+ ])
+
+ let propsWeControl = {
+ id,
+ role: 'option',
+ tabIndex: disabled === true ? undefined : -1,
+ 'aria-disabled': disabled === true ? true : undefined,
+ 'aria-selected': selected === true ? true : undefined,
+ disabled: undefined, // Never forward the `disabled` prop
+ onClick: handleClick,
+ onFocus: handleFocus,
+ onPointerMove: handleMove,
+ onMouseMove: handleMove,
+ onPointerLeave: handleLeave,
+ onMouseLeave: handleLeave,
+ }
+
+ return render({
+ props: { ...passthroughProps, ...propsWeControl },
+ slot,
+ defaultTag: DEFAULT_OPTION_TAG,
+ name: 'Combobox.Option',
+ })
+}
+
+// ---
+
+Combobox.Input = Input
+Combobox.Button = Button
+Combobox.Label = Label
+Combobox.Options = Options
+Combobox.Option = Option
diff --git a/packages/@headlessui-react/src/hooks/use-computed.ts b/packages/@headlessui-react/src/hooks/use-computed.ts
index 980ef9b..3d41644 100644
--- a/packages/@headlessui-react/src/hooks/use-computed.ts
+++ b/packages/@headlessui-react/src/hooks/use-computed.ts
@@ -1,12 +1,10 @@
-import { useState, useRef } from 'react'
+import { useState } from 'react'
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
+import { useLatestValue } from './use-latest-value'
export function useComputed(cb: () => T, dependencies: React.DependencyList) {
let [value, setValue] = useState(cb)
- let cbRef = useRef(cb)
- useIsoMorphicEffect(() => {
- cbRef.current = cb
- }, [cb])
+ let cbRef = useLatestValue(cb)
useIsoMorphicEffect(() => setValue(cbRef.current), [cbRef, setValue, ...dependencies])
return value
}
diff --git a/packages/@headlessui-react/src/hooks/use-latest-value.ts b/packages/@headlessui-react/src/hooks/use-latest-value.ts
new file mode 100644
index 0000000..6795e7e
--- /dev/null
+++ b/packages/@headlessui-react/src/hooks/use-latest-value.ts
@@ -0,0 +1,11 @@
+import { useRef, useEffect } from 'react'
+
+export function useLatestValue(value: T) {
+ let cache = useRef(value)
+
+ useEffect(() => {
+ cache.current = value
+ }, [value])
+
+ return cache
+}
diff --git a/packages/@headlessui-react/src/index.test.ts b/packages/@headlessui-react/src/index.test.ts
index 145b355..1058a5e 100644
--- a/packages/@headlessui-react/src/index.test.ts
+++ b/packages/@headlessui-react/src/index.test.ts
@@ -6,6 +6,7 @@ import * as HeadlessUI from './index'
*/
it('should expose the correct components', () => {
expect(Object.keys(HeadlessUI)).toEqual([
+ 'Combobox',
'Dialog',
'Disclosure',
'FocusTrap',
diff --git a/packages/@headlessui-react/src/index.ts b/packages/@headlessui-react/src/index.ts
index 2ef29db..2ba6c32 100644
--- a/packages/@headlessui-react/src/index.ts
+++ b/packages/@headlessui-react/src/index.ts
@@ -1,3 +1,4 @@
+export * from './components/combobox/combobox'
export * from './components/dialog/dialog'
export * from './components/disclosure/disclosure'
export * from './components/focus-trap/focus-trap'
diff --git a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts
index 1fb40a1..f478642 100644
--- a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts
+++ b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts
@@ -91,7 +91,7 @@ export function assertMenuButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
- Error.captureStackTrace(err, assertMenuButton)
+ if (err instanceof Error) Error.captureStackTrace(err, assertMenuButton)
throw err
}
}
@@ -105,7 +105,7 @@ export function assertMenuButtonLinkedWithMenu(button = getMenuButton(), menu =
expect(button).toHaveAttribute('aria-controls', menu.getAttribute('id'))
expect(menu).toHaveAttribute('aria-labelledby', button.getAttribute('id'))
} catch (err) {
- Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu)
+ if (err instanceof Error) Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu)
throw err
}
}
@@ -118,7 +118,7 @@ export function assertMenuLinkedWithMenuItem(item: HTMLElement | null, menu = ge
// Ensure link between menu & menu item is correct
expect(menu).toHaveAttribute('aria-activedescendant', item.getAttribute('id'))
} catch (err) {
- Error.captureStackTrace(err, assertMenuLinkedWithMenuItem)
+ if (err instanceof Error) Error.captureStackTrace(err, assertMenuLinkedWithMenuItem)
throw err
}
}
@@ -130,7 +130,7 @@ export function assertNoActiveMenuItem(menu = getMenu()) {
// Ensure we don't have an active menu
expect(menu).not.toHaveAttribute('aria-activedescendant')
} catch (err) {
- Error.captureStackTrace(err, assertNoActiveMenuItem)
+ if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveMenuItem)
throw err
}
}
@@ -183,7 +183,7 @@ export function assertMenu(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertMenu)
+ if (err instanceof Error) Error.captureStackTrace(err, assertMenu)
throw err
}
}
@@ -214,7 +214,393 @@ export function assertMenuItem(
}
}
} catch (err) {
- Error.captureStackTrace(err, assertMenuItem)
+ if (err instanceof Error) Error.captureStackTrace(err, assertMenuItem)
+ throw err
+ }
+}
+
+// ---
+
+export function getComboboxLabel(): HTMLElement | null {
+ return document.querySelector('label,[id^="headlessui-combobox-label"]')
+}
+
+export function getComboboxButton(): HTMLElement | null {
+ return document.querySelector('button,[role="button"],[id^="headlessui-combobox-button-"]')
+}
+
+export function getComboboxButtons(): HTMLElement[] {
+ return Array.from(document.querySelectorAll('button,[role="button"]'))
+}
+
+export function getComboboxInput(): HTMLInputElement | null {
+ return document.querySelector('[role="combobox"]')
+}
+
+export function getCombobox(): HTMLElement | null {
+ return document.querySelector('[role="listbox"]')
+}
+
+export function getComboboxInputs(): HTMLElement[] {
+ return Array.from(document.querySelectorAll('[role="combobox"]'))
+}
+
+export function getComboboxes(): HTMLElement[] {
+ return Array.from(document.querySelectorAll('[role="listbox"]'))
+}
+
+export function getComboboxOptions(): HTMLElement[] {
+ return Array.from(document.querySelectorAll('[role="option"]'))
+}
+
+// ---
+
+export enum ComboboxState {
+ /** The combobox is visible to the user. */
+ Visible,
+
+ /** The combobox is **not** visible to the user. It's still in the DOM, but it is hidden. */
+ InvisibleHidden,
+
+ /** The combobox is **not** visible to the user. It's not in the DOM, it is unmounted. */
+ InvisibleUnmounted,
+}
+
+export function assertCombobox(
+ options: {
+ attributes?: Record
+ textContent?: string
+ state: ComboboxState
+ orientation?: 'horizontal' | 'vertical'
+ },
+ combobox = getComboboxInput()
+) {
+ let { orientation = 'vertical' } = options
+
+ try {
+ switch (options.state) {
+ case ComboboxState.InvisibleHidden:
+ if (combobox === null) return expect(combobox).not.toBe(null)
+
+ assertHidden(combobox)
+
+ expect(combobox).toHaveAttribute('aria-labelledby')
+ expect(combobox).toHaveAttribute('aria-orientation', orientation)
+ expect(combobox).toHaveAttribute('role', 'combobox')
+
+ if (options.textContent) expect(combobox).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case ComboboxState.Visible:
+ if (combobox === null) return expect(combobox).not.toBe(null)
+
+ assertVisible(combobox)
+
+ expect(combobox).toHaveAttribute('aria-labelledby')
+ expect(combobox).toHaveAttribute('aria-orientation', orientation)
+ expect(combobox).toHaveAttribute('role', 'combobox')
+
+ if (options.textContent) expect(combobox).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case ComboboxState.InvisibleUnmounted:
+ expect(combobox).toBe(null)
+ break
+
+ default:
+ assertNever(options.state)
+ }
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertCombobox)
+ throw err
+ }
+}
+
+export function assertComboboxList(
+ options: {
+ attributes?: Record
+ textContent?: string
+ state: ComboboxState
+ orientation?: 'horizontal' | 'vertical'
+ },
+ listbox = getCombobox()
+) {
+ let { orientation = 'vertical' } = options
+
+ try {
+ switch (options.state) {
+ case ComboboxState.InvisibleHidden:
+ if (listbox === null) return expect(listbox).not.toBe(null)
+
+ assertHidden(listbox)
+
+ expect(listbox).toHaveAttribute('aria-labelledby')
+ expect(listbox).toHaveAttribute('aria-orientation', orientation)
+ expect(listbox).toHaveAttribute('role', 'listbox')
+
+ if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case ComboboxState.Visible:
+ if (listbox === null) return expect(listbox).not.toBe(null)
+
+ assertVisible(listbox)
+
+ expect(listbox).toHaveAttribute('aria-labelledby')
+ expect(listbox).toHaveAttribute('aria-orientation', orientation)
+ expect(listbox).toHaveAttribute('role', 'listbox')
+
+ if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case ComboboxState.InvisibleUnmounted:
+ expect(listbox).toBe(null)
+ break
+
+ default:
+ assertNever(options.state)
+ }
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertCombobox)
+ throw err
+ }
+}
+
+export function assertComboboxButton(
+ options: {
+ attributes?: Record
+ textContent?: string
+ state: ComboboxState
+ },
+ button = getComboboxButton()
+) {
+ try {
+ if (button === null) return expect(button).not.toBe(null)
+
+ // Ensure menu button have these properties
+ expect(button).toHaveAttribute('id')
+ expect(button).toHaveAttribute('aria-haspopup')
+
+ switch (options.state) {
+ case ComboboxState.Visible:
+ expect(button).toHaveAttribute('aria-controls')
+ expect(button).toHaveAttribute('aria-expanded', 'true')
+ break
+
+ case ComboboxState.InvisibleHidden:
+ expect(button).toHaveAttribute('aria-controls')
+ if (button.hasAttribute('disabled')) {
+ expect(button).not.toHaveAttribute('aria-expanded')
+ } else {
+ expect(button).toHaveAttribute('aria-expanded', 'false')
+ }
+ break
+
+ case ComboboxState.InvisibleUnmounted:
+ expect(button).not.toHaveAttribute('aria-controls')
+ if (button.hasAttribute('disabled')) {
+ expect(button).not.toHaveAttribute('aria-expanded')
+ } else {
+ expect(button).toHaveAttribute('aria-expanded', 'false')
+ }
+ break
+
+ default:
+ assertNever(options.state)
+ }
+
+ if (options.textContent) {
+ expect(button).toHaveTextContent(options.textContent)
+ }
+
+ // Ensure menu button has the following attributes
+ for (let attributeName in options.attributes) {
+ expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButton)
+ throw err
+ }
+}
+
+export function assertComboboxLabel(
+ options: {
+ attributes?: Record
+ tag?: string
+ textContent?: string
+ },
+ label = getComboboxLabel()
+) {
+ try {
+ if (label === null) return expect(label).not.toBe(null)
+
+ // Ensure menu button have these properties
+ expect(label).toHaveAttribute('id')
+
+ if (options.textContent) {
+ expect(label).toHaveTextContent(options.textContent)
+ }
+
+ if (options.tag) {
+ expect(label.tagName.toLowerCase()).toBe(options.tag)
+ }
+
+ // Ensure menu button has the following attributes
+ for (let attributeName in options.attributes) {
+ expect(label).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabel)
+ throw err
+ }
+}
+
+export function assertComboboxButtonLinkedWithCombobox(
+ button = getComboboxButton(),
+ combobox = getCombobox()
+) {
+ try {
+ if (button === null) return expect(button).not.toBe(null)
+ if (combobox === null) return expect(combobox).not.toBe(null)
+
+ // Ensure link between button & combobox is correct
+ expect(button).toHaveAttribute('aria-controls', combobox.getAttribute('id'))
+ expect(combobox).toHaveAttribute('aria-labelledby', button.getAttribute('id'))
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButtonLinkedWithCombobox)
+ throw err
+ }
+}
+
+export function assertComboboxLabelLinkedWithCombobox(
+ label = getComboboxLabel(),
+ combobox = getComboboxInput()
+) {
+ try {
+ if (label === null) return expect(label).not.toBe(null)
+ if (combobox === null) return expect(combobox).not.toBe(null)
+
+ expect(combobox).toHaveAttribute('aria-labelledby', label.getAttribute('id'))
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabelLinkedWithCombobox)
+ throw err
+ }
+}
+
+export function assertComboboxButtonLinkedWithComboboxLabel(
+ button = getComboboxButton(),
+ label = getComboboxLabel()
+) {
+ try {
+ if (button === null) return expect(button).not.toBe(null)
+ if (label === null) return expect(label).not.toBe(null)
+
+ // Ensure link between button & label is correct
+ expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`)
+ } catch (err) {
+ if (err instanceof Error)
+ Error.captureStackTrace(err, assertComboboxButtonLinkedWithComboboxLabel)
+ throw err
+ }
+}
+
+export function assertActiveComboboxOption(
+ item: HTMLElement | null,
+ combobox = getComboboxInput()
+) {
+ try {
+ if (combobox === null) return expect(combobox).not.toBe(null)
+ if (item === null) return expect(item).not.toBe(null)
+
+ // Ensure link between combobox & combobox item is correct
+ expect(combobox).toHaveAttribute('aria-activedescendant', item.getAttribute('id'))
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertActiveComboboxOption)
+ throw err
+ }
+}
+
+export function assertNoActiveComboboxOption(combobox = getComboboxInput()) {
+ try {
+ if (combobox === null) return expect(combobox).not.toBe(null)
+
+ // Ensure we don't have an active combobox
+ expect(combobox).not.toHaveAttribute('aria-activedescendant')
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveComboboxOption)
+ throw err
+ }
+}
+
+export function assertNoSelectedComboboxOption(items = getComboboxOptions()) {
+ try {
+ for (let item of items) expect(item).not.toHaveAttribute('aria-selected')
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedComboboxOption)
+ throw err
+ }
+}
+
+export function assertComboboxOption(
+ item: HTMLElement | null,
+ options?: {
+ tag?: string
+ attributes?: Record
+ selected?: boolean
+ }
+) {
+ try {
+ if (item === null) return expect(item).not.toBe(null)
+
+ // Check that some attributes exists, doesn't really matter what the values are at this point in
+ // time, we just require them.
+ expect(item).toHaveAttribute('id')
+
+ // Check that we have the correct values for certain attributes
+ expect(item).toHaveAttribute('role', 'option')
+ if (!item.getAttribute('aria-disabled')) expect(item).toHaveAttribute('tabindex', '-1')
+
+ // Ensure combobox button has the following attributes
+ if (!options) return
+
+ for (let attributeName in options.attributes) {
+ expect(item).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+
+ if (options.tag) {
+ expect(item.tagName.toLowerCase()).toBe(options.tag)
+ }
+
+ if (options.selected != null) {
+ switch (options.selected) {
+ case true:
+ return expect(item).toHaveAttribute('aria-selected', 'true')
+
+ case false:
+ return expect(item).not.toHaveAttribute('aria-selected')
+
+ default:
+ assertNever(options.selected)
+ }
+ }
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertComboboxOption)
throw err
}
}
@@ -311,7 +697,7 @@ export function assertListbox(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertListbox)
+ if (err instanceof Error) Error.captureStackTrace(err, assertListbox)
throw err
}
}
@@ -368,7 +754,7 @@ export function assertListboxButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
- Error.captureStackTrace(err, assertListboxButton)
+ if (err instanceof Error) Error.captureStackTrace(err, assertListboxButton)
throw err
}
}
@@ -400,7 +786,7 @@ export function assertListboxLabel(
expect(label).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
- Error.captureStackTrace(err, assertListboxLabel)
+ if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabel)
throw err
}
}
@@ -417,7 +803,7 @@ export function assertListboxButtonLinkedWithListbox(
expect(button).toHaveAttribute('aria-controls', listbox.getAttribute('id'))
expect(listbox).toHaveAttribute('aria-labelledby', button.getAttribute('id'))
} catch (err) {
- Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox)
+ if (err instanceof Error) Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox)
throw err
}
}
@@ -432,7 +818,7 @@ export function assertListboxLabelLinkedWithListbox(
expect(listbox).toHaveAttribute('aria-labelledby', label.getAttribute('id'))
} catch (err) {
- Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox)
+ if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox)
throw err
}
}
@@ -448,7 +834,8 @@ export function assertListboxButtonLinkedWithListboxLabel(
// Ensure link between button & label is correct
expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`)
} catch (err) {
- Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel)
+ if (err instanceof Error)
+ Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel)
throw err
}
}
@@ -461,7 +848,7 @@ export function assertActiveListboxOption(item: HTMLElement | null, listbox = ge
// Ensure link between listbox & listbox item is correct
expect(listbox).toHaveAttribute('aria-activedescendant', item.getAttribute('id'))
} catch (err) {
- Error.captureStackTrace(err, assertActiveListboxOption)
+ if (err instanceof Error) Error.captureStackTrace(err, assertActiveListboxOption)
throw err
}
}
@@ -473,7 +860,7 @@ export function assertNoActiveListboxOption(listbox = getListbox()) {
// Ensure we don't have an active listbox
expect(listbox).not.toHaveAttribute('aria-activedescendant')
} catch (err) {
- Error.captureStackTrace(err, assertNoActiveListboxOption)
+ if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveListboxOption)
throw err
}
}
@@ -482,7 +869,7 @@ export function assertNoSelectedListboxOption(items = getListboxOptions()) {
try {
for (let item of items) expect(item).not.toHaveAttribute('aria-selected')
} catch (err) {
- Error.captureStackTrace(err, assertNoSelectedListboxOption)
+ if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedListboxOption)
throw err
}
}
@@ -530,7 +917,7 @@ export function assertListboxOption(
}
}
} catch (err) {
- Error.captureStackTrace(err, assertListboxOption)
+ if (err instanceof Error) Error.captureStackTrace(err, assertListboxOption)
throw err
}
}
@@ -597,7 +984,7 @@ export function assertSwitch(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertSwitch)
+ if (err instanceof Error) Error.captureStackTrace(err, assertSwitch)
throw err
}
}
@@ -678,7 +1065,7 @@ export function assertDisclosureButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
- Error.captureStackTrace(err, assertDisclosureButton)
+ if (err instanceof Error) Error.captureStackTrace(err, assertDisclosureButton)
throw err
}
}
@@ -725,7 +1112,7 @@ export function assertDisclosurePanel(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertDisclosurePanel)
+ if (err instanceof Error) Error.captureStackTrace(err, assertDisclosurePanel)
throw err
}
}
@@ -810,7 +1197,7 @@ export function assertPopoverButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
- Error.captureStackTrace(err, assertPopoverButton)
+ if (err instanceof Error) Error.captureStackTrace(err, assertPopoverButton)
throw err
}
}
@@ -857,7 +1244,7 @@ export function assertPopoverPanel(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertPopoverPanel)
+ if (err instanceof Error) Error.captureStackTrace(err, assertPopoverPanel)
throw err
}
}
@@ -984,7 +1371,7 @@ export function assertDialog(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertDialog)
+ if (err instanceof Error) Error.captureStackTrace(err, assertDialog)
throw err
}
}
@@ -1040,7 +1427,7 @@ export function assertDialogTitle(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertDialogTitle)
+ if (err instanceof Error) Error.captureStackTrace(err, assertDialogTitle)
throw err
}
}
@@ -1096,7 +1483,7 @@ export function assertDialogDescription(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertDialogDescription)
+ if (err instanceof Error) Error.captureStackTrace(err, assertDialogDescription)
throw err
}
}
@@ -1143,7 +1530,7 @@ export function assertDialogOverlay(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertDialogOverlay)
+ if (err instanceof Error) Error.captureStackTrace(err, assertDialogOverlay)
throw err
}
}
@@ -1185,7 +1572,7 @@ export function assertRadioGroupLabel(
expect(label).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
- Error.captureStackTrace(err, assertRadioGroupLabel)
+ if (err instanceof Error) Error.captureStackTrace(err, assertRadioGroupLabel)
throw err
}
}
@@ -1267,7 +1654,7 @@ export function assertTabs(
}
}
} catch (err) {
- Error.captureStackTrace(err, assertTabs)
+ if (err instanceof Error) Error.captureStackTrace(err, assertTabs)
throw err
}
}
@@ -1287,7 +1674,7 @@ export function assertActiveElement(element: HTMLElement | null) {
expect(document.activeElement?.outerHTML).toBe(element.outerHTML)
}
} catch (err) {
- Error.captureStackTrace(err, assertActiveElement)
+ if (err instanceof Error) Error.captureStackTrace(err, assertActiveElement)
throw err
}
}
@@ -1297,7 +1684,7 @@ export function assertContainsActiveElement(element: HTMLElement | null) {
if (element === null) return expect(element).not.toBe(null)
expect(element.contains(document.activeElement)).toBe(true)
} catch (err) {
- Error.captureStackTrace(err, assertContainsActiveElement)
+ if (err instanceof Error) Error.captureStackTrace(err, assertContainsActiveElement)
throw err
}
}
@@ -1311,7 +1698,7 @@ export function assertHidden(element: HTMLElement | null) {
expect(element).toHaveAttribute('hidden')
expect(element).toHaveStyle({ display: 'none' })
} catch (err) {
- Error.captureStackTrace(err, assertHidden)
+ if (err instanceof Error) Error.captureStackTrace(err, assertHidden)
throw err
}
}
@@ -1323,7 +1710,7 @@ export function assertVisible(element: HTMLElement | null) {
expect(element).not.toHaveAttribute('hidden')
expect(element).not.toHaveStyle({ display: 'none' })
} catch (err) {
- Error.captureStackTrace(err, assertVisible)
+ if (err instanceof Error) Error.captureStackTrace(err, assertVisible)
throw err
}
}
@@ -1336,7 +1723,7 @@ export function assertFocusable(element: HTMLElement | null) {
expect(isFocusableElement(element, FocusableMode.Strict)).toBe(true)
} catch (err) {
- Error.captureStackTrace(err, assertFocusable)
+ if (err instanceof Error) Error.captureStackTrace(err, assertFocusable)
throw err
}
}
@@ -1347,7 +1734,7 @@ export function assertNotFocusable(element: HTMLElement | null) {
expect(isFocusableElement(element, FocusableMode.Strict)).toBe(false)
} catch (err) {
- Error.captureStackTrace(err, assertNotFocusable)
+ if (err instanceof Error) Error.captureStackTrace(err, assertNotFocusable)
throw err
}
}
diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts
index d78f9b6..b65ece9 100644
--- a/packages/@headlessui-react/src/test-utils/interactions.ts
+++ b/packages/@headlessui-react/src/test-utils/interactions.ts
@@ -1,4 +1,7 @@
import { fireEvent } from '@testing-library/react'
+import { disposables } from '../utils/disposables'
+
+let d = disposables()
function nextFrame(cb: Function): void {
setImmediate(() =>
@@ -33,7 +36,19 @@ export function shift(event: Partial) {
}
export function word(input: string): Partial[] {
- return input.split('').map(key => ({ key }))
+ let result = input.split('').map(key => ({ key }))
+
+ d.enqueue(() => {
+ let element = document.activeElement
+
+ if (element instanceof HTMLInputElement) {
+ fireEvent.change(element, {
+ target: Object.assign({}, element, { value: input }),
+ })
+ }
+ })
+
+ return result
}
let Default = Symbol()
@@ -76,6 +91,9 @@ let order: Record<
function keypress(element, event) {
return fireEvent.keyPress(element, event)
},
+ function input(element, event) {
+ return fireEvent.input(element, event)
+ },
function keyup(element, event) {
return fireEvent.keyUp(element, event)
},
@@ -159,9 +177,11 @@ export async function type(events: Partial[], element = document.
// We don't want to actually wait in our tests, so let's advance
jest.runAllTimers()
+ await d.workQueue()
+
await new Promise(nextFrame)
} catch (err) {
- Error.captureStackTrace(err, type)
+ if (err instanceof Error) Error.captureStackTrace(err, type)
throw err
} finally {
jest.useRealTimers()
@@ -224,7 +244,7 @@ export async function click(
await new Promise(nextFrame)
} catch (err) {
- Error.captureStackTrace(err, click)
+ if (err instanceof Error) Error.captureStackTrace(err, click)
throw err
}
}
@@ -237,7 +257,7 @@ export async function focus(element: Document | Element | Window | Node | null)
await new Promise(nextFrame)
} catch (err) {
- Error.captureStackTrace(err, focus)
+ if (err instanceof Error) Error.captureStackTrace(err, focus)
throw err
}
}
@@ -251,7 +271,7 @@ export async function mouseEnter(element: Document | Element | Window | null) {
await new Promise(nextFrame)
} catch (err) {
- Error.captureStackTrace(err, mouseEnter)
+ if (err instanceof Error) Error.captureStackTrace(err, mouseEnter)
throw err
}
}
@@ -265,7 +285,7 @@ export async function mouseMove(element: Document | Element | Window | null) {
await new Promise(nextFrame)
} catch (err) {
- Error.captureStackTrace(err, mouseMove)
+ if (err instanceof Error) Error.captureStackTrace(err, mouseMove)
throw err
}
}
@@ -281,7 +301,7 @@ export async function mouseLeave(element: Document | Element | Window | null) {
await new Promise(nextFrame)
} catch (err) {
- Error.captureStackTrace(err, mouseLeave)
+ if (err instanceof Error) Error.captureStackTrace(err, mouseLeave)
throw err
}
}
diff --git a/packages/@headlessui-react/src/utils/disposables.ts b/packages/@headlessui-react/src/utils/disposables.ts
index 7c9a388..4c0f89c 100644
--- a/packages/@headlessui-react/src/utils/disposables.ts
+++ b/packages/@headlessui-react/src/utils/disposables.ts
@@ -1,7 +1,12 @@
export function disposables() {
let disposables: Function[] = []
+ let queue: Function[] = []
let api = {
+ enqueue(fn: Function) {
+ queue.push(fn)
+ },
+
requestAnimationFrame(...args: Parameters) {
let raf = requestAnimationFrame(...args)
api.add(() => cancelAnimationFrame(raf))
@@ -27,6 +32,12 @@ export function disposables() {
dispose()
}
},
+
+ async workQueue() {
+ for (let handle of queue.splice(0)) {
+ await handle()
+ }
+ },
}
return api
diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.tsx b/packages/@headlessui-vue/src/components/combobox/combobox.test.tsx
new file mode 100644
index 0000000..fbe2047
--- /dev/null
+++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.tsx
@@ -0,0 +1,5334 @@
+import {
+ DefineComponent,
+ defineComponent,
+ nextTick,
+ ref,
+ watch,
+ h,
+ reactive,
+ computed,
+ PropType,
+} from 'vue'
+import { render } from '../../test-utils/vue-testing-library'
+import {
+ Combobox,
+ ComboboxInput,
+ ComboboxLabel,
+ ComboboxButton,
+ ComboboxOptions,
+ ComboboxOption,
+} from './combobox'
+import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
+import {
+ click,
+ focus,
+ mouseMove,
+ mouseLeave,
+ press,
+ shift,
+ type,
+ word,
+ Keys,
+ MouseButton,
+} from '../../test-utils/interactions'
+import {
+ assertActiveElement,
+ assertActiveComboboxOption,
+ assertComboboxList,
+ assertComboboxButton,
+ assertComboboxButtonLinkedWithCombobox,
+ assertComboboxButtonLinkedWithComboboxLabel,
+ assertComboboxOption,
+ assertComboboxLabel,
+ assertComboboxLabelLinkedWithCombobox,
+ assertNoActiveComboboxOption,
+ assertNoSelectedComboboxOption,
+ getComboboxInput,
+ getComboboxButton,
+ getComboboxButtons,
+ getComboboxInputs,
+ getComboboxOptions,
+ getComboboxLabel,
+ ComboboxState,
+ getByText,
+ getComboboxes,
+} from '../../test-utils/accessibility-assertions'
+import { html } from '../../test-utils/html'
+import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
+
+jest.mock('../../hooks/use-id')
+
+beforeAll(() => {
+ jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
+ jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
+})
+
+afterAll(() => jest.restoreAllMocks())
+
+function nextFrame() {
+ return new Promise(resolve => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ resolve()
+ })
+ })
+ })
+}
+
+function getDefaultComponents() {
+ return {
+ Combobox,
+ ComboboxInput,
+ ComboboxLabel,
+ ComboboxButton,
+ ComboboxOptions,
+ ComboboxOption,
+ }
+}
+
+function renderTemplate(input: string | Partial) {
+ let defaultComponents = getDefaultComponents()
+
+ if (typeof input === 'string') {
+ return render(defineComponent({ template: input, components: defaultComponents }))
+ }
+
+ return render(
+ defineComponent(
+ (Object.assign({}, input, {
+ components: { ...defaultComponents, ...input.components },
+ }) as unknown) as DefineComponent
+ )
+ )
+}
+
+describe('safeguards', () => {
+ it.each([
+ ['ComboboxButton', ComboboxButton],
+ ['ComboboxLabel', ComboboxLabel],
+ ['ComboboxOptions', ComboboxOptions],
+ ['ComboboxOption', ComboboxOption],
+ ])(
+ 'should error when we are using a <%s /> without a parent ',
+ suppressConsoleLogs((name, Component) => {
+ expect(() => render(Component)).toThrowError(
+ `<${name} /> is missing a parent component.`
+ )
+ })
+ )
+
+ it(
+ 'should be possible to render a Combobox without crashing',
+ 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 })
+ })
+ )
+})
+
+describe('Rendering', () => {
+ describe('Combobox', () => {
+ it(
+ 'should be possible to render a Combobox using a render prop',
+ 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 })
+
+ await click(getComboboxButton())
+
+ assertComboboxButton({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+
+ assertComboboxList({ state: ComboboxState.Visible })
+ })
+ )
+
+ it(
+ 'should be possible to disable a Combobox',
+ 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 })
+
+ await click(getComboboxButton())
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await press(Keys.Enter, getComboboxButton())
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+ })
+ )
+ })
+
+ describe('Combobox.Input', () => {
+ it(
+ 'selecting an option puts the value into Combobox.Input when displayValue is not provided',
+ suppressConsoleLogs(async () => {
+ let Example = defineComponent({
+ template: html`
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // TODO: Rendering Example directly reveals a vue bug — I think it's been fixed for a while but I can't find the commit
+ renderTemplate(Example)
+
+ await click(getComboboxButton())
+
+ assertComboboxList({ state: ComboboxState.Visible })
+
+ await click(getComboboxOptions()[1])
+
+ expect(getComboboxInput()).toHaveValue('b')
+ })
+ )
+
+ it(
+ 'selecting an option puts the display value into Combobox.Input when displayValue is provided',
+ suppressConsoleLogs(async () => {
+ let Example = defineComponent({
+ template: html`
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ renderTemplate(Example)
+
+ await click(getComboboxButton())
+
+ assertComboboxList({ state: ComboboxState.Visible })
+
+ await click(getComboboxOptions()[1])
+
+ expect(getComboboxInput()).toHaveValue('B')
+ })
+ )
+ })
+
+ describe('ComboboxLabel', () => {
+ it(
+ 'should be possible to render a ComboboxLabel using a render prop',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+ {{JSON.stringify(data)}}
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-3' },
+ })
+ assertComboboxLabel({
+ attributes: { id: 'headlessui-combobox-label-1' },
+ textContent: JSON.stringify({ open: false, disabled: false }),
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await click(getComboboxButton())
+
+ assertComboboxLabel({
+ attributes: { id: 'headlessui-combobox-label-1' },
+ textContent: JSON.stringify({ open: true, disabled: false }),
+ })
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertComboboxLabelLinkedWithCombobox()
+ assertComboboxButtonLinkedWithComboboxLabel()
+ })
+ )
+
+ it(
+ 'should be possible to render a ComboboxLabel using a render prop and an `as` prop',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+ {{JSON.stringify(data)}}
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ assertComboboxLabel({
+ attributes: { id: 'headlessui-combobox-label-1' },
+ textContent: JSON.stringify({ open: false, disabled: false }),
+ tag: 'p',
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await click(getComboboxButton())
+ assertComboboxLabel({
+ attributes: { id: 'headlessui-combobox-label-1' },
+ textContent: JSON.stringify({ open: true, disabled: false }),
+ tag: 'p',
+ })
+ assertComboboxList({ state: ComboboxState.Visible })
+ })
+ )
+ })
+
+ describe('ComboboxButton', () => {
+ it(
+ 'should be possible to render a ComboboxButton using a render prop',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ {{JSON.stringify(data)}}
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ textContent: JSON.stringify({ open: false, disabled: false }),
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await click(getComboboxButton())
+
+ assertComboboxButton({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ textContent: JSON.stringify({ open: true, disabled: false }),
+ })
+ assertComboboxList({ state: ComboboxState.Visible })
+ })
+ )
+
+ it(
+ 'should be possible to render a ComboboxButton using a render prop and an `as` prop',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ {{JSON.stringify(data)}}
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ textContent: JSON.stringify({ open: false, disabled: false }),
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await click(getComboboxButton())
+
+ assertComboboxButton({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ textContent: JSON.stringify({ open: true, disabled: false }),
+ })
+ assertComboboxList({ state: ComboboxState.Visible })
+ })
+ )
+
+ it(
+ 'should be possible to render a ComboboxButton and a ComboboxLabel and see them linked together',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+ Label
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ await new Promise(requestAnimationFrame)
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-3' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+ assertComboboxButtonLinkedWithComboboxLabel()
+ })
+ )
+
+ describe('`type` attribute', () => {
+ it('should set the `type` to "button" by default', async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ expect(getComboboxButton()).toHaveAttribute('type', 'button')
+ })
+
+ it('should not set the `type` to "button" if it already contains a `type`', async () => {
+ renderTemplate({
+ template: html`
+
+
+
+ Trigger
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ expect(getComboboxButton()).toHaveAttribute('type', 'submit')
+ })
+
+ it(
+ 'should set the `type` to "button" when using the `as` prop which resolves to a "button"',
+ suppressConsoleLogs(async () => {
+ let CustomButton = defineComponent({
+ setup: props => () => h('button', { ...props }),
+ })
+
+ renderTemplate({
+ template: html`
+
+
+
+ Trigger
+
+
+ `,
+ setup: () => ({
+ value: ref(null),
+ CustomButton,
+ }),
+ })
+
+ await new Promise(requestAnimationFrame)
+
+ expect(getComboboxButton()).toHaveAttribute('type', 'button')
+ })
+ )
+
+ it('should not set the type if the "as" prop is not a "button"', async () => {
+ renderTemplate({
+ template: html`
+
+
+
+ Trigger
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ expect(getComboboxButton()).not.toHaveAttribute('type')
+ })
+
+ it(
+ 'should not set the `type` to "button" when using the `as` prop which resolves to a "div"',
+ suppressConsoleLogs(async () => {
+ let CustomButton = defineComponent({
+ setup: props => () => h('div', props),
+ })
+
+ renderTemplate({
+ template: html`
+
+
+
+ Trigger
+
+
+ `,
+ setup: () => ({
+ value: ref(null),
+ CustomButton,
+ }),
+ })
+
+ await new Promise(requestAnimationFrame)
+
+ expect(getComboboxButton()).not.toHaveAttribute('type')
+ })
+ )
+ })
+ })
+
+ describe('ComboboxOptions', () => {
+ it(
+ 'should be possible to render ComboboxOptions using a render prop',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ {{JSON.stringify(data)}}
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await click(getComboboxButton())
+
+ assertComboboxButton({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ textContent: JSON.stringify({ open: true }),
+ })
+
+ assertActiveElement(getComboboxInput())
+ })
+ )
+
+ it('should be possible to always render the ComboboxOptions if we provide it a `static` prop', () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Let's verify that the combobox is already there
+ expect(getComboboxInput()).not.toBe(null)
+ })
+
+ it('should be possible to use a different render strategy for the ComboboxOptions', async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ await new Promise(nextTick)
+
+ assertComboboxList({ state: ComboboxState.InvisibleHidden })
+
+ // Let's open the combobox, to see if it is not hidden anymore
+ await click(getComboboxButton())
+
+ assertComboboxList({ state: ComboboxState.Visible })
+ })
+ })
+
+ describe('ComboboxOption', () => {
+ it(
+ 'should be possible to render a ComboboxOption using a render prop',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ {{JSON.stringify(data)}}
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ await click(getComboboxButton())
+
+ assertComboboxButton({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ textContent: JSON.stringify({ active: false, selected: false, disabled: false }),
+ })
+ })
+ )
+ })
+
+ it('should guarantee the order of DOM nodes 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()
+
+ // Focus the first item
+ await press(Keys.ArrowDown)
+
+ // 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('Rendering composition', () => {
+ it(
+ 'should be possible to swap the Combobox option with a button for example',
+ 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 options are buttons now
+ getComboboxOptions().forEach(option => assertComboboxOption(option, { tag: 'button' }))
+ })
+ )
+})
+
+describe('Composition', () => {
+ let OpenClosedWrite = defineComponent({
+ props: { open: { type: Boolean } },
+ setup(props, { slots }) {
+ useOpenClosedProvider(ref(props.open ? State.Open : State.Closed))
+ return () => slots.default?.()
+ },
+ })
+
+ let OpenClosedRead = defineComponent({
+ emits: ['read'],
+ setup(_, { slots, emit }) {
+ let state = useOpenClosed()
+ watch([state], ([value]) => emit('read', value))
+ return () => slots.default?.()
+ },
+ })
+
+ it(
+ 'should always open the ComboboxOptions because of a wrapping OpenClosed component',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ components: { OpenClosedWrite },
+ template: html`
+
+
+ Trigger
+
+
+ {{JSON.stringify(data)}}
+
+
+
+ `,
+ })
+
+ await new Promise(nextTick)
+
+ // Verify the combobox is visible
+ assertComboboxList({ state: ComboboxState.Visible })
+
+ // Let's try and open the combobox
+ await click(getComboboxButton())
+
+ // Verify the combobox is still visible
+ assertComboboxList({ state: ComboboxState.Visible })
+ })
+ )
+
+ it(
+ 'should always close the ComboboxOptions because of a wrapping OpenClosed component',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ components: { OpenClosedWrite },
+ template: html`
+
+
+ Trigger
+
+
+ {{JSON.stringify(data)}}
+
+
+
+ `,
+ })
+
+ await new Promise(nextTick)
+
+ // Verify the combobox is hidden
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ // Let's try and open the combobox
+ await click(getComboboxButton())
+
+ // Verify the combobox is still hidden
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to read the OpenClosed state',
+ suppressConsoleLogs(async () => {
+ let readFn = jest.fn()
+ renderTemplate({
+ components: { OpenClosedRead },
+ template: html`
+
+
+ Trigger
+
+
+ Option A
+
+
+
+ `,
+ setup() {
+ return { value: ref(null), readFn }
+ },
+ })
+
+ await new Promise(nextTick)
+
+ // Verify the combobox is hidden
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ // Let's toggle the combobox 3 times
+ await click(getComboboxButton())
+ await click(getComboboxButton())
+ await click(getComboboxButton())
+
+ // Verify the combobox is visible
+ assertComboboxList({ state: ComboboxState.Visible })
+
+ expect(readFn).toHaveBeenCalledTimes(3)
+ expect(readFn).toHaveBeenNthCalledWith(1, State.Open)
+ expect(readFn).toHaveBeenNthCalledWith(2, State.Closed)
+ expect(readFn).toHaveBeenNthCalledWith(3, State.Open)
+ })
+ )
+})
+
+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 }))
+
+ assertNoActiveComboboxOption()
+ 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))
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ 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())
+ })
+ )
+ })
+
+ 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
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ 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('`ArrowRight` key', () => {
+ it(
+ 'should be possible to open the combobox with ArrowRight',
+ 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, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowRight)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should not be possible to open the combobox with ArrowRight 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, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Try to open the combobox
+ await press(Keys.ArrowRight)
+
+ // Verify it is still closed
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+ })
+ )
+
+ it(
+ 'should be possible to open the combobox with ArrowRight, 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, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowRight)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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('test') }),
+ })
+
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowRight)
+ assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' })
+ 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('`ArrowLeft` key', () => {
+ it(
+ 'should be possible to open the combobox with ArrowLeft 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, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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 ArrowLeft 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, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Try to open the combobox
+ await press(Keys.ArrowLeft)
+
+ // Verify it is still closed
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+ })
+ )
+
+ it(
+ 'should be possible to open the combobox with ArrowLeft, 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, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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('test') }),
+ })
+
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+ assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' })
+ assertActiveElement(getComboboxInput())
+
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowLeft 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, orientation: 'horizontal' })
+
+ // Focus the button
+ getComboboxButton()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+
+ // 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])
+ })
+ )
+ })
+
+ 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) }),
+ })
+
+ 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)
+ 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 () => {
+ 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())
+
+ // Select the 2nd option
+ await press(Keys.ArrowDown)
+ 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 () => {
+ 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()
+
+ // 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())
+ })
+ )
+ })
+
+ 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
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ 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))
+ assertNoActiveComboboxOption()
+
+ // We should be able to go down once
+ await press(Keys.ArrowDown)
+ assertActiveComboboxOption(options[0])
+
+ // We should be able to go down again
+ 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))
+ assertNoActiveComboboxOption()
+
+ // We should be able to go down once
+ await press(Keys.ArrowDown)
+ assertActiveComboboxOption(options[1])
+ })
+ )
+
+ 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))
+ assertNoActiveComboboxOption()
+
+ // Open combobox
+ await press(Keys.ArrowDown)
+ assertActiveComboboxOption(options[2])
+ })
+ )
+ })
+
+ describe('`ArrowRight` key', () => {
+ it(
+ 'should be possible to open the combobox with ArrowRight',
+ 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, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowRight)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should not be possible to open the combobox with ArrowRight 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, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Try to open the combobox
+ await press(Keys.ArrowRight)
+
+ // Verify it is still closed
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+ })
+ )
+
+ it(
+ 'should be possible to open the combobox with ArrowRight, and focus the selected option',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref('b') }),
+ })
+
+ 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, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowRight)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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('test') }),
+ })
+
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowRight)
+ assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' })
+ assertActiveElement(getComboboxInput())
+
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowRight to navigate the combobox options',
+ 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, orientation: 'horizontal' })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Verify we have combobox options
+ let options = getComboboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertComboboxOption(option))
+ assertNoActiveComboboxOption()
+
+ // We should be able to go down once
+ await press(Keys.ArrowRight)
+ assertActiveComboboxOption(options[0])
+
+ // We should be able to go down again
+ await press(Keys.ArrowRight)
+ assertActiveComboboxOption(options[1])
+
+ // We should be able to go down again
+ await press(Keys.ArrowRight)
+ assertActiveComboboxOption(options[2])
+
+ // We should NOT be able to go down again (because last option). Current implementation won't go around.
+ await press(Keys.ArrowRight)
+ assertActiveComboboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowRight 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('test') }),
+ })
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Verify we have combobox options
+ let options = getComboboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertComboboxOption(option))
+ assertNoActiveComboboxOption()
+
+ // We should be able to go down once
+ await press(Keys.ArrowRight)
+ assertActiveComboboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowRight 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('test') }),
+ })
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ // Verify we have combobox options
+ let options = getComboboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertComboboxOption(option))
+ assertNoActiveComboboxOption()
+
+ // Open combobox
+ await press(Keys.ArrowRight)
+ assertActiveComboboxOption(options[2])
+ })
+ )
+ })
+
+ 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))
+ assertNoActiveComboboxOption()
+
+ // 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('`ArrowLeft` key', () => {
+ it(
+ 'should be possible to open the combobox with ArrowLeft 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, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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 ArrowLeft 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, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Try to open the combobox
+ await press(Keys.ArrowLeft)
+
+ // Verify it is still closed
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+ })
+ )
+
+ it(
+ 'should be possible to open the combobox with ArrowLeft, 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, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+
+ // Verify it is visible
+ assertComboboxButton({ state: ComboboxState.Visible })
+ assertComboboxList({
+ state: ComboboxState.Visible,
+ attributes: { id: 'headlessui-combobox-options-3' },
+ orientation: 'horizontal',
+ })
+ 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('test') }),
+ })
+
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+ assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' })
+ assertActiveElement(getComboboxInput())
+
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowLeft 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, orientation: 'horizontal' })
+
+ // Focus the input
+ getComboboxInput()?.focus()
+
+ // Open combobox
+ await press(Keys.ArrowLeft)
+
+ // Verify we have combobox options
+ let options = getComboboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertComboboxOption(option))
+ 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 have no option selected
+ assertNoActiveComboboxOption()
+
+ // 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 have no option selected
+ assertNoActiveComboboxOption()
+
+ // 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())
+
+ // 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)
+
+ let options = getComboboxOptions()
+ 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
+ assertNoActiveComboboxOption()
+
+ // 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 have nothing active
+ assertNoActiveComboboxOption()
+
+ // 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())
+
+ // 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)
+
+ let options = getComboboxOptions()
+ 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())
+
+ // 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)
+
+ let options = getComboboxOptions()
+
+ // 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())
+
+ // 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)
+
+ let options = getComboboxOptions()
+ 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())
+
+ // 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)
+
+ let options = getComboboxOptions()
+
+ // We should be on the first non-disabled option
+ 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())
+
+ // 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)
+
+ let options = getComboboxOptions()
+ 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('`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())
+ )
+ })
+
+ return {
+ value,
+ query,
+ filteredPeople,
+ setQuery: (event: Event & { target: HTMLInputElement }) => {
+ query.value = event.target.value
+ },
+ }
+ },
+ })
+
+ 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)
+ 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('Mouse interactions', () => {
+ it(
+ 'should focus the ComboboxButton when we click the ComboboxLabel',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+ Label
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Ensure the button is not focused yet
+ assertActiveElement(document.body)
+
+ // Focus the label
+ await click(getComboboxLabel())
+
+ // Ensure that the actual button is focused instead
+ assertActiveElement(getComboboxInput())
+ })
+ )
+
+ it(
+ 'should not focus the ComboboxInput when we right click the ComboboxLabel',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+ Label
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Ensure the button is not focused yet
+ assertActiveElement(document.body)
+
+ // Focus the label
+ await click(getComboboxLabel(), MouseButton.Right)
+
+ // Ensure that the body is still active
+ assertActiveElement(document.body)
+ })
+ )
+
+ it(
+ 'should be possible to open the combobox on click',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ 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 })
+ 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))
+ })
+ )
+
+ it(
+ 'should not be possible to open the combobox on right click',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ Option A
+ Option B
+ Option C
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ assertComboboxButton({
+ state: ComboboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-combobox-button-2' },
+ })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ // Try to open the combobox
+ await click(getComboboxButton(), MouseButton.Right)
+
+ // Verify it is still closed
+ assertComboboxButton({ state: ComboboxState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should not be possible to open the combobox on click when the button is disabled',
+ suppressConsoleLogs(async () => {
+ 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 open the combobox
+ await click(getComboboxButton())
+
+ // 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 on click, 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 })
+
+ // 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()
+
+ // 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 close a combobox on click',
+ 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 })
+
+ // Click to close
+ await click(getComboboxButton())
+
+ // Verify it is closed
+ assertComboboxButton({ state: ComboboxState.InvisibleUnmounted })
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be a no-op when we click outside of a closed combobox',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Verify that the window is closed
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ // Click something that is not related to the combobox
+ await click(document.body)
+
+ // Should still be closed
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to click outside of the combobox which should close the combobox',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertActiveElement(getComboboxInput())
+
+ // Click something that is not related to the combobox
+ await click(document.body)
+
+ // Should be closed now
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ // Verify the input is focused again
+ assertActiveElement(getComboboxInput())
+ })
+ )
+
+ it(
+ 'should be possible to click outside of the combobox on another combobox button which should close the current combobox and open the new combobox',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ let [button1, button2] = getComboboxButtons()
+
+ // Click the first combobox button
+ await click(button1)
+ expect(getComboboxes()).toHaveLength(1) // Only 1 combobox should be visible
+
+ // Verify that the first input is focused
+ assertActiveElement(getComboboxInputs()[0])
+
+ // Click the second combobox button
+ await click(button2)
+
+ expect(getComboboxes()).toHaveLength(1) // Only 1 combobox should be visible
+
+ // Verify that the first input is focused
+ assertActiveElement(getComboboxInputs()[1])
+ })
+ )
+
+ it(
+ 'should be possible to click outside of the combobox which should close the combobox (even if we press the combobox button)',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertActiveElement(getComboboxInput())
+
+ // Click the combobox button again
+ await click(getComboboxButton())
+
+ // Should be closed now
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ // Verify the input is focused again
+ assertActiveElement(getComboboxInput())
+ })
+ )
+
+ it(
+ 'should be possible to click outside of the combobox, on an element which is within a focusable element, which closes the combobox',
+ suppressConsoleLogs(async () => {
+ let focusFn = jest.fn()
+ renderTemplate({
+ template: html`
+
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+
+
+
+ `,
+ setup: () => ({ value: ref('test'), focusFn }),
+ })
+
+ // Click the combobox button
+ await click(getComboboxButton())
+
+ // Ensure the combobox is open
+ assertComboboxList({ state: ComboboxState.Visible })
+
+ // Click the span inside the button
+ await click(getByText('Next'))
+
+ // Ensure the combobox is closed
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+
+ // Ensure the outside button is focused
+ assertActiveElement(document.getElementById('btn'))
+
+ // Ensure that the focus button only got focus once (first click)
+ expect(focusFn).toHaveBeenCalledTimes(1)
+ })
+ )
+
+ it(
+ 'should be possible to hover an option and make it active',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+ // We should be able to go to the second option
+ await mouseMove(options[1])
+ assertActiveComboboxOption(options[1])
+
+ // We should be able to go to the first option
+ await mouseMove(options[0])
+ assertActiveComboboxOption(options[0])
+
+ // We should be able to go to the last option
+ await mouseMove(options[2])
+ assertActiveComboboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should make a combobox option active when you move the mouse over it',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+ // We should be able to go to the second option
+ await mouseMove(options[1])
+ assertActiveComboboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should be a no-op when we move the mouse and the combobox option is already active',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+
+ // We should be able to go to the second option
+ await mouseMove(options[1])
+ assertActiveComboboxOption(options[1])
+
+ await mouseMove(options[1])
+
+ // Nothing should be changed
+ assertActiveComboboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should be a no-op when we move the mouse and the combobox option is disabled',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ alice
+
+ bob
+
+ charlie
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+
+ await mouseMove(options[1])
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should not be possible to hover an option that is disabled',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ alice
+
+ bob
+
+ charlie
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+
+ // Try to hover over option 1, which is disabled
+ await mouseMove(options[1])
+
+ // We should not have an active option now
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to mouse leave an option and make it inactive',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+
+ // We should be able to go to the second option
+ await mouseMove(options[1])
+ assertActiveComboboxOption(options[1])
+
+ await mouseLeave(options[1])
+ assertNoActiveComboboxOption()
+
+ // We should be able to go to the first option
+ await mouseMove(options[0])
+ assertActiveComboboxOption(options[0])
+
+ await mouseLeave(options[0])
+ assertNoActiveComboboxOption()
+
+ // We should be able to go to the last option
+ await mouseMove(options[2])
+ assertActiveComboboxOption(options[2])
+
+ await mouseLeave(options[2])
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to mouse leave a disabled option and be a no-op',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ alice
+
+ bob
+
+ charlie
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+
+ let options = getComboboxOptions()
+
+ // Try to hover over option 1, which is disabled
+ await mouseMove(options[1])
+ assertNoActiveComboboxOption()
+
+ await mouseLeave(options[1])
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to click a combobox option, which closes the combobox',
+ suppressConsoleLogs(async () => {
+ let handleChange = jest.fn()
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ `,
+ setup() {
+ let value = ref(null)
+ watch([value], () => handleChange(value.value))
+ return { value }
+ },
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertActiveElement(getComboboxInput())
+
+ let options = getComboboxOptions()
+
+ // We should be able to click the first option
+ await click(options[1])
+ assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
+ expect(handleChange).toHaveBeenCalledTimes(1)
+ expect(handleChange).toHaveBeenCalledWith('bob')
+
+ // Verify the input is focused again
+ assertActiveElement(getComboboxInput())
+
+ // Open combobox again
+ await click(getComboboxButton())
+
+ // Verify the active option is the previously selected one
+ assertActiveComboboxOption(getComboboxOptions()[1])
+ })
+ )
+
+ it(
+ 'should be possible to click a disabled combobox option, which is a no-op',
+ suppressConsoleLogs(async () => {
+ let handleChange = jest.fn()
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ alice
+
+ bob
+
+ charlie
+
+
+ `,
+ setup() {
+ let value = ref(null)
+ watch([value], () => handleChange(value.value))
+ return { value }
+ },
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertActiveElement(getComboboxInput())
+
+ let options = getComboboxOptions()
+
+ // We should be able to click the first option
+ await click(options[1])
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertActiveElement(getComboboxInput())
+ expect(handleChange).toHaveBeenCalledTimes(0)
+
+ // Close the combobox
+ await click(getComboboxButton())
+
+ // Open combobox again
+ await click(getComboboxButton())
+
+ // Verify the active option is non existing
+ assertNoActiveComboboxOption()
+ })
+ )
+
+ it(
+ 'should be possible focus a combobox option, so that it becomes active',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ alice
+ bob
+ charlie
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertActiveElement(getComboboxInput())
+
+ let options = getComboboxOptions()
+
+ // Verify that nothing is active yet
+ assertNoActiveComboboxOption()
+
+ // We should be able to focus the first option
+ await focus(options[1])
+ assertActiveComboboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should not be possible to focus a combobox option which is disabled',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+
+ Trigger
+
+ alice
+
+ bob
+
+ charlie
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Open combobox
+ await click(getComboboxButton())
+ assertComboboxList({ state: ComboboxState.Visible })
+ assertActiveElement(getComboboxInput())
+
+ let options = getComboboxOptions()
+
+ // We should not be able to focus the first option
+ await focus(options[1])
+ assertNoActiveComboboxOption()
+ })
+ )
+})
diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts
new file mode 100644
index 0000000..784ee1c
--- /dev/null
+++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts
@@ -0,0 +1,686 @@
+import {
+ defineComponent,
+ ref,
+ provide,
+ inject,
+ onMounted,
+ onUnmounted,
+ computed,
+ nextTick,
+ InjectionKey,
+ Ref,
+ ComputedRef,
+ watchEffect,
+ toRaw,
+ watch,
+ PropType,
+} from 'vue'
+
+import { Features, render, omit } from '../../utils/render'
+import { useId } from '../../hooks/use-id'
+import { Keys } from '../../keyboard'
+import { calculateActiveIndex, Focus } from '../../utils/calculate-active-index'
+import { dom } from '../../utils/dom'
+import { useWindowEvent } from '../../hooks/use-window-event'
+import { useOpenClosed, State, useOpenClosedProvider } from '../../internal/open-closed'
+import { match } from '../../utils/match'
+import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
+
+enum ComboboxStates {
+ Open,
+ Closed,
+}
+
+type ComboboxOptionDataRef = Ref<{ disabled: boolean; value: unknown }>
+type StateDefinition = {
+ // State
+ ComboboxState: Ref
+ value: ComputedRef
+ orientation: Ref<'vertical' | 'horizontal'>
+
+ labelRef: Ref
+ inputRef: Ref
+ buttonRef: Ref
+ optionsRef: Ref
+ inputPropsRef: Ref<{ displayValue?: (item: unknown) => string }>
+
+ disabled: Ref
+ options: Ref<{ id: string; dataRef: ComboboxOptionDataRef }[]>
+ activeOptionIndex: Ref
+
+ // State mutators
+ closeCombobox(): void
+ openCombobox(): void
+ goToOption(focus: Focus, id?: string): void
+ selectOption(id: string): void
+ selectActiveOption(): void
+ registerOption(id: string, dataRef: ComboboxOptionDataRef): void
+ unregisterOption(id: string): void
+ select(value: unknown): void
+}
+
+let ComboboxContext = Symbol('ComboboxContext') as InjectionKey
+
+function useComboboxContext(component: string) {
+ let context = inject(ComboboxContext, null)
+
+ if (context === null) {
+ let err = new Error(`<${component} /> is missing a parent component.`)
+ if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxContext)
+ throw err
+ }
+
+ return context
+}
+
+// ---
+
+export let Combobox = defineComponent({
+ name: 'Combobox',
+ emits: { 'update:modelValue': (_value: any) => true },
+ props: {
+ as: { type: [Object, String], default: 'template' },
+ disabled: { type: [Boolean], default: false },
+ horizontal: { type: [Boolean], default: false },
+ modelValue: { type: [Object, String, Number, Boolean] },
+ },
+ setup(props, { slots, attrs, emit }) {
+ let ComboboxState = ref(ComboboxStates.Closed)
+ let labelRef = ref(null)
+ let inputRef = ref(null) as StateDefinition['inputRef']
+ let buttonRef = ref(null) as StateDefinition['buttonRef']
+ let optionsRef = ref(
+ null
+ ) as StateDefinition['optionsRef']
+ let options = ref([])
+ let activeOptionIndex = ref(null)
+
+ let value = computed(() => props.modelValue)
+
+ let api = {
+ ComboboxState,
+ value,
+ orientation: computed(() => (props.horizontal ? 'horizontal' : 'vertical')),
+ inputRef,
+ labelRef,
+ buttonRef,
+ optionsRef,
+ disabled: computed(() => props.disabled),
+ options,
+ activeOptionIndex,
+ inputPropsRef: ref<{ displayValue?: (item: unknown) => string }>({ displayValue: undefined }),
+ closeCombobox() {
+ if (props.disabled) return
+ if (ComboboxState.value === ComboboxStates.Closed) return
+ ComboboxState.value = ComboboxStates.Closed
+ activeOptionIndex.value = null
+ },
+ openCombobox() {
+ if (props.disabled) return
+ if (ComboboxState.value === ComboboxStates.Open) return
+ ComboboxState.value = ComboboxStates.Open
+ },
+ goToOption(focus: Focus, id?: string) {
+ if (props.disabled) return
+ if (ComboboxState.value === ComboboxStates.Closed) return
+
+ let nextActiveOptionIndex = calculateActiveIndex(
+ focus === Focus.Specific
+ ? { focus: Focus.Specific, id: id! }
+ : { focus: focus as Exclude },
+ {
+ resolveItems: () => options.value,
+ resolveActiveIndex: () => activeOptionIndex.value,
+ resolveId: option => option.id,
+ resolveDisabled: option => option.dataRef.disabled,
+ }
+ )
+
+ if (activeOptionIndex.value === nextActiveOptionIndex) return
+ activeOptionIndex.value = nextActiveOptionIndex
+ },
+ syncInputValue() {
+ let value = api.value.value
+ if (!dom(api.inputRef)) return
+ if (value === undefined) return
+ let displayValue = api.inputPropsRef.value.displayValue
+
+ if (typeof displayValue === 'function') {
+ api.inputRef!.value!.value = displayValue(value)
+ } else if (typeof value === 'string') {
+ api.inputRef!.value!.value = value
+ }
+ },
+ selectOption(id: string) {
+ let option = options.value.find(item => item.id === id)
+ if (!option) return
+
+ let { dataRef } = option
+ emit('update:modelValue', dataRef.value)
+ api.syncInputValue()
+ },
+ selectActiveOption() {
+ if (activeOptionIndex.value === null) return
+
+ let { dataRef } = options.value[activeOptionIndex.value]
+ emit('update:modelValue', dataRef.value)
+ api.syncInputValue()
+ },
+ registerOption(id: string, dataRef: ComboboxOptionDataRef) {
+ let currentActiveOption =
+ activeOptionIndex.value !== null ? options.value[activeOptionIndex.value] : null
+ let orderMap = Array.from(
+ optionsRef.value?.querySelectorAll('[id^="headlessui-combobox-option-"]') ?? []
+ ).reduce(
+ (lookup, element, index) => Object.assign(lookup, { [element.id]: index }),
+ {}
+ ) as Record
+
+ // @ts-expect-error The expected type comes from property 'dataRef' which is declared here on type '{ id: string; dataRef: { textValue: string; disabled: boolean; }; }'
+ options.value = [...options.value, { id, dataRef }].sort(
+ (a, z) => orderMap[a.id] - orderMap[z.id]
+ )
+
+ // 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.
+ activeOptionIndex.value = (() => {
+ if (currentActiveOption === null) return null
+ return options.value.indexOf(currentActiveOption)
+ })()
+ },
+ unregisterOption(id: string) {
+ let nextOptions = options.value.slice()
+ let currentActiveOption =
+ activeOptionIndex.value !== null ? nextOptions[activeOptionIndex.value] : null
+ let idx = nextOptions.findIndex(a => a.id === id)
+ if (idx !== -1) nextOptions.splice(idx, 1)
+ options.value = nextOptions
+ activeOptionIndex.value = (() => {
+ if (idx === activeOptionIndex.value) return null
+ if (currentActiveOption === null) return null
+
+ // If we removed the option before the actual active index, then it would be out of sync. To
+ // fix this, we will find the correct (new) index position.
+ return nextOptions.indexOf(currentActiveOption)
+ })()
+ },
+ }
+
+ useWindowEvent('mousedown', event => {
+ let target = event.target as HTMLElement
+ let active = document.activeElement
+
+ if (ComboboxState.value !== ComboboxStates.Open) return
+
+ if (dom(inputRef)?.contains(target)) return
+ if (dom(buttonRef)?.contains(target)) return
+ if (dom(optionsRef)?.contains(target)) return
+
+ api.closeCombobox()
+
+ if (active !== document.body && active?.contains(target)) return // Keep focus on newly clicked/focused element
+ if (!event.defaultPrevented) dom(inputRef)?.focus({ preventScroll: true })
+ })
+
+ watchEffect(() => {
+ api.syncInputValue()
+ })
+
+ // @ts-expect-error Types of property 'dataRef' are incompatible.
+ provide(ComboboxContext, api)
+ useOpenClosedProvider(
+ computed(() =>
+ match(ComboboxState.value, {
+ [ComboboxStates.Open]: State.Open,
+ [ComboboxStates.Closed]: State.Closed,
+ })
+ )
+ )
+
+ return () => {
+ let slot = { open: ComboboxState.value === ComboboxStates.Open, disabled: props.disabled }
+ return render({
+ props: omit(props, ['modelValue', 'onUpdate:modelValue', 'disabled', 'horizontal']),
+ slot,
+ slots,
+ attrs,
+ name: 'Combobox',
+ })
+ }
+ },
+})
+
+// ---
+
+export let ComboboxLabel = defineComponent({
+ name: 'ComboboxLabel',
+ props: { as: { type: [Object, String], default: 'label' } },
+ render() {
+ let api = useComboboxContext('ComboboxLabel')
+
+ let slot = {
+ open: api.ComboboxState.value === ComboboxStates.Open,
+ disabled: api.disabled.value,
+ }
+ let propsWeControl = { id: this.id, ref: 'el', onClick: this.handleClick }
+
+ return render({
+ props: { ...this.$props, ...propsWeControl },
+ slot,
+ attrs: this.$attrs,
+ slots: this.$slots,
+ name: 'ComboboxLabel',
+ })
+ },
+ setup() {
+ let api = useComboboxContext('ComboboxLabel')
+ let id = `headlessui-combobox-label-${useId()}`
+
+ return {
+ id,
+ el: api.labelRef,
+ handleClick() {
+ dom(api.inputRef)?.focus({ preventScroll: true })
+ },
+ }
+ },
+})
+
+// ---
+
+export let ComboboxButton = defineComponent({
+ name: 'ComboboxButton',
+ props: {
+ as: { type: [Object, String], default: 'button' },
+ },
+ render() {
+ let api = useComboboxContext('ComboboxButton')
+
+ let slot = {
+ open: api.ComboboxState.value === ComboboxStates.Open,
+ disabled: api.disabled.value,
+ }
+ let propsWeControl = {
+ ref: 'el',
+ id: this.id,
+ type: this.type,
+ tabindex: '-1',
+ 'aria-haspopup': true,
+ 'aria-controls': dom(api.optionsRef)?.id,
+ 'aria-expanded': api.disabled.value
+ ? undefined
+ : api.ComboboxState.value === ComboboxStates.Open,
+ 'aria-labelledby': api.labelRef.value
+ ? [dom(api.labelRef)?.id, this.id].join(' ')
+ : undefined,
+ disabled: api.disabled.value === true ? true : undefined,
+ onKeydown: this.handleKeydown,
+ onClick: this.handleClick,
+ }
+
+ return render({
+ props: { ...this.$props, ...propsWeControl },
+ slot,
+ attrs: this.$attrs,
+ slots: this.$slots,
+ name: 'ComboboxButton',
+ })
+ },
+ setup(props, { attrs }) {
+ let api = useComboboxContext('ComboboxButton')
+ let id = `headlessui-combobox-button-${useId()}`
+
+ function handleClick(event: MouseEvent) {
+ if (api.disabled.value) return
+ if (api.ComboboxState.value === ComboboxStates.Open) {
+ api.closeCombobox()
+ } else {
+ event.preventDefault()
+ api.openCombobox()
+ }
+
+ nextTick(() => dom(api.inputRef)?.focus({ preventScroll: true }))
+ }
+
+ function handleKeydown(event: KeyboardEvent) {
+ switch (event.key) {
+ // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
+
+ case match(api.orientation.value, {
+ vertical: Keys.ArrowDown,
+ horizontal: Keys.ArrowRight,
+ }):
+ event.preventDefault()
+ event.stopPropagation()
+ if (api.ComboboxState.value === ComboboxStates.Closed) {
+ api.openCombobox()
+ // TODO: We can't do this outside next frame because the options aren't rendered yet
+ // But doing this in next frame results in a flicker because the dom mutations are async here
+ // Basically:
+ // Sync -> no option list yet
+ // Next frame -> option list already rendered with selection -> dispatch -> next frame -> now we have the focus on the right element
+
+ // TODO: The spec here is underspecified. There's mention of skipping to the next item when autocomplete has suggested something but nothing regarding a non-autocomplete selection/value
+ nextTick(() => {
+ if (!api.value.value) {
+ api.goToOption(Focus.First)
+ }
+ })
+ }
+ nextTick(() => api.inputRef.value?.focus({ preventScroll: true }))
+ return
+
+ case match(api.orientation.value, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
+ event.preventDefault()
+ event.stopPropagation()
+ if (api.ComboboxState.value === ComboboxStates.Closed) {
+ api.openCombobox()
+ nextTick(() => {
+ if (!api.value.value) {
+ api.goToOption(Focus.Last)
+ }
+ })
+ }
+ nextTick(() => api.inputRef.value?.focus({ preventScroll: true }))
+ return
+
+ case Keys.Escape:
+ event.preventDefault()
+ event.stopPropagation()
+ api.closeCombobox()
+ nextTick(() => api.inputRef.value?.focus({ preventScroll: true }))
+ return
+ }
+ }
+
+ return {
+ api,
+ id,
+ el: api.buttonRef,
+ type: useResolveButtonType(
+ computed(() => ({ as: props.as, type: attrs.type })),
+ api.buttonRef
+ ),
+ handleClick,
+ handleKeydown,
+ }
+ },
+})
+
+// ---
+
+export let ComboboxInput = defineComponent({
+ name: 'ComboboxInput',
+ props: {
+ as: { type: [Object, String], default: 'input' },
+ static: { type: Boolean, default: false },
+ unmount: { type: Boolean, default: true },
+ displayValue: { type: Function as PropType<(item: unknown) => string> },
+ },
+ emits: {
+ change: (_value: Event & { target: HTMLInputElement }) => true,
+ },
+ render() {
+ let api = useComboboxContext('ComboboxInput')
+
+ let slot = { open: api.ComboboxState.value === ComboboxStates.Open }
+ let propsWeControl = {
+ 'aria-activedescendant':
+ api.activeOptionIndex.value === null
+ ? undefined
+ : api.options.value[api.activeOptionIndex.value]?.id,
+ 'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id,
+ 'aria-orientation': api.orientation.value,
+ id: this.id,
+ onKeydown: this.handleKeyDown,
+ onChange: this.handleChange,
+ role: 'combobox',
+ tabIndex: 0,
+ ref: 'el',
+ }
+ let passThroughProps = this.$props
+
+ return render({
+ props: { ...passThroughProps, ...propsWeControl },
+ slot,
+ attrs: this.$attrs,
+ slots: this.$slots,
+ features: Features.RenderStrategy | Features.Static,
+ name: 'ComboboxInput',
+ })
+ },
+ setup(props, { emit }) {
+ let api = useComboboxContext('ComboboxInput')
+ let id = `headlessui-combobox-input-${useId()}`
+ api.inputPropsRef = computed(() => props)
+
+ function handleKeyDown(event: KeyboardEvent) {
+ switch (event.key) {
+ // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
+
+ case Keys.Enter:
+ event.preventDefault()
+ event.stopPropagation()
+
+ api.selectActiveOption()
+ api.closeCombobox()
+ break
+
+ case match(api.orientation.value, {
+ vertical: Keys.ArrowDown,
+ horizontal: Keys.ArrowRight,
+ }):
+ event.preventDefault()
+ event.stopPropagation()
+ return match(api.ComboboxState.value, {
+ [ComboboxStates.Open]: () => api.goToOption(Focus.Next),
+ [ComboboxStates.Closed]: () => {
+ api.openCombobox()
+ nextTick(() => {
+ if (!api.value.value) {
+ api.goToOption(Focus.First)
+ }
+ })
+ },
+ })
+
+ case match(api.orientation.value, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
+ event.preventDefault()
+ event.stopPropagation()
+ return match(api.ComboboxState.value, {
+ [ComboboxStates.Open]: () => api.goToOption(Focus.Previous),
+ [ComboboxStates.Closed]: () => {
+ api.openCombobox()
+ nextTick(() => {
+ if (!api.value.value) {
+ api.goToOption(Focus.Last)
+ }
+ })
+ },
+ })
+
+ case Keys.Home:
+ case Keys.PageUp:
+ event.preventDefault()
+ event.stopPropagation()
+ return api.goToOption(Focus.First)
+
+ case Keys.End:
+ case Keys.PageDown:
+ event.preventDefault()
+ event.stopPropagation()
+ return api.goToOption(Focus.Last)
+
+ case Keys.Escape:
+ event.preventDefault()
+ event.stopPropagation()
+ api.closeCombobox()
+ break
+
+ case Keys.Tab:
+ api.selectActiveOption()
+ api.closeCombobox()
+ break
+ }
+ }
+
+ function handleChange(event: Event & { target: HTMLInputElement }) {
+ api.openCombobox()
+ emit('change', event)
+ }
+
+ return { id, el: api.inputRef, handleKeyDown, handleChange }
+ },
+})
+
+// ---
+
+export let ComboboxOptions = defineComponent({
+ name: 'ComboboxOptions',
+ props: {
+ as: { type: [Object, String], default: 'ul' },
+ static: { type: Boolean, default: false },
+ unmount: { type: Boolean, default: true },
+ },
+ render() {
+ let api = useComboboxContext('ComboboxOptions')
+
+ let slot = { open: api.ComboboxState.value === ComboboxStates.Open }
+ let propsWeControl = {
+ 'aria-activedescendant':
+ api.activeOptionIndex.value === null
+ ? undefined
+ : api.options.value[api.activeOptionIndex.value]?.id,
+ 'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id,
+ 'aria-orientation': api.orientation.value,
+ id: this.id,
+ ref: 'el',
+ role: 'listbox',
+ }
+ let passThroughProps = this.$props
+
+ return render({
+ props: { ...passThroughProps, ...propsWeControl },
+ slot,
+ attrs: this.$attrs,
+ slots: this.$slots,
+ features: Features.RenderStrategy | Features.Static,
+ visible: this.visible,
+ name: 'ComboboxOptions',
+ })
+ },
+ setup() {
+ let api = useComboboxContext('ComboboxOptions')
+ let id = `headlessui-combobox-options-${useId()}`
+
+ let usesOpenClosedState = useOpenClosed()
+ let visible = computed(() => {
+ if (usesOpenClosedState !== null) {
+ return usesOpenClosedState.value === State.Open
+ }
+
+ return api.ComboboxState.value === ComboboxStates.Open
+ })
+
+ return { id, el: api.optionsRef, visible }
+ },
+})
+
+export let ComboboxOption = defineComponent({
+ name: 'ComboboxOption',
+ props: {
+ as: { type: [Object, String], default: 'li' },
+ value: { type: [Object, String, Number, Boolean] },
+ disabled: { type: Boolean, default: false },
+ },
+ setup(props, { slots, attrs }) {
+ let api = useComboboxContext('ComboboxOption')
+ let id = `headlessui-combobox-option-${useId()}`
+
+ let active = computed(() => {
+ return api.activeOptionIndex.value !== null
+ ? api.options.value[api.activeOptionIndex.value].id === id
+ : false
+ })
+
+ let selected = computed(() => toRaw(api.value.value) === toRaw(props.value))
+
+ let dataRef = computed(() => ({
+ disabled: props.disabled,
+ value: props.value,
+ }))
+
+ onMounted(() => api.registerOption(id, dataRef))
+ onUnmounted(() => api.unregisterOption(id))
+
+ onMounted(() => {
+ watch(
+ [api.ComboboxState, selected],
+ () => {
+ if (api.ComboboxState.value !== ComboboxStates.Open) return
+ if (!selected.value) return
+ api.goToOption(Focus.Specific, id)
+ },
+ { immediate: true }
+ )
+ })
+
+ watchEffect(() => {
+ if (api.ComboboxState.value !== ComboboxStates.Open) return
+ if (!active.value) return
+ nextTick(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }))
+ })
+
+ function handleClick(event: MouseEvent) {
+ if (props.disabled) return event.preventDefault()
+ api.selectOption(id)
+ api.closeCombobox()
+ nextTick(() => dom(api.inputRef)?.focus({ preventScroll: true }))
+ }
+
+ function handleFocus() {
+ if (props.disabled) return api.goToOption(Focus.Nothing)
+ api.goToOption(Focus.Specific, id)
+ }
+
+ function handleMove() {
+ if (props.disabled) return
+ if (active.value) return
+ api.goToOption(Focus.Specific, id)
+ }
+
+ function handleLeave() {
+ if (props.disabled) return
+ if (!active.value) return
+ api.goToOption(Focus.Nothing)
+ }
+
+ return () => {
+ let { disabled } = props
+ let slot = { active: active.value, selected: selected.value, disabled }
+ let propsWeControl = {
+ id,
+ role: 'option',
+ tabIndex: disabled === true ? undefined : -1,
+ 'aria-disabled': disabled === true ? true : undefined,
+ 'aria-selected': selected.value === true ? selected.value : undefined,
+ disabled: undefined, // Never forward the `disabled` prop
+ onClick: handleClick,
+ onFocus: handleFocus,
+ onPointermove: handleMove,
+ onMousemove: handleMove,
+ onPointerleave: handleLeave,
+ onMouseleave: handleLeave,
+ }
+
+ return render({
+ props: { ...props, ...propsWeControl },
+ slot,
+ attrs,
+ slots,
+ name: 'ComboboxOption',
+ })
+ }
+ },
+})
diff --git a/packages/@headlessui-vue/src/index.test.ts b/packages/@headlessui-vue/src/index.test.ts
index 97ddbee..d8d8fac 100644
--- a/packages/@headlessui-vue/src/index.test.ts
+++ b/packages/@headlessui-vue/src/index.test.ts
@@ -6,6 +6,14 @@ import * as HeadlessUI from './index'
*/
it('should expose the correct components', () => {
expect(Object.keys(HeadlessUI)).toEqual([
+ // Combobox
+ 'Combobox',
+ 'ComboboxLabel',
+ 'ComboboxButton',
+ 'ComboboxInput',
+ 'ComboboxOptions',
+ 'ComboboxOption',
+
// Dialog
'Dialog',
'DialogOverlay',
diff --git a/packages/@headlessui-vue/src/index.ts b/packages/@headlessui-vue/src/index.ts
index 2ef29db..2ba6c32 100644
--- a/packages/@headlessui-vue/src/index.ts
+++ b/packages/@headlessui-vue/src/index.ts
@@ -1,3 +1,4 @@
+export * from './components/combobox/combobox'
export * from './components/dialog/dialog'
export * from './components/disclosure/disclosure'
export * from './components/focus-trap/focus-trap'
diff --git a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts
index 1fb40a1..f478642 100644
--- a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts
+++ b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts
@@ -91,7 +91,7 @@ export function assertMenuButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
- Error.captureStackTrace(err, assertMenuButton)
+ if (err instanceof Error) Error.captureStackTrace(err, assertMenuButton)
throw err
}
}
@@ -105,7 +105,7 @@ export function assertMenuButtonLinkedWithMenu(button = getMenuButton(), menu =
expect(button).toHaveAttribute('aria-controls', menu.getAttribute('id'))
expect(menu).toHaveAttribute('aria-labelledby', button.getAttribute('id'))
} catch (err) {
- Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu)
+ if (err instanceof Error) Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu)
throw err
}
}
@@ -118,7 +118,7 @@ export function assertMenuLinkedWithMenuItem(item: HTMLElement | null, menu = ge
// Ensure link between menu & menu item is correct
expect(menu).toHaveAttribute('aria-activedescendant', item.getAttribute('id'))
} catch (err) {
- Error.captureStackTrace(err, assertMenuLinkedWithMenuItem)
+ if (err instanceof Error) Error.captureStackTrace(err, assertMenuLinkedWithMenuItem)
throw err
}
}
@@ -130,7 +130,7 @@ export function assertNoActiveMenuItem(menu = getMenu()) {
// Ensure we don't have an active menu
expect(menu).not.toHaveAttribute('aria-activedescendant')
} catch (err) {
- Error.captureStackTrace(err, assertNoActiveMenuItem)
+ if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveMenuItem)
throw err
}
}
@@ -183,7 +183,7 @@ export function assertMenu(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertMenu)
+ if (err instanceof Error) Error.captureStackTrace(err, assertMenu)
throw err
}
}
@@ -214,7 +214,393 @@ export function assertMenuItem(
}
}
} catch (err) {
- Error.captureStackTrace(err, assertMenuItem)
+ if (err instanceof Error) Error.captureStackTrace(err, assertMenuItem)
+ throw err
+ }
+}
+
+// ---
+
+export function getComboboxLabel(): HTMLElement | null {
+ return document.querySelector('label,[id^="headlessui-combobox-label"]')
+}
+
+export function getComboboxButton(): HTMLElement | null {
+ return document.querySelector('button,[role="button"],[id^="headlessui-combobox-button-"]')
+}
+
+export function getComboboxButtons(): HTMLElement[] {
+ return Array.from(document.querySelectorAll('button,[role="button"]'))
+}
+
+export function getComboboxInput(): HTMLInputElement | null {
+ return document.querySelector('[role="combobox"]')
+}
+
+export function getCombobox(): HTMLElement | null {
+ return document.querySelector('[role="listbox"]')
+}
+
+export function getComboboxInputs(): HTMLElement[] {
+ return Array.from(document.querySelectorAll('[role="combobox"]'))
+}
+
+export function getComboboxes(): HTMLElement[] {
+ return Array.from(document.querySelectorAll('[role="listbox"]'))
+}
+
+export function getComboboxOptions(): HTMLElement[] {
+ return Array.from(document.querySelectorAll('[role="option"]'))
+}
+
+// ---
+
+export enum ComboboxState {
+ /** The combobox is visible to the user. */
+ Visible,
+
+ /** The combobox is **not** visible to the user. It's still in the DOM, but it is hidden. */
+ InvisibleHidden,
+
+ /** The combobox is **not** visible to the user. It's not in the DOM, it is unmounted. */
+ InvisibleUnmounted,
+}
+
+export function assertCombobox(
+ options: {
+ attributes?: Record
+ textContent?: string
+ state: ComboboxState
+ orientation?: 'horizontal' | 'vertical'
+ },
+ combobox = getComboboxInput()
+) {
+ let { orientation = 'vertical' } = options
+
+ try {
+ switch (options.state) {
+ case ComboboxState.InvisibleHidden:
+ if (combobox === null) return expect(combobox).not.toBe(null)
+
+ assertHidden(combobox)
+
+ expect(combobox).toHaveAttribute('aria-labelledby')
+ expect(combobox).toHaveAttribute('aria-orientation', orientation)
+ expect(combobox).toHaveAttribute('role', 'combobox')
+
+ if (options.textContent) expect(combobox).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case ComboboxState.Visible:
+ if (combobox === null) return expect(combobox).not.toBe(null)
+
+ assertVisible(combobox)
+
+ expect(combobox).toHaveAttribute('aria-labelledby')
+ expect(combobox).toHaveAttribute('aria-orientation', orientation)
+ expect(combobox).toHaveAttribute('role', 'combobox')
+
+ if (options.textContent) expect(combobox).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case ComboboxState.InvisibleUnmounted:
+ expect(combobox).toBe(null)
+ break
+
+ default:
+ assertNever(options.state)
+ }
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertCombobox)
+ throw err
+ }
+}
+
+export function assertComboboxList(
+ options: {
+ attributes?: Record
+ textContent?: string
+ state: ComboboxState
+ orientation?: 'horizontal' | 'vertical'
+ },
+ listbox = getCombobox()
+) {
+ let { orientation = 'vertical' } = options
+
+ try {
+ switch (options.state) {
+ case ComboboxState.InvisibleHidden:
+ if (listbox === null) return expect(listbox).not.toBe(null)
+
+ assertHidden(listbox)
+
+ expect(listbox).toHaveAttribute('aria-labelledby')
+ expect(listbox).toHaveAttribute('aria-orientation', orientation)
+ expect(listbox).toHaveAttribute('role', 'listbox')
+
+ if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case ComboboxState.Visible:
+ if (listbox === null) return expect(listbox).not.toBe(null)
+
+ assertVisible(listbox)
+
+ expect(listbox).toHaveAttribute('aria-labelledby')
+ expect(listbox).toHaveAttribute('aria-orientation', orientation)
+ expect(listbox).toHaveAttribute('role', 'listbox')
+
+ if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
+
+ for (let attributeName in options.attributes) {
+ expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ break
+
+ case ComboboxState.InvisibleUnmounted:
+ expect(listbox).toBe(null)
+ break
+
+ default:
+ assertNever(options.state)
+ }
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertCombobox)
+ throw err
+ }
+}
+
+export function assertComboboxButton(
+ options: {
+ attributes?: Record
+ textContent?: string
+ state: ComboboxState
+ },
+ button = getComboboxButton()
+) {
+ try {
+ if (button === null) return expect(button).not.toBe(null)
+
+ // Ensure menu button have these properties
+ expect(button).toHaveAttribute('id')
+ expect(button).toHaveAttribute('aria-haspopup')
+
+ switch (options.state) {
+ case ComboboxState.Visible:
+ expect(button).toHaveAttribute('aria-controls')
+ expect(button).toHaveAttribute('aria-expanded', 'true')
+ break
+
+ case ComboboxState.InvisibleHidden:
+ expect(button).toHaveAttribute('aria-controls')
+ if (button.hasAttribute('disabled')) {
+ expect(button).not.toHaveAttribute('aria-expanded')
+ } else {
+ expect(button).toHaveAttribute('aria-expanded', 'false')
+ }
+ break
+
+ case ComboboxState.InvisibleUnmounted:
+ expect(button).not.toHaveAttribute('aria-controls')
+ if (button.hasAttribute('disabled')) {
+ expect(button).not.toHaveAttribute('aria-expanded')
+ } else {
+ expect(button).toHaveAttribute('aria-expanded', 'false')
+ }
+ break
+
+ default:
+ assertNever(options.state)
+ }
+
+ if (options.textContent) {
+ expect(button).toHaveTextContent(options.textContent)
+ }
+
+ // Ensure menu button has the following attributes
+ for (let attributeName in options.attributes) {
+ expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButton)
+ throw err
+ }
+}
+
+export function assertComboboxLabel(
+ options: {
+ attributes?: Record
+ tag?: string
+ textContent?: string
+ },
+ label = getComboboxLabel()
+) {
+ try {
+ if (label === null) return expect(label).not.toBe(null)
+
+ // Ensure menu button have these properties
+ expect(label).toHaveAttribute('id')
+
+ if (options.textContent) {
+ expect(label).toHaveTextContent(options.textContent)
+ }
+
+ if (options.tag) {
+ expect(label.tagName.toLowerCase()).toBe(options.tag)
+ }
+
+ // Ensure menu button has the following attributes
+ for (let attributeName in options.attributes) {
+ expect(label).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabel)
+ throw err
+ }
+}
+
+export function assertComboboxButtonLinkedWithCombobox(
+ button = getComboboxButton(),
+ combobox = getCombobox()
+) {
+ try {
+ if (button === null) return expect(button).not.toBe(null)
+ if (combobox === null) return expect(combobox).not.toBe(null)
+
+ // Ensure link between button & combobox is correct
+ expect(button).toHaveAttribute('aria-controls', combobox.getAttribute('id'))
+ expect(combobox).toHaveAttribute('aria-labelledby', button.getAttribute('id'))
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButtonLinkedWithCombobox)
+ throw err
+ }
+}
+
+export function assertComboboxLabelLinkedWithCombobox(
+ label = getComboboxLabel(),
+ combobox = getComboboxInput()
+) {
+ try {
+ if (label === null) return expect(label).not.toBe(null)
+ if (combobox === null) return expect(combobox).not.toBe(null)
+
+ expect(combobox).toHaveAttribute('aria-labelledby', label.getAttribute('id'))
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabelLinkedWithCombobox)
+ throw err
+ }
+}
+
+export function assertComboboxButtonLinkedWithComboboxLabel(
+ button = getComboboxButton(),
+ label = getComboboxLabel()
+) {
+ try {
+ if (button === null) return expect(button).not.toBe(null)
+ if (label === null) return expect(label).not.toBe(null)
+
+ // Ensure link between button & label is correct
+ expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`)
+ } catch (err) {
+ if (err instanceof Error)
+ Error.captureStackTrace(err, assertComboboxButtonLinkedWithComboboxLabel)
+ throw err
+ }
+}
+
+export function assertActiveComboboxOption(
+ item: HTMLElement | null,
+ combobox = getComboboxInput()
+) {
+ try {
+ if (combobox === null) return expect(combobox).not.toBe(null)
+ if (item === null) return expect(item).not.toBe(null)
+
+ // Ensure link between combobox & combobox item is correct
+ expect(combobox).toHaveAttribute('aria-activedescendant', item.getAttribute('id'))
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertActiveComboboxOption)
+ throw err
+ }
+}
+
+export function assertNoActiveComboboxOption(combobox = getComboboxInput()) {
+ try {
+ if (combobox === null) return expect(combobox).not.toBe(null)
+
+ // Ensure we don't have an active combobox
+ expect(combobox).not.toHaveAttribute('aria-activedescendant')
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveComboboxOption)
+ throw err
+ }
+}
+
+export function assertNoSelectedComboboxOption(items = getComboboxOptions()) {
+ try {
+ for (let item of items) expect(item).not.toHaveAttribute('aria-selected')
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedComboboxOption)
+ throw err
+ }
+}
+
+export function assertComboboxOption(
+ item: HTMLElement | null,
+ options?: {
+ tag?: string
+ attributes?: Record
+ selected?: boolean
+ }
+) {
+ try {
+ if (item === null) return expect(item).not.toBe(null)
+
+ // Check that some attributes exists, doesn't really matter what the values are at this point in
+ // time, we just require them.
+ expect(item).toHaveAttribute('id')
+
+ // Check that we have the correct values for certain attributes
+ expect(item).toHaveAttribute('role', 'option')
+ if (!item.getAttribute('aria-disabled')) expect(item).toHaveAttribute('tabindex', '-1')
+
+ // Ensure combobox button has the following attributes
+ if (!options) return
+
+ for (let attributeName in options.attributes) {
+ expect(item).toHaveAttribute(attributeName, options.attributes[attributeName])
+ }
+
+ if (options.tag) {
+ expect(item.tagName.toLowerCase()).toBe(options.tag)
+ }
+
+ if (options.selected != null) {
+ switch (options.selected) {
+ case true:
+ return expect(item).toHaveAttribute('aria-selected', 'true')
+
+ case false:
+ return expect(item).not.toHaveAttribute('aria-selected')
+
+ default:
+ assertNever(options.selected)
+ }
+ }
+ } catch (err) {
+ if (err instanceof Error) Error.captureStackTrace(err, assertComboboxOption)
throw err
}
}
@@ -311,7 +697,7 @@ export function assertListbox(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertListbox)
+ if (err instanceof Error) Error.captureStackTrace(err, assertListbox)
throw err
}
}
@@ -368,7 +754,7 @@ export function assertListboxButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
- Error.captureStackTrace(err, assertListboxButton)
+ if (err instanceof Error) Error.captureStackTrace(err, assertListboxButton)
throw err
}
}
@@ -400,7 +786,7 @@ export function assertListboxLabel(
expect(label).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
- Error.captureStackTrace(err, assertListboxLabel)
+ if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabel)
throw err
}
}
@@ -417,7 +803,7 @@ export function assertListboxButtonLinkedWithListbox(
expect(button).toHaveAttribute('aria-controls', listbox.getAttribute('id'))
expect(listbox).toHaveAttribute('aria-labelledby', button.getAttribute('id'))
} catch (err) {
- Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox)
+ if (err instanceof Error) Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox)
throw err
}
}
@@ -432,7 +818,7 @@ export function assertListboxLabelLinkedWithListbox(
expect(listbox).toHaveAttribute('aria-labelledby', label.getAttribute('id'))
} catch (err) {
- Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox)
+ if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox)
throw err
}
}
@@ -448,7 +834,8 @@ export function assertListboxButtonLinkedWithListboxLabel(
// Ensure link between button & label is correct
expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`)
} catch (err) {
- Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel)
+ if (err instanceof Error)
+ Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel)
throw err
}
}
@@ -461,7 +848,7 @@ export function assertActiveListboxOption(item: HTMLElement | null, listbox = ge
// Ensure link between listbox & listbox item is correct
expect(listbox).toHaveAttribute('aria-activedescendant', item.getAttribute('id'))
} catch (err) {
- Error.captureStackTrace(err, assertActiveListboxOption)
+ if (err instanceof Error) Error.captureStackTrace(err, assertActiveListboxOption)
throw err
}
}
@@ -473,7 +860,7 @@ export function assertNoActiveListboxOption(listbox = getListbox()) {
// Ensure we don't have an active listbox
expect(listbox).not.toHaveAttribute('aria-activedescendant')
} catch (err) {
- Error.captureStackTrace(err, assertNoActiveListboxOption)
+ if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveListboxOption)
throw err
}
}
@@ -482,7 +869,7 @@ export function assertNoSelectedListboxOption(items = getListboxOptions()) {
try {
for (let item of items) expect(item).not.toHaveAttribute('aria-selected')
} catch (err) {
- Error.captureStackTrace(err, assertNoSelectedListboxOption)
+ if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedListboxOption)
throw err
}
}
@@ -530,7 +917,7 @@ export function assertListboxOption(
}
}
} catch (err) {
- Error.captureStackTrace(err, assertListboxOption)
+ if (err instanceof Error) Error.captureStackTrace(err, assertListboxOption)
throw err
}
}
@@ -597,7 +984,7 @@ export function assertSwitch(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertSwitch)
+ if (err instanceof Error) Error.captureStackTrace(err, assertSwitch)
throw err
}
}
@@ -678,7 +1065,7 @@ export function assertDisclosureButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
- Error.captureStackTrace(err, assertDisclosureButton)
+ if (err instanceof Error) Error.captureStackTrace(err, assertDisclosureButton)
throw err
}
}
@@ -725,7 +1112,7 @@ export function assertDisclosurePanel(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertDisclosurePanel)
+ if (err instanceof Error) Error.captureStackTrace(err, assertDisclosurePanel)
throw err
}
}
@@ -810,7 +1197,7 @@ export function assertPopoverButton(
expect(button).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
- Error.captureStackTrace(err, assertPopoverButton)
+ if (err instanceof Error) Error.captureStackTrace(err, assertPopoverButton)
throw err
}
}
@@ -857,7 +1244,7 @@ export function assertPopoverPanel(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertPopoverPanel)
+ if (err instanceof Error) Error.captureStackTrace(err, assertPopoverPanel)
throw err
}
}
@@ -984,7 +1371,7 @@ export function assertDialog(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertDialog)
+ if (err instanceof Error) Error.captureStackTrace(err, assertDialog)
throw err
}
}
@@ -1040,7 +1427,7 @@ export function assertDialogTitle(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertDialogTitle)
+ if (err instanceof Error) Error.captureStackTrace(err, assertDialogTitle)
throw err
}
}
@@ -1096,7 +1483,7 @@ export function assertDialogDescription(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertDialogDescription)
+ if (err instanceof Error) Error.captureStackTrace(err, assertDialogDescription)
throw err
}
}
@@ -1143,7 +1530,7 @@ export function assertDialogOverlay(
assertNever(options.state)
}
} catch (err) {
- Error.captureStackTrace(err, assertDialogOverlay)
+ if (err instanceof Error) Error.captureStackTrace(err, assertDialogOverlay)
throw err
}
}
@@ -1185,7 +1572,7 @@ export function assertRadioGroupLabel(
expect(label).toHaveAttribute(attributeName, options.attributes[attributeName])
}
} catch (err) {
- Error.captureStackTrace(err, assertRadioGroupLabel)
+ if (err instanceof Error) Error.captureStackTrace(err, assertRadioGroupLabel)
throw err
}
}
@@ -1267,7 +1654,7 @@ export function assertTabs(
}
}
} catch (err) {
- Error.captureStackTrace(err, assertTabs)
+ if (err instanceof Error) Error.captureStackTrace(err, assertTabs)
throw err
}
}
@@ -1287,7 +1674,7 @@ export function assertActiveElement(element: HTMLElement | null) {
expect(document.activeElement?.outerHTML).toBe(element.outerHTML)
}
} catch (err) {
- Error.captureStackTrace(err, assertActiveElement)
+ if (err instanceof Error) Error.captureStackTrace(err, assertActiveElement)
throw err
}
}
@@ -1297,7 +1684,7 @@ export function assertContainsActiveElement(element: HTMLElement | null) {
if (element === null) return expect(element).not.toBe(null)
expect(element.contains(document.activeElement)).toBe(true)
} catch (err) {
- Error.captureStackTrace(err, assertContainsActiveElement)
+ if (err instanceof Error) Error.captureStackTrace(err, assertContainsActiveElement)
throw err
}
}
@@ -1311,7 +1698,7 @@ export function assertHidden(element: HTMLElement | null) {
expect(element).toHaveAttribute('hidden')
expect(element).toHaveStyle({ display: 'none' })
} catch (err) {
- Error.captureStackTrace(err, assertHidden)
+ if (err instanceof Error) Error.captureStackTrace(err, assertHidden)
throw err
}
}
@@ -1323,7 +1710,7 @@ export function assertVisible(element: HTMLElement | null) {
expect(element).not.toHaveAttribute('hidden')
expect(element).not.toHaveStyle({ display: 'none' })
} catch (err) {
- Error.captureStackTrace(err, assertVisible)
+ if (err instanceof Error) Error.captureStackTrace(err, assertVisible)
throw err
}
}
@@ -1336,7 +1723,7 @@ export function assertFocusable(element: HTMLElement | null) {
expect(isFocusableElement(element, FocusableMode.Strict)).toBe(true)
} catch (err) {
- Error.captureStackTrace(err, assertFocusable)
+ if (err instanceof Error) Error.captureStackTrace(err, assertFocusable)
throw err
}
}
@@ -1347,7 +1734,7 @@ export function assertNotFocusable(element: HTMLElement | null) {
expect(isFocusableElement(element, FocusableMode.Strict)).toBe(false)
} catch (err) {
- Error.captureStackTrace(err, assertNotFocusable)
+ if (err instanceof Error) Error.captureStackTrace(err, assertNotFocusable)
throw err
}
}
diff --git a/packages/@headlessui-vue/src/test-utils/interactions.ts b/packages/@headlessui-vue/src/test-utils/interactions.ts
index dd483d0..8e93518 100644
--- a/packages/@headlessui-vue/src/test-utils/interactions.ts
+++ b/packages/@headlessui-vue/src/test-utils/interactions.ts
@@ -1,4 +1,7 @@
import { fireEvent } from '@testing-library/dom'
+import { disposables } from '../utils/disposables'
+
+let d = disposables()
function nextFrame(cb: Function): void {
setImmediate(() =>
@@ -33,7 +36,19 @@ export function shift(event: Partial) {
}
export function word(input: string): Partial[] {
- return input.split('').map(key => ({ key }))
+ let result = input.split('').map(key => ({ key }))
+
+ d.enqueue(() => {
+ let element = document.activeElement
+
+ if (element instanceof HTMLInputElement) {
+ fireEvent.change(element, {
+ target: Object.assign({}, element, { value: input }),
+ })
+ }
+ })
+
+ return result
}
let Default = Symbol()
@@ -76,6 +91,9 @@ let order: Record<
function keypress(element, event) {
return fireEvent.keyPress(element, event)
},
+ function input(element, event) {
+ return fireEvent.input(element, event)
+ },
function keyup(element, event) {
return fireEvent.keyUp(element, event)
},
@@ -159,9 +177,11 @@ export async function type(events: Partial[], element = document.
// We don't want to actually wait in our tests, so let's advance
jest.runAllTimers()
+ await d.workQueue()
+
await new Promise(nextFrame)
} catch (err) {
- Error.captureStackTrace(err, type)
+ if (err instanceof Error) Error.captureStackTrace(err, type)
throw err
} finally {
jest.useRealTimers()
@@ -178,7 +198,7 @@ export enum MouseButton {
}
export async function click(
- element: Document | Element | Window | null,
+ element: Document | Element | Window | Node | null,
button = MouseButton.Left
) {
try {
@@ -224,12 +244,12 @@ export async function click(
await new Promise(nextFrame)
} catch (err) {
- Error.captureStackTrace(err, click)
+ if (err instanceof Error) Error.captureStackTrace(err, click)
throw err
}
}
-export async function focus(element: Document | Element | Window | null) {
+export async function focus(element: Document | Element | Window | Node | null) {
try {
if (element === null) return expect(element).not.toBe(null)
@@ -237,11 +257,10 @@ export async function focus(element: Document | Element | Window | null) {
await new Promise(nextFrame)
} catch (err) {
- Error.captureStackTrace(err, focus)
+ if (err instanceof Error) Error.captureStackTrace(err, focus)
throw err
}
}
-
export async function mouseEnter(element: Document | Element | Window | null) {
try {
if (element === null) return expect(element).not.toBe(null)
@@ -252,7 +271,7 @@ export async function mouseEnter(element: Document | Element | Window | null) {
await new Promise(nextFrame)
} catch (err) {
- Error.captureStackTrace(err, mouseEnter)
+ if (err instanceof Error) Error.captureStackTrace(err, mouseEnter)
throw err
}
}
@@ -266,7 +285,7 @@ export async function mouseMove(element: Document | Element | Window | null) {
await new Promise(nextFrame)
} catch (err) {
- Error.captureStackTrace(err, mouseMove)
+ if (err instanceof Error) Error.captureStackTrace(err, mouseMove)
throw err
}
}
@@ -282,7 +301,7 @@ export async function mouseLeave(element: Document | Element | Window | null) {
await new Promise(nextFrame)
} catch (err) {
- Error.captureStackTrace(err, mouseLeave)
+ if (err instanceof Error) Error.captureStackTrace(err, mouseLeave)
throw err
}
}
diff --git a/packages/@headlessui-vue/src/utils/disposables.ts b/packages/@headlessui-vue/src/utils/disposables.ts
index 7c9a388..4c0f89c 100644
--- a/packages/@headlessui-vue/src/utils/disposables.ts
+++ b/packages/@headlessui-vue/src/utils/disposables.ts
@@ -1,7 +1,12 @@
export function disposables() {
let disposables: Function[] = []
+ let queue: Function[] = []
let api = {
+ enqueue(fn: Function) {
+ queue.push(fn)
+ },
+
requestAnimationFrame(...args: Parameters) {
let raf = requestAnimationFrame(...args)
api.add(() => cancelAnimationFrame(raf))
@@ -27,6 +32,12 @@ export function disposables() {
dispose()
}
},
+
+ async workQueue() {
+ for (let handle of queue.splice(0)) {
+ await handle()
+ }
+ },
}
return api
diff --git a/packages/playground-react/next-env.d.ts b/packages/playground-react/next-env.d.ts
new file mode 100644
index 0000000..4f11a03
--- /dev/null
+++ b/packages/playground-react/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/packages/playground-react/next.config.js b/packages/playground-react/next.config.js
new file mode 100644
index 0000000..5b8efdf
--- /dev/null
+++ b/packages/playground-react/next.config.js
@@ -0,0 +1,5 @@
+module.exports = {
+ devIndicators: {
+ autoPrerender: false,
+ },
+}
diff --git a/packages/playground-react/package.json b/packages/playground-react/package.json
new file mode 100644
index 0000000..40c4325
--- /dev/null
+++ b/packages/playground-react/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "playground-react",
+ "version": "1.0.0",
+ "main": "next.config.js",
+ "scripts": {
+ "prebuild": "yarn workspace @headlessui/react build",
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start"
+ },
+ "keywords": [],
+ "author": "Robin Malfait",
+ "license": "ISC",
+ "description": "",
+ "dependencies": {
+ "@headlessui/react": "*",
+ "@popperjs/core": "^2.6.0",
+ "framer-motion": "^6.0.0",
+ "next": "^12.0.8",
+ "react": "16.14.0",
+ "react-dom": "16.14.0"
+ }
+}
diff --git a/packages/playground-react/pages/_app.tsx b/packages/playground-react/pages/_app.tsx
new file mode 100644
index 0000000..671e2ee
--- /dev/null
+++ b/packages/playground-react/pages/_app.tsx
@@ -0,0 +1,239 @@
+import React, { useState, useEffect } from 'react'
+import Link from 'next/link'
+import Head from 'next/head'
+
+function disposables() {
+ let disposables: Function[] = []
+
+ let api = {
+ requestAnimationFrame(...args: Parameters) {
+ let raf = requestAnimationFrame(...args)
+ api.add(() => cancelAnimationFrame(raf))
+ },
+
+ nextFrame(...args: Parameters) {
+ api.requestAnimationFrame(() => {
+ api.requestAnimationFrame(...args)
+ })
+ },
+
+ setTimeout(...args: Parameters) {
+ let timer = setTimeout(...args)
+ api.add(() => clearTimeout(timer))
+ },
+
+ add(cb: () => void) {
+ disposables.push(cb)
+ },
+
+ dispose() {
+ for (let dispose of disposables.splice(0)) {
+ dispose()
+ }
+ },
+ }
+
+ return api
+}
+
+export function useDisposables() {
+ // Using useState instead of useRef so that we can use the initializer function.
+ let [d] = useState(disposables)
+ useEffect(() => () => d.dispose(), [d])
+ return d
+}
+
+function NextLink(props: React.ComponentProps<'a'>) {
+ let { href, children, ...rest } = props
+ return (
+
+ {children}
+
+ )
+}
+
+enum KeyDisplayMac {
+ ArrowUp = '↑',
+ ArrowDown = '↓',
+ ArrowLeft = '←',
+ ArrowRight = '→',
+ Home = '↖',
+ End = '↘',
+ Alt = '⌥',
+ CapsLock = '⇪',
+ Meta = '⌘',
+ Shift = '⇧',
+ Control = '⌃',
+ Backspace = '⌫',
+ Delete = '⌦',
+ Enter = '↵',
+ Escape = '⎋',
+ Tab = '↹',
+ PageUp = '⇞',
+ PageDown = '⇟',
+ ' ' = '␣',
+}
+
+enum KeyDisplayWindows {
+ ArrowUp = '↑',
+ ArrowDown = '↓',
+ ArrowLeft = '←',
+ ArrowRight = '→',
+ Meta = 'Win',
+ Control = 'Ctrl',
+ Backspace = '⌫',
+ Delete = 'Del',
+ Escape = 'Esc',
+ PageUp = 'PgUp',
+ PageDown = 'PgDn',
+ ' ' = '␣',
+}
+
+function tap(value: T, cb: (value: T) => void) {
+ cb(value)
+ return value
+}
+
+function useKeyDisplay() {
+ let [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ if (!mounted) return {}
+ let isMac = navigator.userAgent.indexOf('Mac OS X') !== -1
+ return isMac ? KeyDisplayMac : KeyDisplayWindows
+}
+
+function KeyCaster() {
+ let [keys, setKeys] = useState([])
+ let d = useDisposables()
+ let KeyDisplay = useKeyDisplay()
+
+ useEffect(() => {
+ function handler(event: KeyboardEvent) {
+ setKeys(current => [
+ event.shiftKey && event.key !== 'Shift'
+ ? KeyDisplay[`Shift${event.key}`] ?? event.key
+ : KeyDisplay[event.key] ?? event.key,
+ ...current,
+ ])
+ d.setTimeout(() => setKeys(current => tap(current.slice(), clone => clone.pop())), 2000)
+ }
+
+ window.addEventListener('keydown', handler, true)
+ return () => window.removeEventListener('keydown', handler, true)
+ }, [d, KeyDisplay])
+
+ if (keys.length <= 0) return null
+
+ return (
+
+ )
+}
diff --git a/packages/playground-react/pages/radio-group/radio-group.tsx b/packages/playground-react/pages/radio-group/radio-group.tsx
new file mode 100644
index 0000000..e92c54f
--- /dev/null
+++ b/packages/playground-react/pages/radio-group/radio-group.tsx
@@ -0,0 +1,101 @@
+import React, { useState } from 'react'
+import { RadioGroup } from '@headlessui/react'
+import { classNames } from '../../utils/class-names'
+
+export default function Home() {
+ let access = [
+ {
+ id: 'access-1',
+ name: 'Public access',
+ description: 'This project would be available to anyone who has the link',
+ },
+ {
+ id: 'access-2',
+ name: 'Private to Project Members',
+ description: 'Only members of this project would be able to access',
+ },
+ {
+ id: 'access-3',
+ name: 'Private to you',
+ description: 'You are the only one able to access this project',
+ },
+ ]
+ let [active, setActive] = useState()
+
+ return (
+