import { render } from '@testing-library/react' import React, { Fragment, createElement, useEffect, useState } from 'react' import { ComboboxMode, ComboboxState, assertActiveComboboxOption, assertActiveElement, assertCombobox, assertComboboxButton, assertComboboxButtonLinkedWithCombobox, assertComboboxButtonLinkedWithComboboxLabel, assertComboboxInput, assertComboboxLabel, assertComboboxLabelLinkedWithCombobox, assertComboboxList, assertComboboxOption, assertNoActiveComboboxOption, assertNoSelectedComboboxOption, assertNotActiveComboboxOption, getByText, getComboboxButton, getComboboxButtons, getComboboxInput, getComboboxInputs, getComboboxLabel, getComboboxOptions, getComboboxes, } from '../../test-utils/accessibility-assertions' import { Keys, MouseButton, blur, click, focus, mouseLeave, mouseMove, press, rawClick, shift, type, word, } from '../../test-utils/interactions' import { mockingConsoleLogs, suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { Transition } from '../transition/transition' import { Combobox } from './combobox' let NOOP = () => {} global.ResizeObserver = class FakeResizeObserver { observe() {} unobserve() {} disconnect() {} } jest.mock('../../hooks/use-id') beforeAll(() => { jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any) jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any) }) afterAll(() => jest.restoreAllMocks()) describe('safeguards', () => { it.each([ ['Combobox.Button', Combobox.Button], ['Combobox.Label', Combobox.Label], ['Combobox.Options', Combobox.Options], ['Combobox.Option', Combobox.Option], ])( 'should error when we are using a <%s /> without a parent ', suppressConsoleLogs((name, Component) => { if (name === 'Combobox.Label') { // @ts-expect-error This is fine expect(() => render(createElement(Component))).toThrow( 'You used a ) // Open the combobox await click(getComboboxButton()) // Verify it is open assertComboboxList({ state: ComboboxState.Visible }) // Close the combobox expect(closeHandler).toHaveBeenCalledTimes(0) await blur(getComboboxInput()) expect(closeHandler).toHaveBeenCalledTimes(1) // Verify it is closed assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should not crash when the `Combobox` still contains a `nullable` prop', suppressConsoleLogs(async () => { let data = [ { id: 1, name: 'alice', label: 'Alice' }, { id: 2, name: 'bob', label: 'Bob' }, { id: 3, name: 'charlie', label: 'Charlie' }, ] render( {data.map((person) => ( {person.label} ))} ) }) ) }) describe('Combobox.Input', () => { it( 'selecting an option puts the value into Combobox.Input when displayValue is not provided', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(null) return ( Trigger Option A Option B Option C ) } render() assertComboboxInput({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxInput({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible }) await click(getComboboxOptions()[1]) expect(getComboboxInput()).toHaveValue('b') }) ) it( 'selecting an option puts the display value into Combobox.Input when displayValue is provided', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(null) return ( str?.toUpperCase() ?? ''} /> Trigger Option A Option B Option C ) } render() await click(getComboboxButton()) assertComboboxList({ state: ComboboxState.Visible }) await click(getComboboxOptions()[1]) expect(getComboboxInput()).toHaveValue('B') }) ) it( 'selecting an option puts the display value into Combobox.Input when displayValue is provided (when value is undefined)', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(undefined) return ( str?.toUpperCase() ?? ''} /> Trigger Option A Option B Option C ) } render() // Focus the input await focus(getComboboxInput()) // Type in it await type(word('A'), getComboboxInput()) // Stop typing (and clear the input) await press(Keys.Escape, getComboboxInput()) // Focus the body (so the input loses focus) await focus(document.body) expect(getComboboxInput()).toHaveValue('') }) ) it( 'conditionally rendering the input should allow changing the display value', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(null) let [suffix, setSuffix] = useState(false) return ( <> `${str?.toUpperCase() ?? ''} ${suffix ? 'with suffix' : 'no suffix'}` } /> Trigger Option A Option B Option C ) } render() expect(getComboboxInput()).toHaveValue(' no suffix') await click(getComboboxButton()) expect(getComboboxInput()).toHaveValue(' no suffix') await click(getComboboxOptions()[1]) expect(getComboboxInput()).toHaveValue('B no suffix') await click(getByText('Toggle suffix')) expect(getComboboxInput()).toHaveValue('B with suffix') await click(getComboboxButton()) expect(getComboboxInput()).toHaveValue('B with suffix') await click(getComboboxOptions()[0]) expect(getComboboxInput()).toHaveValue('A with suffix') }) ) it( 'should be possible to override the `type` on the input', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(null) return ( Trigger Option A Option B Option C ) } render() expect(getComboboxInput()).toHaveAttribute('type', 'search') }) ) xit( 'should reflect the value in the input when the value changes and when you are typing', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState('bob') let [_query, setQuery] = useState('') return ( {({ open }) => ( <> setQuery(event.target.value)} displayValue={(person) => `${person ?? ''} - ${open ? 'open' : 'closed'}`} /> alice bob charlie )} ) } render() // Check for proper state sync expect(getComboboxInput()).toHaveValue('bob - closed') await click(getComboboxButton()) expect(getComboboxInput()).toHaveValue('bob - open') await click(getComboboxButton()) expect(getComboboxInput()).toHaveValue('bob - closed') // Check if we can still edit the input for (let _ of Array(' - closed'.length)) { await press(Keys.Backspace, getComboboxInput()) } getComboboxInput()?.select() await type(word('alice'), getComboboxInput()) expect(getComboboxInput()).toHaveValue('alice') // Open the combobox and choose an option await click(getComboboxOptions()[2]) expect(getComboboxInput()).toHaveValue('charlie - closed') }) ) it( 'should move the caret to the end of the input when syncing the value', suppressConsoleLogs(async () => { function Example() { return ( alice bob charlie ) } render() // Open the combobox await click(getComboboxButton()) // Choose charlie await click(getComboboxOptions()[2]) expect(getComboboxInput()).toHaveValue('charlie') // Ensure the selection is in the correct position expect(getComboboxInput()?.selectionStart).toBe('charlie'.length) expect(getComboboxInput()?.selectionEnd).toBe('charlie'.length) }) ) // Skipped because JSDOM doesn't implement this properly: https://github.com/jsdom/jsdom/issues/3524 xit( 'should not move the caret to the end of the input when syncing the value if a custom selection is made', suppressConsoleLogs(async () => { function Example() { return ( { e.target.select() e.target.setSelectionRange(0, e.target.value.length) }} /> alice bob charlie ) } render() // Open the combobox await click(getComboboxButton()) // Choose charlie await click(getComboboxOptions()[2]) expect(getComboboxInput()).toHaveValue('charlie') // Ensure the selection is in the correct position expect(getComboboxInput()?.selectionStart).toBe(0) expect(getComboboxInput()?.selectionEnd).toBe('charlie'.length) }) ) }) describe('Combobox.Label', () => { it( 'should be possible to render a Combobox.Label using a render prop', suppressConsoleLogs(async () => { render( console.log(x)}> {(slot) => <>{JSON.stringify(slot)}} Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-3' }, }) assertComboboxLabel({ attributes: { id: 'headlessui-label-1' }, textContent: JSON.stringify({ open: false, disabled: false }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxLabel({ attributes: { id: 'headlessui-label-1' }, textContent: JSON.stringify({ open: true, disabled: false }), }) assertComboboxList({ state: ComboboxState.Visible }) assertComboboxLabelLinkedWithCombobox() assertComboboxButtonLinkedWithComboboxLabel() }) ) it( 'should be possible to link Input/Button and Label if Label is rendered last', suppressConsoleLogs(async () => { render( console.log(x)}> Label ) assertComboboxLabelLinkedWithCombobox() assertComboboxButtonLinkedWithComboboxLabel() }) ) it( 'should be possible to render a Combobox.Label using a render prop and an `as` prop', suppressConsoleLogs(async () => { render( console.log(x)}> {(slot) => <>{JSON.stringify(slot)}} Trigger Option A Option B Option C ) assertComboboxLabel({ attributes: { id: 'headlessui-label-1' }, textContent: JSON.stringify({ open: false, disabled: false }), tag: 'p', }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxLabel({ attributes: { id: 'headlessui-label-1' }, textContent: JSON.stringify({ open: true, disabled: false }), tag: 'p', }) assertComboboxList({ state: ComboboxState.Visible }) }) ) }) describe('Combobox.Button', () => { it( 'should be possible to render a Combobox.Button using a render prop', suppressConsoleLogs(async () => { render( console.log(x)}> {(slot) => <>{JSON.stringify(slot)}} Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: false, active: false, disabled: false, value: 'test', hover: false, focus: false, }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: true, active: true, disabled: false, value: 'test', hover: false, focus: false, }), }) assertComboboxList({ state: ComboboxState.Visible }) }) ) it( 'should be possible to render a Combobox.Button using a render prop and an `as` prop', suppressConsoleLogs(async () => { render( console.log(x)}> {(slot) => <>{JSON.stringify(slot)}} Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: false, active: false, disabled: false, value: 'test', hover: false, focus: false, }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: true, active: true, disabled: false, value: 'test', hover: false, focus: false, }), }) assertComboboxList({ state: ComboboxState.Visible }) }) ) it( 'should be possible to render a Combobox.Button and a Combobox.Label and see them linked together', suppressConsoleLogs(async () => { render( console.log(x)}> Label Trigger Option A Option B Option C ) // TODO: Needed to make it similar to vue test implementation? // await new Promise(requestAnimationFrame) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-3' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) assertComboboxButtonLinkedWithComboboxLabel() }) ) describe('`type` attribute', () => { it('should set the `type` to "button" by default', async () => { render( console.log(x)}> Trigger ) expect(getComboboxButton()).toHaveAttribute('type', 'button') }) it('should not set the `type` to "button" if it already contains a `type`', async () => { render( console.log(x)}> Trigger ) expect(getComboboxButton()).toHaveAttribute('type', 'submit') }) it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => { let CustomButton = React.forwardRef((props, ref) => ( ) await click(document.getElementById('submit')) // No values expect(handleSubmission).toHaveBeenLastCalledWith({}) // Open combobox await click(getComboboxButton()) // Choose alice await click(getComboboxOptions()[0]) // Submit await click(document.getElementById('submit')) // Alice should be submitted expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' }) // Open combobox await click(getComboboxButton()) // Choose charlie await click(getComboboxOptions()[2]) // Submit await click(document.getElementById('submit')) // Charlie should be submitted expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' }) }) it('should expose the value via the render prop', async () => { let handleSubmission = jest.fn() let { getByTestId } = render(
{ e.preventDefault() handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement))) }} > name="assignee"> {({ value }) => ( <>
{value}
{({ value }) => ( <> Trigger
{value}
)}
Alice Bob Charlie )} ) await click(document.getElementById('submit')) // No values expect(handleSubmission).toHaveBeenLastCalledWith({}) // Open combobox await click(getComboboxButton()) // Choose alice await click(getComboboxOptions()[0]) expect(getByTestId('value')).toHaveTextContent('alice') expect(getByTestId('value-2')).toHaveTextContent('alice') // Submit await click(document.getElementById('submit')) // Alice should be submitted expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' }) // Open combobox await click(getComboboxButton()) // Choose charlie await click(getComboboxOptions()[2]) expect(getByTestId('value')).toHaveTextContent('charlie') expect(getByTestId('value-2')).toHaveTextContent('charlie') // Submit await click(document.getElementById('submit')) // Charlie should be submitted expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' }) }) it('should be possible to provide a default value', async () => { let handleSubmission = jest.fn() render(
{ e.preventDefault() handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement))) }} > Trigger Alice Bob Charlie
) await click(document.getElementById('submit')) // Bob is the defaultValue expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' }) // Open combobox await click(getComboboxButton()) // Choose alice await click(getComboboxOptions()[0]) // Submit await click(document.getElementById('submit')) // Alice should be submitted expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' }) }) it('should be possible to reset to the default value if the form is reset', async () => { let handleSubmission = jest.fn() render(
{ e.preventDefault() handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement))) }} > {({ value }) => value ?? 'Trigger'} value} /> Alice Bob Charlie
) await click(document.getElementById('submit')) // Bob is the defaultValue expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' }) // Open combobox await click(getComboboxButton()) // Choose alice await click(getComboboxOptions()[0]) expect(getComboboxButton()).toHaveTextContent('alice') expect(getComboboxInput()).toHaveValue('alice') // Reset await click(document.getElementById('reset')) // The combobox should be reset to bob expect(getComboboxButton()).toHaveTextContent('bob') expect(getComboboxInput()).toHaveValue('bob') // Open combobox await click(getComboboxButton()) assertActiveComboboxOption(getComboboxOptions()[1]) }) it('should be possible to reset to the default value if the form is reset (using objects)', async () => { let handleSubmission = jest.fn() let data = [ { id: 1, name: 'alice', label: 'Alice' }, { id: 2, name: 'bob', label: 'Bob' }, { id: 3, name: 'charlie', label: 'Charlie' }, ] render(
{ e.preventDefault() handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement))) }} > name="assignee" defaultValue={{ id: 2, name: 'bob', label: 'Bob' }} by="id" > {({ value }) => value?.name ?? 'Trigger'} value.name} /> {data.map((person) => ( {person.label} ))} ) await click(document.getElementById('submit')) // Bob is the defaultValue expect(handleSubmission).toHaveBeenLastCalledWith({ 'assignee[id]': '2', 'assignee[name]': 'bob', 'assignee[label]': 'Bob', }) // Open combobox await click(getComboboxButton()) // Choose alice await click(getComboboxOptions()[0]) expect(getComboboxButton()).toHaveTextContent('alice') expect(getComboboxInput()).toHaveValue('alice') // Reset await click(document.getElementById('reset')) // The combobox should be reset to bob expect(getComboboxButton()).toHaveTextContent('bob') expect(getComboboxInput()).toHaveValue('bob') // Open combobox await click(getComboboxButton()) assertActiveComboboxOption(getComboboxOptions()[1]) }) it('should be possible to reset to the default value in multiple mode', async () => { let handleSubmission = jest.fn() let data = ['alice', 'bob', 'charlie'] render(
{ e.preventDefault() handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement))) }} > {({ value }) => value.join(', ') || 'Trigger'} {data.map((person) => ( {person} ))}
) await click(document.getElementById('submit')) // Bob is the defaultValue expect(handleSubmission).toHaveBeenLastCalledWith({ 'assignee[0]': 'bob', }) await click(document.getElementById('reset')) await click(document.getElementById('submit')) // Bob is still the defaultValue expect(handleSubmission).toHaveBeenLastCalledWith({ 'assignee[0]': 'bob', }) }) it('should still call the onChange listeners when choosing new values', async () => { let handleChange = jest.fn() render( Trigger Alice Bob Charlie ) // Open combobox await click(getComboboxButton()) // Choose alice await click(getComboboxOptions()[0]) // Open combobox await click(getComboboxButton()) // Choose bob await click(getComboboxOptions()[1]) // Change handler should have been called twice expect(handleChange).toHaveBeenNthCalledWith(1, 'alice') expect(handleChange).toHaveBeenNthCalledWith(2, 'bob') }) }) describe.each([{ virtual: true }, { virtual: false }])('Data attributes', ({ virtual }) => { let data = ['Option A', 'Option B', 'Option C'] function MyCombobox({ options = data.slice() as T[], useComboboxOptions = true, comboboxProps = {}, inputProps = {}, buttonProps = {}, optionProps = {}, }: { options?: T[] useComboboxOptions?: boolean comboboxProps?: Record inputProps?: Record buttonProps?: Record optionProps?: Record }) { function isDisabled(option: T): boolean { return typeof option === 'string' ? false : typeof option === 'object' && option !== null && 'disabled' in option && typeof option.disabled === 'boolean' ? option?.disabled ?? false : false } if (virtual) { return ( Trigger {useComboboxOptions && ( {({ option }) => { return }} )} ) } return ( Trigger {useComboboxOptions && ( {options.map((option, idx) => { return ( ) })} )} ) } it('Disabled options should get a data-disabled attribute', async () => { render( ) // Open the Combobox await click(getByText('Trigger')) let options = getComboboxOptions() expect(options[0]).not.toHaveAttribute('data-disabled') expect(options[1]).toHaveAttribute('data-disabled', '') expect(options[2]).not.toHaveAttribute('data-disabled') }) }) }) describe('Rendering composition', () => { it( 'should be possible to conditionally render classNames (aka className can be a function?!)', suppressConsoleLogs(async () => { render( console.log(x)}> Trigger JSON.stringify(bag)}> Option A JSON.stringify(bag)}> Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open Combobox await click(getComboboxButton()) let options = getComboboxOptions() // Verify that the first combobox option is active assertActiveComboboxOption(options[0]) // Verify correct classNames expect('' + options[0].classList).toEqual( JSON.stringify({ active: true, focus: true, selected: false, disabled: false, }) ) expect('' + options[1].classList).toEqual( JSON.stringify({ active: false, focus: false, selected: false, disabled: true, }) ) expect('' + options[2].classList).toEqual('no-special-treatment') // 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, focus: false, selected: false, disabled: false, }) ) expect('' + options[1].classList).toEqual( JSON.stringify({ active: false, focus: false, selected: false, disabled: true, }) ) expect('' + options[2].classList).toEqual('no-special-treatment') // Double check that the last option is the active one assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to swap the Combobox option with a button for example', suppressConsoleLogs(async () => { render( console.log(x)}> Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open Combobox await click(getComboboxButton()) // Verify options are buttons now getComboboxOptions().forEach((option) => assertComboboxOption(option, { tag: 'button' })) }) ) it( 'should mark all the elements between Combobox.Options and Combobox.Option with role none', suppressConsoleLogs(async () => { render( console.log(x)}>
Option A Option B
Option C
Option D
Option E
) // Open combobox await click(getComboboxButton()) expect.hasAssertions() document.querySelectorAll('.outer').forEach((element) => { expect(element).not.toHaveAttribute('role', 'none') }) document.querySelectorAll('.inner').forEach((element) => { expect(element).toHaveAttribute('role', 'none') }) }) ) }) describe('Composition', () => { function Debug({ fn, name }: { fn: (text: string) => void; name: string }) { useEffect(() => { fn(`Mounting - ${name}`) return () => { fn(`Unmounting - ${name}`) } }, [fn, name]) return null } it( 'should be possible to wrap the Combobox.Options with a Transition component', suppressConsoleLogs(async () => { let orderFn = jest.fn() render( console.log(x)}> Trigger {(data) => ( <> {JSON.stringify(data)} )} ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await rawClick(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.Visible, textContent: JSON.stringify({ active: true, focus: true, selected: false, disabled: false, }), }) await rawClick(getComboboxButton()) // Verify that we tracked the `mounts` and `unmounts` in the correct order expect(orderFn.mock.calls).toEqual([ ['Mounting - Combobox'], ['Mounting - Transition'], ['Mounting - Combobox.Option'], ['Unmounting - Transition'], ['Unmounting - Combobox.Option'], ]) }) ) }) describe.each([{ virtual: true }, { virtual: false }])( 'Keyboard interactions %s', ({ virtual }) => { let data = ['Option A', 'Option B', 'Option C'] function MyCombobox({ options = data.slice() as T[], useComboboxOptions = true, comboboxProps = {}, inputProps = {}, buttonProps = {}, optionProps = {}, }: { options?: T[] useComboboxOptions?: boolean comboboxProps?: Record inputProps?: Record buttonProps?: Record optionProps?: Record }) { function isDisabled(option: T): boolean { return typeof option === 'string' ? false : typeof option === 'object' && option !== null && 'disabled' in option && typeof option.disabled === 'boolean' ? option?.disabled ?? false : false } if (virtual) { return ( Trigger {useComboboxOptions && ( {({ option }) => { return }} )} ) } return ( Trigger {useComboboxOptions && ( {options.map((option, idx) => { return ( ) })} )} ) } describe('Button', () => { describe('`Enter` key', () => { it( 'should be possible to open the combobox with Enter', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Enter) // Verify we moved focus to the input field assertActiveElement(getComboboxInput()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option, { selected: false })) assertActiveComboboxOption(options[0]) assertNoSelectedComboboxOption() }) ) it( 'should not be possible to open the combobox with Enter when the button is disabled', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Try to focus the button await focus(getComboboxButton()) // Try to open the combobox await press(Keys.Enter) // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should be possible to open the combobox with Enter, and focus the selected option', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Enter) // Verify we moved focus to the input field assertActiveElement(getComboboxInput()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should be possible to open the combobox with Enter, and focus the selected option (when using the `hidden` render strategy)', suppressConsoleLogs(async () => { if (virtual) return // Incompatible with virtual rendering render( console.log(x)}> Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleHidden, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleHidden }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Enter) // Verify we moved focus to the input field assertActiveElement(getComboboxInput()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() let options = getComboboxOptions() // Hover over Option A await mouseMove(options[0]) // Verify that Option A is active assertActiveComboboxOption(options[0]) // Verify that Option B is still selected assertComboboxOption(options[1], { selected: true }) // Close/Hide the combobox await press(Keys.Escape) // Re-open the combobox await click(getComboboxButton()) // Verify we have combobox options expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should be possible to open the combobox with Enter, and focus the selected option (with a list of objects)', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Enter) // Verify we moved focus to the input field assertActiveElement(getComboboxInput()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { render() assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Enter) // Verify we moved focus to the input field assertActiveElement(getComboboxInput()) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() }) ) }) describe('`Space` key', () => { it( 'should be possible to open the combobox with Space', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Space) // Verify we moved focus to the input field assertActiveElement(getComboboxInput()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) }) ) it( 'should not be possible to open the combobox with Space when the button is disabled', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Try to open the combobox await press(Keys.Space) // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should be possible to open the combobox with Space, and focus the selected option', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted, }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Space) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { render() assertComboboxList({ state: ComboboxState.InvisibleUnmounted, }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Space) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() }) ) it( 'should have no active combobox option upon Space key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { render( ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted, }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Space) assertNoActiveComboboxOption() }) ) }) describe('`Escape` key', () => { it( 'should be possible to close an open combobox with Escape', suppressConsoleLogs(async () => { render() // Open combobox await click(getComboboxButton()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Re-focus the button await focus(getComboboxButton()) assertActiveElement(getComboboxButton()) // Close combobox await press(Keys.Escape) // Verify it is closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Verify the input is focused again assertActiveElement(getComboboxInput()) }) ) it( 'should not propagate the Escape event when the combobox is open', suppressConsoleLogs(async () => { let handleKeyDown = jest.fn() render(
) // Open combobox await click(getComboboxButton()) // Close combobox await press(Keys.Escape) // We should never see the Escape event expect(handleKeyDown).toHaveBeenCalledTimes(0) }) ) it( 'should propagate the Escape event when the combobox is closed', suppressConsoleLogs(async () => { let handleKeyDown = jest.fn() render(
) // Focus the input field await focus(getComboboxInput()) // Close combobox await press(Keys.Escape) // We should never see the Escape event expect(handleKeyDown).toHaveBeenCalledTimes(1) }) ) }) describe('`ArrowDown` key', () => { it( 'should be possible to open the combobox with ArrowDown', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowDown) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) // Verify that the first combobox option is active assertActiveComboboxOption(options[0]) }) ) it( 'should not be possible to open the combobox with ArrowDown when the button is disabled', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Try to open the combobox await press(Keys.ArrowDown) // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should be possible to open the combobox with ArrowDown, and focus the selected option', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowDown) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { render() assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowDown) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() }) ) }) describe('`ArrowUp` key', () => { it( 'should be possible to open the combobox with ArrowUp and the last option should be active', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowUp) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) // ! ALERT: The LAST option should now be active assertActiveComboboxOption(options[2]) }) ) it( 'should not be possible to open the combobox with ArrowUp and the last option should be active when the button is disabled', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Try to open the combobox await press(Keys.ArrowUp) // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should be possible to open the combobox with ArrowUp, and focus the selected option', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowUp) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { render() assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowUp) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() }) ) it( 'should be possible to use ArrowUp to navigate the combobox options and jump to the first non-disabled one', suppressConsoleLogs(async () => { render( ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowUp) // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) }) ) }) }) describe('Input', () => { describe('`Enter` key', () => { it( 'should be possible to close the combobox with Enter and choose the active combobox option', suppressConsoleLogs(async () => { let handleChange = jest.fn() let closeHandler = jest.fn() function Example() { let [value, setValue] = useState(undefined) return ( ) } render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) // Activate the first combobox option let options = getComboboxOptions() await mouseMove(options[0]) // Choose option, and close combobox expect(closeHandler).toHaveBeenCalledTimes(0) await press(Keys.Enter) expect(closeHandler).toHaveBeenCalledTimes(1) // Verify it is closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Verify we got the change event expect(handleChange).toHaveBeenCalledTimes(1) expect(handleChange).toHaveBeenCalledWith('Option A') // Verify the button is focused again assertActiveElement(getComboboxInput()) // Open combobox again await click(getComboboxButton()) // Verify the active option is the previously selected one assertActiveComboboxOption(getComboboxOptions()[0]) }) ) it( 'should submit the form on `Enter`', suppressConsoleLogs(async () => { let submits = jest.fn() function Example() { let [value, setValue] = useState('b') return (
{ // JSDom doesn't automatically submit the form but if we can // catch an `Enter` event, we can assume it was a submit. if (event.key === 'Enter') event.currentTarget.submit() }} onSubmit={(event) => { event.preventDefault() submits([...new FormData(event.currentTarget).entries()]) }} > ) } render() // Focus the input field await focus(getComboboxInput()) assertActiveElement(getComboboxInput()) // Press enter (which should submit the form) await press(Keys.Enter) // Verify the form was submitted expect(submits).toHaveBeenCalledTimes(1) expect(submits).toHaveBeenCalledWith([['option', 'b']]) }) ) it( 'should submit the form on `Enter` (when no submit button was found)', suppressConsoleLogs(async () => { let submits = jest.fn() function Example() { let [value, setValue] = useState('b') return (
{ // JSDom doesn't automatically submit the form but if we can // catch an `Enter` event, we can assume it was a submit. if (event.key === 'Enter') event.currentTarget.submit() }} onSubmit={(event) => { event.preventDefault() submits([...new FormData(event.currentTarget).entries()]) }} > ) } render() // Focus the input field await focus(getComboboxInput()) assertActiveElement(getComboboxInput()) // Press enter (which should submit the form) await press(Keys.Enter) // Verify the form was submitted expect(submits).toHaveBeenCalledTimes(1) expect(submits).toHaveBeenCalledWith([['option', 'b']]) }) ) }) describe('`Tab` key', () => { it( 'pressing Tab should select the active item and move to the next DOM node', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(undefined) return ( <> ) } render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Select the 2nd option await press(Keys.ArrowDown) // Tab to the next DOM node await press(Keys.Tab) // Verify it is closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // That the selected value was the highlighted one expect(getComboboxInput()?.value).toBe('Option B') // And focus has moved to the next element assertActiveElement(document.querySelector('#after-combobox')) }) ) it( 'pressing Shift+Tab should select the active item and move to the previous DOM node', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(undefined) return ( <> ) } render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Select the 2nd option await press(Keys.ArrowDown) // Tab to the next DOM node await press(shift(Keys.Tab)) // Verify it is closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // That the selected value was the highlighted one expect(getComboboxInput()?.value).toBe('Option B') // And focus has moved to the next element assertActiveElement(document.querySelector('#before-combobox')) }) ) }) describe('`Escape` key', () => { it( 'should be possible to close an open combobox with Escape', suppressConsoleLogs(async () => { render() // Open combobox await click(getComboboxButton()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Close combobox await press(Keys.Escape) // Verify it is closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getComboboxInput()) }) ) it( 'should bubble escape when using `static` on Combobox.Options', suppressConsoleLogs(async () => { if (virtual) return // Incompatible with virtual rendering render( console.log(x)}> Trigger Option A Option B Option C ) let spy = jest.fn() window.addEventListener( 'keydown', (evt) => { if (evt.key === 'Escape') { spy() } }, { capture: true } ) window.addEventListener('keydown', (evt) => { if (evt.key === 'Escape') { spy() } }) // Open combobox await click(getComboboxButton()) // Verify the input is focused assertActiveElement(getComboboxInput()) // Close combobox await press(Keys.Escape) // Verify the input is still focused assertActiveElement(getComboboxInput()) // The external event handler should've been called twice // Once in the capture phase and once in the bubble phase expect(spy).toHaveBeenCalledTimes(2) }) ) it( 'should bubble escape when not using Combobox.Options at all', suppressConsoleLogs(async () => { render() let spy = jest.fn() window.addEventListener( 'keydown', (evt) => { if (evt.key === 'Escape') { spy() } }, { capture: true } ) window.addEventListener('keydown', (evt) => { if (evt.key === 'Escape') { spy() } }) // Open combobox await click(getComboboxButton()) // Verify the input is focused assertActiveElement(getComboboxInput()) // Close combobox await press(Keys.Escape) // Verify the input is still focused assertActiveElement(getComboboxInput()) // The external event handler should've been called twice // Once in the capture phase and once in the bubble phase expect(spy).toHaveBeenCalledTimes(2) }) ) it( 'should sync the input field correctly and reset it when pressing Escape', suppressConsoleLogs(async () => { render() // Open combobox await click(getComboboxButton()) // Verify the input has the selected value expect(getComboboxInput()?.value).toBe('Option B') // Override the input by typing something await type(word('test'), getComboboxInput()) expect(getComboboxInput()?.value).toBe('test') // Close combobox await press(Keys.Escape) // Verify the input is reset correctly expect(getComboboxInput()?.value).toBe('Option B') }) ) }) describe('`ArrowDown` key', () => { it( 'should be possible to open the combobox with ArrowDown', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowDown) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) // Verify that the first combobox option is active assertActiveComboboxOption(options[0]) }) ) it( 'should not be possible to open the combobox with ArrowDown when the button is disabled', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Try to open the combobox await press(Keys.ArrowDown) // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should be possible to open the combobox with ArrowDown, and focus the selected option', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowDown) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { render() assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowDown) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() }) ) it( 'should be possible to use ArrowDown to navigate the combobox options', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) // We should be able to go down once await press(Keys.ArrowDown) assertActiveComboboxOption(options[1]) // We should be able to go down again await press(Keys.ArrowDown) assertActiveComboboxOption(options[2]) // We should NOT be able to go down again (because last option). // Current implementation won't go around. await press(Keys.ArrowDown) assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to use ArrowDown to navigate the combobox options and skip the first disabled one', suppressConsoleLogs(async () => { render( ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[1]) // We should be able to go down once await press(Keys.ArrowDown) assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to use ArrowDown to navigate the combobox options and jump to the first non-disabled one', suppressConsoleLogs(async () => { render( ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[2]) // Open combobox await press(Keys.ArrowDown) assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to go to the next item if no value is set', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // Verify that we are on the first option assertActiveComboboxOption(options[0]) // Go down once await press(Keys.ArrowDown) // We should be on the next item assertActiveComboboxOption(options[1]) }) ) }) describe('`ArrowUp` key', () => { it( 'should be possible to open the combobox with ArrowUp and the last option should be active', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) // ! ALERT: The LAST option should now be active assertActiveComboboxOption(options[2]) }) ) it( 'should not be possible to open the combobox with ArrowUp and the last option should be active when the button is disabled', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Try to open the combobox await press(Keys.ArrowUp) // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should be possible to open the combobox with ArrowUp, and focus the selected option', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { render() assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() }) ) it( 'should be possible to use ArrowUp to navigate the combobox options and jump to the first non-disabled one', suppressConsoleLogs(async () => { render( ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) }) ) it( 'should not be possible to navigate up or down if there is only a single non-disabled option', suppressConsoleLogs(async () => { render( ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[2]) // Going up or down should select the single available option await press(Keys.ArrowUp) // We should not be able to go up (because those are disabled) await press(Keys.ArrowUp) assertActiveComboboxOption(options[2]) // We should not be able to go down (because this is the last option) await press(Keys.ArrowDown) assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to use ArrowUp to navigate the combobox options', suppressConsoleLogs(async () => { render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[2]) // We should be able to go down once await press(Keys.ArrowUp) assertActiveComboboxOption(options[1]) // We should be able to go down again await press(Keys.ArrowUp) assertActiveComboboxOption(options[0]) // We should NOT be able to go up again (because first option). Current implementation won't go around. await press(Keys.ArrowUp) assertActiveComboboxOption(options[0]) }) ) }) describe('`End` key', () => { it( 'should be possible to use the End key to go to the last combobox option', suppressConsoleLogs(async () => { render() // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be on the first non-disabled option assertActiveComboboxOption(options[0]) // We should be able to go to the last option await press(Keys.End) assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to use the End key to go to the last non disabled combobox option', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be on the first non-disabled option assertActiveComboboxOption(options[0]) // We should be able to go to the last non-disabled option await press(Keys.End) assertActiveComboboxOption(options[1]) }) ) it( 'should be possible to use the End key to go to the first combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be on the first non-disabled option assertActiveComboboxOption(options[0]) // We should not be able to go to the end (no-op) await press(Keys.End) assertActiveComboboxOption(options[0]) }) ) it( 'should have no active combobox option upon End key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) // We opened via click, we don't have an active option assertNoActiveComboboxOption() // We should not be able to go to the end await press(Keys.End) assertNoActiveComboboxOption() }) ) }) describe('`PageDown` key', () => { it( 'should be possible to use the PageDown key to go to the last combobox option', suppressConsoleLogs(async () => { render() // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be on the first option assertActiveComboboxOption(options[0]) // We should be able to go to the last option await press(Keys.PageDown) assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to use the PageDown key to go to the last non disabled combobox option', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) // Open combobox await press(Keys.Space) let options = getComboboxOptions() // We should be on the first non-disabled option assertActiveComboboxOption(options[0]) // We should be able to go to the last non-disabled option await press(Keys.PageDown) assertActiveComboboxOption(options[1]) }) ) it( 'should be possible to use the PageDown key to go to the first combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be on the first non-disabled option assertActiveComboboxOption(options[0]) // We should not be able to go to the end await press(Keys.PageDown) assertActiveComboboxOption(options[0]) }) ) it( 'should have no active combobox option upon PageDown key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) // We opened via click, we don't have an active option assertNoActiveComboboxOption() // We should not be able to go to the end await press(Keys.PageDown) assertNoActiveComboboxOption() }) ) }) describe('`Home` key', () => { it( 'should be possible to use the Home key to go to the first combobox option', suppressConsoleLogs(async () => { render() // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) let options = getComboboxOptions() // We should be on the last option assertActiveComboboxOption(options[2]) // We should be able to go to the first option await press(Keys.Home) assertActiveComboboxOption(options[0]) }) ) it( 'should be possible to use the Home key to go to the first non disabled combobox option', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be on the first non-disabled option assertActiveComboboxOption(options[2]) // We should not be able to go to the end await press(Keys.Home) // We should be on the first non-disabled option assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to use the Home key to go to the last combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be on the last option assertActiveComboboxOption(options[3]) // We should not be able to go to the end await press(Keys.Home) assertActiveComboboxOption(options[3]) }) ) it( 'should have no active combobox option upon Home key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) // We opened via click, we don't have an active option assertNoActiveComboboxOption() // We should not be able to go to the end await press(Keys.Home) assertNoActiveComboboxOption() }) ) }) describe('`PageUp` key', () => { it( 'should be possible to use the PageUp key to go to the first combobox option', suppressConsoleLogs(async () => { render() // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) let options = getComboboxOptions() // We should be on the last option assertActiveComboboxOption(options[2]) // We should be able to go to the first option await press(Keys.PageUp) assertActiveComboboxOption(options[0]) }) ) it( 'should be possible to use the PageUp key to go to the first non disabled combobox option', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We opened via click, we default to the first non-disabled option assertActiveComboboxOption(options[2]) // We should not be able to go to the end (no-op — already there) await press(Keys.PageUp) assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to use the PageUp key to go to the last combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We opened via click, we default to the first non-disabled option assertActiveComboboxOption(options[3]) // We should not be able to go to the end (no-op — already there) await press(Keys.PageUp) assertActiveComboboxOption(options[3]) }) ) it( 'should have no active combobox option upon PageUp key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) // We opened via click, we don't have an active option assertNoActiveComboboxOption() // We should not be able to go to the end await press(Keys.PageUp) assertNoActiveComboboxOption() }) ) }) describe('`Backspace` key', () => { it( 'should reset the value when the last character is removed', suppressConsoleLogs(async () => { let handleChange = jest.fn() function Example() { let [value, setValue] = useState('bob') let [, setQuery] = useState('') return ( { setValue(value) handleChange(value) }} > setQuery(event.target.value)} /> Trigger Alice Bob Charlie ) } render() // Open combobox await click(getComboboxButton()) let options: ReturnType // Bob should be active options = getComboboxOptions() expect(getComboboxInput()).toHaveValue('bob') assertActiveComboboxOption(options[1]) assertActiveElement(getComboboxInput()) // Delete a character await press(Keys.Backspace) expect(getComboboxInput()?.value).toBe('bo') assertActiveComboboxOption(options[1]) // Delete a character await press(Keys.Backspace) expect(getComboboxInput()?.value).toBe('b') assertActiveComboboxOption(options[1]) // Delete a character await press(Keys.Backspace) expect(getComboboxInput()?.value).toBe('') // Verify that we don't have an selected option anymore assertNotActiveComboboxOption(options[1]) assertNoSelectedComboboxOption() // Verify that we saw the `null` change coming in expect(handleChange).toHaveBeenCalledTimes(1) expect(handleChange).toHaveBeenCalledWith(null) }) ) }) describe('`Any` key aka search', () => { type Option = { value: string; name: string; disabled: boolean } function Example(props: { people: { value: string; name: string; disabled: boolean }[] }) { let [value, setValue] = useState