import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from './command'; const renderPalette = (props: React.ComponentProps = {}) => render( No results found. Calendar Search emoji Billing Profile ); const options = () => screen.getAllByRole('option').map((node) => node.textContent); const input = () => screen.getByPlaceholderText('Search'); describe('Command', () => { it('lists every item before anything is typed', () => { renderPalette(); expect(options()).toEqual(['Calendar', 'Search emoji', 'Billing', 'Profile']); }); describe('accessibility', () => { it('wires the input to the list as a combobox', () => { renderPalette(); const combobox = screen.getByRole('combobox'); const list = screen.getByRole('listbox'); expect(combobox).toHaveAttribute('aria-controls', list.id); expect(combobox).toHaveAttribute('aria-expanded', 'true'); expect(combobox).toHaveAccessibleName('Suggestions'); }); it('takes a custom list label', () => { renderPalette({ label: 'Commands' }); expect(screen.getByRole('combobox')).toHaveAccessibleName('Commands'); }); it('points aria-activedescendant at the highlighted row', async () => { const user = userEvent.setup(); renderPalette(); const first = screen.getByRole('option', { name: 'Calendar' }); expect(screen.getByRole('combobox')).toHaveAttribute('aria-activedescendant', first.id); await user.click(input()); await user.keyboard('{ArrowDown}'); const second = screen.getByRole('option', { name: 'Search emoji' }); expect(screen.getByRole('combobox')).toHaveAttribute('aria-activedescendant', second.id); }); it('marks the disabled item rather than hiding it', () => { renderPalette(); expect(screen.getByRole('option', { name: 'Billing' })).toHaveAttribute( 'aria-disabled', 'true' ); }); }); describe('filtering', () => { it('narrows the list as you type', async () => { const user = userEvent.setup(); renderPalette(); await user.type(input(), 'cal'); expect(options()).toEqual(['Calendar']); }); it('is case-insensitive', async () => { const user = userEvent.setup(); renderPalette(); await user.type(input(), 'CALENDAR'); expect(options()).toEqual(['Calendar']); }); it('matches keywords the label does not contain', async () => { const user = userEvent.setup(); renderPalette(); await user.type(input(), 'smiley'); expect(options()).toEqual(['Search emoji']); }); it('keeps the declared order rather than re-ranking', async () => { const user = userEvent.setup(); renderPalette(); /* "i" hits Billing at a word start but "emoji" and "Profile" mid-token, so a score-sorted list would lift Billing to the front. A palette that reshuffles under the cursor is easy to mis-click, so order is fixed. */ await user.type(input(), 'i'); expect(options()).toEqual(['Search emoji', 'Billing', 'Profile']); }); it('shows the empty state when nothing matches', async () => { const user = userEvent.setup(); renderPalette(); await user.type(input(), 'zzz'); expect(screen.queryAllByRole('option')).toHaveLength(0); expect(screen.getByText('No results found.')).toBeInTheDocument(); }); it('does not flash the empty state before items register', () => { renderPalette(); expect(screen.queryByText('No results found.')).not.toBeInTheDocument(); }); it('leaves the list alone when shouldFilter is off', async () => { const user = userEvent.setup(); renderPalette({ shouldFilter: false }); await user.type(input(), 'zzz'); expect(options()).toHaveLength(4); }); it('accepts a custom filter', async () => { const user = userEvent.setup(); const filter = vi.fn((value: string, search: string) => value.startsWith(search) ? 1 : 0 ); renderPalette({ filter }); await user.type(input(), 'P'); expect(options()).toEqual(['Profile']); }); }); describe('keyboard', () => { it('highlights the first selectable item by default', () => { renderPalette(); expect(screen.getByRole('option', { name: 'Calendar' })).toHaveAttribute( 'aria-selected', 'true' ); }); it('moves the highlight with the arrow keys', async () => { const user = userEvent.setup(); renderPalette(); await user.click(input()); await user.keyboard('{ArrowDown}'); expect(screen.getByRole('option', { name: 'Search emoji' })).toHaveAttribute( 'aria-selected', 'true' ); await user.keyboard('{ArrowUp}'); expect(screen.getByRole('option', { name: 'Calendar' })).toHaveAttribute( 'aria-selected', 'true' ); }); it('skips over a disabled item', async () => { const user = userEvent.setup(); renderPalette(); await user.click(input()); await user.keyboard('{ArrowDown}{ArrowDown}'); expect(screen.getByRole('option', { name: 'Profile' })).toHaveAttribute( 'aria-selected', 'true' ); }); it('stops at the ends by default', async () => { const user = userEvent.setup(); renderPalette(); await user.click(input()); await user.keyboard('{ArrowUp}{ArrowUp}'); expect(screen.getByRole('option', { name: 'Calendar' })).toHaveAttribute( 'aria-selected', 'true' ); }); it('wraps around when loop is set', async () => { const user = userEvent.setup(); renderPalette({ loop: true }); await user.click(input()); await user.keyboard('{ArrowUp}'); expect(screen.getByRole('option', { name: 'Profile' })).toHaveAttribute( 'aria-selected', 'true' ); }); it('jumps to the ends with Home and End', async () => { const user = userEvent.setup(); renderPalette(); await user.click(input()); await user.keyboard('{End}'); expect(screen.getByRole('option', { name: 'Profile' })).toHaveAttribute( 'aria-selected', 'true' ); await user.keyboard('{Home}'); expect(screen.getByRole('option', { name: 'Calendar' })).toHaveAttribute( 'aria-selected', 'true' ); }); it('selects the highlighted item with Enter', async () => { const user = userEvent.setup(); const onSelect = vi.fn(); render( Calendar Profile ); await user.click(input()); await user.keyboard('{ArrowDown}{Enter}'); expect(onSelect).toHaveBeenCalledWith('Profile'); }); it('re-homes the highlight when filtering removes it', async () => { const user = userEvent.setup(); renderPalette(); await user.click(input()); await user.keyboard('{End}'); // highlight Profile await user.type(input(), 'cal'); // which then filters out expect(screen.getByRole('option', { name: 'Calendar' })).toHaveAttribute( 'aria-selected', 'true' ); }); }); describe('selection', () => { it('fires onSelect on click', async () => { const user = userEvent.setup(); const onSelect = vi.fn(); render( Calendar ); await user.click(screen.getByRole('option', { name: 'Calendar' })); expect(onSelect).toHaveBeenCalledWith('Calendar'); }); it('ignores a click on a disabled item', async () => { const user = userEvent.setup(); const onSelect = vi.fn(); render( Billing ); await user.click(screen.getByRole('option', { name: 'Billing' })); expect(onSelect).not.toHaveBeenCalled(); }); it('reports the highlight through onValueChange', async () => { const user = userEvent.setup(); const onValueChange = vi.fn(); renderPalette({ onValueChange }); await user.click(input()); await user.keyboard('{ArrowDown}'); expect(onValueChange).toHaveBeenLastCalledWith('Search emoji'); }); it('holds a controlled highlight until the parent moves it', async () => { const user = userEvent.setup(); renderPalette({ value: 'Profile', onValueChange: vi.fn() }); await user.click(input()); await user.keyboard('{ArrowDown}'); expect(screen.getByRole('option', { name: 'Profile' })).toHaveAttribute( 'aria-selected', 'true' ); }); }); });