import React, { createElement, useState, useEffect } from 'react' import { render } from '@testing-library/react' import { Listbox } from './listbox' 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, assertActiveListboxOption, assertListbox, assertListboxButton, assertListboxButtonLinkedWithListbox, assertListboxButtonLinkedWithListboxLabel, assertListboxOption, assertListboxLabel, assertListboxLabelLinkedWithListbox, assertNoActiveListboxOption, assertNoSelectedListboxOption, getListbox, getListboxButton, getListboxButtons, getListboxes, getListboxOptions, getListboxLabel, ListboxState, getByText, ListboxMode, } from '../../test-utils/accessibility-assertions' import { Transition } from '../transitions/transition' 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([ ['Listbox.Button', Listbox.Button], ['Listbox.Label', Listbox.Label], ['Listbox.Options', Listbox.Options], ['Listbox.Option', Listbox.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 Listbox without crashing', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertListboxButton({ state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) }) describe('Rendering', () => { describe('Listbox', () => { it( 'should be possible to render a Listbox using a render prop', suppressConsoleLogs(async () => { render( {({ open }) => ( <> Trigger {open && ( Option A Option B Option C )} )} ) assertListboxButton({ state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxButton({ state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-button-1' }, }) assertListbox({ state: ListboxState.Visible }) }) ) it( 'should be possible to disable a Listbox', suppressConsoleLogs(async () => { render( Trigger Option A Option B Option C ) assertListboxButton({ state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxButton({ state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) assertListbox({ state: ListboxState.InvisibleUnmounted }) await press(Keys.Enter, getListboxButton()) assertListboxButton({ state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, }) assertListbox({ state: ListboxState.InvisibleUnmounted }) }) ) describe('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(getListboxButton()) let bob = getListboxOptions()[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(getListboxButton()) let bob = getListboxOptions()[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(getListboxButton()) let bob = getListboxOptions()[1] expect(bob).toHaveAttribute( 'class', JSON.stringify({ active: true, selected: true, disabled: false }) ) }) ) }) }) describe('Listbox.Label', () => { it( 'should be possible to render a Listbox.Label using a render prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Trigger Option A Option B Option C ) assertListboxButton({ state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-2' }, }) assertListboxLabel({ attributes: { id: 'headlessui-listbox-label-1' }, textContent: JSON.stringify({ open: false, disabled: false }), }) assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxLabel({ attributes: { id: 'headlessui-listbox-label-1' }, textContent: JSON.stringify({ open: true, disabled: false }), }) assertListbox({ state: ListboxState.Visible }) assertListboxLabelLinkedWithListbox() assertListboxButtonLinkedWithListboxLabel() }) ) it( 'should be possible to render a Listbox.Label using a render prop and an `as` prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Trigger Option A Option B Option C ) assertListboxLabel({ attributes: { id: 'headlessui-listbox-label-1' }, textContent: JSON.stringify({ open: false, disabled: false }), tag: 'p', }) assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxLabel({ attributes: { id: 'headlessui-listbox-label-1' }, textContent: JSON.stringify({ open: true, disabled: false }), tag: 'p', }) assertListbox({ state: ListboxState.Visible }) }) ) }) describe('Listbox.Button', () => { it( 'should be possible to render a Listbox.Button using a render prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Option A Option B Option C ) assertListboxButton({ state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, textContent: JSON.stringify({ open: false, disabled: false }), }) assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxButton({ state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-button-1' }, textContent: JSON.stringify({ open: true, disabled: false }), }) assertListbox({ state: ListboxState.Visible }) }) ) it( 'should be possible to render a Listbox.Button using a render prop and an `as` prop', suppressConsoleLogs(async () => { render( {JSON.stringify} Option A Option B Option C ) assertListboxButton({ state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-1' }, textContent: JSON.stringify({ open: false, disabled: false }), }) assertListbox({ state: ListboxState.InvisibleUnmounted }) await click(getListboxButton()) assertListboxButton({ state: ListboxState.Visible, attributes: { id: 'headlessui-listbox-button-1' }, textContent: JSON.stringify({ open: true, disabled: false }), }) assertListbox({ state: ListboxState.Visible }) }) ) it( 'should be possible to render a Listbox.Button and a Listbox.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) assertListboxButton({ state: ListboxState.InvisibleUnmounted, attributes: { id: 'headlessui-listbox-button-2' }, }) assertListbox({ state: ListboxState.InvisibleUnmounted }) assertListboxButtonLinkedWithListboxLabel() }) ) describe('`type` attribute', () => { it('should set the `type` to "button" by default', async () => { render( Trigger ) expect(getListboxButton()).toHaveAttribute('type', 'button') }) it('should not set the `type` to "button" if it already contains a `type`', async () => { render( Trigger ) expect(getListboxButton()).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 listbox button await click(getListboxButton()) // Ensure the listbox is open assertListbox({ state: ListboxState.Visible }) // Click the span inside the button await click(getByText('Next')) // Ensure the listbox is closed assertListbox({ state: ListboxState.InvisibleUnmounted }) // Ensure the outside button is focused assertActiveElement(document.getElementById('btn')) // Ensure that the focus button only got focus once (first click) expect(focusFn).toHaveBeenCalledTimes(1) }) ) it( 'should be possible to hover an option and make it active', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open listbox await click(getListboxButton()) let options = getListboxOptions() // We should be able to go to the second option await mouseMove(options[1]) assertActiveListboxOption(options[1]) // We should be able to go to the first option await mouseMove(options[0]) assertActiveListboxOption(options[0]) // We should be able to go to the last option await mouseMove(options[2]) assertActiveListboxOption(options[2]) }) ) it( 'should make a listbox option active when you move the mouse over it', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open listbox await click(getListboxButton()) let options = getListboxOptions() // We should be able to go to the second option await mouseMove(options[1]) assertActiveListboxOption(options[1]) }) ) it( 'should be a no-op when we move the mouse and the listbox option is already active', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open listbox await click(getListboxButton()) let options = getListboxOptions() // We should be able to go to the second option await mouseMove(options[1]) assertActiveListboxOption(options[1]) await mouseMove(options[1]) // Nothing should be changed assertActiveListboxOption(options[1]) }) ) it( 'should be a no-op when we move the mouse and the listbox option is disabled', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open listbox await click(getListboxButton()) let options = getListboxOptions() await mouseMove(options[1]) assertNoActiveListboxOption() }) ) it( 'should not be possible to hover an option that is disabled', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open listbox await click(getListboxButton()) let options = getListboxOptions() // Try to hover over option 1, which is disabled await mouseMove(options[1]) // We should not have an active option now assertNoActiveListboxOption() }) ) it( 'should be possible to mouse leave an option and make it inactive', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open listbox await click(getListboxButton()) let options = getListboxOptions() // We should be able to go to the second option await mouseMove(options[1]) assertActiveListboxOption(options[1]) await mouseLeave(options[1]) assertNoActiveListboxOption() // We should be able to go to the first option await mouseMove(options[0]) assertActiveListboxOption(options[0]) await mouseLeave(options[0]) assertNoActiveListboxOption() // We should be able to go to the last option await mouseMove(options[2]) assertActiveListboxOption(options[2]) await mouseLeave(options[2]) assertNoActiveListboxOption() }) ) it( 'should be possible to mouse leave a disabled option and be a no-op', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open listbox await click(getListboxButton()) let options = getListboxOptions() // Try to hover over option 1, which is disabled await mouseMove(options[1]) assertNoActiveListboxOption() await mouseLeave(options[1]) assertNoActiveListboxOption() }) ) it( 'should be possible to click a listbox option, which closes the listbox', suppressConsoleLogs(async () => { let handleChange = jest.fn() function Example() { let [value, setValue] = useState(undefined) return ( { setValue(value) handleChange(value) }} > Trigger alice bob charlie ) } render() // Open listbox await click(getListboxButton()) assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) let options = getListboxOptions() // We should be able to click the first option await click(options[1]) assertListbox({ state: ListboxState.InvisibleUnmounted }) expect(handleChange).toHaveBeenCalledTimes(1) expect(handleChange).toHaveBeenCalledWith('bob') // Verify the button is focused again assertActiveElement(getListboxButton()) // Open listbox again await click(getListboxButton()) // Verify the active option is the previously selected one assertActiveListboxOption(getListboxOptions()[1]) }) ) it( 'should be possible to click a disabled listbox option, which is a no-op', suppressConsoleLogs(async () => { let handleChange = jest.fn() function Example() { let [value, setValue] = useState(undefined) return ( { setValue(value) handleChange(value) }} > Trigger alice bob charlie ) } render() // Open listbox await click(getListboxButton()) assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) let options = getListboxOptions() // We should be able to click the first option await click(options[1]) assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) expect(handleChange).toHaveBeenCalledTimes(0) // Close the listbox await click(getListboxButton()) // Open listbox again await click(getListboxButton()) // Verify the active option is non existing assertNoActiveListboxOption() }) ) it( 'should be possible focus a listbox option, so that it becomes active', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open listbox await click(getListboxButton()) assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) let options = getListboxOptions() // Verify that nothing is active yet assertNoActiveListboxOption() // We should be able to focus the first option await focus(options[1]) assertActiveListboxOption(options[1]) }) ) it( 'should not be possible to focus a listbox option which is disabled', suppressConsoleLogs(async () => { render( Trigger alice bob charlie ) // Open listbox await click(getListboxButton()) assertListbox({ state: ListboxState.Visible }) assertActiveElement(getListbox()) let options = getListboxOptions() // We should not be able to focus the first option await focus(options[1]) assertNoActiveListboxOption() }) ) }) describe('Multi-select', () => { it( 'should be possible to pass multiple values to the Listbox component', suppressConsoleLogs(async () => { function Example() { let [value, setValue] = useState(['bob', 'charlie']) return ( Trigger alice bob charlie ) } render() // Open listbox await click(getListboxButton()) // Verify that we have an open listbox with multiple mode assertListbox({ state: ListboxState.Visible, mode: ListboxMode.Multiple }) // Verify that we have multiple selected listbox options let options = getListboxOptions() assertListboxOption(options[0], { selected: false }) assertListboxOption(options[1], { selected: true }) assertListboxOption(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 listbox await click(getListboxButton()) // Verify that bob is the active option assertActiveListboxOption(getListboxOptions()[1]) }) ) it( 'should keep the listbox 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 listbox await click(getListboxButton()) assertListbox({ state: ListboxState.Visible }) // Verify that bob is the active option await click(getListboxOptions()[0]) // Verify that the listbox is still open assertListbox({ state: ListboxState.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 listbox await click(getListboxButton()) assertListbox({ state: ListboxState.Visible }) let options = getListboxOptions() assertListboxOption(options[0], { selected: false }) assertListboxOption(options[1], { selected: true }) assertListboxOption(options[2], { selected: true }) // Click on bob await click(getListboxOptions()[1]) assertListboxOption(options[0], { selected: false }) assertListboxOption(options[1], { selected: false }) assertListboxOption(options[2], { selected: true }) // Click on bob again await click(getListboxOptions()[1]) assertListboxOption(options[0], { selected: false }) assertListboxOption(options[1], { selected: true }) assertListboxOption(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 listbox await click(getListboxButton()) // Submit the form await click(getByText('Submit')) // Verify that the form has been submitted expect(submits).lastCalledWith([]) // no data // Open listbox again await click(getListboxButton()) // 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 listbox again await click(getListboxButton()) // 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 listbox await click(getListboxButton()) // 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 listbox await click(getListboxButton()) // 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 listbox await click(getListboxButton()) // 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'], ]) }) })