import * as React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { axe } from 'vitest-axe'
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from './combobox'
const FRAMEWORKS = ['Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro']
type SingleArgs = {
defaultValue?: string | null
onValueChange?: (value: string | null) => void
size?: 'sm' | 'md' | 'lg'
variant?: 'outline' | 'soft'
clearable?: boolean
icon?: boolean
placeholder?: string
}
function renderSingle({
defaultValue,
onValueChange,
size,
variant,
clearable,
icon,
placeholder = 'Select a framework',
}: SingleArgs = {}) {
return render(
No items found.
{(item: string) => (
{item}
)}
,
)
}
function MultiHarness({
variant,
onValueChange,
}: {
variant?: 'outline' | 'soft'
onValueChange?: (value: string[]) => void
}) {
const [value, setValue] = React.useState([])
return (
{
const arr = next as string[]
setValue(arr)
onValueChange?.(arr)
}}
>
{(items: string[]) =>
items.map((item) => (
{item}
))
}
No items found.
{(item: string) => (
{item}
)}
)
}
const setupUser = () => userEvent.setup({ pointerEventsCheck: 0 })
describe('Combobox', () => {
it('tags input and content with data-slot', async () => {
const user = setupUser()
renderSingle()
const input = screen.getByRole('combobox')
const group = input.closest('[data-slot="combobox-input"]')
expect(group).not.toBeNull()
await user.click(input)
const popup = await screen.findByRole('listbox')
const content = popup.closest('[data-slot="combobox-content"]')
expect(content).not.toBeNull()
})
it('does not render the popup before opening', () => {
renderSingle()
expect(screen.queryByRole('option', { name: 'Next.js' })).toBeNull()
})
it('opens on click and renders all items', async () => {
const user = setupUser()
renderSingle({ size: 'sm' })
await user.click(screen.getByRole('combobox'))
for (const name of FRAMEWORKS) {
expect(await screen.findByRole('option', { name })).toBeInTheDocument()
}
})
it('filters items as the user types', async () => {
const user = setupUser()
renderSingle()
const input = screen.getByRole('combobox')
await user.click(input)
await screen.findByRole('option', { name: 'Next.js' })
await user.type(input, 'sve')
await waitFor(() => {
expect(screen.queryByRole('option', { name: 'Next.js' })).toBeNull()
})
expect(screen.getByRole('option', { name: 'SvelteKit' })).toBeInTheDocument()
})
it('selects an item and fires onValueChange', async () => {
const user = setupUser()
const onValueChange = vi.fn()
renderSingle({ onValueChange })
await user.click(screen.getByRole('combobox'))
await user.click(await screen.findByRole('option', { name: 'Remix' }))
expect(onValueChange).toHaveBeenCalledWith('Remix', expect.anything())
})
it('renders the check indicator on the selected item', async () => {
const user = setupUser()
renderSingle({ defaultValue: 'Astro' })
await user.click(screen.getByRole('combobox'))
const items = await screen.findAllByRole('option')
const selected = items.find((item) => item.getAttribute('data-selected') !== null)
expect(selected).toBeDefined()
expect(selected!.querySelector('[data-slot="combobox-item-indicator"] svg')).not.toBeNull()
})
it('rotates the chevron when the popup opens via a named-group selector', async () => {
const user = setupUser()
renderSingle()
const input = screen.getByRole('combobox')
const trigger = input.parentElement!.querySelector('[data-slot="combobox-toggle"]') as HTMLElement
expect(trigger).not.toBeNull()
expect(trigger.className).toContain('group/combobox-toggle')
const icon = trigger.querySelector('svg')
expect(icon).not.toBeNull()
expect(icon!.getAttribute('class') ?? '').toContain('group-data-popup-open/combobox-toggle:rotate-180')
await user.click(input)
await screen.findByRole('option', { name: 'Next.js' })
expect(trigger.getAttribute('data-popup-open')).toBe('')
})
it('renders the clear control when clearable is set and value is present, and clears on click', async () => {
const user = setupUser()
const onValueChange = vi.fn()
renderSingle({ defaultValue: 'Astro', clearable: true, onValueChange })
const clearBtn = await screen.findByRole('button', { name: 'Clear selection' })
await user.click(clearBtn)
await waitFor(() => {
expect(onValueChange).toHaveBeenCalled()
})
const lastCall = onValueChange.mock.calls[onValueChange.mock.calls.length - 1]
expect(lastCall![0]).toBeNull()
})
it('does not render the clear control when there is no value', () => {
renderSingle({ clearable: true })
expect(screen.queryByRole('button', { name: 'Clear selection' })).toBeNull()
})
it('renders the chevron trigger by default', () => {
renderSingle()
expect(screen.getByRole('button', { name: 'Toggle popup' })).toBeInTheDocument()
})
it('hides the chevron trigger when icon is false', () => {
renderSingle({ icon: false })
expect(screen.queryByRole('button', { name: 'Toggle popup' })).toBeNull()
})
it('shows ComboboxEmpty when the filter has no matches', async () => {
const user = setupUser()
renderSingle()
const input = screen.getByRole('combobox')
await user.click(input)
await user.type(input, 'zzzzzz')
expect(await screen.findByText('No items found.')).toBeInTheDocument()
})
it('closes the popup on Escape', async () => {
const user = setupUser()
renderSingle()
await user.click(screen.getByRole('combobox'))
await screen.findByRole('option', { name: 'Next.js' })
await user.keyboard('{Escape}')
await waitFor(() => {
expect(screen.queryByRole('option', { name: 'Next.js' })).toBeNull()
})
})
it('supports static children in ComboboxList', async () => {
const user = setupUser()
render(
One
Two
,
)
await user.click(screen.getByRole('combobox'))
expect(await screen.findByRole('option', { name: 'One' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Two' })).toBeInTheDocument()
})
it('mirrors a standalone aria-invalid to data-invalid on the input group', () => {
render(
{(item: string) => (
{item}
)}
,
)
const input = screen.getByRole('combobox')
expect(input.getAttribute('aria-invalid')).toBe('true')
const group = input.closest('[data-slot="combobox-input"]')
expect(group?.getAttribute('data-invalid')).toBe('')
})
it('does not set data-invalid when aria-invalid is absent', () => {
renderSingle()
const group = screen.getByRole('combobox').closest('[data-slot="combobox-input"]')
expect(group?.getAttribute('data-invalid')).toBeNull()
})
it('has no accessibility violations in the closed state', async () => {
const { container } = renderSingle()
expect(await axe(container)).toHaveNoViolations()
})
describe('multiple', () => {
it('renders a chip per selected item and fires onValueChange with the remaining array when removed', async () => {
const user = setupUser()
const onValueChange = vi.fn()
render()
const input = screen.getByRole('combobox')
await user.click(input)
await user.click(await screen.findByRole('option', { name: 'Next.js' }))
await user.click(await screen.findByRole('option', { name: 'Remix' }))
await waitFor(() => {
expect(screen.getByLabelText('chip-Next.js')).toBeInTheDocument()
expect(screen.getByLabelText('chip-Remix')).toBeInTheDocument()
})
const removeButtons = screen.getAllByRole('button', { name: 'Remove' })
await user.click(removeButtons[0]!)
await waitFor(() => {
const lastCall = onValueChange.mock.calls[onValueChange.mock.calls.length - 1]
expect(lastCall![0]).toHaveLength(1)
})
})
it('applies button "soft" classes to chips when Combobox variant is outline', async () => {
const user = setupUser()
render()
await user.click(screen.getByRole('combobox'))
await user.click(await screen.findByRole('option', { name: 'Astro' }))
const chip = await screen.findByLabelText('chip-Astro')
expect(chip.className).toContain('before:bg-background-muted')
})
it('applies button "outline" classes to chips when Combobox variant is soft', async () => {
const user = setupUser()
render()
await user.click(screen.getByRole('combobox'))
await user.click(await screen.findByRole('option', { name: 'Astro' }))
const chip = await screen.findByLabelText('chip-Astro')
expect(chip.className).toContain('before:bg-background')
expect(chip.className).toContain('before:border-border')
})
})
describe('grid layout', () => {
function renderGrid({ cols }: { cols?: number } = {}) {
return render(
{(item: string) => (
{item}
)}
,
)
}
it('forwards grid to Base UI Root - popup uses the grid role', async () => {
const user = setupUser()
renderGrid()
await user.click(screen.getByRole('combobox'))
expect(await screen.findByRole('grid')).toBeInTheDocument()
expect(screen.queryByRole('listbox')).toBeNull()
})
it('chunks filtered items into rows of 2 by default when grid is set', async () => {
const user = setupUser()
renderGrid()
await user.click(screen.getByRole('combobox'))
await screen.findByRole('grid')
const rows = document.querySelectorAll('[data-slot="combobox-row"]')
// FRAMEWORKS has 5 items → ceil(5 / 2) = 3 rows
expect(rows).toHaveLength(3)
expect(rows[0]!.children).toHaveLength(2)
expect(rows[2]!.children).toHaveLength(1)
})
it('respects an explicit cols override on ComboboxList', async () => {
const user = setupUser()
renderGrid({ cols: 4 })
await user.click(screen.getByRole('combobox'))
await screen.findByRole('grid')
const rows = document.querySelectorAll('[data-slot="combobox-row"]')
// 5 items, cols=4 → 2 rows (4 + 1)
expect(rows).toHaveLength(2)
expect(rows[0]!.children).toHaveLength(4)
expect(rows[1]!.children).toHaveLength(1)
})
it('does not auto-chunk when children is static JSX (no render fn)', async () => {
const user = setupUser()
render(
One
Two
,
)
await user.click(screen.getByRole('combobox'))
await screen.findByRole('grid')
expect(document.querySelectorAll('[data-slot="combobox-row"]')).toHaveLength(0)
})
})
})