Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent,} from 'react';import type { RegisteredAction } from './types';import type { HotkeyStore } from './hotkeyStore';import { formatHotkeyForDisplay } from './hotkey-utils';
interface CommandPaletteProps { /** Whether the palette is open */ isOpen: boolean; /** Callback when the palette should close */ onClose: () => void; /** Callback when a command is executed */ onExecute: (action: RegisteredAction) => void; /** All available actions */ actions: RegisteredAction[]; /** Hotkey store for resolving effective hotkeys */ hotkeyStore: HotkeyStore; /** Whether the platform is macOS (affects hotkey display) */ isMac: boolean; /** Optional placeholder text */ placeholder?: string;}
/** * Command palette modal overlay. * * Features: * - Fuzzy search filtering of actions * - Keyboard navigation (Up/Down arrows, Enter, Escape) * - Click to execute * - Shows command hotkey hint if available */export function CommandPalette({ isOpen, onClose, onExecute, actions, hotkeyStore, isMac, placeholder = 'Type a command...',}: CommandPaletteProps) { const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef<HTMLInputElement>(null); const listRef = useRef<HTMLDivElement>(null);
// Filter actions by query const filteredActions = useMemo(() => { if (!query.trim()) return actions; return defaultFilter(actions, query); }, [actions, query]);
// Reset state when opening useEffect(() => { if (isOpen) { setQuery(''); setSelectedIndex(0); setTimeout(() => inputRef.current?.focus(), 0); } }, [isOpen]);
// Stable identity for the filtered result set const filteredKey = useMemo( () => filteredActions.map((a) => a.id).join('\0'), [filteredActions], );
// Reset selection when results change useEffect(() => { setSelectedIndex(0); }, [filteredKey]);
// Scroll selected item into view useEffect(() => { if (listRef.current && filteredActions.length > 0) { const selectedEl = listRef.current.querySelector( '[aria-selected="true"]', ); if (selectedEl && typeof selectedEl.scrollIntoView === 'function') { selectedEl.scrollIntoView({ block: 'nearest' }); } } }, [selectedIndex, filteredActions.length]);
const handleKeyDown = useCallback( (e: KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault(); setSelectedIndex( (prev) => (prev + 1) % Math.max(filteredActions.length, 1), ); break; case 'ArrowUp': e.preventDefault(); setSelectedIndex((prev) => prev <= 0 ? Math.max(filteredActions.length - 1, 0) : prev - 1, ); break; case 'Enter': e.preventDefault(); if (filteredActions[selectedIndex]) { onExecute(filteredActions[selectedIndex]); onClose(); } break; case 'Escape': e.preventDefault(); onClose(); break; } }, [filteredActions, selectedIndex, onExecute, onClose], );
const handleActionClick = useCallback( (action: RegisteredAction) => { onExecute(action); onClose(); }, [onExecute, onClose], );
if (!isOpen) return null;
const listId = 'command-palette-list'; const selectedId = filteredActions.length > 0 ? `command-palette-item-${selectedIndex}` : undefined;
return ( <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/35 pt-[20vh] backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="Command palette" > <div className="border-border bg-background text-foreground w-full max-w-lg overflow-hidden rounded-lg border shadow-xl" onMouseDown={(event) => event.stopPropagation()} > {/* Search input */} <div className="border-border border-b p-3"> <input ref={inputRef} type="text" className="bg-background text-foreground border-border focus-visible:ring-accent/50 w-full rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2" placeholder={placeholder} value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={handleKeyDown} role="combobox" aria-controls={listId} aria-activedescendant={selectedId} aria-autocomplete="list" aria-expanded={filteredActions.length > 0} /> </div>
{/* Results */} <div className="max-h-[50vh] min-h-0 overflow-y-auto p-1" ref={listRef}> {filteredActions.length === 0 ? ( <div className="text-foreground/50 px-2 py-3 text-center text-sm"> {query.trim() ? 'No commands found' : actions.length === 0 ? 'No commands available' : 'Type to search commands'} </div> ) : ( <div role="listbox" id={listId}> {filteredActions.map((action, index) => { const isSelected = index === selectedIndex; const effectiveHotkeys = hotkeyStore.getEffectiveHotkeys(action); const hotkeyHint = effectiveHotkeys[0] ? formatHotkeyForDisplay(effectiveHotkeys[0], isMac) : undefined;
return ( <div key={action.id} id={`command-palette-item-${index}`} role="option" aria-selected={isSelected} className={`flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-none select-none ${ isSelected ? 'bg-accent/10 text-foreground' : 'text-foreground/70 hover:bg-border/35' }`} title={action.description} onClick={() => handleActionClick(action)} onMouseEnter={() => setSelectedIndex(index)} > <span className="flex-1 truncate">{action.name}</span> {hotkeyHint ? ( <kbd className="border-border text-foreground/60 shrink-0 rounded border px-1.5 py-0.5 text-xs"> {hotkeyHint} </kbd> ) : null} </div> ); })} </div> )} </div> </div> </div> );}
/** * Default filter when no external search is provided. * All query words must appear in the action name or description (case-insensitive). */function defaultFilter( actions: RegisteredAction[], query: string,): RegisteredAction[] { const words = query.toLowerCase().split(/\s+/).filter(Boolean);
return actions.filter((action) => { const haystack = ( action.name + ' ' + (action.description ?? '') ).toLowerCase(); return words.every((word) => haystack.includes(word)); });}