import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import { Search } from 'lucide-react'; import { rank } from './match'; import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems'; // Spotlight: type a few letters, go somewhere. // // The dock is full and cannot grow, which is what this is for — once navigation lives here, the dock is // free to be a short list of what you actually use rather than a complete one. // // ── Keyboard first, and the trigger ── // // `ctrl-space`. `alt-space` is the more macOS-shaped chord and is already load-bearing for the owner // elsewhere, so it is deliberately not taken. `cmd-k` is not used either: it belongs to the browser's own // surface on some platforms and to text fields on others. // // Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it. const MAX_RESULTS = 8; /** Label with the matched characters picked out, so it is obvious WHY a result is here. */ const Highlighted = ({ label, hits }: { label: string; hits: number[] }) => { if (!hits.length) return <>{label}; const set = new Set(hits); return ( <> {label.split('').map((char, i) => set.has(i) ? ( {char} ) : ( {char} ), )} ); }; const Row = ({ item, hits, active, onPick, }: { item: SpotlightItem; hits: number[]; active: boolean; onPick: () => void; }) => { const Icon = item.icon; return ( ); }; export const Spotlight = () => { const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const [active, setActive] = useState(0); const inputRef = useRef(null); const navigate = useNavigate(); const items = useSpotlightItems(); const results = useMemo(() => rank(items, query).slice(0, MAX_RESULTS), [items, query]); const close = useCallback(() => { setOpen(false); setQuery(''); setActive(0); }, []); const pick = useCallback( (item: SpotlightItem | undefined) => { if (!item) return; close(); navigate(item.to); }, [close, navigate], ); // The trigger. Bound to the window rather than to a container, because it has to work while focus is // anywhere — including inside a panel that has its own key handling. useEffect(() => { const onKey = (ev: KeyboardEvent) => { if (ev.ctrlKey && ev.code === 'Space') { ev.preventDefault(); setOpen((wasOpen) => !wasOpen); return; } if (ev.key === 'Escape' && open) close(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, close]); useEffect(() => { if (open) inputRef.current?.focus(); }, [open]); // Reset the cursor whenever the result set changes, so it never points past the end. useEffect(() => setActive(0), [query]); if (!open) return null; return (
ev.stopPropagation()} >
setQuery(ev.target.value)} onKeyDown={(ev) => { if (ev.key === 'ArrowDown') { ev.preventDefault(); setActive((i) => Math.min(i + 1, results.length - 1)); } else if (ev.key === 'ArrowUp') { ev.preventDefault(); setActive((i) => Math.max(i - 1, 0)); } else if (ev.key === 'Enter') { ev.preventDefault(); pick(results[active]?.item); } }} placeholder="Go to…" className="w-full bg-transparent py-3.5 text-sm text-foreground outline-none placeholder:text-muted-foreground" />
{results.length === 0 ? (

Nothing matches “{query}”.

) : ( results.map((result, i) => ( pick(result.item)} /> )) )}
); };