import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CommandPalette } from './command-palette'; import { COMMAND_PALETTE_CLOSE_EVENT, COMMAND_PALETTE_OPEN_EVENT, COMMAND_PALETTE_SELECT_EVENT, type CommandPaletteSelectEvent, } from './command-palette.types'; const MARKUP = `
No results
`; function setup(): HTMLElement { document.body.innerHTML = MARKUP; return document.body.firstElementChild as HTMLElement; } function items(root: HTMLElement): HTMLElement[] { return Array.from(root.querySelectorAll('[data-c42-command-item]')); } function input(root: HTMLElement): HTMLInputElement { return root.querySelector('[data-c42-command-input]')!; } function groups(root: HTMLElement): HTMLElement[] { return Array.from(root.querySelectorAll('[data-c42-command-group]')); } describe('CommandPalette', () => { beforeEach(() => { document.body.innerHTML = ''; }); it('wires combobox/listbox ARIA and starts closed', () => { const root = setup(); new CommandPalette(root); expect(root.hasAttribute('hidden')).toBe(true); expect(root.dataset.state).toBe('closed'); expect(input(root).getAttribute('role')).toBe('combobox'); expect(root.querySelector('[data-c42-command-list]')!.getAttribute('role')).toBe('listbox'); expect(items(root)[0].getAttribute('role')).toBe('option'); }); it('throws when required parts are missing', () => { document.body.innerHTML = '
'; const root = document.body.firstElementChild as HTMLElement; expect(() => new CommandPalette(root)).toThrow(/dialog/); }); it('opens with defaultOpen and focuses the input', () => { const root = setup(); new CommandPalette(root, { defaultOpen: true }); expect(root.dataset.state).toBe('open'); expect(document.activeElement).toBe(input(root)); }); it('toggles open via the Cmd/Ctrl+K hotkey', () => { const root = setup(); const palette = new CommandPalette(root); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true })); expect(palette.isOpen).toBe(true); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true })); expect(palette.isOpen).toBe(false); }); it('does not bind a hotkey when disabled', () => { const root = setup(); const palette = new CommandPalette(root, { hotkey: null }); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true })); expect(palette.isOpen).toBe(false); }); it('filters items by label and keywords, hiding empty groups', () => { const root = setup(); const palette = new CommandPalette(root, { defaultOpen: true }); const field = input(root); field.value = 'create'; field.dispatchEvent(new Event('input', { bubbles: true })); const [newFile, openFile, copy] = items(root); expect(newFile.hasAttribute('hidden')).toBe(false); // matched by keyword expect(openFile.hasAttribute('hidden')).toBe(true); expect(copy.hasAttribute('hidden')).toBe(true); const [files, edit] = groups(root); expect(files.hasAttribute('hidden')).toBe(false); expect(edit.hasAttribute('hidden')).toBe(true); expect(palette.activeItem).toBe(newFile); }); it('shows the empty state when nothing matches', () => { const root = setup(); new CommandPalette(root, { defaultOpen: true }); const field = input(root); field.value = 'zzzz'; field.dispatchEvent(new Event('input', { bubbles: true })); const empty = root.querySelector('[data-c42-command-empty]')!; expect(empty.hasAttribute('hidden')).toBe(false); expect(items(root).every((i) => i.hasAttribute('hidden'))).toBe(true); }); it('navigates visible items with arrow keys and wraps', () => { const root = setup(); const palette = new CommandPalette(root, { defaultOpen: true }); const field = input(root); expect(palette.activeItem).toBe(items(root)[0]); field.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); expect(palette.activeItem).toBe(items(root)[1]); field.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); expect(palette.activeItem).toBe(items(root)[0]); field.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); expect(palette.activeItem).toBe(items(root)[2]); // wrap to last }); it('sets aria-activedescendant to the active item', () => { const root = setup(); new CommandPalette(root, { defaultOpen: true }); const field = input(root); expect(field.getAttribute('aria-activedescendant')).toBe(items(root)[0].id); }); it('selects the active item on Enter and emits select then close', () => { const root = setup(); const palette = new CommandPalette(root, { defaultOpen: true }); const onSelect = vi.fn(); palette.on(COMMAND_PALETTE_SELECT_EVENT, onSelect); input(root).dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); expect(onSelect).toHaveBeenCalledOnce(); expect(onSelect.mock.calls[0][0].detail.value).toBe('new'); expect(palette.isOpen).toBe(false); }); it('selects an item on click', () => { const root = setup(); const palette = new CommandPalette(root, { defaultOpen: true }); const onSelect = vi.fn(); palette.on(COMMAND_PALETTE_SELECT_EVENT, onSelect); items(root)[2].click(); expect(onSelect.mock.calls[0][0].detail.value).toBe('copy'); }); it('closes on Escape and clears the query', () => { const root = setup(); const palette = new CommandPalette(root, { defaultOpen: true }); const field = input(root); field.value = 'open'; field.dispatchEvent(new Event('input', { bubbles: true })); field.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(palette.isOpen).toBe(false); expect(field.value).toBe(''); }); it('closes when clicking the overlay', () => { const root = setup(); const palette = new CommandPalette(root, { defaultOpen: true }); root.querySelector('[data-c42-command-overlay]')!.click(); expect(palette.isOpen).toBe(false); }); it('emits open and close events', () => { const root = setup(); const palette = new CommandPalette(root); const onOpen = vi.fn(); const onClose = vi.fn(); palette.on(COMMAND_PALETTE_OPEN_EVENT, onOpen); palette.on(COMMAND_PALETTE_CLOSE_EVENT, onClose); palette.openPalette(); palette.close(); expect(onOpen).toHaveBeenCalledOnce(); expect(onClose).toHaveBeenCalledOnce(); }); it('removes listeners on destroy', () => { const root = setup(); const palette = new CommandPalette(root); palette.destroy(); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true })); expect(palette.isOpen).toBe(false); }); });