import React, { createElement, useState, useEffect } from 'react' import { render } from '@testing-library/react' import { Combobox } from './combobox' import { mockingConsoleLogs, suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { click, focus, mouseMove, mouseLeave, press, shift, type, word, Keys, MouseButton, } from '../../test-utils/interactions' import { assertActiveElement, assertActiveComboboxOption, assertComboboxList, assertComboboxButton, assertComboboxButtonLinkedWithCombobox, assertComboboxButtonLinkedWithComboboxLabel, assertComboboxOption, assertComboboxLabel, assertComboboxLabelLinkedWithCombobox, assertNoActiveComboboxOption, assertNoSelectedComboboxOption, getComboboxInput, getComboboxButton, getComboboxButtons, getComboboxInputs, getComboboxOptions, getComboboxLabel, ComboboxState, getByText, getComboboxes, assertCombobox, ComboboxMode, assertNotActiveComboboxOption, assertComboboxInput, } from '../../test-utils/accessibility-assertions' import { Transition } from '../transitions/transition' let NOOP = () => {} jest.mock('../../hooks/use-id') beforeAll(() => { jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any) jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any) }) afterAll(() => jest.restoreAllMocks()) describe('safeguards', () => { it.each([ ['Combobox.Button', Combobox.Button], ['Combobox.Label', Combobox.Label], ['Combobox.Options', Combobox.Options], ['Combobox.Option', Combobox.Option], ])( 'should error when we are using a <%s /> without a parent ', suppressConsoleLogs((name, Component) => { // @ts-expect-error This is fine expect(() => render(createElement(Component))).toThrowError( `<${name} /> is missing a parent component.` ) }) ) it( 'should be possible to render a Combobox without crashing', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) }) describe('Rendering', () => { describe('Combobox', () => { it( 'should be possible to render a Combobox using a render prop', suppressConsoleLogs(async () => { render( {({ open }) => ( <> Trigger {open && ( Option A Option B Option C )} )} ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.Visible }) }) ) it( 'should be possible to disable a Combobox', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await press(Keys.Enter, getComboboxButton()) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should not crash in multiple mode', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) await click(getComboboxButton()) let [alice, bob, charlie] = getComboboxOptions() await click(alice) await click(bob) await click(charlie) }) ) describe('Equality', () => { let options = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 3, name: 'Charlie' }, ] it( 'should use object equality by default', suppressConsoleLogs(async () => { render( Trigger {options.map((option) => ( JSON.stringify(info)} > {option.name} ))} ) await click(getComboboxButton()) let bob = getComboboxOptions()[1] expect(bob).toHaveAttribute( 'class', JSON.stringify({ active: true, selected: true, disabled: false }) ) }) ) it( 'should be possible to compare null values by a field', suppressConsoleLogs(async () => { render( Trigger {options.map((option) => ( JSON.stringify(info)} > {option.name} ))} ) await click(getComboboxButton()) let [alice, bob, charlie] = getComboboxOptions() expect(alice).toHaveAttribute( 'class', JSON.stringify({ active: true, selected: false, disabled: false }) ) expect(bob).toHaveAttribute( 'class', JSON.stringify({ active: false, selected: false, disabled: false }) ) expect(charlie).toHaveAttribute( 'class', JSON.stringify({ active: false, selected: false, disabled: false }) ) }) ) it( 'should be possible to compare objects by a field', suppressConsoleLogs(async () => { render( Trigger {options.map((option) => ( JSON.stringify(info)} > {option.name} ))} ) await click(getComboboxButton()) let bob = getComboboxOptions()[1] expect(bob).toHaveAttribute( 'class', JSON.stringify({ active: true, selected: true, disabled: false }) ) }) ) it( 'should be possible to compare objects by a comparator function', suppressConsoleLogs(async () => { render( a.id === z.id} > Trigger {options.map((option) => ( JSON.stringify(info)} > {option.name} ))} ) await click(getComboboxButton()) let bob = getComboboxOptions()[1] expect(bob).toHaveAttribute( 'class', JSON.stringify({ active: true, selected: true, disabled: false }) ) }) ) it( 'should be possible to use completely new objects while rendering (single mode)', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState({ id: 2, name: 'Bob' }) return ( setValue(value)} by="id"> Trigger alice bob charlie ) } render() await click(getComboboxButton()) let [alice, bob, charlie] = getComboboxOptions() expect(alice).toHaveAttribute('aria-selected', 'false') expect(bob).toHaveAttribute('aria-selected', 'true') expect(charlie).toHaveAttribute('aria-selected', 'false') await click(getComboboxOptions()[2]) await click(getComboboxButton()) ;[alice, bob, charlie] = getComboboxOptions() expect(alice).toHaveAttribute('aria-selected', 'false') expect(bob).toHaveAttribute('aria-selected', 'false') expect(charlie).toHaveAttribute('aria-selected', 'true') await click(getComboboxOptions()[1]) await click(getComboboxButton()) ;[alice, bob, charlie] = getComboboxOptions() expect(alice).toHaveAttribute('aria-selected', 'false') expect(bob).toHaveAttribute('aria-selected', 'true') expect(charlie).toHaveAttribute('aria-selected', 'false') }) ) it( 'should be possible to use completely new objects while rendering (multiple mode)', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState([{ id: 2, name: 'Bob' }]) return ( setValue(value)} by="id" multiple> Trigger alice bob charlie ) } render() await click(getComboboxButton()) await click(getComboboxOptions()[2]) let [alice, bob, charlie] = getComboboxOptions() expect(alice).toHaveAttribute('aria-selected', 'false') expect(bob).toHaveAttribute('aria-selected', 'true') expect(charlie).toHaveAttribute('aria-selected', 'true') await click(getComboboxOptions()[2]) ;[alice, bob, charlie] = getComboboxOptions() expect(alice).toHaveAttribute('aria-selected', 'false') expect(bob).toHaveAttribute('aria-selected', 'true') expect(charlie).toHaveAttribute('aria-selected', 'false') }) ) }) it( 'should not crash when a defaultValue is not given', suppressConsoleLogs(async () => { let data = [ { id: 1, name: 'alice', label: 'Alice' }, { id: 2, name: 'bob', label: 'Bob' }, { id: 3, name: 'charlie', label: 'Charlie' }, ] render( value.name} onChange={NOOP} /> {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') }) ) }) describe('Combobox.Label', () => { it( 'should be possible to render a Combobox.Label using a render prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-3' }, }) assertComboboxLabel({ attributes: { id: 'headlessui-combobox-label-1' }, textContent: JSON.stringify({ open: false, disabled: false }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxLabel({ attributes: { id: 'headlessui-combobox-label-1' }, textContent: JSON.stringify({ open: true, disabled: false }), }) assertComboboxList({ state: ComboboxState.Visible }) assertComboboxLabelLinkedWithCombobox() assertComboboxButtonLinkedWithComboboxLabel() }) ) it( 'should be possible to link Input/Button and Label if Label is rendered last', suppressConsoleLogs(async () => { render( Label ) assertComboboxLabelLinkedWithCombobox() assertComboboxButtonLinkedWithComboboxLabel() }) ) it( 'should be possible to render a Combobox.Label using a render prop and an `as` prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Trigger Option A Option B Option C ) assertComboboxLabel({ attributes: { id: 'headlessui-combobox-label-1' }, textContent: JSON.stringify({ open: false, disabled: false }), tag: 'p', }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxLabel({ attributes: { id: 'headlessui-combobox-label-1' }, textContent: JSON.stringify({ open: true, disabled: false }), tag: 'p', }) assertComboboxList({ state: ComboboxState.Visible }) }) ) }) describe('Combobox.Button', () => { it( 'should be possible to render a Combobox.Button using a render prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: false, disabled: false, value: 'test' }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: true, disabled: false, value: 'test' }), }) assertComboboxList({ state: ComboboxState.Visible }) }) ) it( 'should be possible to render a Combobox.Button using a render prop and an `as` prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: false, disabled: false, value: 'test' }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: true, disabled: false, value: 'test' }), }) assertComboboxList({ state: ComboboxState.Visible }) }) ) it( 'should be possible to render a Combobox.Button and a Combobox.Label and see them linked together', suppressConsoleLogs(async () => { render( Label Trigger Option A Option B Option C ) // TODO: Needed to make it similar to vue test implementation? // await new Promise(requestAnimationFrame) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-3' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) assertComboboxButtonLinkedWithComboboxLabel() }) ) describe('`type` attribute', () => { it('should set the `type` to "button" by default', async () => { render( Trigger ) expect(getComboboxButton()).toHaveAttribute('type', 'button') }) it('should not set the `type` to "button" if it already contains a `type`', async () => { render( Trigger ) expect(getComboboxButton()).toHaveAttribute('type', 'submit') }) it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => { let CustomButton = React.forwardRef((props, ref) => ( ) 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))) }} > {({ 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 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('Rendering composition', () => { it( 'should be possible to conditionally render classNames (aka className can be a function?!)', suppressConsoleLogs(async () => { render( Trigger JSON.stringify(bag)}> Option A JSON.stringify(bag)}> Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open Combobox await click(getComboboxButton()) let options = getComboboxOptions() // Verify that the first combobox option is active assertActiveComboboxOption(options[0]) // Verify correct 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') // Let's go down, this should go to the third option since the second option is disabled! await press(Keys.ArrowDown) // Verify the classNames expect('' + options[0].classList).toEqual( JSON.stringify({ active: false, selected: false, disabled: false }) ) expect('' + options[1].classList).toEqual( JSON.stringify({ active: false, selected: false, disabled: true }) ) expect('' + options[2].classList).toEqual('no-special-treatment') // Double check that the last option is the active one assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to swap the Combobox option with a button for example', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open Combobox await click(getComboboxButton()) // Verify options are buttons now getComboboxOptions().forEach((option) => assertComboboxOption(option, { tag: 'button' })) }) ) it( 'should mark all the elements between Combobox.Options and Combobox.Option with role none', suppressConsoleLogs(async () => { render(
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( Trigger {(data) => ( <> {JSON.stringify(data)} )} ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.Visible, textContent: JSON.stringify({ active: true, selected: false, disabled: false }), }) await click(getComboboxButton()) // Verify that we tracked the `mounts` and `unmounts` in the correct order expect(orderFn.mock.calls).toEqual([ ['Mounting - Combobox'], ['Mounting - Transition'], ['Mounting - Combobox.Option'], ['Unmounting - Transition'], ['Unmounting - Combobox.Option'], ]) }) ) }) describe('Keyboard interactions', () => { describe('Button', () => { describe('`Enter` key', () => { it( 'should be possible to open the combobox with Enter', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button 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( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Try to focus the button await focus(getComboboxButton()) // Try to open the combobox await press(Keys.Enter) // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should be possible to open the combobox with Enter, and focus the selected option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Enter) // Verify we moved focus to the input field assertActiveElement(getComboboxInput()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should be possible to open the combobox with Enter, and focus the selected option (when using the `hidden` render strategy)', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleHidden, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleHidden }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Enter) // Verify we moved focus to the input field assertActiveElement(getComboboxInput()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() let options = getComboboxOptions() // Hover over Option A await mouseMove(options[0]) // Verify that Option A is active assertActiveComboboxOption(options[0]) // Verify that Option B is still selected assertComboboxOption(options[1], { selected: true }) // Close/Hide the combobox await press(Keys.Escape) // Re-open the combobox await click(getComboboxButton()) // Verify we have combobox options expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should be possible to open the combobox with Enter, and focus the selected option (with a list of objects)', suppressConsoleLogs(async () => { let myOptions = [ { id: 'a', name: 'Option A' }, { id: 'b', name: 'Option B' }, { id: 'c', name: 'Option C' }, ] let selectedOption = myOptions[1] render( Trigger {myOptions.map((myOption) => ( {myOption.name} ))} ) 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( Trigger ) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Enter) // Verify we moved focus to the input field assertActiveElement(getComboboxInput()) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() }) ) }) describe('`Space` key', () => { it( 'should be possible to open the combobox with Space', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Space) // Verify we moved focus to the input field assertActiveElement(getComboboxInput()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) }) ) it( 'should not be possible to open the combobox with Space when the button is disabled', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Try to open the combobox await press(Keys.Space) // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should be possible to open the combobox with Space, and focus the selected option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted, }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Space) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { render( Trigger ) assertComboboxList({ state: ComboboxState.InvisibleUnmounted, }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Space) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() }) ) it( 'should have no active combobox option upon Space key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted, }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.Space) assertNoActiveComboboxOption() }) ) }) describe('`Escape` key', () => { it( 'should be possible to close an open combobox with Escape', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) // Open combobox await click(getComboboxButton()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Re-focus the button await focus(getComboboxButton()) assertActiveElement(getComboboxButton()) // Close combobox await press(Keys.Escape) // Verify it is closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Verify the input is focused again assertActiveElement(getComboboxInput()) }) ) it( 'should not propagate the Escape event when the combobox is open', suppressConsoleLogs(async () => { let handleKeyDown = jest.fn() render(
Trigger Option A Option B Option C
) // Open combobox await click(getComboboxButton()) // Close combobox await press(Keys.Escape) // We should never see the Escape event expect(handleKeyDown).toHaveBeenCalledTimes(0) }) ) it( 'should propagate the Escape event when the combobox is closed', suppressConsoleLogs(async () => { let handleKeyDown = jest.fn() render(
Trigger Option A Option B Option C
) // Focus the input field await focus(getComboboxInput()) // Close combobox await press(Keys.Escape) // We should never see the Escape event expect(handleKeyDown).toHaveBeenCalledTimes(1) }) ) }) describe('`ArrowDown` key', () => { it( 'should be possible to open the combobox with ArrowDown', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowDown) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) // Verify that the first combobox option is active assertActiveComboboxOption(options[0]) }) ) it( 'should not be possible to open the combobox with ArrowDown when the button is disabled', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Try to open the combobox await press(Keys.ArrowDown) // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should be possible to open the combobox with ArrowDown, and focus the selected option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowDown) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { render( Trigger ) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowDown) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() }) ) }) describe('`ArrowUp` key', () => { it( 'should be possible to open the combobox with ArrowUp and the last option should be active', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowUp) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) // ! ALERT: The LAST option should now be active assertActiveComboboxOption(options[2]) }) ) it( 'should not be possible to open the combobox with ArrowUp and the last option should be active when the button is disabled', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Try to open the combobox await press(Keys.ArrowUp) // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should be possible to open the combobox with ArrowUp, and focus the selected option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowUp) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { render( Trigger ) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowUp) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() }) ) it( 'should be possible to use ArrowUp to navigate the combobox options and jump to the first non-disabled one', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowUp) // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) }) ) }) }) describe('`Backspace` key', () => { it( 'should reset the value when the last character is removed, when in `nullable` mode', suppressConsoleLogs(async () => { let handleChange = jest.fn() function Example() { let [value, setValue] = useState('bob') let [, setQuery] = useState('') return ( { setValue(value) handleChange(value) }} nullable > setQuery(event.target.value)} /> Trigger Alice Bob Charlie ) } render() // Open combobox await click(getComboboxButton()) let options: ReturnType // Bob should be active options = getComboboxOptions() expect(getComboboxInput()).toHaveValue('bob') assertActiveComboboxOption(options[1]) assertActiveElement(getComboboxInput()) // Delete a character await press(Keys.Backspace) expect(getComboboxInput()?.value).toBe('bo') assertActiveComboboxOption(options[1]) // Delete a character await press(Keys.Backspace) expect(getComboboxInput()?.value).toBe('b') assertActiveComboboxOption(options[1]) // Delete a character await press(Keys.Backspace) expect(getComboboxInput()?.value).toBe('') // Verify that we don't have an active option anymore since we are in `nullable` mode assertNotActiveComboboxOption(options[1]) assertNoActiveComboboxOption() // Verify that we saw the `null` change coming in expect(handleChange).toHaveBeenCalledTimes(1) expect(handleChange).toHaveBeenCalledWith(null) }) ) }) describe('Input', () => { describe('`Enter` key', () => { it( 'should be possible to close the combobox with Enter and choose the active combobox option', suppressConsoleLogs(async () => { let handleChange = jest.fn() function Example() { let [value, setValue] = useState(undefined) return ( { setValue(value) handleChange(value) }} > Trigger Option A Option B Option C ) } render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) // Activate the first combobox option let options = getComboboxOptions() await mouseMove(options[0]) // Choose option, and close combobox await press(Keys.Enter) // Verify it is closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Verify we got the change event expect(handleChange).toHaveBeenCalledTimes(1) expect(handleChange).toHaveBeenCalledWith('a') // Verify the button is focused again assertActiveElement(getComboboxInput()) // Open combobox again await click(getComboboxButton()) // Verify the active option is the previously selected one assertActiveComboboxOption(getComboboxOptions()[0]) }) ) it( 'should submit the form on `Enter`', suppressConsoleLogs(async () => { let submits = jest.fn() function Example() { let [value, setValue] = useState('b') return (
{ // JSDom doesn't automatically submit the form but if we can // catch an `Enter` event, we can assume it was a submit. if (event.key === 'Enter') event.currentTarget.submit() }} onSubmit={(event) => { event.preventDefault() submits([...new FormData(event.currentTarget).entries()]) }} > Trigger Option A Option B Option C
) } render() // Focus the input field await focus(getComboboxInput()) assertActiveElement(getComboboxInput()) // Press enter (which should submit the form) await press(Keys.Enter) // Verify the form was submitted expect(submits).toHaveBeenCalledTimes(1) expect(submits).toHaveBeenCalledWith([['option', 'b']]) }) ) }) describe('`Tab` key', () => { it( 'pressing Tab should select the active item and move to the next DOM node', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(undefined) return ( <> Trigger Option A Option B Option C ) } render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Select the 2nd option await press(Keys.ArrowDown) // Tab to the next DOM node await press(Keys.Tab) // Verify it is closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // That the selected value was the highlighted one expect(getComboboxInput()?.value).toBe('b') // And focus has moved to the next element assertActiveElement(document.querySelector('#after-combobox')) }) ) it( 'pressing Shift+Tab should select the active item and move to the previous DOM node', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(undefined) return ( <> Trigger Option A Option B Option C ) } render() assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Select the 2nd option await press(Keys.ArrowDown) // Tab to the next DOM node await press(shift(Keys.Tab)) // Verify it is closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // That the selected value was the highlighted one expect(getComboboxInput()?.value).toBe('b') // And focus has moved to the next element assertActiveElement(document.querySelector('#before-combobox')) }) ) }) describe('`Escape` key', () => { it( 'should be possible to close an open combobox with Escape', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) // Open combobox await click(getComboboxButton()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Close combobox await press(Keys.Escape) // Verify it is closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Verify the button is focused again assertActiveElement(getComboboxInput()) }) ) it( 'should bubble escape when using `static` on Combobox.Options', suppressConsoleLogs(async () => { render( 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( Trigger ) let spy = jest.fn() window.addEventListener( 'keydown', (evt) => { if (evt.key === 'Escape') { spy() } }, { capture: true } ) window.addEventListener('keydown', (evt) => { if (evt.key === 'Escape') { spy() } }) // Open combobox await click(getComboboxButton()) // Verify the input is focused assertActiveElement(getComboboxInput()) // Close combobox await press(Keys.Escape) // Verify the input is still focused assertActiveElement(getComboboxInput()) // The external event handler should've been called twice // Once in the capture phase and once in the bubble phase expect(spy).toHaveBeenCalledTimes(2) }) ) it( 'should sync the input field correctly and reset it when pressing Escape', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) // Open combobox await click(getComboboxButton()) // Verify the input has the selected value expect(getComboboxInput()?.value).toBe('option-b') // Override the input by typing something await type(word('test'), getComboboxInput()) expect(getComboboxInput()?.value).toBe('test') // Close combobox await press(Keys.Escape) // Verify the input is reset correctly expect(getComboboxInput()?.value).toBe('option-b') }) ) }) describe('`ArrowDown` key', () => { it( 'should be possible to open the combobox with ArrowDown', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowDown) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) // Verify that the first combobox option is active assertActiveComboboxOption(options[0]) }) ) it( 'should not be possible to open the combobox with ArrowDown when the button is disabled', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Try to open the combobox await press(Keys.ArrowDown) // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should be possible to open the combobox with ArrowDown, and focus the selected option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowDown) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { render( Trigger ) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowDown) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() }) ) it( 'should be possible to use ArrowDown to navigate the combobox options', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) // We should be able to go down once await press(Keys.ArrowDown) assertActiveComboboxOption(options[1]) // We should be able to go down again await press(Keys.ArrowDown) assertActiveComboboxOption(options[2]) // We should NOT be able to go down again (because last option). // Current implementation won't go around. await press(Keys.ArrowDown) assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to use ArrowDown to navigate the combobox options and skip the first disabled one', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[1]) // We should be able to go down once await press(Keys.ArrowDown) assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to use ArrowDown to navigate the combobox options and jump to the first non-disabled one', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[2]) // Open combobox await press(Keys.ArrowDown) assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to go to the next item if no value is set', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // Verify that we are on the first option assertActiveComboboxOption(options[0]) // Go down once await press(Keys.ArrowDown) // We should be on the next item assertActiveComboboxOption(options[1]) }) ) }) describe('`ArrowUp` key', () => { it( 'should be possible to open the combobox with ArrowUp and the last option should be active', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) // ! ALERT: The LAST option should now be active assertActiveComboboxOption(options[2]) }) ) it( 'should not be possible to open the combobox with ArrowUp and the last option should be active when the button is disabled', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Try to open the combobox await press(Keys.ArrowUp) // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should be possible to open the combobox with ArrowUp, and focus the selected option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should have no active combobox option when there are no combobox options at all', suppressConsoleLogs(async () => { render( Trigger ) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) assertNoActiveComboboxOption() }) ) it( 'should be possible to use ArrowUp to navigate the combobox options and jump to the first non-disabled one', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) }) ) it( 'should not be possible to navigate up or down if there is only a single non-disabled option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[2]) // Going up or down should select the single available option await press(Keys.ArrowUp) // We should not be able to go up (because those are disabled) await press(Keys.ArrowUp) assertActiveComboboxOption(options[2]) // We should not be able to go down (because this is the last option) await press(Keys.ArrowDown) assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to use ArrowUp to navigate the combobox options', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[2]) // We should be able to go down once await press(Keys.ArrowUp) assertActiveComboboxOption(options[1]) // We should be able to go down again await press(Keys.ArrowUp) assertActiveComboboxOption(options[0]) // We should NOT be able to go up again (because first option). Current implementation won't go around. await press(Keys.ArrowUp) assertActiveComboboxOption(options[0]) }) ) }) describe('`End` key', () => { it( 'should be possible to use the End key to go to the last combobox option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be on the first non-disabled option assertActiveComboboxOption(options[0]) // We should be able to go to the last option await press(Keys.End) assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to use the End key to go to the last non disabled combobox option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C Option D ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be on the first non-disabled option assertActiveComboboxOption(options[0]) // We should be able to go to the last non-disabled option await press(Keys.End) assertActiveComboboxOption(options[1]) }) ) it( 'should be possible to use the End key to go to the first combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C Option D ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be on the first non-disabled option assertActiveComboboxOption(options[0]) // We should not be able to go to the end (no-op) await press(Keys.End) assertActiveComboboxOption(options[0]) }) ) it( 'should have no active combobox option upon End key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C Option D ) // Open combobox await click(getComboboxButton()) // We opened via click, we don't have an active option assertNoActiveComboboxOption() // We should not be able to go to the end await press(Keys.End) assertNoActiveComboboxOption() }) ) }) describe('`PageDown` key', () => { it( 'should be possible to use the PageDown key to go to the last combobox option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be on the first option 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( Trigger Option A Option B Option C Option D ) // Open combobox await click(getComboboxButton()) // Open combobox await press(Keys.Space) let options = getComboboxOptions() // We should be on the first non-disabled option assertActiveComboboxOption(options[0]) // We should be able to go to the last non-disabled option await press(Keys.PageDown) assertActiveComboboxOption(options[1]) }) ) it( 'should be possible to use the PageDown key to go to the first combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C Option D ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be on the first non-disabled option assertActiveComboboxOption(options[0]) // We should not be able to go to the end await press(Keys.PageDown) assertActiveComboboxOption(options[0]) }) ) it( 'should have no active combobox option upon PageDown key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C Option D ) // Open combobox await click(getComboboxButton()) // We opened via click, we don't have an active option assertNoActiveComboboxOption() // We should not be able to go to the end await press(Keys.PageDown) assertNoActiveComboboxOption() }) ) }) describe('`Home` key', () => { it( 'should be possible to use the Home key to go to the first combobox option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) // Focus the input 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( Trigger Option A Option B Option C Option D ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be on the first non-disabled option assertActiveComboboxOption(options[2]) // We should not be able to go to the end await press(Keys.Home) // We should be on the first non-disabled option assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to use the Home key to go to the last combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C Option D ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be on the last option assertActiveComboboxOption(options[3]) // We should not be able to go to the end await press(Keys.Home) assertActiveComboboxOption(options[3]) }) ) it( 'should have no active combobox option upon Home key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C Option D ) // Open combobox await click(getComboboxButton()) // We opened via click, we don't have an active option assertNoActiveComboboxOption() // We should not be able to go to the end await press(Keys.Home) assertNoActiveComboboxOption() }) ) }) describe('`PageUp` key', () => { it( 'should be possible to use the PageUp key to go to the first combobox option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) // Focus the input 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( Trigger Option A Option B Option C Option D ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We opened via click, we default to the first non-disabled option assertActiveComboboxOption(options[2]) // We should not be able to go to the end (no-op — already there) await press(Keys.PageUp) assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to use the PageUp key to go to the last combobox option if that is the only non-disabled combobox option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C Option D ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We opened via click, we default to the first non-disabled option assertActiveComboboxOption(options[3]) // We should not be able to go to the end (no-op — already there) await press(Keys.PageUp) assertActiveComboboxOption(options[3]) }) ) it( 'should have no active combobox option upon PageUp key press, when there are no non-disabled combobox options', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C Option D ) // Open combobox await click(getComboboxButton()) // We opened via click, we don't have an active option assertNoActiveComboboxOption() // We should not be able to go to the end await press(Keys.PageUp) assertNoActiveComboboxOption() }) ) }) describe('`Any` key aka search', () => { function Example(props: { people: { value: string; name: string; disabled: boolean }[] }) { let [value, setValue] = useState(undefined) let [query, setQuery] = useState('') let filteredPeople = query === '' ? props.people : props.people.filter((person) => person.name.toLowerCase().includes(query.toLowerCase()) ) return ( setQuery(event.target.value)} /> Trigger {filteredPeople.map((person) => ( {person.name} ))} ) } it( 'should be possible to type a full word that has a perfect match', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) // Verify we moved focus to the input field assertActiveElement(getComboboxInput()) let options: ReturnType // We should be able to go to the second option await type(word('bob')) await press(Keys.Home) options = getComboboxOptions() expect(options).toHaveLength(1) expect(options[0]).toHaveTextContent('bob') assertActiveComboboxOption(options[0]) // We should be able to go to the first option await type(word('alice')) await press(Keys.Home) options = getComboboxOptions() expect(options).toHaveLength(1) expect(options[0]).toHaveTextContent('alice') assertActiveComboboxOption(options[0]) // We should be able to go to the last option await type(word('charlie')) await press(Keys.Home) options = getComboboxOptions() expect(options).toHaveLength(1) expect(options[0]).toHaveTextContent('charlie') assertActiveComboboxOption(options[0]) }) ) it( 'should be possible to type a partial of a word', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) let options: ReturnType // We should be able to go to the second option await type(word('bo')) await press(Keys.Home) options = getComboboxOptions() expect(options).toHaveLength(1) expect(options[0]).toHaveTextContent('bob') assertActiveComboboxOption(options[0]) // We should be able to go to the first option await type(word('ali')) await press(Keys.Home) options = getComboboxOptions() expect(options).toHaveLength(1) expect(options[0]).toHaveTextContent('alice') assertActiveComboboxOption(options[0]) // We should be able to go to the last option await type(word('char')) await press(Keys.Home) options = getComboboxOptions() expect(options).toHaveLength(1) expect(options[0]).toHaveTextContent('charlie') assertActiveComboboxOption(options[0]) }) ) it( 'should be possible to type words with spaces', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) let options: ReturnType // We should be able to go to the second option await type(word('bob t')) await press(Keys.Home) options = getComboboxOptions() expect(options).toHaveLength(1) expect(options[0]).toHaveTextContent('bob the builder') assertActiveComboboxOption(options[0]) // We should be able to go to the first option await type(word('alice j')) await press(Keys.Home) options = getComboboxOptions() expect(options).toHaveLength(1) expect(options[0]).toHaveTextContent('alice jones') assertActiveComboboxOption(options[0]) // We should be able to go to the last option await type(word('charlie b')) await press(Keys.Home) options = getComboboxOptions() expect(options).toHaveLength(1) expect(options[0]).toHaveTextContent('charlie bit me') assertActiveComboboxOption(options[0]) }) ) it( 'should not be possible to search and activate a disabled option', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) // We should not be able to go to the disabled option await type(word('bo')) await press(Keys.Home) assertNoActiveComboboxOption() assertNoSelectedComboboxOption() }) ) it( 'should maintain activeIndex and activeOption when filtering', suppressConsoleLogs(async () => { render( ) // Open combobox await click(getComboboxButton()) let options: ReturnType options = getComboboxOptions() expect(options[0]).toHaveTextContent('person a') assertActiveComboboxOption(options[0]) await press(Keys.ArrowDown) // Person B should be active options = getComboboxOptions() expect(options[1]).toHaveTextContent('person b') assertActiveComboboxOption(options[1]) // Filter more, remove `person a` await type(word('person b')) options = getComboboxOptions() expect(options[0]).toHaveTextContent('person b') assertActiveComboboxOption(options[0]) // Filter less, insert `person a` before `person b` await type(word('person')) options = getComboboxOptions() expect(options[1]).toHaveTextContent('person b') assertActiveComboboxOption(options[1]) }) ) }) }) }) describe('Mouse interactions', () => { it( 'should focus the Combobox.Input when we click the Combobox.Label', suppressConsoleLogs(async () => { render( Label Trigger Option A Option B Option C ) // Ensure the button is not focused yet assertActiveElement(document.body) // Focus the label await click(getComboboxLabel()) // Ensure that the actual button is focused instead assertActiveElement(getComboboxInput()) }) ) it( 'should not focus the Combobox.Input when we right click the Combobox.Label', suppressConsoleLogs(async () => { render( Label Trigger Option A Option B Option C ) // Ensure the button is not focused yet assertActiveElement(document.body) // Focus the label await click(getComboboxLabel(), MouseButton.Right) // Ensure that the body is still active assertActiveElement(document.body) }) ) it( 'should be possible to open the combobox on click', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option) => assertComboboxOption(option)) }) ) it( 'should not be possible to open the combobox on right click', suppressConsoleLogs(async () => { render( Trigger Item A Item B Item C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Try to open the combobox await click(getComboboxButton(), MouseButton.Right) // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should not be possible to open the combobox on click when the button is disabled', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Try to open the combobox await click(getComboboxButton()) // Verify it is still closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should be possible to open the combobox on click, and focus the selected option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Open combobox await click(getComboboxButton()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) assertComboboxList({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-options-3' }, }) assertActiveElement(getComboboxInput()) assertComboboxButtonLinkedWithCombobox() // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) // Verify that the second combobox option is active (because it is already selected) assertActiveComboboxOption(options[1]) }) ) it( 'should be possible to close a combobox on click', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) // Open combobox await click(getComboboxButton()) // Verify it is visible assertComboboxButton({ state: ComboboxState.Visible }) // Click to close await click(getComboboxButton()) // Verify it is closed assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) it( 'should be a no-op when we click outside of a closed combobox', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Verify that the window is closed assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Click something that is not related to the combobox await click(document.body) // Should still be closed assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) // TODO: JSDOM doesn't quite work here // Clicking outside on the body should fire a mousedown (which it does) and then change the active element (which it doesn't) xit( 'should be possible to click outside of the combobox which should close the combobox', suppressConsoleLogs(async () => { render( <> Trigger alice bob charlie
after
) // Open combobox await click(getComboboxButton()) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) // Click something that is not related to the combobox await click(getByText('after')) // Should be closed now assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Verify the button is focused assertActiveElement(getByText('after')) }) ) it( 'should be possible to click outside of the combobox on another combobox button which should close the current combobox and open the new combobox', suppressConsoleLogs(async () => { render(
Trigger alice bob charlie Trigger alice bob charlie
) let [button1, button2] = getComboboxButtons() // Click the first combobox button await click(button1) expect(getComboboxes()).toHaveLength(1) // Only 1 combobox should be visible // Verify that the first input is focused assertActiveElement(getComboboxInputs()[0]) // Click the second combobox button await click(button2) expect(getComboboxes()).toHaveLength(1) // Only 1 combobox should be visible // Verify that the first input is focused assertActiveElement(getComboboxInputs()[1]) }) ) it( 'should be possible to click outside of the combobox which should close the combobox (even if we press the combobox button)', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) // Click the combobox button again await click(getComboboxButton()) // Should be closed now assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Verify the input is focused again assertActiveElement(getComboboxInput()) }) ) it( 'should be possible to click outside of the combobox, on an element which is within a focusable element, which closes the combobox', suppressConsoleLogs(async () => { let focusFn = jest.fn() render(
Trigger alice bob charlie
) // Click the combobox button await click(getComboboxButton()) // Ensure the combobox is open assertComboboxList({ state: ComboboxState.Visible }) // Click the span inside the button await click(getByText('Next')) // Ensure the combobox is closed assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Ensure the outside button is focused assertActiveElement(document.getElementById('btn')) // Ensure that the focus button only got focus once (first click) expect(focusFn).toHaveBeenCalledTimes(1) }) ) it( 'should be possible to hover an option and make it active', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be able to go to the second option await mouseMove(options[1]) assertActiveComboboxOption(options[1]) // We should be able to go to the first option await mouseMove(options[0]) assertActiveComboboxOption(options[0]) // We should be able to go to the last option await mouseMove(options[2]) assertActiveComboboxOption(options[2]) }) ) it( 'should be possible to hover an option and make it active when using `static`', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) let options = getComboboxOptions() // We should be able to go to the second option await mouseMove(options[1]) assertActiveComboboxOption(options[1]) // We should be able to go to the first option await mouseMove(options[0]) assertActiveComboboxOption(options[0]) // We should be able to go to the last option await mouseMove(options[2]) assertActiveComboboxOption(options[2]) }) ) it( 'should make a combobox option active when you move the mouse over it', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be able to go to the second option await mouseMove(options[1]) assertActiveComboboxOption(options[1]) }) ) it( 'should be a no-op when we move the mouse and the combobox option is already active', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be able to go to the second option await mouseMove(options[1]) assertActiveComboboxOption(options[1]) await mouseMove(options[1]) // Nothing should be changed assertActiveComboboxOption(options[1]) }) ) it( 'should be a no-op when we move the mouse and the combobox option is disabled', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() await mouseMove(options[1]) assertNotActiveComboboxOption(options[1]) }) ) it( 'should not be possible to hover an option that is disabled', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // Try to hover over option 1, which is disabled await mouseMove(options[1]) // We should not have option 1 as the active option now assertNotActiveComboboxOption(options[1]) }) ) it( 'should be possible to mouse leave an option and make it inactive', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be able to go to the second option await mouseMove(options[1]) assertActiveComboboxOption(options[1]) await mouseLeave(options[1]) assertNoActiveComboboxOption() // We should be able to go to the first option await mouseMove(options[0]) assertActiveComboboxOption(options[0]) await mouseLeave(options[0]) assertNoActiveComboboxOption() // We should be able to go to the last option await mouseMove(options[2]) assertActiveComboboxOption(options[2]) await mouseLeave(options[2]) assertNoActiveComboboxOption() }) ) it( 'should be possible to mouse leave a disabled option and be a no-op', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // Try to hover over option 1, which is disabled await mouseMove(options[1]) assertNotActiveComboboxOption(options[1]) await mouseLeave(options[1]) assertNotActiveComboboxOption(options[1]) }) ) it( 'should be possible to click a combobox option, which closes the combobox', suppressConsoleLogs(async () => { let handleChange = jest.fn() function Example() { let [value, setValue] = useState(undefined) return ( { setValue(value) handleChange(value) }} > Trigger alice bob charlie ) } render() // Open combobox await click(getComboboxButton()) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) let options = getComboboxOptions() // We should be able to click the first option await click(options[1]) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) expect(handleChange).toHaveBeenCalledTimes(1) expect(handleChange).toHaveBeenCalledWith('bob') // Verify the input is focused again assertActiveElement(getComboboxInput()) // Open combobox again await click(getComboboxButton()) // Verify the active option is the previously selected one assertActiveComboboxOption(getComboboxOptions()[1]) }) ) it( 'should be possible to click a disabled combobox option, which is a no-op', suppressConsoleLogs(async () => { let handleChange = jest.fn() function Example() { let [value, setValue] = useState(undefined) return ( { setValue(value) handleChange(value) }} > Trigger alice bob charlie ) } render() // Open combobox await click(getComboboxButton()) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) let options = getComboboxOptions() // We should not be able to click the disabled option await click(options[1]) assertComboboxList({ state: ComboboxState.Visible }) assertNotActiveComboboxOption(options[1]) assertActiveElement(getComboboxInput()) expect(handleChange).toHaveBeenCalledTimes(0) // Close the combobox await click(getComboboxButton()) // Open combobox again await click(getComboboxButton()) options = getComboboxOptions() // Verify the active option is not the disabled one assertNotActiveComboboxOption(options[1]) }) ) it( 'should be possible focus a combobox option, so that it becomes active', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(undefined) return ( Trigger alice bob charlie ) } render() // Open combobox await click(getComboboxButton()) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) let options = getComboboxOptions() // Verify that the first item is active assertActiveComboboxOption(options[0]) // We should be able to focus the second option await focus(options[1]) assertActiveComboboxOption(options[1]) }) ) it( 'should not be possible to focus a combobox option which is disabled', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) let options = getComboboxOptions() // We should not be able to focus the first option await focus(options[1]) assertNotActiveComboboxOption(options[1]) }) ) it( 'should be possible to hold the last active option', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.Visible }) let options = getComboboxOptions() // Hover the first item await mouseMove(options[0]) // Verify that the first combobox option is active assertActiveComboboxOption(options[0]) // Focus the second item await mouseMove(options[1]) // Verify that the second combobox option is active assertActiveComboboxOption(options[1]) // Move the mouse off of the second combobox option await mouseLeave(options[1]) await mouseMove(document.body) // Verify that the second combobox option is still active assertActiveComboboxOption(options[1]) }) ) it( 'should sync the input field correctly and reset it when resetting the value from outside (to null)', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState('bob') return ( <> Trigger alice bob charlie ) } render() // Open combobox await click(getComboboxButton()) // Verify the input has the selected value expect(getComboboxInput()?.value).toBe('bob') // Override the input by typing something await type(word('test'), getComboboxInput()) expect(getComboboxInput()?.value).toBe('test') // Reset from outside await click(getByText('reset')) // Verify the input is reset correctly expect(getComboboxInput()?.value).toBe('') }) ) it( 'should warn when changing the combobox from uncontrolled to controlled', mockingConsoleLogs(async (spy) => { function Example() { let [value, setValue] = useState(undefined) return ( <> Trigger alice bob charlie ) } // Render a uncontrolled combobox render() // Change to an controlled combobox await click(getByText('to controlled')) // Make sure we get a warning expect(spy).toBeCalledTimes(1) expect(spy.mock.calls.map((args) => args[0])).toEqual([ 'A component is changing from uncontrolled to controlled. This may be caused by the value changing from undefined to a defined value, which should not happen.', ]) // Render a fresh uncontrolled combobox render() // Change to an controlled combobox await click(getByText('to controlled')) // We shouldn't have gotten another warning as we do not want to warn on every render expect(spy).toBeCalledTimes(1) }) ) it( 'should warn when changing the combobox from controlled to uncontrolled', mockingConsoleLogs(async (spy) => { function Example() { let [value, setValue] = useState('bob') return ( <> Trigger alice bob charlie ) } // Render a controlled combobox render() // Change to an uncontrolled combobox await click(getByText('to uncontrolled')) // Make sure we get a warning expect(spy).toBeCalledTimes(1) expect(spy.mock.calls.map((args) => args[0])).toEqual([ 'A component is changing from controlled to uncontrolled. This may be caused by the value changing from a defined value to undefined, which should not happen.', ]) // Render a fresh controlled combobox render() // Change to an uncontrolled combobox await click(getByText('to uncontrolled')) // We shouldn't have gotten another warning as we do not want to warn on every render expect(spy).toBeCalledTimes(1) }) ) it( 'should sync the input field correctly and reset it when resetting the value from outside (when using displayValue)', suppressConsoleLogs(async () => { let people = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 3, name: 'Charlie' }, ] function Example() { let [value, setValue] = useState(people[1]) return ( <> person?.name} /> Trigger {people.map((person) => ( {person.name} ))} ) } render() // Open combobox await click(getComboboxButton()) // Verify the input has the selected value expect(getComboboxInput()?.value).toBe('Bob') // Override the input by typing something await type(word('test'), getComboboxInput()) expect(getComboboxInput()?.value).toBe('test') // Reset from outside await click(getByText('reset')) // Verify the input is reset correctly expect(getComboboxInput()?.value).toBe('') }) ) }) describe('Multi-select', () => { it( 'should be possible to pass multiple values to the Combobox component', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(['bob', 'charlie']) return ( setValue(value)} multiple> {}} /> Trigger alice bob charlie ) } render() // Open combobox await click(getComboboxButton()) // Verify that we have an open combobox with multiple mode assertCombobox({ state: ComboboxState.Visible, mode: ComboboxMode.Multiple }) // Verify that we have multiple selected combobox options let options = getComboboxOptions() assertComboboxOption(options[0], { selected: false }) assertComboboxOption(options[1], { selected: true }) assertComboboxOption(options[2], { selected: true }) }) ) it( 'should make the first selected option the active item', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(['bob', 'charlie']) return ( setValue(value)} multiple> {}} /> Trigger alice bob charlie ) } render() // Open combobox await click(getComboboxButton()) // Verify that bob is the active option assertActiveComboboxOption(getComboboxOptions()[1]) }) ) it( 'should keep the combobox open when selecting an item via the keyboard', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(['bob', 'charlie']) return ( setValue(value)} multiple> {}} /> Trigger alice bob charlie ) } render() // Open combobox await click(getComboboxButton()) assertCombobox({ state: ComboboxState.Visible }) // Verify that bob is the active option await click(getComboboxOptions()[0]) // Verify that the combobox is still open assertCombobox({ state: ComboboxState.Visible }) }) ) it( 'should toggle the selected state of an option when clicking on it', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(['bob', 'charlie']) return ( setValue(value)} multiple> {}} /> Trigger alice bob charlie ) } render() // Open combobox await click(getComboboxButton()) assertCombobox({ state: ComboboxState.Visible }) let options = getComboboxOptions() assertComboboxOption(options[0], { selected: false }) assertComboboxOption(options[1], { selected: true }) assertComboboxOption(options[2], { selected: true }) // Click on bob await click(getComboboxOptions()[1]) assertComboboxOption(options[0], { selected: false }) assertComboboxOption(options[1], { selected: false }) assertComboboxOption(options[2], { selected: true }) // Click on bob again await click(getComboboxOptions()[1]) assertComboboxOption(options[0], { selected: false }) assertComboboxOption(options[1], { selected: true }) assertComboboxOption(options[2], { selected: true }) }) ) it( 'should reset the active option, if the active option gets unmounted', suppressConsoleLogs(async () => { let users = ['alice', 'bob', 'charlie'] function Example() { let [value, setValue] = useState([]) return ( setValue(value)} multiple> {}} /> Trigger {users .filter((user) => !value.includes(user)) .map((user) => ( {user} ))} ) } render() // Open combobox await click(getComboboxButton()) assertCombobox({ state: ComboboxState.Visible }) let options = getComboboxOptions() // Go to the next option await press(Keys.ArrowDown) assertActiveComboboxOption(options[1]) // Select the option await press(Keys.Enter) // The active option is reset to the very first one assertActiveComboboxOption(options[0]) }) ) }) describe('Form compatibility', () => { it('should be possible to submit a form with a value', async () => { let submits = jest.fn() function Example() { let [value, setValue] = useState(null) return (
{ event.preventDefault() submits([...new FormData(event.currentTarget).entries()]) }} > Trigger Pizza Delivery Pickup Home delivery Dine in
) } render() // Open combobox await click(getComboboxButton()) // Submit the form await click(getByText('Submit')) // Verify that the form has been submitted expect(submits).lastCalledWith([]) // no data // Open combobox again await click(getComboboxButton()) // Choose home delivery await click(getByText('Home delivery')) // Submit the form again await click(getByText('Submit')) // Verify that the form has been submitted expect(submits).lastCalledWith([['delivery', 'home-delivery']]) // Open combobox again await click(getComboboxButton()) // Choose pickup await click(getByText('Pickup')) // Submit the form again await click(getByText('Submit')) // Verify that the form has been submitted expect(submits).lastCalledWith([['delivery', 'pickup']]) }) it('should be possible to submit a form with a complex value object', async () => { let submits = jest.fn() let options = [ { id: 1, value: 'pickup', label: 'Pickup', extra: { info: 'Some extra info' }, }, { id: 2, value: 'home-delivery', label: 'Home delivery', extra: { info: 'Some extra info' }, }, { id: 3, value: 'dine-in', label: 'Dine in', extra: { info: 'Some extra info' }, }, ] function Example() { let [value, setValue] = useState(options[0]) return (
{ event.preventDefault() submits([...new FormData(event.currentTarget).entries()]) }} > Trigger Pizza Delivery {options.map((option) => ( {option.label} ))}
) } render() // Open combobox await click(getComboboxButton()) // Submit the form await click(getByText('Submit')) // Verify that the form has been submitted expect(submits).lastCalledWith([ ['delivery[id]', '1'], ['delivery[value]', 'pickup'], ['delivery[label]', 'Pickup'], ['delivery[extra][info]', 'Some extra info'], ]) // Open combobox await click(getComboboxButton()) // Choose home delivery await click(getByText('Home delivery')) // Submit the form again await click(getByText('Submit')) // Verify that the form has been submitted expect(submits).lastCalledWith([ ['delivery[id]', '2'], ['delivery[value]', 'home-delivery'], ['delivery[label]', 'Home delivery'], ['delivery[extra][info]', 'Some extra info'], ]) // Open combobox await click(getComboboxButton()) // Choose pickup await click(getByText('Pickup')) // Submit the form again await click(getByText('Submit')) // Verify that the form has been submitted expect(submits).lastCalledWith([ ['delivery[id]', '1'], ['delivery[value]', 'pickup'], ['delivery[label]', 'Pickup'], ['delivery[extra][info]', 'Some extra info'], ]) }) })