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 { Autocomplete, AutocompleteContent, AutocompleteEmpty, AutocompleteInput, AutocompleteItem, AutocompleteList, AutocompleteStatus, AutocompleteTrigger, } from './autocomplete' const FRAMEWORKS = ['Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'] type SingleArgs = { defaultValue?: string onValueChange?: (value: string) => void size?: 'sm' | 'md' | 'lg' variant?: 'outline' | 'soft' clearable?: boolean icon?: boolean placeholder?: string } function renderSingle({ defaultValue, onValueChange, size, variant, clearable, icon = true, placeholder = 'Search a framework', }: SingleArgs = {}) { return render( No items found. {(item: string) => ( {item} )} , ) } const setupUser = () => userEvent.setup({ pointerEventsCheck: 0 }) describe('Autocomplete', () => { it('tags input and content with data-slot', async () => { const user = setupUser() renderSingle() const input = screen.getByRole('combobox') const group = input.closest('[data-slot="autocomplete-input"]') expect(group).not.toBeNull() await user.click(screen.getByRole('button', { name: 'Toggle popup' })) const popup = await screen.findByRole('listbox') const content = popup.closest('[data-slot="autocomplete-content"]') expect(content).not.toBeNull() }) it('does not render the popup before opening', () => { renderSingle() expect(screen.queryByRole('option', { name: 'Next.js' })).toBeNull() }) it('opens via the trigger and renders all items', async () => { const user = setupUser() renderSingle({ size: 'sm' }) await user.click(screen.getByRole('button', { name: 'Toggle popup' })) 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 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 with a string', async () => { const user = setupUser() const onValueChange = vi.fn() renderSingle({ onValueChange }) await user.click(screen.getByRole('button', { name: 'Toggle popup' })) await user.click(await screen.findByRole('option', { name: 'Remix' })) expect(onValueChange).toHaveBeenCalledWith('Remix', expect.anything()) }) it('rotates the chevron when the popup opens via a named-group selector', async () => { const user = setupUser() renderSingle() const input = screen.getByRole('combobox') const iconBtn = input.parentElement!.querySelector('[data-slot="autocomplete-icon"]') as HTMLElement expect(iconBtn).not.toBeNull() expect(iconBtn.className).toContain('group/autocomplete-icon') const svg = iconBtn.querySelector('svg') expect(svg).not.toBeNull() expect(svg!.getAttribute('class') ?? '').toContain('group-data-popup-open/autocomplete-icon:rotate-180') await user.click(iconBtn) await screen.findByRole('option', { name: 'Next.js' }) expect(iconBtn.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]).toBe('') }) it('does not render the clear control when there is no value', () => { renderSingle({ clearable: true }) expect(screen.queryByRole('button', { name: 'Clear selection' })).toBeNull() }) it('does not render the chevron icon by default', () => { render( {(item: string) => ( {item} )} , ) expect(screen.queryByRole('button', { name: 'Toggle popup' })).toBeNull() }) it('renders the chevron icon when icon is true', () => { renderSingle({ icon: true }) expect(screen.getByRole('button', { name: 'Toggle popup' })).toBeInTheDocument() }) it('renders a standalone AutocompleteTrigger that opens the popup', async () => { const user = setupUser() const { container } = render( Open {(item: string) => ( {item} )} , ) const trigger = container.querySelector('[data-slot="autocomplete-trigger"]') as HTMLElement expect(trigger).not.toBeNull() expect(trigger.tagName).toBe('BUTTON') await user.click(trigger) expect(await screen.findByRole('option', { name: 'Next.js' })).toBeInTheDocument() }) it('disables the icon and clear buttons when the input is disabled', () => { render( {(item: string) => ( {item} )} , ) const iconBtn = screen.getByRole('button', { name: 'Toggle popup' }) const clear = screen.getByRole('button', { name: 'Clear selection' }) expect(iconBtn).toBeDisabled() expect(clear).toBeDisabled() }) it('shows AutocompleteEmpty 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('button', { name: 'Toggle popup' })) 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 AutocompleteList', async () => { const user = setupUser() render( One Two , ) await user.click(screen.getByRole('button', { name: 'Toggle popup' })) expect(await screen.findByRole('option', { name: 'One' })).toBeInTheDocument() expect(screen.getByRole('option', { name: 'Two' })).toBeInTheDocument() }) it('keeps AutocompleteStatus mounted in the popup', async () => { const user = setupUser() render( Loading… {(item: string) => ( {item} )} , ) await user.click(screen.getByRole('button', { name: 'Toggle popup' })) const status = await screen.findByText('Loading…') expect(status.closest('[data-slot="autocomplete-status"]')).not.toBeNull() }) it('has no accessibility violations in the closed state', async () => { const { container } = renderSingle() expect(await axe(container)).toHaveNoViolations() }) describe('grid layout', () => { function renderGrid({ cols, icon = true }: { cols?: number; icon?: boolean } = {}) { 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('button', { name: 'Toggle popup' })) 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('button', { name: 'Toggle popup' })) await screen.findByRole('grid') const rows = document.querySelectorAll('[data-slot="autocomplete-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 AutocompleteList', async () => { const user = setupUser() renderGrid({ cols: 4 }) await user.click(screen.getByRole('button', { name: 'Toggle popup' })) await screen.findByRole('grid') const rows = document.querySelectorAll('[data-slot="autocomplete-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('button', { name: 'Toggle popup' })) await screen.findByRole('grid') expect(document.querySelectorAll('[data-slot="autocomplete-row"]')).toHaveLength(0) }) }) it('accepts a render prop on AutocompleteTrigger (composes with another element)', async () => { const user = setupUser() const { container } = render( }>Open {(item: string) => ( {item} )} , ) const trigger = container.querySelector('[data-slot="autocomplete-trigger"]') as HTMLElement expect(trigger).not.toBeNull() expect(trigger.getAttribute('data-custom')).toBe('yes') await user.click(trigger) expect(await screen.findByRole('option', { name: 'Next.js' })).toBeInTheDocument() }) })