import React, { createElement, useState, useEffect } from 'react' import { render } from '@testing-library/react' import { Combobox } from './combobox' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { click, focus, mouseMove, mouseLeave, press, shift, type, word, Keys, MouseButton, } from '../../test-utils/interactions' import { assertActiveElement, assertActiveComboboxOption, assertComboboxList, assertComboboxButton, assertComboboxButtonLinkedWithCombobox, assertComboboxButtonLinkedWithComboboxLabel, assertComboboxOption, assertComboboxLabel, assertComboboxLabelLinkedWithCombobox, assertNoActiveComboboxOption, assertNoSelectedComboboxOption, getComboboxInput, getComboboxButton, getComboboxButtons, getComboboxInputs, getComboboxOptions, getComboboxLabel, ComboboxState, getByText, getComboboxes, } from '../../test-utils/accessibility-assertions' import { Transition } from '../transitions/transition' let NOOP = () => {} jest.mock('../../hooks/use-id') beforeAll(() => { jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any) jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any) }) afterAll(() => jest.restoreAllMocks()) describe('safeguards', () => { it.each([ ['Combobox.Button', Combobox.Button], ['Combobox.Label', Combobox.Label], ['Combobox.Options', Combobox.Options], ['Combobox.Option', Combobox.Option], ])( 'should error when we are using a <%s /> without a parent ', suppressConsoleLogs((name, Component) => { // @ts-expect-error This is fine expect(() => render(createElement(Component))).toThrowError( `<${name} /> is missing a parent component.` ) }) ) it( 'should be possible to render a Combobox without crashing', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) }) describe('Rendering', () => { describe('Combobox', () => { it( 'should be possible to render a Combobox using a render prop', suppressConsoleLogs(async () => { render( {({ open }) => ( <> Trigger {open && ( Option A Option B Option C )} )} ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.Visible }) }) ) it( 'should be possible to disable a Combobox', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await press(Keys.Enter, getComboboxButton()) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) }) ) }) describe('Combobox.Input', () => { it( 'selecting an option puts the value into Combobox.Input when displayValue is not provided', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(undefined) return ( Trigger Option A Option B Option C ) } render() await click(getComboboxButton()) assertComboboxList({ state: ComboboxState.Visible }) await click(getComboboxOptions()[1]) expect(getComboboxInput()).toHaveValue('b') }) ) it( 'selecting an option puts the display value into Combobox.Input when displayValue is provided', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(undefined) return ( str?.toUpperCase() ?? ''} /> Trigger Option A Option B Option C ) } render() await click(getComboboxButton()) assertComboboxList({ state: ComboboxState.Visible }) await click(getComboboxOptions()[1]) expect(getComboboxInput()).toHaveValue('B') }) ) }) describe('Combobox.Label', () => { it( 'should be possible to render a Combobox.Label using a render prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Trigger Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-3' }, }) assertComboboxLabel({ attributes: { id: 'headlessui-combobox-label-1' }, textContent: JSON.stringify({ open: false, disabled: false }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxLabel({ attributes: { id: 'headlessui-combobox-label-1' }, textContent: JSON.stringify({ open: true, disabled: false }), }) assertComboboxList({ state: ComboboxState.Visible }) assertComboboxLabelLinkedWithCombobox() assertComboboxButtonLinkedWithComboboxLabel() }) ) it( 'should be possible to render a Combobox.Label using a render prop and an `as` prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Trigger Option A Option B Option C ) assertComboboxLabel({ attributes: { id: 'headlessui-combobox-label-1' }, textContent: JSON.stringify({ open: false, disabled: false }), tag: 'p', }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxLabel({ attributes: { id: 'headlessui-combobox-label-1' }, textContent: JSON.stringify({ open: true, disabled: false }), tag: 'p', }) assertComboboxList({ state: ComboboxState.Visible }) }) ) }) describe('Combobox.Button', () => { it( 'should be possible to render a Combobox.Button using a render prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: false, disabled: false }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: true, disabled: false }), }) assertComboboxList({ state: ComboboxState.Visible }) }) ) it( 'should be possible to render a Combobox.Button using a render prop and an `as` prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Option A Option B Option C ) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: false, disabled: false }), }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) await click(getComboboxButton()) assertComboboxButton({ state: ComboboxState.Visible, attributes: { id: 'headlessui-combobox-button-2' }, textContent: JSON.stringify({ open: true, disabled: false }), }) assertComboboxList({ state: ComboboxState.Visible }) }) ) it( 'should be possible to render a Combobox.Button and a Combobox.Label and see them linked together', suppressConsoleLogs(async () => { render( Label Trigger Option A Option B Option C ) // TODO: Needed to make it similar to vue test implementation? // await new Promise(requestAnimationFrame) assertComboboxButton({ state: ComboboxState.InvisibleUnmounted, attributes: { id: 'headlessui-combobox-button-3' }, }) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) assertComboboxButtonLinkedWithComboboxLabel() }) ) describe('`type` attribute', () => { it('should set the `type` to "button" by default', async () => { render( Trigger ) expect(getComboboxButton()).toHaveAttribute('type', 'button') }) it('should not set the `type` to "button" if it already contains a `type`', async () => { render( Trigger ) expect(getComboboxButton()).toHaveAttribute('type', 'submit') }) it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => { let CustomButton = React.forwardRef((props, ref) => ( ) // Click the combobox button await click(getComboboxButton()) // Ensure the combobox is open assertComboboxList({ state: ComboboxState.Visible }) // Click the span inside the button await click(getByText('Next')) // Ensure the combobox is closed assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Ensure the outside button is focused assertActiveElement(document.getElementById('btn')) // Ensure that the focus button only got focus once (first click) expect(focusFn).toHaveBeenCalledTimes(1) }) ) it( 'should be possible to hover an option and make it active', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be able to go to the second option await mouseMove(options[1]) assertActiveComboboxOption(options[1]) // We should be able to go to the first option await mouseMove(options[0]) assertActiveComboboxOption(options[0]) // We should be able to go to the last option await mouseMove(options[2]) assertActiveComboboxOption(options[2]) }) ) it( 'should make a combobox option active when you move the mouse over it', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be able to go to the second option await mouseMove(options[1]) assertActiveComboboxOption(options[1]) }) ) it( 'should be a no-op when we move the mouse and the combobox option is already active', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be able to go to the second option await mouseMove(options[1]) assertActiveComboboxOption(options[1]) await mouseMove(options[1]) // Nothing should be changed assertActiveComboboxOption(options[1]) }) ) it( 'should be a no-op when we move the mouse and the combobox option is disabled', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() await mouseMove(options[1]) assertNoActiveComboboxOption() }) ) it( 'should not be possible to hover an option that is disabled', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // Try to hover over option 1, which is disabled await mouseMove(options[1]) // We should not have an active option now assertNoActiveComboboxOption() }) ) it( 'should be possible to mouse leave an option and make it inactive', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // We should be able to go to the second option await mouseMove(options[1]) assertActiveComboboxOption(options[1]) await mouseLeave(options[1]) assertNoActiveComboboxOption() // We should be able to go to the first option await mouseMove(options[0]) assertActiveComboboxOption(options[0]) await mouseLeave(options[0]) assertNoActiveComboboxOption() // We should be able to go to the last option await mouseMove(options[2]) assertActiveComboboxOption(options[2]) await mouseLeave(options[2]) assertNoActiveComboboxOption() }) ) it( 'should be possible to mouse leave a disabled option and be a no-op', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) let options = getComboboxOptions() // Try to hover over option 1, which is disabled await mouseMove(options[1]) assertNoActiveComboboxOption() await mouseLeave(options[1]) assertNoActiveComboboxOption() }) ) it( 'should be possible to click a combobox option, which closes the combobox', suppressConsoleLogs(async () => { let handleChange = jest.fn() function Example() { let [value, setValue] = useState(undefined) return ( { setValue(value) handleChange(value) }} > Trigger alice bob charlie ) } render() // Open combobox await click(getComboboxButton()) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) let options = getComboboxOptions() // We should be able to click the first option await click(options[1]) assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) expect(handleChange).toHaveBeenCalledTimes(1) expect(handleChange).toHaveBeenCalledWith('bob') // Verify the input is focused again assertActiveElement(getComboboxInput()) // Open combobox again await click(getComboboxButton()) // Verify the active option is the previously selected one assertActiveComboboxOption(getComboboxOptions()[1]) }) ) it( 'should be possible to click a disabled combobox option, which is a no-op', suppressConsoleLogs(async () => { let handleChange = jest.fn() function Example() { let [value, setValue] = useState(undefined) return ( { setValue(value) handleChange(value) }} > Trigger alice bob charlie ) } render() // Open combobox await click(getComboboxButton()) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) let options = getComboboxOptions() // We should be able to click the first option await click(options[1]) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) expect(handleChange).toHaveBeenCalledTimes(0) // Close the combobox await click(getComboboxButton()) // Open combobox again await click(getComboboxButton()) // Verify the active option is non existing assertNoActiveComboboxOption() }) ) it( 'should be possible focus a combobox option, so that it becomes active', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(undefined) return ( Trigger alice bob charlie ) } render() // Open combobox await click(getComboboxButton()) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) let options = getComboboxOptions() // Verify that nothing is active yet assertNoActiveComboboxOption() // We should be able to focus the first option await focus(options[1]) assertActiveComboboxOption(options[1]) }) ) it( 'should not be possible to focus a combobox option which is disabled', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open combobox await click(getComboboxButton()) assertComboboxList({ state: ComboboxState.Visible }) assertActiveElement(getComboboxInput()) let options = getComboboxOptions() // We should not be able to focus the first option await focus(options[1]) assertNoActiveComboboxOption() }) ) })