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);
});
});