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 = [];
}
}