b8c214eebb
* Export explicit props types * wip * wip * wip * wip dialog types * wip * Fix build * Upgrade esbuild * Add aliased types for ComponentLabel and ComponentDescription * Update lockfile * Update changelog * Update exported prop type names * Make onChange optional * Update tests * Use `never` in CleanProps Using a branded type doesn’t work properly with unions * Fix types * wip * work on types * wip * wip * Tweak types in render helpers * Fix CS * Fix changelog * Tweak render prop types for combobox * Update hidden props type name * remove unused type * Tweak types * Update TypeScript version
5867 lines
195 KiB
TypeScript
5867 lines
195 KiB
TypeScript
import React, { createElement, useState, useEffect } from 'react'
|
|
import { render } from '@testing-library/react'
|
|
|
|
import { Combobox } from './combobox'
|
|
import { mockingConsoleLogs, suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
|
|
import {
|
|
click,
|
|
focus,
|
|
mouseMove,
|
|
mouseLeave,
|
|
press,
|
|
shift,
|
|
type,
|
|
word,
|
|
Keys,
|
|
MouseButton,
|
|
} from '../../test-utils/interactions'
|
|
import {
|
|
assertActiveElement,
|
|
assertActiveComboboxOption,
|
|
assertComboboxList,
|
|
assertComboboxButton,
|
|
assertComboboxButtonLinkedWithCombobox,
|
|
assertComboboxButtonLinkedWithComboboxLabel,
|
|
assertComboboxOption,
|
|
assertComboboxLabel,
|
|
assertComboboxLabelLinkedWithCombobox,
|
|
assertNoActiveComboboxOption,
|
|
assertNoSelectedComboboxOption,
|
|
getComboboxInput,
|
|
getComboboxButton,
|
|
getComboboxButtons,
|
|
getComboboxInputs,
|
|
getComboboxOptions,
|
|
getComboboxLabel,
|
|
ComboboxState,
|
|
getByText,
|
|
getComboboxes,
|
|
assertCombobox,
|
|
ComboboxMode,
|
|
assertNotActiveComboboxOption,
|
|
assertComboboxInput,
|
|
} from '../../test-utils/accessibility-assertions'
|
|
import { Transition } from '../transitions/transition'
|
|
|
|
let NOOP = () => {}
|
|
|
|
jest.mock('../../hooks/use-id')
|
|
|
|
beforeAll(() => {
|
|
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
|
|
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
|
|
})
|
|
|
|
afterAll(() => jest.restoreAllMocks())
|
|
|
|
describe('safeguards', () => {
|
|
it.each([
|
|
['Combobox.Button', Combobox.Button],
|
|
['Combobox.Label', Combobox.Label],
|
|
['Combobox.Options', Combobox.Options],
|
|
['Combobox.Option', Combobox.Option],
|
|
])(
|
|
'should error when we are using a <%s /> without a parent <Combobox />',
|
|
suppressConsoleLogs((name, Component) => {
|
|
// @ts-expect-error This is fine
|
|
expect(() => render(createElement(Component))).toThrowError(
|
|
`<${name} /> is missing a parent <Combobox /> component.`
|
|
)
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to render a Combobox without crashing',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
{({ open }) => (
|
|
<>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
{open && (
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
)}
|
|
</>
|
|
)}
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value={undefined} onChange={console.log} disabled>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
await click(getComboboxButton())
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
await press(Keys.Enter, getComboboxButton())
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should not crash in multiple mode',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox multiple name="abc">
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value={{ id: 1, name: 'alice' }}>alice</Combobox.Option>
|
|
<Combobox.Option value={{ id: 2, name: 'bob' }}>bob</Combobox.Option>
|
|
<Combobox.Option value={{ id: 3, name: 'charlie' }}>charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
await click(getComboboxButton())
|
|
let [alice, bob, charlie] = getComboboxOptions()
|
|
|
|
await click(alice)
|
|
await click(bob)
|
|
await click(charlie)
|
|
})
|
|
)
|
|
|
|
describe('Equality', () => {
|
|
let options = [
|
|
{ id: 1, name: 'Alice' },
|
|
{ id: 2, name: 'Bob' },
|
|
{ id: 3, name: 'Charlie' },
|
|
]
|
|
|
|
it(
|
|
'should use object equality by default',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value={options[1]} onChange={console.log}>
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
{options.map((option) => (
|
|
<Combobox.Option
|
|
key={option.id}
|
|
value={option}
|
|
className={(info) => JSON.stringify(info)}
|
|
>
|
|
{option.name}
|
|
</Combobox.Option>
|
|
))}
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
await click(getComboboxButton())
|
|
|
|
let bob = getComboboxOptions()[1]
|
|
expect(bob).toHaveAttribute(
|
|
'class',
|
|
JSON.stringify({ active: true, selected: true, disabled: false })
|
|
)
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to compare null values by a field',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value={null} onChange={console.log} by="id">
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
{options.map((option) => (
|
|
<Combobox.Option
|
|
key={option.id}
|
|
value={option}
|
|
className={(info) => JSON.stringify(info)}
|
|
>
|
|
{option.name}
|
|
</Combobox.Option>
|
|
))}
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
await click(getComboboxButton())
|
|
|
|
let [alice, bob, charlie] = getComboboxOptions()
|
|
expect(alice).toHaveAttribute(
|
|
'class',
|
|
JSON.stringify({ active: true, selected: false, disabled: false })
|
|
)
|
|
expect(bob).toHaveAttribute(
|
|
'class',
|
|
JSON.stringify({ active: false, selected: false, disabled: false })
|
|
)
|
|
expect(charlie).toHaveAttribute(
|
|
'class',
|
|
JSON.stringify({ active: false, selected: false, disabled: false })
|
|
)
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to compare objects by a field',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value={{ id: 2, name: 'Bob' }} onChange={console.log} by="id">
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
{options.map((option) => (
|
|
<Combobox.Option
|
|
key={option.id}
|
|
value={option}
|
|
className={(info) => JSON.stringify(info)}
|
|
>
|
|
{option.name}
|
|
</Combobox.Option>
|
|
))}
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox
|
|
value={{ id: 2, name: 'Bob' }}
|
|
onChange={console.log}
|
|
by={(a, z) => a.id === z.id}
|
|
>
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
{options.map((option) => (
|
|
<Combobox.Option
|
|
key={option.id}
|
|
value={option}
|
|
className={(info) => JSON.stringify(info)}
|
|
>
|
|
{option.name}
|
|
</Combobox.Option>
|
|
))}
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
await click(getComboboxButton())
|
|
|
|
let bob = getComboboxOptions()[1]
|
|
expect(bob).toHaveAttribute(
|
|
'class',
|
|
JSON.stringify({ active: true, selected: true, disabled: false })
|
|
)
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to use completely new objects while rendering (single mode)',
|
|
suppressConsoleLogs(async () => {
|
|
function Example() {
|
|
let [value, setValue] = useState({ id: 2, name: 'Bob' })
|
|
|
|
return (
|
|
<Combobox value={value} onChange={(value) => setValue(value)} by="id">
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value={{ id: 1, name: 'alice' }}>alice</Combobox.Option>
|
|
<Combobox.Option value={{ id: 2, name: 'bob' }}>bob</Combobox.Option>
|
|
<Combobox.Option value={{ id: 3, name: 'charlie' }}>charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
await click(getComboboxButton())
|
|
let [alice, bob, charlie] = getComboboxOptions()
|
|
expect(alice).toHaveAttribute('aria-selected', 'false')
|
|
expect(bob).toHaveAttribute('aria-selected', 'true')
|
|
expect(charlie).toHaveAttribute('aria-selected', 'false')
|
|
|
|
await click(getComboboxOptions()[2])
|
|
await click(getComboboxButton())
|
|
;[alice, bob, charlie] = getComboboxOptions()
|
|
expect(alice).toHaveAttribute('aria-selected', 'false')
|
|
expect(bob).toHaveAttribute('aria-selected', 'false')
|
|
expect(charlie).toHaveAttribute('aria-selected', 'true')
|
|
|
|
await click(getComboboxOptions()[1])
|
|
await click(getComboboxButton())
|
|
;[alice, bob, charlie] = getComboboxOptions()
|
|
expect(alice).toHaveAttribute('aria-selected', 'false')
|
|
expect(bob).toHaveAttribute('aria-selected', 'true')
|
|
expect(charlie).toHaveAttribute('aria-selected', 'false')
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to use completely new objects while rendering (multiple mode)',
|
|
suppressConsoleLogs(async () => {
|
|
function Example() {
|
|
let [value, setValue] = useState([{ id: 2, name: 'Bob' }])
|
|
|
|
return (
|
|
<Combobox value={value} onChange={(value) => setValue(value)} by="id" multiple>
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value={{ id: 1, name: 'alice' }}>alice</Combobox.Option>
|
|
<Combobox.Option value={{ id: 2, name: 'bob' }}>bob</Combobox.Option>
|
|
<Combobox.Option value={{ id: 3, name: 'charlie' }}>charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
await click(getComboboxButton())
|
|
|
|
await click(getComboboxOptions()[2])
|
|
let [alice, bob, charlie] = getComboboxOptions()
|
|
expect(alice).toHaveAttribute('aria-selected', 'false')
|
|
expect(bob).toHaveAttribute('aria-selected', 'true')
|
|
expect(charlie).toHaveAttribute('aria-selected', 'true')
|
|
|
|
await click(getComboboxOptions()[2])
|
|
;[alice, bob, charlie] = getComboboxOptions()
|
|
expect(alice).toHaveAttribute('aria-selected', 'false')
|
|
expect(bob).toHaveAttribute('aria-selected', 'true')
|
|
expect(charlie).toHaveAttribute('aria-selected', 'false')
|
|
})
|
|
)
|
|
})
|
|
|
|
it(
|
|
'should not crash when a defaultValue is not given',
|
|
suppressConsoleLogs(async () => {
|
|
let data = [
|
|
{ id: 1, name: 'alice', label: 'Alice' },
|
|
{ id: 2, name: 'bob', label: 'Bob' },
|
|
{ id: 3, name: 'charlie', label: 'Charlie' },
|
|
]
|
|
|
|
render(
|
|
<Combobox name="assignee" by="id">
|
|
<Combobox.Input
|
|
displayValue={(value: { name: string }) => value.name}
|
|
onChange={NOOP}
|
|
/>
|
|
<Combobox.Options>
|
|
{data.map((person) => (
|
|
<Combobox.Option key={person.id} value={person}>
|
|
{person.label}
|
|
</Combobox.Option>
|
|
))}
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
})
|
|
)
|
|
})
|
|
|
|
describe('Combobox.Input', () => {
|
|
it(
|
|
'selecting an option puts the value into Combobox.Input when displayValue is not provided',
|
|
suppressConsoleLogs(async () => {
|
|
function Example() {
|
|
let [value, setValue] = useState(null)
|
|
|
|
return (
|
|
<Combobox value={value} onChange={setValue}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
assertComboboxInput({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
await click(getComboboxButton())
|
|
|
|
assertComboboxInput({ state: ComboboxState.Visible })
|
|
assertComboboxList({ state: ComboboxState.Visible })
|
|
|
|
await click(getComboboxOptions()[1])
|
|
|
|
expect(getComboboxInput()).toHaveValue('b')
|
|
})
|
|
)
|
|
|
|
it(
|
|
'selecting an option puts the display value into Combobox.Input when displayValue is provided',
|
|
suppressConsoleLogs(async () => {
|
|
function Example() {
|
|
let [value, setValue] = useState(null)
|
|
|
|
return (
|
|
<Combobox value={value} onChange={setValue}>
|
|
<Combobox.Input
|
|
onChange={NOOP}
|
|
displayValue={(str?: string) => str?.toUpperCase() ?? ''}
|
|
/>
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
await click(getComboboxButton())
|
|
|
|
assertComboboxList({ state: ComboboxState.Visible })
|
|
|
|
await click(getComboboxOptions()[1])
|
|
|
|
expect(getComboboxInput()).toHaveValue('B')
|
|
})
|
|
)
|
|
|
|
it(
|
|
'selecting an option puts the display value into Combobox.Input when displayValue is provided (when value is undefined)',
|
|
suppressConsoleLogs(async () => {
|
|
function Example() {
|
|
let [value, setValue] = useState(undefined)
|
|
|
|
return (
|
|
<Combobox value={value} onChange={setValue}>
|
|
<Combobox.Input
|
|
onChange={NOOP}
|
|
displayValue={(str?: string) => str?.toUpperCase() ?? ''}
|
|
/>
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// Focus the input
|
|
await focus(getComboboxInput())
|
|
|
|
// Type in it
|
|
await type(word('A'), getComboboxInput())
|
|
|
|
// Stop typing (and clear the input)
|
|
await press(Keys.Escape, getComboboxInput())
|
|
|
|
// Focus the body (so the input loses focus)
|
|
await focus(document.body)
|
|
|
|
expect(getComboboxInput()).toHaveValue('')
|
|
})
|
|
)
|
|
|
|
it(
|
|
'conditionally rendering the input should allow changing the display value',
|
|
suppressConsoleLogs(async () => {
|
|
function Example() {
|
|
let [value, setValue] = useState(null)
|
|
let [suffix, setSuffix] = useState(false)
|
|
|
|
return (
|
|
<>
|
|
<Combobox value={value} onChange={setValue} nullable>
|
|
<Combobox.Input
|
|
onChange={NOOP}
|
|
displayValue={(str?: string) =>
|
|
`${str?.toUpperCase() ?? ''} ${suffix ? 'with suffix' : 'no suffix'}`
|
|
}
|
|
/>
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
<button onClick={() => setSuffix((v) => !v)}>Toggle suffix</button>
|
|
</Combobox>
|
|
</>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
expect(getComboboxInput()).toHaveValue(' no suffix')
|
|
|
|
await click(getComboboxButton())
|
|
|
|
expect(getComboboxInput()).toHaveValue(' no suffix')
|
|
|
|
await click(getComboboxOptions()[1])
|
|
|
|
expect(getComboboxInput()).toHaveValue('B no suffix')
|
|
|
|
await click(getByText('Toggle suffix'))
|
|
|
|
expect(getComboboxInput()).toHaveValue('B with suffix')
|
|
|
|
await click(getComboboxButton())
|
|
|
|
expect(getComboboxInput()).toHaveValue('B with suffix')
|
|
|
|
await click(getComboboxOptions()[0])
|
|
|
|
expect(getComboboxInput()).toHaveValue('A with suffix')
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to override the `type` on the input',
|
|
suppressConsoleLogs(async () => {
|
|
function Example() {
|
|
let [value, setValue] = useState(null)
|
|
|
|
return (
|
|
<Combobox value={value} onChange={setValue}>
|
|
<Combobox.Input type="search" onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
expect(getComboboxInput()).toHaveAttribute('type', 'search')
|
|
})
|
|
)
|
|
|
|
xit(
|
|
'should reflect the value in the input when the value changes and when you are typing',
|
|
suppressConsoleLogs(async () => {
|
|
function Example() {
|
|
let [value, setValue] = useState('bob')
|
|
let [_query, setQuery] = useState('')
|
|
|
|
return (
|
|
<Combobox value={value} onChange={setValue}>
|
|
{({ open }) => (
|
|
<>
|
|
<Combobox.Input
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
displayValue={(person) => `${person ?? ''} - ${open ? 'open' : 'closed'}`}
|
|
/>
|
|
|
|
<Combobox.Button />
|
|
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</>
|
|
)}
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// Check for proper state sync
|
|
expect(getComboboxInput()).toHaveValue('bob - closed')
|
|
await click(getComboboxButton())
|
|
expect(getComboboxInput()).toHaveValue('bob - open')
|
|
await click(getComboboxButton())
|
|
expect(getComboboxInput()).toHaveValue('bob - closed')
|
|
|
|
// Check if we can still edit the input
|
|
for (let _ of Array(' - closed'.length)) {
|
|
await press(Keys.Backspace, getComboboxInput())
|
|
}
|
|
getComboboxInput()?.select()
|
|
await type(word('alice'), getComboboxInput())
|
|
expect(getComboboxInput()).toHaveValue('alice')
|
|
|
|
// Open the combobox and choose an option
|
|
await click(getComboboxOptions()[2])
|
|
expect(getComboboxInput()).toHaveValue('charlie - closed')
|
|
})
|
|
)
|
|
})
|
|
|
|
describe('Combobox.Label', () => {
|
|
it(
|
|
'should be possible to render a Combobox.Label using a render prop',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Label>{JSON.stringify}</Combobox.Label>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-3' },
|
|
})
|
|
assertComboboxLabel({
|
|
attributes: { id: 'headlessui-combobox-label-1' },
|
|
textContent: JSON.stringify({ open: false, disabled: false }),
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
await click(getComboboxButton())
|
|
|
|
assertComboboxLabel({
|
|
attributes: { id: 'headlessui-combobox-label-1' },
|
|
textContent: JSON.stringify({ open: true, disabled: false }),
|
|
})
|
|
assertComboboxList({ state: ComboboxState.Visible })
|
|
assertComboboxLabelLinkedWithCombobox()
|
|
assertComboboxButtonLinkedWithComboboxLabel()
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to link Input/Button and Label if Label is rendered last',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="Test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button />
|
|
<Combobox.Label>Label</Combobox.Label>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxLabelLinkedWithCombobox()
|
|
assertComboboxButtonLinkedWithComboboxLabel()
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to render a Combobox.Label using a render prop and an `as` prop',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Label as="p">{JSON.stringify}</Combobox.Label>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>{JSON.stringify}</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
textContent: JSON.stringify({ open: false, disabled: false, value: 'test' }),
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
await click(getComboboxButton())
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
textContent: JSON.stringify({ open: true, disabled: false, value: 'test' }),
|
|
})
|
|
assertComboboxList({ state: ComboboxState.Visible })
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to render a Combobox.Button using a render prop and an `as` prop',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button as="div" role="button">
|
|
{JSON.stringify}
|
|
</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
textContent: JSON.stringify({ open: false, disabled: false, value: 'test' }),
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
await click(getComboboxButton())
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
textContent: JSON.stringify({ open: true, disabled: false, value: 'test' }),
|
|
})
|
|
assertComboboxList({ state: ComboboxState.Visible })
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to render a Combobox.Button and a Combobox.Label and see them linked together',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Label>Label</Combobox.Label>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value={null} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
</Combobox>
|
|
)
|
|
|
|
expect(getComboboxButton()).toHaveAttribute('type', 'button')
|
|
})
|
|
|
|
it('should not set the `type` to "button" if it already contains a `type`', async () => {
|
|
render(
|
|
<Combobox value={null} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button type="submit">Trigger</Combobox.Button>
|
|
</Combobox>
|
|
)
|
|
|
|
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<HTMLButtonElement>((props, ref) => (
|
|
<button ref={ref} {...props} />
|
|
))
|
|
|
|
render(
|
|
<Combobox value={null} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button as={CustomButton}>Trigger</Combobox.Button>
|
|
</Combobox>
|
|
)
|
|
|
|
expect(getComboboxButton()).toHaveAttribute('type', 'button')
|
|
})
|
|
|
|
it('should not set the type if the "as" prop is not a "button"', async () => {
|
|
render(
|
|
<Combobox value={null} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button as="div">Trigger</Combobox.Button>
|
|
</Combobox>
|
|
)
|
|
|
|
expect(getComboboxButton()).not.toHaveAttribute('type')
|
|
})
|
|
|
|
it('should not set the `type` to "button" when using the `as` prop which resolves to a "div"', async () => {
|
|
let CustomButton = React.forwardRef<HTMLDivElement>((props, ref) => (
|
|
<div ref={ref} {...props} />
|
|
))
|
|
|
|
render(
|
|
<Combobox value={null} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button as={CustomButton}>Trigger</Combobox.Button>
|
|
</Combobox>
|
|
)
|
|
|
|
expect(getComboboxButton()).not.toHaveAttribute('type')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Combobox.Options', () => {
|
|
it(
|
|
'should be possible to render Combobox.Options using a render prop',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
{(data) => (
|
|
<>
|
|
<Combobox.Option value="a">{JSON.stringify(data)}</Combobox.Option>
|
|
</>
|
|
)}
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
await click(getComboboxButton())
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({
|
|
state: ComboboxState.Visible,
|
|
textContent: JSON.stringify({ open: true }),
|
|
})
|
|
assertActiveElement(getComboboxInput())
|
|
})
|
|
)
|
|
|
|
it('should be possible to always render the Combobox.Options if we provide it a `static` prop', () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options static>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// Let's verify that the Combobox is already there
|
|
expect(getComboboxInput()).not.toBe(null)
|
|
})
|
|
|
|
it('should be possible to use a different render strategy for the Combobox.Options', async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options unmount={false}>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxList({ state: ComboboxState.InvisibleHidden })
|
|
|
|
// Let's open the Combobox, to see if it is not hidden anymore
|
|
await click(getComboboxButton())
|
|
|
|
assertComboboxList({ state: ComboboxState.Visible })
|
|
})
|
|
})
|
|
|
|
describe('Combobox.Option', () => {
|
|
it(
|
|
'should be possible to render a Combobox.Option using a render prop',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">{JSON.stringify}</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
await click(getComboboxButton())
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({
|
|
state: ComboboxState.Visible,
|
|
textContent: JSON.stringify({ active: true, selected: false, disabled: false }),
|
|
})
|
|
})
|
|
)
|
|
})
|
|
|
|
it('should guarantee the order of DOM nodes when performing actions', async () => {
|
|
function Example({ hide = false }) {
|
|
return (
|
|
<>
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option 1</Combobox.Option>
|
|
{!hide && <Combobox.Option value="b">Option 2</Combobox.Option>}
|
|
<Combobox.Option value="c">Option 3</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
</>
|
|
)
|
|
}
|
|
|
|
let { rerender } = render(<Example />)
|
|
|
|
// Open the Combobox
|
|
await click(getByText('Trigger'))
|
|
|
|
rerender(<Example hide={true} />) // Remove Combobox.Option 2
|
|
rerender(<Example hide={false} />) // Re-add Combobox.Option 2
|
|
|
|
assertComboboxList({ state: ComboboxState.Visible })
|
|
|
|
let options = getComboboxOptions()
|
|
|
|
// Verify that the first combobox option is active
|
|
assertActiveComboboxOption(options[0])
|
|
|
|
await press(Keys.ArrowDown)
|
|
|
|
// Verify that the second combobox option is active
|
|
assertActiveComboboxOption(options[1])
|
|
|
|
await press(Keys.ArrowDown)
|
|
|
|
// Verify that the third combobox option is active
|
|
assertActiveComboboxOption(options[2])
|
|
})
|
|
|
|
describe('Uncontrolled', () => {
|
|
it('should be possible to use in an uncontrolled way', async () => {
|
|
let handleSubmission = jest.fn()
|
|
|
|
render(
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
|
|
}}
|
|
>
|
|
<Combobox name="assignee">
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">Alice</Combobox.Option>
|
|
<Combobox.Option value="bob">Bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">Charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
<button id="submit">submit</button>
|
|
</form>
|
|
)
|
|
|
|
await click(document.getElementById('submit'))
|
|
|
|
// No values
|
|
expect(handleSubmission).toHaveBeenLastCalledWith({})
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Choose alice
|
|
await click(getComboboxOptions()[0])
|
|
|
|
// Submit
|
|
await click(document.getElementById('submit'))
|
|
|
|
// Alice should be submitted
|
|
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' })
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Choose charlie
|
|
await click(getComboboxOptions()[2])
|
|
|
|
// Submit
|
|
await click(document.getElementById('submit'))
|
|
|
|
// Charlie should be submitted
|
|
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' })
|
|
})
|
|
|
|
it('should expose the value via the render prop', async () => {
|
|
let handleSubmission = jest.fn()
|
|
|
|
let { getByTestId } = render(
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
|
|
}}
|
|
>
|
|
<Combobox<string> name="assignee">
|
|
{({ value }) => (
|
|
<>
|
|
<div data-testid="value">{value}</div>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>
|
|
{({ value }) => (
|
|
<>
|
|
Trigger
|
|
<div data-testid="value-2">{value}</div>
|
|
</>
|
|
)}
|
|
</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">Alice</Combobox.Option>
|
|
<Combobox.Option value="bob">Bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">Charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</>
|
|
)}
|
|
</Combobox>
|
|
<button id="submit">submit</button>
|
|
</form>
|
|
)
|
|
|
|
await click(document.getElementById('submit'))
|
|
|
|
// No values
|
|
expect(handleSubmission).toHaveBeenLastCalledWith({})
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Choose alice
|
|
await click(getComboboxOptions()[0])
|
|
expect(getByTestId('value')).toHaveTextContent('alice')
|
|
expect(getByTestId('value-2')).toHaveTextContent('alice')
|
|
|
|
// Submit
|
|
await click(document.getElementById('submit'))
|
|
|
|
// Alice should be submitted
|
|
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' })
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Choose charlie
|
|
await click(getComboboxOptions()[2])
|
|
expect(getByTestId('value')).toHaveTextContent('charlie')
|
|
expect(getByTestId('value-2')).toHaveTextContent('charlie')
|
|
|
|
// Submit
|
|
await click(document.getElementById('submit'))
|
|
|
|
// Charlie should be submitted
|
|
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'charlie' })
|
|
})
|
|
|
|
it('should be possible to provide a default value', async () => {
|
|
let handleSubmission = jest.fn()
|
|
|
|
render(
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
|
|
}}
|
|
>
|
|
<Combobox name="assignee" defaultValue="bob">
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">Alice</Combobox.Option>
|
|
<Combobox.Option value="bob">Bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">Charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
<button id="submit">submit</button>
|
|
</form>
|
|
)
|
|
|
|
await click(document.getElementById('submit'))
|
|
|
|
// Bob is the defaultValue
|
|
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Choose alice
|
|
await click(getComboboxOptions()[0])
|
|
|
|
// Submit
|
|
await click(document.getElementById('submit'))
|
|
|
|
// Alice should be submitted
|
|
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'alice' })
|
|
})
|
|
|
|
it('should be possible to reset to the default value if the form is reset', async () => {
|
|
let handleSubmission = jest.fn()
|
|
|
|
render(
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
|
|
}}
|
|
>
|
|
<Combobox name="assignee" defaultValue="bob">
|
|
<Combobox.Button>{({ value }) => value ?? 'Trigger'}</Combobox.Button>
|
|
<Combobox.Input onChange={NOOP} displayValue={(value: string) => value} />
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">Alice</Combobox.Option>
|
|
<Combobox.Option value="bob">Bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">Charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
<button id="submit">submit</button>
|
|
<button type="reset" id="reset">
|
|
reset
|
|
</button>
|
|
</form>
|
|
)
|
|
|
|
await click(document.getElementById('submit'))
|
|
|
|
// Bob is the defaultValue
|
|
expect(handleSubmission).toHaveBeenLastCalledWith({ assignee: 'bob' })
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Choose alice
|
|
await click(getComboboxOptions()[0])
|
|
expect(getComboboxButton()).toHaveTextContent('alice')
|
|
expect(getComboboxInput()).toHaveValue('alice')
|
|
|
|
// Reset
|
|
await click(document.getElementById('reset'))
|
|
|
|
// The combobox should be reset to bob
|
|
expect(getComboboxButton()).toHaveTextContent('bob')
|
|
expect(getComboboxInput()).toHaveValue('bob')
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
assertActiveComboboxOption(getComboboxOptions()[1])
|
|
})
|
|
|
|
it('should be possible to reset to the default value if the form is reset (using objects)', async () => {
|
|
let handleSubmission = jest.fn()
|
|
|
|
let data = [
|
|
{ id: 1, name: 'alice', label: 'Alice' },
|
|
{ id: 2, name: 'bob', label: 'Bob' },
|
|
{ id: 3, name: 'charlie', label: 'Charlie' },
|
|
]
|
|
|
|
render(
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
handleSubmission(Object.fromEntries(new FormData(e.target as HTMLFormElement)))
|
|
}}
|
|
>
|
|
<Combobox name="assignee" defaultValue={{ id: 2, name: 'bob', label: 'Bob' }} by="id">
|
|
<Combobox.Button>{({ value }) => value?.name ?? 'Trigger'}</Combobox.Button>
|
|
<Combobox.Input onChange={NOOP} displayValue={(value: typeof data[0]) => value.name} />
|
|
<Combobox.Options>
|
|
{data.map((person) => (
|
|
<Combobox.Option key={person.id} value={person}>
|
|
{person.label}
|
|
</Combobox.Option>
|
|
))}
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
<button id="submit">submit</button>
|
|
<button type="reset" id="reset">
|
|
reset
|
|
</button>
|
|
</form>
|
|
)
|
|
|
|
await click(document.getElementById('submit'))
|
|
|
|
// Bob is the defaultValue
|
|
expect(handleSubmission).toHaveBeenLastCalledWith({
|
|
'assignee[id]': '2',
|
|
'assignee[name]': 'bob',
|
|
'assignee[label]': 'Bob',
|
|
})
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Choose alice
|
|
await click(getComboboxOptions()[0])
|
|
expect(getComboboxButton()).toHaveTextContent('alice')
|
|
expect(getComboboxInput()).toHaveValue('alice')
|
|
|
|
// Reset
|
|
await click(document.getElementById('reset'))
|
|
|
|
// The combobox should be reset to bob
|
|
expect(getComboboxButton()).toHaveTextContent('bob')
|
|
expect(getComboboxInput()).toHaveValue('bob')
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
assertActiveComboboxOption(getComboboxOptions()[1])
|
|
})
|
|
|
|
it('should still call the onChange listeners when choosing new values', async () => {
|
|
let handleChange = jest.fn()
|
|
|
|
render(
|
|
<Combobox name="assignee" onChange={handleChange}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">Alice</Combobox.Option>
|
|
<Combobox.Option value="bob">Bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">Charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Choose alice
|
|
await click(getComboboxOptions()[0])
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Choose bob
|
|
await click(getComboboxOptions()[1])
|
|
|
|
// Change handler should have been called twice
|
|
expect(handleChange).toHaveBeenNthCalledWith(1, 'alice')
|
|
expect(handleChange).toHaveBeenNthCalledWith(2, 'bob')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Rendering composition', () => {
|
|
it(
|
|
'should be possible to conditionally render classNames (aka className can be a function?!)',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a" className={(bag) => JSON.stringify(bag)}>
|
|
Option A
|
|
</Combobox.Option>
|
|
<Combobox.Option value="b" disabled className={(bag) => JSON.stringify(bag)}>
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option value="c" className="no-special-treatment">
|
|
Option C
|
|
</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Open Combobox
|
|
await click(getComboboxButton())
|
|
|
|
let options = getComboboxOptions()
|
|
|
|
// Verify that the first combobox option is active
|
|
assertActiveComboboxOption(options[0])
|
|
|
|
// Verify correct classNames
|
|
expect('' + options[0].classList).toEqual(
|
|
JSON.stringify({ active: true, selected: false, disabled: false })
|
|
)
|
|
expect('' + options[1].classList).toEqual(
|
|
JSON.stringify({ active: false, selected: false, disabled: true })
|
|
)
|
|
expect('' + options[2].classList).toEqual('no-special-treatment')
|
|
|
|
// Let's go down, this should go to the third option since the second option is disabled!
|
|
await press(Keys.ArrowDown)
|
|
|
|
// Verify the classNames
|
|
expect('' + options[0].classList).toEqual(
|
|
JSON.stringify({ active: false, selected: false, disabled: false })
|
|
)
|
|
expect('' + options[1].classList).toEqual(
|
|
JSON.stringify({ active: false, selected: false, disabled: true })
|
|
)
|
|
expect('' + options[2].classList).toEqual('no-special-treatment')
|
|
|
|
// Double check that the last option is the active one
|
|
assertActiveComboboxOption(options[2])
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to swap the Combobox option with a button for example',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option as="button" value="a">
|
|
Option A
|
|
</Combobox.Option>
|
|
<Combobox.Option as="button" value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option as="button" value="c">
|
|
Option C
|
|
</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Open Combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Verify options are buttons now
|
|
getComboboxOptions().forEach((option) => assertComboboxOption(option, { tag: 'button' }))
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should mark all the elements between Combobox.Options and Combobox.Option with role none',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button />
|
|
<div className="outer">
|
|
<Combobox.Options>
|
|
<div className="inner py-1">
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
</div>
|
|
<div className="inner py-1">
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
<Combobox.Option value="d">
|
|
<div>
|
|
<div className="outer">Option D</div>
|
|
</div>
|
|
</Combobox.Option>
|
|
</div>
|
|
<div className="inner py-1">
|
|
<form className="inner">
|
|
<Combobox.Option value="e">Option E</Combobox.Option>
|
|
</form>
|
|
</div>
|
|
</Combobox.Options>
|
|
</div>
|
|
</Combobox>
|
|
)
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
expect.hasAssertions()
|
|
|
|
document.querySelectorAll('.outer').forEach((element) => {
|
|
expect(element).not.toHaveAttribute('role', 'none')
|
|
})
|
|
|
|
document.querySelectorAll('.inner').forEach((element) => {
|
|
expect(element).toHaveAttribute('role', 'none')
|
|
})
|
|
})
|
|
)
|
|
})
|
|
|
|
describe('Composition', () => {
|
|
function Debug({ fn, name }: { fn: (text: string) => void; name: string }) {
|
|
useEffect(() => {
|
|
fn(`Mounting - ${name}`)
|
|
return () => {
|
|
fn(`Unmounting - ${name}`)
|
|
}
|
|
}, [fn, name])
|
|
return null
|
|
}
|
|
|
|
it(
|
|
'should be possible to wrap the Combobox.Options with a Transition component',
|
|
suppressConsoleLogs(async () => {
|
|
let orderFn = jest.fn()
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Debug name="Combobox" fn={orderFn} />
|
|
<Transition>
|
|
<Debug name="Transition" fn={orderFn} />
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">
|
|
{(data) => (
|
|
<>
|
|
{JSON.stringify(data)}
|
|
<Debug name="Combobox.Option" fn={orderFn} />
|
|
</>
|
|
)}
|
|
</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Transition>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
await click(getComboboxButton())
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({
|
|
state: ComboboxState.Visible,
|
|
textContent: JSON.stringify({ active: true, selected: false, disabled: false }),
|
|
})
|
|
|
|
await click(getComboboxButton())
|
|
|
|
// Verify that we tracked the `mounts` and `unmounts` in the correct order
|
|
expect(orderFn.mock.calls).toEqual([
|
|
['Mounting - Combobox'],
|
|
['Mounting - Transition'],
|
|
['Mounting - Combobox.Option'],
|
|
['Unmounting - Transition'],
|
|
['Unmounting - Combobox.Option'],
|
|
])
|
|
})
|
|
)
|
|
})
|
|
|
|
describe('Keyboard interactions', () => {
|
|
describe('Button', () => {
|
|
describe('`Enter` key', () => {
|
|
it(
|
|
'should be possible to open the combobox with Enter',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.Enter)
|
|
|
|
// Verify we moved focus to the input field
|
|
assertActiveElement(getComboboxInput())
|
|
|
|
// Verify it is visible
|
|
assertComboboxButton({ state: ComboboxState.Visible })
|
|
assertComboboxList({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-options-3' },
|
|
})
|
|
assertActiveElement(getComboboxInput())
|
|
assertComboboxButtonLinkedWithCombobox()
|
|
|
|
// Verify we have combobox options
|
|
let options = getComboboxOptions()
|
|
expect(options).toHaveLength(3)
|
|
options.forEach((option) => assertComboboxOption(option, { selected: false }))
|
|
|
|
assertActiveComboboxOption(options[0])
|
|
assertNoSelectedComboboxOption()
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should not be possible to open the combobox with Enter when the button is disabled',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value={undefined} onChange={console.log} disabled>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Try to focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Try to open the combobox
|
|
await press(Keys.Enter)
|
|
|
|
// Verify it is still closed
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to open the combobox with Enter, and focus the selected option',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="b" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.Enter)
|
|
|
|
// Verify we moved focus to the input field
|
|
assertActiveElement(getComboboxInput())
|
|
|
|
// Verify it is visible
|
|
assertComboboxButton({ state: ComboboxState.Visible })
|
|
assertComboboxList({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-options-3' },
|
|
})
|
|
assertActiveElement(getComboboxInput())
|
|
assertComboboxButtonLinkedWithCombobox()
|
|
|
|
// Verify we have combobox options
|
|
let options = getComboboxOptions()
|
|
expect(options).toHaveLength(3)
|
|
options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 }))
|
|
|
|
// Verify that the second combobox option is active (because it is already selected)
|
|
assertActiveComboboxOption(options[1])
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to open the combobox with Enter, and focus the selected option (when using the `hidden` render strategy)',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="b" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options unmount={false}>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleHidden,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleHidden })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.Enter)
|
|
|
|
// Verify we moved focus to the input field
|
|
assertActiveElement(getComboboxInput())
|
|
|
|
// Verify it is visible
|
|
assertComboboxButton({ state: ComboboxState.Visible })
|
|
assertComboboxList({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-options-3' },
|
|
})
|
|
assertActiveElement(getComboboxInput())
|
|
assertComboboxButtonLinkedWithCombobox()
|
|
|
|
let options = getComboboxOptions()
|
|
|
|
// Hover over Option A
|
|
await mouseMove(options[0])
|
|
|
|
// Verify that Option A is active
|
|
assertActiveComboboxOption(options[0])
|
|
|
|
// Verify that Option B is still selected
|
|
assertComboboxOption(options[1], { selected: true })
|
|
|
|
// Close/Hide the combobox
|
|
await press(Keys.Escape)
|
|
|
|
// Re-open the combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Verify we have combobox options
|
|
expect(options).toHaveLength(3)
|
|
options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 }))
|
|
|
|
// Verify that the second combobox option is active (because it is already selected)
|
|
assertActiveComboboxOption(options[1])
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to open the combobox with Enter, and focus the selected option (with a list of objects)',
|
|
suppressConsoleLogs(async () => {
|
|
let myOptions = [
|
|
{ id: 'a', name: 'Option A' },
|
|
{ id: 'b', name: 'Option B' },
|
|
{ id: 'c', name: 'Option C' },
|
|
]
|
|
let selectedOption = myOptions[1]
|
|
render(
|
|
<Combobox value={selectedOption} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
{myOptions.map((myOption) => (
|
|
<Combobox.Option key={myOption.id} value={myOption}>
|
|
{myOption.name}
|
|
</Combobox.Option>
|
|
))}
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.Enter)
|
|
|
|
// Verify we moved focus to the input field
|
|
assertActiveElement(getComboboxInput())
|
|
|
|
// Verify it is visible
|
|
assertComboboxButton({ state: ComboboxState.Visible })
|
|
assertComboboxList({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-options-3' },
|
|
})
|
|
assertActiveElement(getComboboxInput())
|
|
assertComboboxButtonLinkedWithCombobox()
|
|
|
|
// Verify we have combobox options
|
|
let options = getComboboxOptions()
|
|
expect(options).toHaveLength(3)
|
|
options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 }))
|
|
|
|
// Verify that the second combobox option is active (because it is already selected)
|
|
assertActiveComboboxOption(options[1])
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should have no active combobox option when there are no combobox options at all',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options />
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.Enter)
|
|
|
|
// Verify we moved focus to the input field
|
|
assertActiveElement(getComboboxInput())
|
|
|
|
assertComboboxList({ state: ComboboxState.Visible })
|
|
assertActiveElement(getComboboxInput())
|
|
|
|
assertNoActiveComboboxOption()
|
|
})
|
|
)
|
|
})
|
|
|
|
describe('`Space` key', () => {
|
|
it(
|
|
'should be possible to open the combobox with Space',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.Space)
|
|
|
|
// Verify we moved focus to the input field
|
|
assertActiveElement(getComboboxInput())
|
|
|
|
// Verify it is visible
|
|
assertComboboxButton({ state: ComboboxState.Visible })
|
|
assertComboboxList({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-options-3' },
|
|
})
|
|
assertActiveElement(getComboboxInput())
|
|
assertComboboxButtonLinkedWithCombobox()
|
|
|
|
// Verify we have combobox options
|
|
let options = getComboboxOptions()
|
|
expect(options).toHaveLength(3)
|
|
options.forEach((option) => assertComboboxOption(option))
|
|
assertActiveComboboxOption(options[0])
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should not be possible to open the combobox with Space when the button is disabled',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value={undefined} onChange={console.log} disabled>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Try to open the combobox
|
|
await press(Keys.Space)
|
|
|
|
// Verify it is still closed
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to open the combobox with Space, and focus the selected option',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="b" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
})
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.Space)
|
|
|
|
// Verify it is visible
|
|
assertComboboxButton({ state: ComboboxState.Visible })
|
|
assertComboboxList({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-options-3' },
|
|
})
|
|
assertActiveElement(getComboboxInput())
|
|
assertComboboxButtonLinkedWithCombobox()
|
|
|
|
// Verify we have combobox options
|
|
let options = getComboboxOptions()
|
|
expect(options).toHaveLength(3)
|
|
options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 }))
|
|
|
|
// Verify that the second combobox option is active (because it is already selected)
|
|
assertActiveComboboxOption(options[1])
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should have no active combobox option when there are no combobox options at all',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options />
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxList({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
})
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.Space)
|
|
assertComboboxList({ state: ComboboxState.Visible })
|
|
assertActiveElement(getComboboxInput())
|
|
|
|
assertNoActiveComboboxOption()
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should have no active combobox option upon Space key press, when there are no non-disabled combobox options',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option disabled value="a">
|
|
Option A
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="c">
|
|
Option C
|
|
</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
})
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.Space)
|
|
|
|
assertNoActiveComboboxOption()
|
|
})
|
|
)
|
|
})
|
|
|
|
describe('`Escape` key', () => {
|
|
it(
|
|
'should be possible to close an open combobox with Escape',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Verify it is visible
|
|
assertComboboxButton({ state: ComboboxState.Visible })
|
|
assertComboboxList({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-options-3' },
|
|
})
|
|
assertActiveElement(getComboboxInput())
|
|
assertComboboxButtonLinkedWithCombobox()
|
|
|
|
// Re-focus the button
|
|
await focus(getComboboxButton())
|
|
assertActiveElement(getComboboxButton())
|
|
|
|
// Close combobox
|
|
await press(Keys.Escape)
|
|
|
|
// Verify it is closed
|
|
assertComboboxButton({ state: ComboboxState.InvisibleUnmounted })
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Verify the input is focused again
|
|
assertActiveElement(getComboboxInput())
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should not propagate the Escape event when the combobox is open',
|
|
suppressConsoleLogs(async () => {
|
|
let handleKeyDown = jest.fn()
|
|
render(
|
|
<div onKeyDown={handleKeyDown}>
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
</div>
|
|
)
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Close combobox
|
|
await press(Keys.Escape)
|
|
|
|
// We should never see the Escape event
|
|
expect(handleKeyDown).toHaveBeenCalledTimes(0)
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should propagate the Escape event when the combobox is closed',
|
|
suppressConsoleLogs(async () => {
|
|
let handleKeyDown = jest.fn()
|
|
render(
|
|
<div onKeyDown={handleKeyDown}>
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
</div>
|
|
)
|
|
|
|
// Focus the input field
|
|
await focus(getComboboxInput())
|
|
|
|
// Close combobox
|
|
await press(Keys.Escape)
|
|
|
|
// We should never see the Escape event
|
|
expect(handleKeyDown).toHaveBeenCalledTimes(1)
|
|
})
|
|
)
|
|
})
|
|
|
|
describe('`ArrowDown` key', () => {
|
|
it(
|
|
'should be possible to open the combobox with ArrowDown',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.ArrowDown)
|
|
|
|
// Verify it is visible
|
|
assertComboboxButton({ state: ComboboxState.Visible })
|
|
assertComboboxList({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-options-3' },
|
|
})
|
|
assertActiveElement(getComboboxInput())
|
|
assertComboboxButtonLinkedWithCombobox()
|
|
|
|
// Verify we have combobox options
|
|
let options = getComboboxOptions()
|
|
expect(options).toHaveLength(3)
|
|
options.forEach((option) => assertComboboxOption(option))
|
|
|
|
// Verify that the first combobox option is active
|
|
assertActiveComboboxOption(options[0])
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should not be possible to open the combobox with ArrowDown when the button is disabled',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value={undefined} onChange={console.log} disabled>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Try to open the combobox
|
|
await press(Keys.ArrowDown)
|
|
|
|
// Verify it is still closed
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to open the combobox with ArrowDown, and focus the selected option',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="b" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.ArrowDown)
|
|
|
|
// Verify it is visible
|
|
assertComboboxButton({ state: ComboboxState.Visible })
|
|
assertComboboxList({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-options-3' },
|
|
})
|
|
assertActiveElement(getComboboxInput())
|
|
assertComboboxButtonLinkedWithCombobox()
|
|
|
|
// Verify we have combobox options
|
|
let options = getComboboxOptions()
|
|
expect(options).toHaveLength(3)
|
|
options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 }))
|
|
|
|
// Verify that the second combobox option is active (because it is already selected)
|
|
assertActiveComboboxOption(options[1])
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should have no active combobox option when there are no combobox options at all',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options />
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.ArrowDown)
|
|
assertComboboxList({ state: ComboboxState.Visible })
|
|
assertActiveElement(getComboboxInput())
|
|
|
|
assertNoActiveComboboxOption()
|
|
})
|
|
)
|
|
})
|
|
|
|
describe('`ArrowUp` key', () => {
|
|
it(
|
|
'should be possible to open the combobox with ArrowUp and the last option should be active',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value={undefined} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.ArrowUp)
|
|
|
|
// Verify it is visible
|
|
assertComboboxButton({ state: ComboboxState.Visible })
|
|
assertComboboxList({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-options-3' },
|
|
})
|
|
assertActiveElement(getComboboxInput())
|
|
assertComboboxButtonLinkedWithCombobox()
|
|
|
|
// Verify we have combobox options
|
|
let options = getComboboxOptions()
|
|
expect(options).toHaveLength(3)
|
|
options.forEach((option) => assertComboboxOption(option))
|
|
|
|
// ! ALERT: The LAST option should now be active
|
|
assertActiveComboboxOption(options[2])
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should not be possible to open the combobox with ArrowUp and the last option should be active when the button is disabled',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value={undefined} onChange={console.log} disabled>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Try to open the combobox
|
|
await press(Keys.ArrowUp)
|
|
|
|
// Verify it is still closed
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to open the combobox with ArrowUp, and focus the selected option',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="b" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.ArrowUp)
|
|
|
|
// Verify it is visible
|
|
assertComboboxButton({ state: ComboboxState.Visible })
|
|
assertComboboxList({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-options-3' },
|
|
})
|
|
assertActiveElement(getComboboxInput())
|
|
assertComboboxButtonLinkedWithCombobox()
|
|
|
|
// Verify we have combobox options
|
|
let options = getComboboxOptions()
|
|
expect(options).toHaveLength(3)
|
|
options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 }))
|
|
|
|
// Verify that the second combobox option is active (because it is already selected)
|
|
assertActiveComboboxOption(options[1])
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should have no active combobox option when there are no combobox options at all',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options />
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.ArrowUp)
|
|
assertComboboxList({ state: ComboboxState.Visible })
|
|
assertActiveElement(getComboboxInput())
|
|
|
|
assertNoActiveComboboxOption()
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should be possible to use ArrowUp to navigate the combobox options and jump to the first non-disabled one',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value={undefined} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option disabled value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="c">
|
|
Option C
|
|
</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Focus the button
|
|
await focus(getComboboxButton())
|
|
|
|
// Open combobox
|
|
await press(Keys.ArrowUp)
|
|
|
|
// Verify we have combobox options
|
|
let options = getComboboxOptions()
|
|
expect(options).toHaveLength(3)
|
|
options.forEach((option) => assertComboboxOption(option))
|
|
assertActiveComboboxOption(options[0])
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('`Backspace` key', () => {
|
|
it(
|
|
'should reset the value when the last character is removed, when in `nullable` mode',
|
|
suppressConsoleLogs(async () => {
|
|
let handleChange = jest.fn()
|
|
function Example() {
|
|
let [value, setValue] = useState<string | null>('bob')
|
|
let [, setQuery] = useState<string>('')
|
|
|
|
return (
|
|
<Combobox
|
|
value={value}
|
|
onChange={(value) => {
|
|
setValue(value)
|
|
handleChange(value)
|
|
}}
|
|
nullable
|
|
>
|
|
<Combobox.Input onChange={(event) => setQuery(event.target.value)} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">Alice</Combobox.Option>
|
|
<Combobox.Option value="bob">Bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">Charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
let options: ReturnType<typeof getComboboxOptions>
|
|
|
|
// Bob should be active
|
|
options = getComboboxOptions()
|
|
expect(getComboboxInput()).toHaveValue('bob')
|
|
assertActiveComboboxOption(options[1])
|
|
|
|
assertActiveElement(getComboboxInput())
|
|
|
|
// Delete a character
|
|
await press(Keys.Backspace)
|
|
expect(getComboboxInput()?.value).toBe('bo')
|
|
assertActiveComboboxOption(options[1])
|
|
|
|
// Delete a character
|
|
await press(Keys.Backspace)
|
|
expect(getComboboxInput()?.value).toBe('b')
|
|
assertActiveComboboxOption(options[1])
|
|
|
|
// Delete a character
|
|
await press(Keys.Backspace)
|
|
expect(getComboboxInput()?.value).toBe('')
|
|
|
|
// Verify that we don't have an active option anymore since we are in `nullable` mode
|
|
assertNotActiveComboboxOption(options[1])
|
|
assertNoActiveComboboxOption()
|
|
|
|
// Verify that we saw the `null` change coming in
|
|
expect(handleChange).toHaveBeenCalledTimes(1)
|
|
expect(handleChange).toHaveBeenCalledWith(null)
|
|
})
|
|
)
|
|
})
|
|
|
|
describe('Input', () => {
|
|
describe('`Enter` key', () => {
|
|
it(
|
|
'should be possible to close the combobox with Enter and choose the active combobox option',
|
|
suppressConsoleLogs(async () => {
|
|
let handleChange = jest.fn()
|
|
|
|
function Example() {
|
|
let [value, setValue] = useState<string | undefined>(undefined)
|
|
|
|
return (
|
|
<Combobox
|
|
value={value}
|
|
onChange={(value) => {
|
|
setValue(value)
|
|
handleChange(value)
|
|
}}
|
|
>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Verify it is visible
|
|
assertComboboxButton({ state: ComboboxState.Visible })
|
|
|
|
// Activate the first combobox option
|
|
let options = getComboboxOptions()
|
|
await mouseMove(options[0])
|
|
|
|
// Choose option, and close combobox
|
|
await press(Keys.Enter)
|
|
|
|
// Verify it is closed
|
|
assertComboboxButton({ state: ComboboxState.InvisibleUnmounted })
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
// Verify we got the change event
|
|
expect(handleChange).toHaveBeenCalledTimes(1)
|
|
expect(handleChange).toHaveBeenCalledWith('a')
|
|
|
|
// Verify the button is focused again
|
|
assertActiveElement(getComboboxInput())
|
|
|
|
// Open combobox again
|
|
await click(getComboboxButton())
|
|
|
|
// Verify the active option is the previously selected one
|
|
assertActiveComboboxOption(getComboboxOptions()[0])
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should submit the form on `Enter`',
|
|
suppressConsoleLogs(async () => {
|
|
let submits = jest.fn()
|
|
|
|
function Example() {
|
|
let [value, setValue] = useState<string>('b')
|
|
|
|
return (
|
|
<form
|
|
onKeyUp={(event) => {
|
|
// JSDom doesn't automatically submit the form but if we can
|
|
// catch an `Enter` event, we can assume it was a submit.
|
|
if (event.key === 'Enter') event.currentTarget.submit()
|
|
}}
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
submits([...new FormData(event.currentTarget).entries()])
|
|
}}
|
|
>
|
|
<Combobox value={value} onChange={setValue} name="option">
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
|
|
<button>Submit</button>
|
|
</form>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// 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<string | undefined>(undefined)
|
|
|
|
return (
|
|
<>
|
|
<input id="before-combobox" />
|
|
<Combobox value={value} onChange={setValue}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
<input id="after-combobox" />
|
|
</>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
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<string | undefined>(undefined)
|
|
|
|
return (
|
|
<>
|
|
<input id="before-combobox" />
|
|
<Combobox value={value} onChange={setValue}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
<input id="after-combobox" />
|
|
</>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options static>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="option-b" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="option-a">Option A</Combobox.Option>
|
|
<Combobox.Option value="option-b">Option B</Combobox.Option>
|
|
<Combobox.Option value="option-c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value={undefined} onChange={console.log} disabled>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="b" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options />
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option disabled value="a">
|
|
Option A
|
|
</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option disabled value="a">
|
|
Option A
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value={null} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value={undefined} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value={undefined} onChange={console.log} disabled>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="b" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options />
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value={undefined} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option disabled value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="c">
|
|
Option C
|
|
</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value={undefined} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option disabled value="a">
|
|
Option A
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value={undefined} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value={undefined} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option disabled value="c">
|
|
Option C
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="d">
|
|
Option D
|
|
</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option disabled value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="c">
|
|
Option C
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="d">
|
|
Option D
|
|
</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option disabled value="a">
|
|
Option A
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="c">
|
|
Option C
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="d">
|
|
Option D
|
|
</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option disabled value="c">
|
|
Option C
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="d">
|
|
Option D
|
|
</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option disabled value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="c">
|
|
Option C
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="d">
|
|
Option D
|
|
</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option disabled value="a">
|
|
Option A
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="c">
|
|
Option C
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="d">
|
|
Option D
|
|
</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value={undefined} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value={undefined} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option disabled value="a">
|
|
Option A
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
<Combobox.Option value="d">Option D</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option disabled value="a">
|
|
Option A
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="c">
|
|
Option C
|
|
</Combobox.Option>
|
|
<Combobox.Option value="d">Option D</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option disabled value="a">
|
|
Option A
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="c">
|
|
Option C
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="d">
|
|
Option D
|
|
</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value={undefined} onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option disabled value="a">
|
|
Option A
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
<Combobox.Option value="d">Option D</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option disabled value="a">
|
|
Option A
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="c">
|
|
Option C
|
|
</Combobox.Option>
|
|
<Combobox.Option value="d">Option D</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option disabled value="a">
|
|
Option A
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="b">
|
|
Option B
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="c">
|
|
Option C
|
|
</Combobox.Option>
|
|
<Combobox.Option disabled value="d">
|
|
Option D
|
|
</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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<string | undefined>(undefined)
|
|
let [query, setQuery] = useState<string>('')
|
|
let filteredPeople =
|
|
query === ''
|
|
? props.people
|
|
: props.people.filter((person) =>
|
|
person.name.toLowerCase().includes(query.toLowerCase())
|
|
)
|
|
|
|
return (
|
|
<Combobox value={value} onChange={setValue}>
|
|
<Combobox.Input onChange={(event) => setQuery(event.target.value)} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
{filteredPeople.map((person) => (
|
|
<Combobox.Option key={person.value} value={person.value} disabled={person.disabled}>
|
|
{person.name}
|
|
</Combobox.Option>
|
|
))}
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
it(
|
|
'should be possible to type a full word that has a perfect match',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Example
|
|
people={[
|
|
{ value: 'alice', name: 'alice', disabled: false },
|
|
{ value: 'bob', name: 'bob', disabled: false },
|
|
{ value: 'charlie', name: 'charlie', disabled: false },
|
|
]}
|
|
/>
|
|
)
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Verify we moved focus to the input field
|
|
assertActiveElement(getComboboxInput())
|
|
let options: ReturnType<typeof getComboboxOptions>
|
|
|
|
// 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(
|
|
<Example
|
|
people={[
|
|
{ value: 'alice', name: 'alice', disabled: false },
|
|
{ value: 'bob', name: 'bob', disabled: false },
|
|
{ value: 'charlie', name: 'charlie', disabled: false },
|
|
]}
|
|
/>
|
|
)
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
let options: ReturnType<typeof getComboboxOptions>
|
|
|
|
// 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(
|
|
<Example
|
|
people={[
|
|
{ value: 'alice', name: 'alice jones', disabled: false },
|
|
{ value: 'bob', name: 'bob the builder', disabled: false },
|
|
{ value: 'charlie', name: 'charlie bit me', disabled: false },
|
|
]}
|
|
/>
|
|
)
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
let options: ReturnType<typeof getComboboxOptions>
|
|
|
|
// 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(
|
|
<Example
|
|
people={[
|
|
{ value: 'alice', name: 'alice', disabled: false },
|
|
{ value: 'bob', name: 'bob', disabled: true },
|
|
{ value: 'charlie', name: 'charlie', disabled: false },
|
|
]}
|
|
/>
|
|
)
|
|
|
|
// 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(
|
|
<Example
|
|
people={[
|
|
{ value: 'a', name: 'person a', disabled: false },
|
|
{ value: 'b', name: 'person b', disabled: false },
|
|
{ value: 'c', name: 'person c', disabled: false },
|
|
]}
|
|
/>
|
|
)
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
let options: ReturnType<typeof getComboboxOptions>
|
|
|
|
options = getComboboxOptions()
|
|
expect(options[0]).toHaveTextContent('person a')
|
|
assertActiveComboboxOption(options[0])
|
|
|
|
await press(Keys.ArrowDown)
|
|
|
|
// Person B should be active
|
|
options = getComboboxOptions()
|
|
expect(options[1]).toHaveTextContent('person b')
|
|
assertActiveComboboxOption(options[1])
|
|
|
|
// Filter more, remove `person a`
|
|
await type(word('person b'))
|
|
options = getComboboxOptions()
|
|
expect(options[0]).toHaveTextContent('person b')
|
|
assertActiveComboboxOption(options[0])
|
|
|
|
// Filter less, insert `person a` before `person b`
|
|
await type(word('person'))
|
|
options = getComboboxOptions()
|
|
expect(options[1]).toHaveTextContent('person b')
|
|
assertActiveComboboxOption(options[1])
|
|
})
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Mouse interactions', () => {
|
|
it(
|
|
'should focus the Combobox.Input when we click the Combobox.Label',
|
|
suppressConsoleLogs(async () => {
|
|
render(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Label>Label</Combobox.Label>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Label>Label</Combobox.Label>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Item A</Combobox.Option>
|
|
<Combobox.Option value="b">Item B</Combobox.Option>
|
|
<Combobox.Option value="c">Item C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value={undefined} onChange={console.log} disabled>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="b" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<>
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
<div tabIndex={-1} data-test-focusable>
|
|
after
|
|
</div>
|
|
</>
|
|
)
|
|
|
|
// 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(
|
|
<div>
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
</div>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<div>
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} onFocus={focusFn} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
|
|
<button id="btn">
|
|
<span>Next</span>
|
|
</button>
|
|
</div>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options static>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option disabled value="bob">
|
|
bob
|
|
</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option disabled value="bob">
|
|
bob
|
|
</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="bob" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option disabled value="bob">
|
|
bob
|
|
</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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<string | undefined>(undefined)
|
|
|
|
return (
|
|
<Combobox
|
|
value={value}
|
|
onChange={(value) => {
|
|
setValue(value)
|
|
handleChange(value)
|
|
}}
|
|
>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// 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<string | undefined>(undefined)
|
|
|
|
return (
|
|
<Combobox
|
|
value={value}
|
|
onChange={(value) => {
|
|
setValue(value)
|
|
handleChange(value)
|
|
}}
|
|
>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option disabled value="bob">
|
|
bob
|
|
</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// 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<string | undefined>(undefined)
|
|
|
|
return (
|
|
<Combobox value={value} onChange={setValue}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option disabled value="bob">
|
|
bob
|
|
</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
// 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(
|
|
<Combobox value="test" onChange={console.log}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options hold>
|
|
<Combobox.Option value="a">Option A</Combobox.Option>
|
|
<Combobox.Option value="b">Option B</Combobox.Option>
|
|
<Combobox.Option value="c">Option C</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.InvisibleUnmounted,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
|
|
|
|
await click(getComboboxButton())
|
|
|
|
assertComboboxButton({
|
|
state: ComboboxState.Visible,
|
|
attributes: { id: 'headlessui-combobox-button-2' },
|
|
})
|
|
assertComboboxList({ state: ComboboxState.Visible })
|
|
|
|
let options = getComboboxOptions()
|
|
|
|
// Hover the first item
|
|
await mouseMove(options[0])
|
|
|
|
// Verify that the first combobox option is active
|
|
assertActiveComboboxOption(options[0])
|
|
|
|
// Focus the second item
|
|
await mouseMove(options[1])
|
|
|
|
// Verify that the second combobox option is active
|
|
assertActiveComboboxOption(options[1])
|
|
|
|
// Move the mouse off of the second combobox option
|
|
await mouseLeave(options[1])
|
|
await mouseMove(document.body)
|
|
|
|
// Verify that the second combobox option is still active
|
|
assertActiveComboboxOption(options[1])
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should sync the input field correctly and reset it when resetting the value from outside (to null)',
|
|
suppressConsoleLogs(async () => {
|
|
function Example() {
|
|
let [value, setValue] = useState<string | null>('bob')
|
|
|
|
return (
|
|
<>
|
|
<Combobox value={value} onChange={setValue}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
<button onClick={() => setValue(null)}>reset</button>
|
|
</>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
|
|
// Verify the input has the selected value
|
|
expect(getComboboxInput()?.value).toBe('bob')
|
|
|
|
// Override the input by typing something
|
|
await type(word('test'), getComboboxInput())
|
|
expect(getComboboxInput()?.value).toBe('test')
|
|
|
|
// Reset from outside
|
|
await click(getByText('reset'))
|
|
|
|
// Verify the input is reset correctly
|
|
expect(getComboboxInput()?.value).toBe('')
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should warn when changing the combobox from uncontrolled to controlled',
|
|
mockingConsoleLogs(async (spy) => {
|
|
function Example() {
|
|
let [value, setValue] = useState<string | undefined>(undefined)
|
|
|
|
return (
|
|
<>
|
|
<Combobox value={value} onChange={setValue}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
<button onClick={() => setValue('bob')}>to controlled</button>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// Render a uncontrolled combobox
|
|
render(<Example />)
|
|
|
|
// Change to an controlled combobox
|
|
await click(getByText('to controlled'))
|
|
|
|
// Make sure we get a warning
|
|
expect(spy).toBeCalledTimes(1)
|
|
expect(spy.mock.calls.map((args) => args[0])).toEqual([
|
|
'A component is changing from uncontrolled to controlled. This may be caused by the value changing from undefined to a defined value, which should not happen.',
|
|
])
|
|
|
|
// Render a fresh uncontrolled combobox
|
|
render(<Example />)
|
|
|
|
// Change to an controlled combobox
|
|
await click(getByText('to controlled'))
|
|
|
|
// We shouldn't have gotten another warning as we do not want to warn on every render
|
|
expect(spy).toBeCalledTimes(1)
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should warn when changing the combobox from controlled to uncontrolled',
|
|
mockingConsoleLogs(async (spy) => {
|
|
function Example() {
|
|
let [value, setValue] = useState<string | undefined>('bob')
|
|
|
|
return (
|
|
<>
|
|
<Combobox value={value} onChange={setValue}>
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
<button onClick={() => setValue(undefined)}>to uncontrolled</button>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// Render a controlled combobox
|
|
render(<Example />)
|
|
|
|
// Change to an uncontrolled combobox
|
|
await click(getByText('to uncontrolled'))
|
|
|
|
// Make sure we get a warning
|
|
expect(spy).toBeCalledTimes(1)
|
|
expect(spy.mock.calls.map((args) => args[0])).toEqual([
|
|
'A component is changing from controlled to uncontrolled. This may be caused by the value changing from a defined value to undefined, which should not happen.',
|
|
])
|
|
|
|
// Render a fresh controlled combobox
|
|
render(<Example />)
|
|
|
|
// Change to an uncontrolled combobox
|
|
await click(getByText('to uncontrolled'))
|
|
|
|
// We shouldn't have gotten another warning as we do not want to warn on every render
|
|
expect(spy).toBeCalledTimes(1)
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should sync the input field correctly and reset it when resetting the value from outside (when using displayValue)',
|
|
suppressConsoleLogs(async () => {
|
|
let people = [
|
|
{ id: 1, name: 'Alice' },
|
|
{ id: 2, name: 'Bob' },
|
|
{ id: 3, name: 'Charlie' },
|
|
]
|
|
|
|
function Example() {
|
|
let [value, setValue] = useState<typeof people[number] | null>(people[1])
|
|
|
|
return (
|
|
<>
|
|
<Combobox value={value} onChange={setValue}>
|
|
<Combobox.Input
|
|
onChange={NOOP}
|
|
displayValue={(person: typeof people[number]) => person?.name}
|
|
/>
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
{people.map((person) => (
|
|
<Combobox.Option key={person.id} value={person}>
|
|
{person.name}
|
|
</Combobox.Option>
|
|
))}
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
<button onClick={() => setValue(null)}>reset</button>
|
|
</>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// 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<string[]>(['bob', 'charlie'])
|
|
|
|
return (
|
|
<Combobox value={value} onChange={(value) => setValue(value)} multiple>
|
|
<Combobox.Input onChange={() => {}} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// 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<string[]>(['bob', 'charlie'])
|
|
|
|
return (
|
|
<Combobox value={value} onChange={(value) => setValue(value)} multiple>
|
|
<Combobox.Input onChange={() => {}} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// 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<string[]>(['bob', 'charlie'])
|
|
|
|
return (
|
|
<Combobox value={value} onChange={(value) => setValue(value)} multiple>
|
|
<Combobox.Input onChange={() => {}} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// 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<string[]>(['bob', 'charlie'])
|
|
|
|
return (
|
|
<Combobox value={value} onChange={(value) => setValue(value)} multiple>
|
|
<Combobox.Input onChange={() => {}} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="alice">alice</Combobox.Option>
|
|
<Combobox.Option value="bob">bob</Combobox.Option>
|
|
<Combobox.Option value="charlie">charlie</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
assertCombobox({ state: ComboboxState.Visible })
|
|
|
|
let options = getComboboxOptions()
|
|
|
|
assertComboboxOption(options[0], { selected: false })
|
|
assertComboboxOption(options[1], { selected: true })
|
|
assertComboboxOption(options[2], { selected: true })
|
|
|
|
// Click on bob
|
|
await click(getComboboxOptions()[1])
|
|
|
|
assertComboboxOption(options[0], { selected: false })
|
|
assertComboboxOption(options[1], { selected: false })
|
|
assertComboboxOption(options[2], { selected: true })
|
|
|
|
// Click on bob again
|
|
await click(getComboboxOptions()[1])
|
|
|
|
assertComboboxOption(options[0], { selected: false })
|
|
assertComboboxOption(options[1], { selected: true })
|
|
assertComboboxOption(options[2], { selected: true })
|
|
})
|
|
)
|
|
|
|
it(
|
|
'should reset the active option, if the active option gets unmounted',
|
|
suppressConsoleLogs(async () => {
|
|
let users = ['alice', 'bob', 'charlie']
|
|
function Example() {
|
|
let [value, setValue] = useState<string[]>([])
|
|
|
|
return (
|
|
<Combobox value={value} onChange={(value) => setValue(value)} multiple>
|
|
<Combobox.Input onChange={() => {}} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Options>
|
|
{users
|
|
.filter((user) => !value.includes(user))
|
|
.map((user) => (
|
|
<Combobox.Option key={user} value={user}>
|
|
{user}
|
|
</Combobox.Option>
|
|
))}
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// Open combobox
|
|
await click(getComboboxButton())
|
|
assertCombobox({ state: ComboboxState.Visible })
|
|
|
|
let options = getComboboxOptions()
|
|
|
|
// Go to the next option
|
|
await press(Keys.ArrowDown)
|
|
assertActiveComboboxOption(options[1])
|
|
|
|
// Select the option
|
|
await press(Keys.Enter)
|
|
|
|
// The active option is reset to the very first one
|
|
assertActiveComboboxOption(options[0])
|
|
})
|
|
)
|
|
})
|
|
|
|
describe('Form compatibility', () => {
|
|
it('should be possible to submit a form with a value', async () => {
|
|
let submits = jest.fn()
|
|
|
|
function Example() {
|
|
let [value, setValue] = useState(null)
|
|
return (
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
submits([...new FormData(event.currentTarget).entries()])
|
|
}}
|
|
>
|
|
<Combobox value={value} onChange={setValue} name="delivery">
|
|
<Combobox.Input onChange={NOOP} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Label>Pizza Delivery</Combobox.Label>
|
|
<Combobox.Options>
|
|
<Combobox.Option value="pickup">Pickup</Combobox.Option>
|
|
<Combobox.Option value="home-delivery">Home delivery</Combobox.Option>
|
|
<Combobox.Option value="dine-in">Dine in</Combobox.Option>
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
<button>Submit</button>
|
|
</form>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// 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 (
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
submits([...new FormData(event.currentTarget).entries()])
|
|
}}
|
|
>
|
|
<Combobox value={value} onChange={setValue} name="delivery">
|
|
<Combobox.Input onChange={console.log} />
|
|
<Combobox.Button>Trigger</Combobox.Button>
|
|
<Combobox.Label>Pizza Delivery</Combobox.Label>
|
|
<Combobox.Options>
|
|
{options.map((option) => (
|
|
<Combobox.Option key={option.id} value={option}>
|
|
{option.label}
|
|
</Combobox.Option>
|
|
))}
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
<button>Submit</button>
|
|
</form>
|
|
)
|
|
}
|
|
|
|
render(<Example />)
|
|
|
|
// 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'],
|
|
])
|
|
})
|
|
})
|