import { uid } from '../shared/dom'; import { FocusTrap } from '../shared/focus'; import { COMMAND_PALETTE_CLOSE_EVENT, COMMAND_PALETTE_FILTER_EVENT, COMMAND_PALETTE_OPEN_EVENT, COMMAND_PALETTE_SELECT_EVENT, type CommandPaletteFilterDetail, type CommandPaletteOptions, type CommandPaletteSelectDetail, } from './command-palette.types'; const SELECTORS = { overlay: '[data-c42-command-overlay]', dialog: '[data-c42-command-dialog]', input: '[data-c42-command-input]', list: '[data-c42-command-list]', group: '[data-c42-command-group]', item: '[data-c42-command-item]', empty: '[data-c42-command-empty]', } as const; /** * Headless command palette (Cmd/Ctrl+K). Wires the combobox/listbox ARIA * pattern over existing markup, filters items as the user types, hides empty * groups, manages active-descendant keyboard navigation and dispatches a * select event. Applies no visual styles — only ARIA + `data-state`. * * Markup: * ```html *
*
*
* *
*
* *
*
No results
*
*
*
* ``` */ export class CommandPalette { private readonly root: HTMLElement; private readonly overlay: HTMLElement | null; private readonly input: HTMLInputElement; private readonly list: HTMLElement; private readonly emptyEl: HTMLElement | null; private readonly hotkey: string | null; private readonly closeOnOverlayClick: boolean; private readonly closeOnEscape: boolean; private readonly clearOnClose: boolean; private readonly filterFn: (query: string, text: string) => boolean; private readonly trap: FocusTrap; private items: HTMLElement[] = []; private visible: HTMLElement[] = []; private activeIndex = -1; private open = false; private previouslyFocused: HTMLElement | null = null; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: CommandPaletteOptions = {}) { const dialog = root.querySelector(SELECTORS.dialog); const input = root.querySelector(SELECTORS.input); const list = root.querySelector(SELECTORS.list); if (!dialog || !input || !list) { throw new Error('[42/command-palette] Needs a dialog, an input and a list element.'); } this.root = root; this.input = input; this.list = list; this.overlay = root.querySelector(SELECTORS.overlay); this.emptyEl = root.querySelector(SELECTORS.empty); this.hotkey = options.hotkey === undefined ? 'k' : options.hotkey; this.closeOnOverlayClick = options.closeOnOverlayClick ?? true; this.closeOnEscape = options.closeOnEscape ?? true; this.clearOnClose = options.clearOnClose ?? true; this.filterFn = options.filter ?? ((query, text) => text.includes(query)); this.trap = new FocusTrap(dialog); this.init(); if (options.defaultOpen) { this.openPalette(); } } private init(): void { this.collect(); const listId = this.list.id || uid('cmd-list'); this.list.id = listId; this.list.setAttribute('role', 'listbox'); this.input.setAttribute('role', 'combobox'); this.input.setAttribute('aria-expanded', 'true'); this.input.setAttribute('aria-controls', listId); this.input.setAttribute('aria-autocomplete', 'list'); if (!this.input.hasAttribute('autocomplete')) { this.input.setAttribute('autocomplete', 'off'); } this.root.setAttribute('hidden', ''); this.root.dataset.state = 'closed'; const onInput = (): void => this.applyFilter(); const onKeydown = (event: KeyboardEvent): void => this.onInputKeydown(event); this.input.addEventListener('input', onInput); this.input.addEventListener('keydown', onKeydown); this.cleanups.push( () => this.input.removeEventListener('input', onInput), () => this.input.removeEventListener('keydown', onKeydown), ); if (this.overlay && this.closeOnOverlayClick) { const onOverlay = (): void => this.close(); this.overlay.addEventListener('click', onOverlay); this.cleanups.push(() => this.overlay?.removeEventListener('click', onOverlay)); } if (this.hotkey) { document.addEventListener('keydown', this.onHotkey, true); this.cleanups.push(() => document.removeEventListener('keydown', this.onHotkey, true)); } this.applyFilter(); } private collect(): void { this.items = Array.from(this.list.querySelectorAll(SELECTORS.item)); this.items.forEach((item) => { if (!item.id) { item.id = uid('cmd-item'); } item.setAttribute('role', 'option'); item.setAttribute('aria-selected', 'false'); const onClick = (): void => this.selectItem(item); const onPointer = (): void => this.setActive(this.visible.indexOf(item)); item.addEventListener('click', onClick); item.addEventListener('pointermove', onPointer); this.cleanups.push( () => item.removeEventListener('click', onClick), () => item.removeEventListener('pointermove', onPointer), ); }); } private itemText(item: HTMLElement): string { const keywords = item.dataset.keywords ?? ''; return `${item.textContent ?? ''} ${keywords}`.toLowerCase().trim(); } private readonly onHotkey = (event: KeyboardEvent): void => { const key = this.hotkey; if (!key) { return; } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === key.toLowerCase()) { event.preventDefault(); this.toggle(); } }; private onInputKeydown(event: KeyboardEvent): void { switch (event.key) { case 'ArrowDown': event.preventDefault(); this.move(1); break; case 'ArrowUp': event.preventDefault(); this.move(-1); break; case 'Home': if (this.visible.length) { event.preventDefault(); this.setActive(0); } break; case 'End': if (this.visible.length) { event.preventDefault(); this.setActive(this.visible.length - 1); } break; case 'Enter': { const item = this.visible[this.activeIndex]; if (item) { event.preventDefault(); this.selectItem(item); } break; } case 'Escape': if (this.closeOnEscape) { event.preventDefault(); this.close(); } break; default: break; } } private applyFilter(): void { const query = this.input.value.trim().toLowerCase(); this.visible = []; this.items.forEach((item) => { const match = query === '' || this.filterFn(query, this.itemText(item)); item.toggleAttribute('hidden', !match); item.dataset.state = match ? 'visible' : 'hidden'; if (match) { this.visible.push(item); } }); // Hide groups that have no visible items. const groups = Array.from(this.list.querySelectorAll(SELECTORS.group)); groups.forEach((group) => { const hasVisible = group.querySelector(`${SELECTORS.item}:not([hidden])`) !== null; group.toggleAttribute('hidden', !hasVisible); }); if (this.emptyEl) { this.emptyEl.toggleAttribute('hidden', this.visible.length > 0); } this.setActive(this.visible.length ? 0 : -1); const detail: CommandPaletteFilterDetail = { query, count: this.visible.length }; this.root.dispatchEvent( new CustomEvent(COMMAND_PALETTE_FILTER_EVENT, { detail, bubbles: true }), ); } private move(delta: number): void { if (this.visible.length === 0) { return; } const base = this.activeIndex < 0 ? (delta > 0 ? -1 : 0) : this.activeIndex; const next = (base + delta + this.visible.length) % this.visible.length; this.setActive(next); } private setActive(index: number): void { this.activeIndex = index; this.items.forEach((item) => { item.removeAttribute('aria-selected'); item.dataset.active = 'false'; }); const active = this.visible[index]; if (active) { active.setAttribute('aria-selected', 'true'); active.dataset.active = 'true'; this.input.setAttribute('aria-activedescendant', active.id); active.scrollIntoView?.({ block: 'nearest' }); } else { this.input.removeAttribute('aria-activedescendant'); } } private selectItem(item: HTMLElement): void { if (item.hasAttribute('hidden') || item.hasAttribute('aria-disabled')) { return; } const value = item.dataset.value ?? item.textContent?.trim() ?? ''; const detail: CommandPaletteSelectDetail = { value, item }; this.root.dispatchEvent( new CustomEvent(COMMAND_PALETTE_SELECT_EVENT, { detail, bubbles: true }), ); this.close(); } /** Open the palette and focus the search input. */ openPalette(): void { if (this.open) { return; } this.open = true; this.previouslyFocused = document.activeElement as HTMLElement | null; this.root.removeAttribute('hidden'); this.root.dataset.state = 'open'; this.trap.activate(); this.input.focus(); this.applyFilter(); this.root.dispatchEvent(new CustomEvent(COMMAND_PALETTE_OPEN_EVENT, { bubbles: true })); } /** Close the palette and restore focus. */ close(): void { if (!this.open) { return; } this.open = false; this.root.setAttribute('hidden', ''); this.root.dataset.state = 'closed'; this.trap.deactivate(); if (this.clearOnClose) { this.input.value = ''; this.applyFilter(); } this.previouslyFocused?.focus?.(); this.previouslyFocused = null; this.root.dispatchEvent(new CustomEvent(COMMAND_PALETTE_CLOSE_EVENT, { bubbles: true })); } toggle(): void { if (this.open) { this.close(); } else { this.openPalette(); } } get isOpen(): boolean { return this.open; } /** Currently active (highlighted) item, or null. */ get activeItem(): HTMLElement | null { return this.visible[this.activeIndex] ?? null; } /** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */ on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.trap.deactivate(); this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }