import React, { createElement, useState, useEffect } from 'react' import { render } from '@testing-library/react' import { Combobox } from './combobox' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { click, focus, mouseMove, mouseLeave, press, shift, type, word, Keys, MouseButton, } from '../../test-utils/interactions' import { assertActiveElement, assertActiveComboboxOption, assertComboboxList, assertComboboxButton, assertComboboxButtonLinkedWithCombobox, assertComboboxButtonLinkedWithComboboxLabel, assertComboboxOption, assertComboboxLabel, assertComboboxLabelLinkedWithCombobox, assertNoActiveComboboxOption, assertNoSelectedComboboxOption, getComboboxInput, getComboboxButton, getComboboxButtons, getComboboxInputs, getComboboxOptions, getComboboxLabel, ComboboxState, getByText, getComboboxes, assertCombobox, ComboboxMode, assertNotActiveComboboxOption, } from '../../test-utils/accessibility-assertions' import { Transition } from '../transitions/transition' let NOOP = () => {} jest.mock('../../hooks/use-id') beforeAll(() => { jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any) jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any) }) afterAll(() => jest.restoreAllMocks()) describe('safeguards', () => { it.each([ ['Combobox.Button', Combobox.Button], ['Combobox.Label', Combobox.Label], ['Combobox.Options', Combobox.Options], ['Combobox.Option', Combobox.Option], ])( 'should error when we are using a <%s /> without a parent ', suppressConsoleLogs((name, Component) => { // @ts-expect-error This is fine expect(() => render(createElement(Component))).toThrowError( `<${name} /> is missing a parent component.` ) }) ) it( 'should be possible to render a Combobox without crashing', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) }) describe('Rendering', () => { describe('Combobox', () => { it( 'should be possible to render a Combobox using a render prop', suppressConsoleLogs(async () => { render( {({ open }) => ( <> Trigger {open && ( Option A Option B Option C )} )} ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.Visible }) }) ) it( 'should be possible to disable a Combobox', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await press(Keys.Enter, getComboboxButton()) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) describe.skip('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 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 }) ) }) ) }) }) describe('Combobox.Input', () => { it( 'selecting an option puts the value into Combobox.Input when displayValue is not provided', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(undefined) return ( Trigger Option A Option B Option C ) } render() await click(getComboboxButton()) assertComboboxList({ state: ComboboxState.Visible }) await click(getComboboxOptions()[1]) expect(getComboboxInput()).toHaveValue('b') }) ) it( 'selecting an option puts the display value into Combobox.Input when displayValue is provided', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(undefined) return ( str?.toUpperCase() ?? ''} /> Trigger Option A Option B Option C ) } render() await click(getComboboxButton()) assertComboboxList({ state: ComboboxState.Visible }) await click(getComboboxOptions()[1]) expect(getComboboxInput()).toHaveValue('B') }) ) it( 'should be possible to override the `type` on the input', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(undefined) return ( Trigger Option A Option B Option C ) } render() expect(getComboboxInput()).toHaveAttribute('type', 'search') }) ) }) describe('Combobox.Label', () => { it( 'should be possible to render a Combobox.Label using a render prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-3' }, }) assertComboboxLabel({ attributes: { id: 'headlessui-combobox-label-1' }, textContent: JSON.stringify({ open: false, disabled: false }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxLabel({ attributes: { id: 'headlessui-combobox-label-1' }, textContent: JSON.stringify({ open: true, disabled: false }), }) assertComboboxList({ state: ComboboxState.Visible }) assertComboboxLabelLinkedWithCombobox() assertComboboxButtonLinkedWithComboboxLabel() }) ) it( 'should be possible to render a Combobox.Label using a render prop and an `as` prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Trigger Option A Option B Option C ) assertComboboxLabel({ attributes: { id: 'headlessui-combobox-label-1' }, textContent: JSON.stringify({ open: false, disabled: false }), tag: 'p', }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxLabel({ attributes: { id: 'headlessui-combobox-label-1' }, textContent: JSON.stringify({ open: true, disabled: false }), tag: 'p', }) assertComboboxList({ state: ComboboxState.Visible }) }) ) }) describe('Combobox.Button', () => { it( 'should be possible to render a Combobox.Button using a render prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: false, disabled: false }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: true, disabled: false }), }) assertComboboxList({ state: ComboboxState.Visible }) }) ) it( 'should be possible to render a Combobox.Button using a render prop and an `as` prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: false, disabled: false }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: true, disabled: false }), }) assertComboboxList({ state: ComboboxState.Visible }) }) ) it( 'should be possible to render a Combobox.Button and a Combobox.Label and see them linked together', suppressConsoleLogs(async () => { render( Label Trigger Option A Option B Option C ) // TODO: Needed to make it similar to vue test implementation? // await new Promise(requestAnimationFrame) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-3' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) assertComboboxButtonLinkedWithComboboxLabel() }) ) describe('`type` attribute', () => { it('should set the `type` to "button" by default', async () => { render( Trigger ) expect(getComboboxButton()).toHaveAttribute('type', 'button') }) it('should not set the `type` to "button" if it already contains a `type`', async () => { render( Trigger ) expect(getComboboxButton()).toHaveAttribute('type', 'submit') }) it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => { let CustomButton = React.forwardRef((props, ref) => ( ) } render() // 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 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', 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 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 ( {}} /> 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 ( {}} /> 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 ( {}} /> 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 ( {}} /> 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 }) }) ) }) 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'], ]) }) })