/** * Command Palette (Cmd+K / Ctrl+K) * * A self-contained, fast command palette that works on all pages * (landing, docs, sheets). Supports fuzzy search, keyboard navigation, * categorized results, and light/dark themes. */ export interface PaletteAction { id: string; label: string; category: 'document' | 'action'; icon?: string; shortcut?: string; action: () => void; } export interface PaletteConfig { actions: PaletteAction[]; fetchDocuments?: () => Promise; } export interface CommandPaletteHandle { open: () => void; close: () => void; destroy: () => void; } /** * Fuzzy match: split query into words, each word must appear * somewhere in the label (case insensitive). Order doesn't matter. */ export function fuzzyMatch(label: string, query: string): boolean { if (!query || !query.trim()) return true; const lower = label.toLowerCase(); const words = query.toLowerCase().trim().split(/\s+/); return words.every(w => lower.includes(w)); } /** * Filter actions by query using fuzzy matching. */ export function filterActions(actions: PaletteAction[], query: string): PaletteAction[] { if (!query || !query.trim()) return actions; return actions.filter(a => fuzzyMatch(a.label, query)); } /** * Group actions by category, returning groups in order: * 'action' first, then 'document'. */ export function groupByCategory(actions: PaletteAction[]): { category: string; items: PaletteAction[] }[] { const actionItems = actions.filter(a => a.category === 'action'); const docItems = actions.filter(a => a.category === 'document'); const groups: { category: string; items: PaletteAction[] }[] = []; if (actionItems.length > 0) groups.push({ category: 'Actions', items: actionItems }); if (docItems.length > 0) groups.push({ category: 'Documents', items: docItems }); return groups; } /** * Compute the next selected index when navigating with arrow keys. * Wraps around. */ export function navigateIndex(current: number, total: number, direction: 'up' | 'down'): number { if (total === 0) return -1; if (direction === 'down') { return (current + 1) % total; } return (current - 1 + total) % total; } /** * Create a command palette instance. Injects DOM, handles keyboard, * renders results. Call `.destroy()` to clean up. */ export function createCommandPalette(config: PaletteConfig): CommandPaletteHandle { // --- Build DOM --- const backdrop = document.createElement('div'); backdrop.className = 'cmd-palette-backdrop'; backdrop.setAttribute('role', 'dialog'); backdrop.setAttribute('aria-modal', 'true'); backdrop.setAttribute('aria-label', 'Command palette'); const container = document.createElement('div'); container.className = 'cmd-palette'; const input = document.createElement('input'); input.className = 'cmd-palette-input'; input.type = 'text'; input.placeholder = 'Type a command or search...'; input.setAttribute('aria-label', 'Command palette search'); input.spellcheck = false; input.autocomplete = 'off'; const resultsList = document.createElement('div'); resultsList.className = 'cmd-palette-results'; resultsList.setAttribute('role', 'listbox'); container.appendChild(input); container.appendChild(resultsList); backdrop.appendChild(container); // --- State --- let isOpen = false; let allActions: PaletteAction[] = [...config.actions]; let filteredActions: PaletteAction[] = []; let selectedIndex = 0; let documentsFetched = false; function render() { const query = input.value; filteredActions = filterActions(allActions, query); const groups = groupByCategory(filteredActions); if (filteredActions.length === 0) { resultsList.innerHTML = '
No results found
'; selectedIndex = -1; return; } // Clamp selected index if (selectedIndex >= filteredActions.length) selectedIndex = 0; if (selectedIndex < 0) selectedIndex = 0; let html = ''; let flatIndex = 0; for (const group of groups) { html += `
${escapeHtml(group.category)}
`; for (const item of group.items) { const isSelected = flatIndex === selectedIndex; html += `
`; if (item.icon) { html += `${escapeHtml(item.icon)}`; } html += `${escapeHtml(item.label)}`; if (item.shortcut) { html += `${escapeHtml(item.shortcut)}`; } html += '
'; flatIndex++; } } resultsList.innerHTML = html; // Scroll selected into view const selectedEl = resultsList.querySelector('.cmd-palette-item-selected'); if (selectedEl) { selectedEl.scrollIntoView({ block: 'nearest' }); } } function executeSelected() { if (selectedIndex >= 0 && selectedIndex < filteredActions.length) { const action = filteredActions[selectedIndex]; close(); action.action(); } } function close() { if (!isOpen) return; isOpen = false; backdrop.classList.remove('cmd-palette-open'); // Remove after animation setTimeout(() => { if (!isOpen) backdrop.remove(); }, 150); } function open() { if (isOpen) return; isOpen = true; input.value = ''; selectedIndex = 0; document.body.appendChild(backdrop); // Force reflow for animation backdrop.offsetHeight; backdrop.classList.add('cmd-palette-open'); input.focus(); // Fetch documents on first open (or always if provided) if (config.fetchDocuments && !documentsFetched) { documentsFetched = true; config.fetchDocuments().then(docs => { // Merge, avoiding duplicate IDs const existingIds = new Set(allActions.map(a => a.id)); for (const doc of docs) { if (!existingIds.has(doc.id)) { allActions.push(doc); } } if (isOpen) render(); }).catch(() => { // silently fail — actions still work }); } render(); } // --- Event handlers --- function onInput() { selectedIndex = 0; render(); } function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); } else if (e.key === 'ArrowDown') { e.preventDefault(); selectedIndex = navigateIndex(selectedIndex, filteredActions.length, 'down'); render(); } else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIndex = navigateIndex(selectedIndex, filteredActions.length, 'up'); render(); } else if (e.key === 'Enter') { e.preventDefault(); executeSelected(); } } function onBackdropClick(e: MouseEvent) { if (e.target === backdrop) { close(); } } function onResultClick(e: MouseEvent) { const item = (e.target as HTMLElement).closest('.cmd-palette-item') as HTMLElement | null; if (item) { const index = parseInt(item.dataset.index || '0', 10); selectedIndex = index; executeSelected(); } } function onGlobalKeydown(e: KeyboardEvent) { const mod = e.metaKey || e.ctrlKey; if (mod && e.key.toLowerCase() === 'k') { // Don't hijack Cmd+K when user is in a contenteditable or specific input // that uses Cmd+K for link insertion. Only intercept if not already open. e.preventDefault(); if (isOpen) { close(); } else { open(); } } } // Attach listeners input.addEventListener('input', onInput); input.addEventListener('keydown', onKeydown); backdrop.addEventListener('click', onBackdropClick); resultsList.addEventListener('click', onResultClick); document.addEventListener('keydown', onGlobalKeydown); return { open, close, destroy() { close(); document.removeEventListener('keydown', onGlobalKeydown); input.removeEventListener('input', onInput); input.removeEventListener('keydown', onKeydown); backdrop.removeEventListener('click', onBackdropClick); resultsList.removeEventListener('click', onResultClick); }, }; } function escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }