diff --git a/src/lib/components/listbox/_ManagedListbox.svelte b/src/lib/components/listbox/_ManagedListbox.svelte
new file mode 100644
index 0000000..7dc8a63
--- /dev/null
+++ b/src/lib/components/listbox/_ManagedListbox.svelte
@@ -0,0 +1,8 @@
+
+
+ (value = e.detail)} on:change>
+
+
diff --git a/src/lib/components/listbox/listbox.test.ts b/src/lib/components/listbox/listbox.test.ts
new file mode 100644
index 0000000..811d1af
--- /dev/null
+++ b/src/lib/components/listbox/listbox.test.ts
@@ -0,0 +1,3836 @@
+import { Listbox, ListboxButton, ListboxLabel, ListboxOption, ListboxOptions } from ".";
+import { suppressConsoleLogs } from "$lib/test-utils/suppress-console-logs";
+import { render } from "@testing-library/svelte";
+import TestRenderer from "$lib/test-utils/TestRenderer.svelte";
+import { assertActiveElement, assertActiveListboxOption, assertListbox, assertListboxButton, assertListboxButtonLinkedWithListbox, assertListboxButtonLinkedWithListboxLabel, assertListboxOption, assertNoActiveListboxOption, assertNoSelectedListboxOption, getByText, getListbox, getListboxButton, getListboxButtons, getListboxes, getListboxLabel, getListboxOptions, ListboxState } from "$lib/test-utils/accessibility-assertions";
+import { click, focus, Keys, MouseButton, mouseLeave, mouseMove, press, shift, type, word } from "$lib/test-utils/interactions";
+import { Transition } from "../transitions";
+import TransitionDebug from "$lib/components/disclosure/_TransitionDebug.svelte";
+import ManagedListbox from "./_ManagedListbox.svelte";
+import Button from "$lib/internal/elements/Button.svelte";
+import Div from "$lib/internal/elements/Div.svelte";
+import Span from "$lib/internal/elements/Span.svelte";
+
+let id = 0;
+jest.mock('../../hooks/use-id', () => {
+ return {
+ useId: jest.fn(() => ++id),
+ }
+})
+
+beforeEach(() => id = 0)
+beforeAll(() => {
+ // jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
+ // jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
+})
+afterAll(() => jest.restoreAllMocks())
+
+describe('safeguards', () => {
+ it.each([
+ ['ListboxButton', ListboxButton],
+ ['ListboxLabel', ListboxLabel],
+ ['ListboxOptions', ListboxOptions],
+ ['ListboxOption', ListboxOption],
+ ])(
+ '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 Listbox without crashing',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+ })
+ )
+})
+
+describe('Rendering', () => {
+ describe('Listbox', () => {
+ // it(
+ // 'should be possible to render a Listbox using a render prop',
+ // suppressConsoleLogs(async () => {
+ // render(
+ //
+ // {({ open }) => (
+ // <>
+ // Trigger
+ // {open && (
+ //
+ // Option A
+ // Option B
+ // Option C
+ //
+ // )}
+ // >
+ // )}
+ //
+ // )
+
+ // assertListboxButton({
+ // state: ListboxState.InvisibleUnmounted,
+ // attributes: { id: 'headlessui-listbox-button-1' },
+ // })
+ // assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // await click(getListboxButton())
+
+ // assertListboxButton({
+ // state: ListboxState.Visible,
+ // attributes: { id: 'headlessui-listbox-button-1' },
+ // })
+ // assertListbox({ state: ListboxState.Visible })
+ // })
+ // )
+
+ it(
+ 'should be possible to disable a Listbox',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log, disabled: true }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ await click(getListboxButton())
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ await press(Keys.Enter, getListboxButton())
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+ })
+ )
+ })
+
+ describe('ListboxLabel', () => {
+ // it(
+ // 'should be possible to render a ListboxLabel using a render prop',
+ // suppressConsoleLogs(async () => {
+ // render(
+ //
+ // { JSON.stringify } < /ListboxLabel>
+ // < ListboxButton > Trigger < /ListboxButton>
+ // < ListboxOptions >
+ // Option A < /ListboxOption>
+ // < ListboxOption value = "b" > Option B < /ListboxOption>
+ // < ListboxOption value = "c" > Option C < /ListboxOption>
+ // < /ListboxOptions>
+ // < /Listbox>
+ // )
+
+ // assertListboxButton({
+ // state: ListboxState.InvisibleUnmounted,
+ // attributes: { id: 'headlessui-listbox-button-2' },
+ // })
+ // assertListboxLabel({
+ // attributes: { id: 'headlessui-listbox-label-1' },
+ // textContent: JSON.stringify({ open: false, disabled: false }),
+ // })
+ // assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // await click(getListboxButton())
+
+ // assertListboxLabel({
+ // attributes: { id: 'headlessui-listbox-label-1' },
+ // textContent: JSON.stringify({ open: true, disabled: false }),
+ // })
+ // assertListbox({ state: ListboxState.Visible })
+ // assertListboxLabelLinkedWithListbox()
+ // assertListboxButtonLinkedWithListboxLabel()
+ // })
+ // )
+
+ // it(
+ // 'should be possible to render a ListboxLabel using a render prop and an `as` prop',
+ // suppressConsoleLogs(async () => {
+ // render(
+ //
+ // { JSON.stringify } < /ListboxLabel>
+ // < ListboxButton > Trigger < /ListboxButton>
+ // < ListboxOptions >
+ // Option A < /ListboxOption>
+ // < ListboxOption value = "b" > Option B < /ListboxOption>
+ // < ListboxOption value = "c" > Option C < /ListboxOption>
+ // < /ListboxOptions>
+ // < /Listbox>
+ // )
+
+ // assertListboxLabel({
+ // attributes: { id: 'headlessui-listbox-label-1' },
+ // textContent: JSON.stringify({ open: false, disabled: false }),
+ // tag: 'p',
+ // })
+ // assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // await click(getListboxButton())
+ // assertListboxLabel({
+ // attributes: { id: 'headlessui-listbox-label-1' },
+ // textContent: JSON.stringify({ open: true, disabled: false }),
+ // tag: 'p',
+ // })
+ // assertListbox({ state: ListboxState.Visible })
+ // })
+ // )
+ })
+
+ describe('ListboxButton', () => {
+ // it(
+ // 'should be possible to render a ListboxButton using a render prop',
+ // suppressConsoleLogs(async () => {
+ // render(
+ //
+ // { JSON.stringify } < /ListboxButton>
+ // < ListboxOptions >
+ // Option A < /ListboxOption>
+ // < ListboxOption value = "b" > Option B < /ListboxOption>
+ // < ListboxOption value = "c" > Option C < /ListboxOption>
+ // < /ListboxOptions>
+ // < /Listbox>
+ // )
+
+ // assertListboxButton({
+ // state: ListboxState.InvisibleUnmounted,
+ // attributes: { id: 'headlessui-listbox-button-1' },
+ // textContent: JSON.stringify({ open: false, disabled: false }),
+ // })
+ // assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // await click(getListboxButton())
+
+ // assertListboxButton({
+ // state: ListboxState.Visible,
+ // attributes: { id: 'headlessui-listbox-button-1' },
+ // textContent: JSON.stringify({ open: true, disabled: false }),
+ // })
+ // assertListbox({ state: ListboxState.Visible })
+ // })
+ // )
+
+ // it(
+ // 'should be possible to render a ListboxButton using a render prop and an `as` prop',
+ // suppressConsoleLogs(async () => {
+ // render(
+ //
+ //
+ // { JSON.stringify }
+ // < /ListboxButton>
+ // < ListboxOptions >
+ // Option A < /ListboxOption>
+ // < ListboxOption value = "b" > Option B < /ListboxOption>
+ // < ListboxOption value = "c" > Option C < /ListboxOption>
+ // < /ListboxOptions>
+ // < /Listbox>
+ // )
+
+ // assertListboxButton({
+ // state: ListboxState.InvisibleUnmounted,
+ // attributes: { id: 'headlessui-listbox-button-1' },
+ // textContent: JSON.stringify({ open: false, disabled: false }),
+ // })
+ // assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // await click(getListboxButton())
+
+ // assertListboxButton({
+ // state: ListboxState.Visible,
+ // attributes: { id: 'headlessui-listbox-button-1' },
+ // textContent: JSON.stringify({ open: true, disabled: false }),
+ // })
+ // assertListbox({ state: ListboxState.Visible })
+ // })
+ // )
+
+ it(
+ 'should be possible to render a ListboxButton and a ListboxLabel and see them linked together',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxLabel, {}, "Label"],
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // TODO: Needed to make it similar to vue test implementation?
+ // await new Promise(requestAnimationFrame)
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-2' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+ assertListboxButtonLinkedWithListboxLabel()
+ })
+ )
+
+ describe('`type` attribute', () => {
+ it('should set the `type` to "button" by default', async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: null, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ ]],
+ ]
+ })
+
+ expect(getListboxButton()).toHaveAttribute('type', 'button')
+ })
+
+ it('should not set the `type` to "button" if it already contains a `type`', async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: null, onChange: console.log }, [
+ [ListboxButton, { type: "submit" }, "Trigger"],
+ ]],
+ ]
+ })
+
+ expect(getListboxButton()).toHaveAttribute('type', 'submit')
+ })
+
+ it('should not set the type if the "as" prop is not a "button"', async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: null, onChange: console.log }, [
+ [ListboxButton, { as: "div" }, "Trigger"],
+ ]],
+ ]
+ })
+
+ expect(getListboxButton()).not.toHaveAttribute('type')
+ })
+
+ })
+ })
+
+ describe('ListboxOptions', () => {
+ // it(
+ // 'should be possible to render ListboxOptions using a render prop',
+ // suppressConsoleLogs(async () => {
+ // render(
+ //
+ // Trigger
+ //
+ // {data => (
+ // <>
+ // {JSON.stringify(data)}
+ // >
+ // )}
+ //
+ //
+ // )
+
+ // assertListboxButton({
+ // state: ListboxState.InvisibleUnmounted,
+ // attributes: { id: 'headlessui-listbox-button-1' },
+ // })
+ // assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // await click(getListboxButton())
+
+ // assertListboxButton({
+ // state: ListboxState.Visible,
+ // attributes: { id: 'headlessui-listbox-button-1' },
+ // })
+ // assertListbox({
+ // state: ListboxState.Visible,
+ // textContent: JSON.stringify({ open: true }),
+ // })
+ // assertActiveElement(getListbox())
+ // })
+ // )
+
+ it('should be possible to always render the ListboxOptions if we provide it a `static` prop', () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, { static: true }, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Let's verify that the Listbox is already there
+ expect(getListbox()).not.toBe(null)
+ })
+
+ it('should be possible to use a different render strategy for the ListboxOptions', async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, { unmount: false }, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListbox({ state: ListboxState.InvisibleHidden })
+
+ // Let's open the Listbox, to see if it is not hidden anymore
+ await click(getListboxButton())
+
+ assertListbox({ state: ListboxState.Visible })
+ })
+ })
+
+ // describe('ListboxOption', () => {
+ // it(
+ // 'should be possible to render a ListboxOption using a render prop',
+ // suppressConsoleLogs(async () => {
+ // render(
+ //
+ // Trigger
+ //
+ // {JSON.stringify}
+ //
+ //
+ // )
+
+ // assertListboxButton({
+ // state: ListboxState.InvisibleUnmounted,
+ // attributes: { id: 'headlessui-listbox-button-1' },
+ // })
+ // assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // await click(getListboxButton())
+
+ // assertListboxButton({
+ // state: ListboxState.Visible,
+ // attributes: { id: 'headlessui-listbox-button-1' },
+ // })
+ // assertListbox({
+ // state: ListboxState.Visible,
+ // textContent: JSON.stringify({ active: false, selected: false, disabled: false }),
+ // })
+ // })
+ // )
+ // })
+})
+
+describe('Rendering composition', () => {
+ it(
+ 'should be possible to conditionally render classNames (aka class can be a function?!)',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a", class: (bag: any) => JSON.stringify(bag) }, "Option A"],
+ [ListboxOption, { value: "b", class: (bag: any) => JSON.stringify(bag), disabled: true }, "Option B"],
+ [ListboxOption, { value: "c", class: "no-special-treatment" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Open Listbox
+ await click(getListboxButton())
+
+ let options = getListboxOptions()
+
+ // 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
+ assertNoActiveListboxOption(getListbox())
+
+ // 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
+ assertActiveListboxOption(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
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should be possible to swap the Listbox option with a button for example',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { as: "button", value: "a" }, "Option A"],
+ [ListboxOption, { as: "button", value: "b" }, "Option B"],
+ [ListboxOption, { as: "button", value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Open Listbox
+ await click(getListboxButton())
+
+ // Verify options are buttons now
+ getListboxOptions().forEach(option => assertListboxOption(option, { tag: 'button' }))
+ })
+ )
+})
+
+
+describe('Composition', () => {
+ // TODO: fix this test
+ it.skip(
+ 'should be possible to wrap the ListboxOptions with a Transition component',
+ suppressConsoleLogs(async () => {
+ let orderFn = jest.fn()
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [TransitionDebug, { name: "Listbox", fn: orderFn }],
+ [Transition, {}, [
+ [TransitionDebug, { name: "Transition", fn: orderFn }],
+ [ListboxOptions, {}, [
+ [ListboxOption, { as: "button", value: "a" }, [
+ [TransitionDebug, { name: "ListboxOption", fn: orderFn }],
+ "Option A"
+ ]]
+ ]]
+ ]],
+ ]]
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ await click(getListboxButton())
+
+ assertListboxButton({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({
+ state: ListboxState.Visible,
+ textContent: "Option A",
+ })
+
+ await click(getListboxButton())
+
+ // Verify that we tracked the `mounts` and `unmounts` in the correct order
+ expect(orderFn.mock.calls).toEqual([
+ ['Mounting - Listbox'],
+ ['Mounting - Transition'],
+ ['Mounting - ListboxOption'],
+ ['Unmounting - Transition'],
+ ['Unmounting - ListboxOption'],
+ ])
+ })
+ )
+})
+
+describe('Keyboard interactions', () => {
+ describe('`Enter` key', () => {
+ it(
+ 'should be possible to open the listbox with Enter',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option, { selected: false }))
+
+ // Verify that the first listbox option is active
+ assertActiveListboxOption(options[0])
+ assertNoSelectedListboxOption()
+ })
+ )
+
+ it(
+ 'should not be possible to open the listbox with Enter when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log, disabled: true }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Try to open the listbox
+ await press(Keys.Enter)
+
+ // Verify it is still closed
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to open the listbox with Enter, and focus the selected option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: "b", onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
+
+ // Verify that the second listbox option is active (because it is already selected)
+ assertActiveListboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should be possible to open the listbox with Enter, and focus the selected option (when using the `hidden` render strategy)',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: "b", onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, { unmount: false }, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleHidden,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleHidden })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ let options = getListboxOptions()
+
+ // Hover over Option A
+ await mouseMove(options[0])
+
+ // Verify that Option A is active
+ assertActiveListboxOption(options[0])
+
+ // Verify that Option B is still selected
+ assertListboxOption(options[1], { selected: true })
+
+ // Close/Hide the listbox
+ await press(Keys.Escape)
+
+ // Re-open the listbox
+ await click(getListboxButton())
+
+ // Verify we have listbox options
+ expect(options).toHaveLength(3)
+ options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
+
+ // Verify that the second listbox option is active (because it is already selected)
+ assertActiveListboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should be possible to open the listbox 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' },
+ ]
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: myOptions[1], onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: myOptions[0] }, "Option A"],
+ [ListboxOption, { value: myOptions[1] }, "Option B"],
+ [ListboxOption, { value: myOptions[2] }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
+
+ // Verify that the second listbox option is active (because it is already selected)
+ assertActiveListboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should have no active listbox option when there are no listbox options at all',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions]
+ ]],
+ ]
+ })
+
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+ assertListbox({ state: ListboxState.Visible })
+ assertActiveElement(getListbox())
+
+ assertNoActiveListboxOption()
+ })
+ )
+
+ it(
+ 'should focus the first non disabled listbox option when opening with Enter',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ let options = getListboxOptions()
+
+ // Verify that the first non-disabled listbox option is active
+ assertActiveListboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should focus the first non disabled listbox option when opening with Enter (jump over multiple disabled ones)',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ let options = getListboxOptions()
+
+ // Verify that the first non-disabled listbox option is active
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should have no active listbox option upon Enter key press, when there are no non-disabled listbox options',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { disabled: true, value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ assertNoActiveListboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to close the listbox with Enter when there is no active listboxoption',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+
+ // Close listbox
+ await press(Keys.Enter)
+
+ // Verify it is closed
+ assertListboxButton({ state: ListboxState.InvisibleUnmounted })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Verify the button is focused again
+ assertActiveElement(getListboxButton())
+ })
+ )
+
+ it(
+ 'should be possible to close the listbox with Enter and choose the active listbox option',
+ suppressConsoleLogs(async () => {
+ let handleChange = jest.fn()
+
+ render(
+ TestRenderer, {
+ allProps: [
+ [ManagedListbox, { value: undefined, onChange: (e: CustomEvent) => handleChange(e.detail) }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+
+ // Activate the first listbox option
+ let options = getListboxOptions()
+ await mouseMove(options[0])
+
+ // Choose option, and close listbox
+ await press(Keys.Enter)
+
+ // Verify it is closed
+ assertListboxButton({ state: ListboxState.InvisibleUnmounted })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Verify we got the change event
+ expect(handleChange).toHaveBeenCalledTimes(1)
+ expect(handleChange).toHaveBeenCalledWith('a')
+
+ // Verify the button is focused again
+ assertActiveElement(getListboxButton())
+
+ // Open listbox again
+ await click(getListboxButton())
+
+ // Verify the active option is the previously selected one
+ assertActiveListboxOption(getListboxOptions()[0])
+ })
+ )
+ })
+
+ describe('`Space` key', () => {
+ it(
+ 'should be possible to open the listbox with Space',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Space)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[0])
+ })
+ )
+
+ it(
+ 'should not be possible to open the listbox with Space when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log, disabled: true }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Try to open the listbox
+ await press(Keys.Space)
+
+ // Verify it is still closed
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to open the listbox with Space, and focus the selected option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: "b", onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Space)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
+
+ // Verify that the second listbox option is active (because it is already selected)
+ assertActiveListboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should have no active listbox option when there are no listbox options at all',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}]
+ ]],
+ ]
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Space)
+ assertListbox({ state: ListboxState.Visible })
+ assertActiveElement(getListbox())
+
+ assertNoActiveListboxOption()
+ })
+ )
+
+ it(
+ 'should focus the first non disabled listbox option when opening with Space',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Space)
+
+ let options = getListboxOptions()
+
+ // Verify that the first non-disabled listbox option is active
+ assertActiveListboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should focus the first non disabled listbox option when opening with Space (jump over multiple disabled ones)',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Space)
+
+ let options = getListboxOptions()
+
+ // Verify that the first non-disabled listbox option is active
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should have no active listbox option upon Space key press, when there are no non-disabled listbox options',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { disabled: true, value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Space)
+
+ assertNoActiveListboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to close the listbox with Space and choose the active listbox option',
+ suppressConsoleLogs(async () => {
+ let handleChange = jest.fn()
+ render(
+ TestRenderer, {
+ allProps: [
+ [ManagedListbox, { value: undefined, onChange: (e: CustomEvent) => handleChange(e.detail) }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+
+ // Activate the first listbox option
+ let options = getListboxOptions()
+ await mouseMove(options[0])
+
+ // Choose option, and close listbox
+ await press(Keys.Space)
+
+ // Verify it is closed
+ assertListboxButton({ state: ListboxState.InvisibleUnmounted })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Verify we got the change event
+ expect(handleChange).toHaveBeenCalledTimes(1)
+ expect(handleChange).toHaveBeenCalledWith('a')
+
+ // Verify the button is focused again
+ assertActiveElement(getListboxButton())
+
+ // Open listbox again
+ await click(getListboxButton())
+
+ // Verify the active option is the previously selected one
+ assertActiveListboxOption(getListboxOptions()[0])
+ })
+ )
+ })
+
+ describe('`Escape` key', () => {
+ it(
+ 'should be possible to close an open listbox with Escape',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Space)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Close listbox
+ await press(Keys.Escape)
+
+ // Verify it is closed
+ assertListboxButton({ state: ListboxState.InvisibleUnmounted })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Verify the button is focused again
+ assertActiveElement(getListboxButton())
+ })
+ )
+ })
+
+ describe('`Tab` key', () => {
+ it(
+ 'should focus trap when we use Tab',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[0])
+
+ // Try to tab
+ await press(Keys.Tab)
+
+ // Verify it is still open
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({ state: ListboxState.Visible })
+ assertActiveElement(getListbox())
+ })
+ )
+
+ it(
+ 'should focus trap when we use Shift+Tab',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[0])
+
+ // Try to Shift+Tab
+ await press(shift(Keys.Tab))
+
+ // Verify it is still open
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({ state: ListboxState.Visible })
+ assertActiveElement(getListbox())
+ })
+ )
+ })
+
+ describe('`ArrowDown` key', () => {
+ it(
+ 'should be possible to open the listbox with ArrowDown',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowDown)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+
+ // Verify that the first listbox option is active
+ assertActiveListboxOption(options[0])
+ })
+ )
+
+ it(
+ 'should not be possible to open the listbox with ArrowDown when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log, disabled: true }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Try to open the listbox
+ await press(Keys.ArrowDown)
+
+ // Verify it is still closed
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to open the listbox with ArrowDown, and focus the selected option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: "b", onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowDown)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
+
+ // Verify that the second listbox option is active (because it is already selected)
+ assertActiveListboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should have no active listbox option when there are no listbox options at all',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions]
+ ]],
+ ]
+ })
+
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowDown)
+ assertListbox({ state: ListboxState.Visible })
+ assertActiveElement(getListbox())
+
+ assertNoActiveListboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowDown to navigate the listbox options',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[0])
+
+ // We should be able to go down once
+ await press(Keys.ArrowDown)
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go down again
+ await press(Keys.ArrowDown)
+ assertActiveListboxOption(options[2])
+
+ // We should NOT be able to go down again (because last option). Current implementation won't go around.
+ await press(Keys.ArrowDown)
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowDown to navigate the listbox options and skip the first disabled one',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go down once
+ await press(Keys.ArrowDown)
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowDown to navigate the listbox options and jump to the first non-disabled one',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[2])
+ })
+ )
+ })
+
+ describe('`ArrowRight` key', () => {
+ it(
+ 'should be possible to use ArrowRight to navigate the listbox options',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log, horizontal: true }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[0])
+
+ // We should be able to go right once
+ await press(Keys.ArrowRight)
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go right again
+ await press(Keys.ArrowRight)
+ assertActiveListboxOption(options[2])
+
+ // We should NOT be able to go right again (because last option). Current implementation won't go around.
+ await press(Keys.ArrowRight)
+ assertActiveListboxOption(options[2])
+ })
+ )
+ })
+
+ describe('`ArrowUp` key', () => {
+ it(
+ 'should be possible to open the listbox with ArrowUp and the last option should be active',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+
+ // ! ALERT: The LAST option should now be active
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should not be possible to open the listbox with ArrowUp and the last option should be active when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log, disabled: true }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Try to open the listbox
+ await press(Keys.ArrowUp)
+
+ // Verify it is still closed
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to open the listbox with ArrowUp, and focus the selected option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: "b", onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
+
+ // Verify that the second listbox option is active (because it is already selected)
+ assertActiveListboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should have no active listbox option when there are no listbox options at all',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}]
+ ]],
+ ]
+ })
+
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+ assertListbox({ state: ListboxState.Visible })
+ assertActiveElement(getListbox())
+
+ assertNoActiveListboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowUp to navigate the listbox options and jump to the first non-disabled one',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { disabled: true, value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[0])
+ })
+ )
+
+ it(
+ 'should not be possible to navigate up or down if there is only a single non-disabled option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[2])
+
+ // We should not be able to go up (because those are disabled)
+ await press(Keys.ArrowUp)
+ assertActiveListboxOption(options[2])
+
+ // We should not be able to go down (because this is the last option)
+ await press(Keys.ArrowDown)
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should be possible to use ArrowUp to navigate the listbox options',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[2])
+
+ // We should be able to go down once
+ await press(Keys.ArrowUp)
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go down again
+ await press(Keys.ArrowUp)
+ assertActiveListboxOption(options[0])
+
+ // We should NOT be able to go up again (because first option). Current implementation won't go around.
+ await press(Keys.ArrowUp)
+ assertActiveListboxOption(options[0])
+ })
+ )
+ })
+
+ describe('`ArrowLeft` key', () => {
+ it(
+ 'should be possible to use ArrowLeft to navigate the listbox options',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log, horizontal: true }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ orientation: 'horizontal',
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ assertActiveListboxOption(options[2])
+
+ // We should be able to go left once
+ await press(Keys.ArrowLeft)
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go left again
+ await press(Keys.ArrowLeft)
+ assertActiveListboxOption(options[0])
+
+ // We should NOT be able to go left again (because first option). Current implementation won't go around.
+ await press(Keys.ArrowLeft)
+ assertActiveListboxOption(options[0])
+ })
+ )
+ })
+
+ describe('`End` key', () => {
+ it(
+ 'should be possible to use the End key to go to the last listbox option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ let options = getListboxOptions()
+
+ // We should be on the first option
+ assertActiveListboxOption(options[0])
+
+ // We should be able to go to the last option
+ await press(Keys.End)
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should be possible to use the End key to go to the last non disabled listbox option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { disabled: true, value: "c" }, "Option C"],
+ [ListboxOption, { disabled: true, value: "d" }, "Option D"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ let options = getListboxOptions()
+
+ // We should be on the first option
+ assertActiveListboxOption(options[0])
+
+ // We should be able to go to the last non-disabled option
+ await press(Keys.End)
+ assertActiveListboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should be possible to use the End key to go to the first listbox option if that is the only non-disabled listbox option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { disabled: true, value: "c" }, "Option C"],
+ [ListboxOption, { disabled: true, value: "d" }, "Option D"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // We opened via click, we don't have an active option
+ assertNoActiveListboxOption()
+
+ // We should not be able to go to the end
+ await press(Keys.End)
+
+ let options = getListboxOptions()
+ assertActiveListboxOption(options[0])
+ })
+ )
+
+ it(
+ 'should have no active listbox option upon End key press, when there are no non-disabled listbox options',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { disabled: true, value: "c" }, "Option C"],
+ [ListboxOption, { disabled: true, value: "d" }, "Option D"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // We opened via click, we don't have an active option
+ assertNoActiveListboxOption()
+
+ // We should not be able to go to the end
+ await press(Keys.End)
+
+ assertNoActiveListboxOption()
+ })
+ )
+ })
+
+ describe('`PageDown` key', () => {
+ it(
+ 'should be possible to use the PageDown key to go to the last listbox option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ let options = getListboxOptions()
+
+ // We should be on the first option
+ assertActiveListboxOption(options[0])
+
+ // We should be able to go to the last option
+ await press(Keys.PageDown)
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should be possible to use the PageDown key to go to the last non disabled listbox option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { disabled: true, value: "c" }, "Option C"],
+ [ListboxOption, { disabled: true, value: "d" }, "Option D"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.Enter)
+
+ let options = getListboxOptions()
+
+ // We should be on the first option
+ assertActiveListboxOption(options[0])
+
+ // We should be able to go to the last non-disabled option
+ await press(Keys.PageDown)
+ assertActiveListboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should be possible to use the PageDown key to go to the first listbox option if that is the only non-disabled listbox option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { disabled: true, value: "c" }, "Option C"],
+ [ListboxOption, { disabled: true, value: "d" }, "Option D"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // We opened via click, we don't have an active option
+ assertNoActiveListboxOption()
+
+ // We should not be able to go to the end
+ await press(Keys.PageDown)
+
+ let options = getListboxOptions()
+ assertActiveListboxOption(options[0])
+ })
+ )
+
+ it(
+ 'should have no active listbox option upon PageDown key press, when there are no non-disabled listbox options',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { disabled: true, value: "c" }, "Option C"],
+ [ListboxOption, { disabled: true, value: "d" }, "Option D"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // We opened via click, we don't have an active option
+ assertNoActiveListboxOption()
+
+ // We should not be able to go to the end
+ await press(Keys.PageDown)
+
+ assertNoActiveListboxOption()
+ })
+ )
+ })
+
+ describe('`Home` key', () => {
+ it(
+ 'should be possible to use the Home key to go to the first listbox option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+
+ let options = getListboxOptions()
+
+ // We should be on the last option
+ assertActiveListboxOption(options[2])
+
+ // We should be able to go to the first option
+ await press(Keys.Home)
+ assertActiveListboxOption(options[0])
+ })
+ )
+
+ it(
+ 'should be possible to use the Home key to go to the first non disabled listbox option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ [ListboxOption, { value: "d" }, "Option D"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // We opened via click, we don't have an active option
+ assertNoActiveListboxOption()
+
+ // We should not be able to go to the end
+ await press(Keys.Home)
+
+ let options = getListboxOptions()
+
+ // We should be on the first non-disabled option
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should be possible to use the Home key to go to the last listbox option if that is the only non-disabled listbox option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { disabled: true, value: "c" }, "Option C"],
+ [ListboxOption, { value: "d" }, "Option D"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // We opened via click, we don't have an active option
+ assertNoActiveListboxOption()
+
+ // We should not be able to go to the end
+ await press(Keys.Home)
+
+ let options = getListboxOptions()
+ assertActiveListboxOption(options[3])
+ })
+ )
+
+ it(
+ 'should have no active listbox option upon Home key press, when there are no non-disabled listbox options',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { disabled: true, value: "c" }, "Option C"],
+ [ListboxOption, { disabled: true, value: "d" }, "Option D"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // We opened via click, we don't have an active option
+ assertNoActiveListboxOption()
+
+ // We should not be able to go to the end
+ await press(Keys.Home)
+
+ assertNoActiveListboxOption()
+ })
+ )
+ })
+
+ describe('`PageUp` key', () => {
+ it(
+ 'should be possible to use the PageUp key to go to the first listbox option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+
+ let options = getListboxOptions()
+
+ // We should be on the last option
+ assertActiveListboxOption(options[2])
+
+ // We should be able to go to the first option
+ await press(Keys.PageUp)
+ assertActiveListboxOption(options[0])
+ })
+ )
+
+ it(
+ 'should be possible to use the PageUp key to go to the first non disabled listbox option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ [ListboxOption, { value: "d" }, "Option D"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // We opened via click, we don't have an active option
+ assertNoActiveListboxOption()
+
+ // We should not be able to go to the end
+ await press(Keys.PageUp)
+
+ let options = getListboxOptions()
+
+ // We should be on the first non-disabled option
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should be possible to use the PageUp key to go to the last listbox option if that is the only non-disabled listbox option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { disabled: true, value: "c" }, "Option C"],
+ [ListboxOption, { value: "d" }, "Option D"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // We opened via click, we don't have an active option
+ assertNoActiveListboxOption()
+
+ // We should not be able to go to the end
+ await press(Keys.PageUp)
+
+ let options = getListboxOptions()
+ assertActiveListboxOption(options[3])
+ })
+ )
+
+ it(
+ 'should have no active listbox option upon PageUp key press, when there are no non-disabled listbox options',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { disabled: true, value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { disabled: true, value: "c" }, "Option C"],
+ [ListboxOption, { disabled: true, value: "d" }, "Option D"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // We opened via click, we don't have an active option
+ assertNoActiveListboxOption()
+
+ // We should not be able to go to the end
+ await press(Keys.PageUp)
+
+ assertNoActiveListboxOption()
+ })
+ )
+ })
+
+ describe('`Any` key aka search', () => {
+ it(
+ 'should be possible to type a full word that has a perfect match',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "alice" }, "alice"],
+ [ListboxOption, { value: "bob" }, "bob"],
+ [ListboxOption, { value: "charlie" }, "charlie"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ let options = getListboxOptions()
+
+ // We should be able to go to the second option
+ await type(word('bob'))
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go to the first option
+ await type(word('alice'))
+ assertActiveListboxOption(options[0])
+
+ // We should be able to go to the last option
+ await type(word('charlie'))
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should be possible to type a partial of a word',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "alice" }, "alice"],
+ [ListboxOption, { value: "bob" }, "bob"],
+ [ListboxOption, { value: "charlie" }, "charlie"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+
+ let options = getListboxOptions()
+
+ // We should be on the last option
+ assertActiveListboxOption(options[2])
+
+ // We should be able to go to the second option
+ await type(word('bo'))
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go to the first option
+ await type(word('ali'))
+ assertActiveListboxOption(options[0])
+
+ // We should be able to go to the last option
+ await type(word('char'))
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should be possible to type words with spaces',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "value a"],
+ [ListboxOption, { value: "b" }, "value b"],
+ [ListboxOption, { value: "c" }, "value c"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+
+ let options = getListboxOptions()
+
+ // We should be on the last option
+ assertActiveListboxOption(options[2])
+
+ // We should be able to go to the second option
+ await type(word('value b'))
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go to the first option
+ await type(word('value a'))
+ assertActiveListboxOption(options[0])
+
+ // We should be able to go to the last option
+ await type(word('value c'))
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should not be possible to search for a disabled option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "alice" }, "alice"],
+ [ListboxOption, { disabled: true, value: "bob" }, "bob"],
+ [ListboxOption, { value: "charlie" }, "charlie"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+
+ let options = getListboxOptions()
+
+ // We should be on the last option
+ assertActiveListboxOption(options[2])
+
+ // We should not be able to go to the disabled option
+ await type(word('bo'))
+
+ // We should still be on the last option
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should be possible to search for a word (case insensitive)',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "alice" }, "alice"],
+ [ListboxOption, { value: "bob" }, "bob"],
+ [ListboxOption, { value: "charlie" }, "charlie"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Focus the button
+ getListboxButton()?.focus()
+
+ // Open listbox
+ await press(Keys.ArrowUp)
+
+ let options = getListboxOptions()
+
+ // We should be on the last option
+ assertActiveListboxOption(options[2])
+
+ // Search for bob in a different casing
+ await type(word('BO'))
+
+ // We should be on `bob`
+ assertActiveListboxOption(options[1])
+ })
+ )
+ })
+})
+
+describe('Mouse interactions', () => {
+ it(
+ 'should focus the ListboxButton when we click the ListboxLabel',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxLabel, {}, "Label"],
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Ensure the button is not focused yet
+ assertActiveElement(document.body)
+
+ // Focus the label
+ await click(getListboxLabel())
+
+ // Ensure that the actual button is focused instead
+ assertActiveElement(getListboxButton())
+ })
+ )
+
+ it(
+ 'should not focus the ListboxButton when we right click the ListboxLabel',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxLabel, {}, "Label"],
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Ensure the button is not focused yet
+ assertActiveElement(document.body)
+
+ // Focus the label
+ await click(getListboxLabel(), MouseButton.Right)
+
+ // Ensure that the body is still active
+ assertActiveElement(document.body)
+ })
+ )
+
+ it(
+ 'should be possible to open the listbox on click',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach(option => assertListboxOption(option))
+ })
+ )
+
+ it(
+ 'should not be possible to open the listbox on right click',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Try to open the listbox
+ await click(getListboxButton(), MouseButton.Right)
+
+ // Verify it is still closed
+ assertListboxButton({ state: ListboxState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should not be possible to open the listbox on click when the button is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log, disabled: true }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Try to open the listbox
+ await click(getListboxButton())
+
+ // Verify it is still closed
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to open the listbox on click, and focus the selected option',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: "b", onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ assertListboxButton({
+ state: ListboxState.InvisibleUnmounted,
+ attributes: { id: 'headlessui-listbox-button-1' },
+ })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+ assertListbox({
+ state: ListboxState.Visible,
+ attributes: { id: 'headlessui-listbox-options-2' },
+ })
+ assertActiveElement(getListbox())
+ assertListboxButtonLinkedWithListbox()
+
+ // Verify we have listbox options
+ let options = getListboxOptions()
+ expect(options).toHaveLength(3)
+ options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
+
+ // Verify that the second listbox option is active (because it is already selected)
+ assertActiveListboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should be possible to close a listbox on click',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ // Verify it is visible
+ assertListboxButton({ state: ListboxState.Visible })
+
+ // Click to close
+ await click(getListboxButton())
+
+ // Verify it is closed
+ assertListboxButton({ state: ListboxState.InvisibleUnmounted })
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be a no-op when we click outside of a closed listbox',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Verify that the window is closed
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Click something that is not related to the listbox
+ await click(document.body)
+
+ // Should still be closed
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+ })
+ )
+
+ it(
+ 'should be possible to click outside of the listbox which should close the listbox',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+ assertListbox({ state: ListboxState.Visible })
+ assertActiveElement(getListbox())
+
+ // Click something that is not related to the listbox
+ await click(document.body)
+
+ // Should be closed now
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Verify the button is focused again
+ assertActiveElement(getListboxButton())
+ })
+ )
+
+ it(
+ 'should be possible to click outside of the listbox on another listbox button which should close the current listbox and open the new listbox',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Div, {}, [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]]
+ ]
+ })
+
+ let [button1, button2] = getListboxButtons()
+
+ // Click the first listbox button
+ await click(button1)
+ expect(getListboxes()).toHaveLength(1) // Only 1 listbox should be visible
+
+ // Ensure the open listbox is linked to the first button
+ assertListboxButtonLinkedWithListbox(button1, getListbox())
+
+ // Click the second listbox button
+ await click(button2)
+
+ expect(getListboxes()).toHaveLength(1) // Only 1 listbox should be visible
+
+ // Ensure the open listbox is linked to the second button
+ assertListboxButtonLinkedWithListbox(button2, getListbox())
+ })
+ )
+
+ it(
+ 'should be possible to click outside of the listbox which should close the listbox (even if we press the listbox button)',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+ assertListbox({ state: ListboxState.Visible })
+ assertActiveElement(getListbox())
+
+ // Click the listbox button again
+ await click(getListboxButton())
+
+ // Should be closed now
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+
+ // Verify the button is focused again
+ assertActiveElement(getListboxButton())
+ })
+ )
+
+ // TODO: This test looks like it's for React-specific behavior (for some reason)
+ it.skip(
+ 'should be possible to click outside of the listbox, on an element which is within a focusable element, which closes the listbox',
+ suppressConsoleLogs(async () => {
+ let focusFn = jest.fn()
+ render(
+ TestRenderer, {
+ allProps: [
+ [Div, {}, [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, { onFocus: focusFn }, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ [Button, { id: "btn" }, [
+ [Span, {}, "Next"]
+ ]],
+ ]]
+ ]
+ })
+
+ // Click the listbox button
+ await click(getListboxButton())
+
+ // Ensure the listbox is open
+ assertListbox({ state: ListboxState.Visible })
+
+ // Click the span inside the button
+ await click(getByText('Next'))
+
+ // Ensure the listbox is closed
+ assertListbox({ state: ListboxState.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(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ let options = getListboxOptions()
+ // We should be able to go to the second option
+ await mouseMove(options[1])
+ assertActiveListboxOption(options[1])
+
+ // We should be able to go to the first option
+ await mouseMove(options[0])
+ assertActiveListboxOption(options[0])
+
+ // We should be able to go to the last option
+ await mouseMove(options[2])
+ assertActiveListboxOption(options[2])
+ })
+ )
+
+ it(
+ 'should make a listbox option active when you move the mouse over it',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ let options = getListboxOptions()
+ // We should be able to go to the second option
+ await mouseMove(options[1])
+ assertActiveListboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should be a no-op when we move the mouse and the listbox option is already active',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ let options = getListboxOptions()
+
+ // We should be able to go to the second option
+ await mouseMove(options[1])
+ assertActiveListboxOption(options[1])
+
+ await mouseMove(options[1])
+
+ // Nothing should be changed
+ assertActiveListboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should be a no-op when we move the mouse and the listbox option is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ let options = getListboxOptions()
+
+ await mouseMove(options[1])
+ assertNoActiveListboxOption()
+ })
+ )
+
+ it(
+ 'should not be possible to hover an option that is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ let options = getListboxOptions()
+
+ // Try to hover over option 1, which is disabled
+ await mouseMove(options[1])
+
+ // We should not have an active option now
+ assertNoActiveListboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to mouse leave an option and make it inactive',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ let options = getListboxOptions()
+
+ // We should be able to go to the second option
+ await mouseMove(options[1])
+ assertActiveListboxOption(options[1])
+
+ await mouseLeave(options[1])
+ assertNoActiveListboxOption()
+
+ // We should be able to go to the first option
+ await mouseMove(options[0])
+ assertActiveListboxOption(options[0])
+
+ await mouseLeave(options[0])
+ assertNoActiveListboxOption()
+
+ // We should be able to go to the last option
+ await mouseMove(options[2])
+ assertActiveListboxOption(options[2])
+
+ await mouseLeave(options[2])
+ assertNoActiveListboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to mouse leave a disabled option and be a no-op',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ let options = getListboxOptions()
+
+ // Try to hover over option 1, which is disabled
+ await mouseMove(options[1])
+ assertNoActiveListboxOption()
+
+ await mouseLeave(options[1])
+ assertNoActiveListboxOption()
+ })
+ )
+
+ it(
+ 'should be possible to click a listbox option, which closes the listbox',
+ suppressConsoleLogs(async () => {
+ let handleChange = jest.fn()
+
+ render(
+ TestRenderer, {
+ allProps: [
+ [ManagedListbox, { value: undefined, onChange: (e: CustomEvent) => handleChange(e.detail) }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+ assertListbox({ state: ListboxState.Visible })
+ assertActiveElement(getListbox())
+
+ let options = getListboxOptions()
+
+ // We should be able to click the first option
+ await click(options[1])
+ assertListbox({ state: ListboxState.InvisibleUnmounted })
+ expect(handleChange).toHaveBeenCalledTimes(1)
+ expect(handleChange).toHaveBeenCalledWith('b')
+
+ // Verify the button is focused again
+ assertActiveElement(getListboxButton())
+
+ // Open listbox again
+ await click(getListboxButton())
+
+ // Verify the active option is the previously selected one
+ assertActiveListboxOption(getListboxOptions()[1])
+ })
+ )
+
+ it(
+ 'should be possible to click a disabled listbox option, which is a no-op',
+ suppressConsoleLogs(async () => {
+ let handleChange = jest.fn()
+
+ render(
+ TestRenderer, {
+ allProps: [
+ [ManagedListbox, { value: undefined, onChange: (e: CustomEvent) => handleChange(e.detail) }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+
+ // Open listbox
+ await click(getListboxButton())
+ assertListbox({ state: ListboxState.Visible })
+ assertActiveElement(getListbox())
+
+ let options = getListboxOptions()
+
+ // We should be able to click the first option
+ await click(options[1])
+ assertListbox({ state: ListboxState.Visible })
+ assertActiveElement(getListbox())
+ expect(handleChange).toHaveBeenCalledTimes(0)
+
+ // Close the listbox
+ await click(getListboxButton())
+
+ // Open listbox again
+ await click(getListboxButton())
+
+ // Verify the active option is non existing
+ assertNoActiveListboxOption()
+ })
+ )
+
+ it(
+ 'should be possible focus a listbox option, so that it becomes active',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+ assertListbox({ state: ListboxState.Visible })
+ assertActiveElement(getListbox())
+
+ let options = getListboxOptions()
+
+ // Verify that nothing is active yet
+ assertNoActiveListboxOption()
+
+ // We should be able to focus the first option
+ await focus(options[1])
+ assertActiveListboxOption(options[1])
+ })
+ )
+
+ it(
+ 'should not be possible to focus a listbox option which is disabled',
+ suppressConsoleLogs(async () => {
+ render(
+ TestRenderer, {
+ allProps: [
+ [Listbox, { value: undefined, onChange: console.log }, [
+ [ListboxButton, {}, "Trigger"],
+ [ListboxOptions, {}, [
+ [ListboxOption, { value: "a" }, "Option A"],
+ [ListboxOption, { disabled: true, value: "b" }, "Option B"],
+ [ListboxOption, { value: "c" }, "Option C"],
+ ]]
+ ]],
+ ]
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+ assertListbox({ state: ListboxState.Visible })
+ assertActiveElement(getListbox())
+
+ let options = getListboxOptions()
+
+ // We should not be able to focus the first option
+ await focus(options[1])
+ assertNoActiveListboxOption()
+ })
+ )
+})