diff options
| author | zwlucas <lucas.fariamo08@gmail.com> | 2026-06-05 20:52:54 +0000 |
|---|---|---|
| committer | zwlucas <lucas.fariamo08@gmail.com> | 2026-06-05 20:52:54 +0000 |
| commit | 09f964451d7d92e9891430ec4595c1276d486aab (patch) | |
| tree | 28da4483f5c28924a8c47fceb648b1baebe88224 /src/components/CommandPalette/CommandPalette.tsx | |
| download | yace-09f964451d7d92e9891430ec4595c1276d486aab.tar.gz yace-09f964451d7d92e9891430ec4595c1276d486aab.zip | |
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'src/components/CommandPalette/CommandPalette.tsx')
| -rw-r--r-- | src/components/CommandPalette/CommandPalette.tsx | 201 |
1 files changed, 201 insertions, 0 deletions
diff --git a/src/components/CommandPalette/CommandPalette.tsx b/src/components/CommandPalette/CommandPalette.tsx new file mode 100644 index 0000000..eba6221 --- /dev/null +++ b/src/components/CommandPalette/CommandPalette.tsx @@ -0,0 +1,201 @@ +import { useEffect, useRef, useMemo } from 'react' +import { useCommandStore } from '@/stores/useCommandStore' + +function SearchIcon() { + return ( + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="11" cy="11" r="8" /> + <line x1="21" y1="21" x2="16.65" y2="16.65" /> + </svg> + ) +} + +function Kbd({ keys }: { keys: string }) { + return ( + <span className="flex items-center gap-0.5 text-ui-xs"> + {keys.split('+').map((k) => ( + <kbd + key={k} + className="px-1 py-[1px] rounded text-[10px] leading-none font-mono" + style={{ + backgroundColor: 'var(--color-panel-hover)', + color: 'var(--color-text-muted)', + border: '1px solid var(--color-panel-border)', + minWidth: '16px', + textAlign: 'center', + }} + > + {k === 'Ctrl' ? '⌃' : k === 'Shift' ? '⇧' : k === 'Cmd' ? '⌘' : k} + </kbd> + ))} + </span> + ) +} + +function CommandGroup({ category, children }: { category: string; children: React.ReactNode }) { + return ( + <div> + <div className="px-4 py-1.5 text-ui-xs font-medium tracking-wider uppercase text-text-muted">{category}</div> + {children} + </div> + ) +} + +export default function CommandPalette() { + const isOpen = useCommandStore((s) => s.isOpen) + const query = useCommandStore((s) => s.query) + const selectedIndex = useCommandStore((s) => s.selectedIndex) + const commands = useCommandStore((s) => s.commands) + const setQuery = useCommandStore((s) => s.setQuery) + const setSelectedIndex = useCommandStore((s) => s.setSelectedIndex) + const executeSelected = useCommandStore((s) => s.executeSelected) + const close = useCommandStore((s) => s.close) + + const inputRef = useRef<HTMLInputElement>(null) + + const filtered = useMemo( + () => commands.filter( + (c) => + c.label.toLowerCase().includes(query.toLowerCase()) || + c.category.toLowerCase().includes(query.toLowerCase()) + ), + [commands, query] + ) + + const grouped = useMemo(() => { + const map = new Map<string, typeof filtered>() + filtered.forEach((c) => { + const group = map.get(c.category) ?? [] + group.push(c) + map.set(c.category, group) + }) + return Array.from(map.entries()) + }, [filtered]) + + useEffect(() => { + if (isOpen) { + inputRef.current?.focus() + } + }, [isOpen]) + + useEffect(() => { + if (!isOpen) return + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault() + close() + return + } + + if (e.key === 'Enter') { + e.preventDefault() + executeSelected() + return + } + + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex(Math.min(selectedIndex + 1, filtered.length - 1)) + return + } + + if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex(Math.max(selectedIndex - 1, 0)) + return + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isOpen, filtered.length, selectedIndex, setSelectedIndex, executeSelected, close]) + + if (!isOpen) return null + + return ( + <div + className="fixed inset-0 z-50 flex justify-center" + style={{ paddingTop: '12vh' }} + onClick={close} + role="dialog" + aria-label="Command palette" + > + <div className="absolute inset-0 backdrop-glass" /> + + <div + className="relative w-full max-w-[520px] rounded-xl shadow-glass overflow-hidden" + style={{ + backgroundColor: 'var(--color-panel-bg)', + border: '1px solid var(--color-panel-border)', + maxHeight: '60vh', + display: 'flex', + flexDirection: 'column', + }} + onClick={(e) => e.stopPropagation()} + > + <div className="flex items-center gap-3 px-4 h-11 shrink-0" style={{ borderBottom: '1px solid var(--color-panel-border)' }}> + <span className="text-text-muted shrink-0"><SearchIcon /></span> + <input + ref={inputRef} + type="text" + value={query} + onChange={(e) => setQuery(e.target.value)} + placeholder="Type a command..." + className="flex-1 bg-transparent text-ui-base text-text-primary outline-none placeholder:text-text-muted" + /> + </div> + + <div className="flex-1 overflow-y-auto py-2" role="listbox"> + {filtered.length === 0 && ( + <div className="px-4 py-6 text-ui-sm text-text-muted text-center"> + No results found + </div> + )} + + {grouped.map(([category, items]) => ( + <CommandGroup key={category} category={category}> + {items.map((cmd) => { + const idx = filtered.indexOf(cmd) + const isSelected = idx === selectedIndex + + return ( + <button + key={cmd.id} + role="option" + aria-selected={isSelected ? 'true' : 'false'} + className="w-full flex items-center justify-between px-4 py-1.5 text-left transition-colors duration-fast" + style={{ + backgroundColor: isSelected ? 'var(--color-panel-hover)' : 'transparent', + color: isSelected ? 'var(--color-text-primary)' : 'var(--color-text-secondary)', + }} + onMouseEnter={() => setSelectedIndex(idx)} + onClick={() => { + cmd.action() + close() + }} + > + <span className="text-ui-sm truncate">{cmd.label}</span> + {cmd.shortcut && <Kbd keys={cmd.shortcut} />} + </button> + ) + })} + </CommandGroup> + ))} + + <div className="px-4 pt-2 pb-1 flex items-center gap-3 text-ui-xs text-text-muted border-t border-panel-border mt-2"> + <span className="flex items-center gap-1"> + <Kbd keys="↑↓" /> navigate + </span> + <span className="flex items-center gap-1"> + <Kbd keys="↵" /> select + </span> + <span className="flex items-center gap-1"> + <Kbd keys="Esc" /> close + </span> + </div> + </div> + </div> + </div> + ) +} |