"use client"; import { motion } from "motion/react"; import { Search, type LucideIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export type CommandItem = { id: string; label: string; group?: string; hint?: string; keywords?: string[]; icon?: LucideIcon; onSelect: () => void; }; export interface CommandPaletteProps { items: CommandItem[]; /** Opens with Cmd/Ctrl + this key. Default: "k" */ shortcut?: string; placeholder?: string; emptyMessage?: string; open?: boolean; onOpenChange?: (open: boolean) => void; } function fuzzyMatch(needle: string, hay: string) { if (!needle) return true; needle = needle.toLowerCase(); hay = hay.toLowerCase(); let i = 0; for (const ch of hay) { if (ch === needle[i]) i++; if (i === needle.length) return true; } return false; } const EASE = [0.16, 1, 0.3, 1] as const; export function CommandPalette({ items, shortcut = "k", placeholder = "Type a command or search…", emptyMessage = "No results found.", open: controlledOpen, onOpenChange, }: CommandPaletteProps) { const [internalOpen, setInternalOpen] = useState(false); const controlled = controlledOpen !== undefined; const open = controlled ? controlledOpen : internalOpen; const setOpen = (v: boolean) => { if (!controlled) setInternalOpen(v); onOpenChange?.(v); }; const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === shortcut.toLowerCase()) { e.preventDefault(); setOpen(!open); return; } if (e.key === "Escape" && open) { e.preventDefault(); setOpen(false); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, shortcut]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (open) { setQuery(""); setActive(0); requestAnimationFrame(() => inputRef.current?.focus()); } }, [open]); useEffect(() => { if (!open) return; const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; }, [open]); const filtered = useMemo(() => { if (!query) return items; return items.filter((it) => { const haystacks = [it.label, it.group ?? "", ...(it.keywords ?? [])]; return haystacks.some((h) => fuzzyMatch(query, h)); }); }, [items, query]); useEffect(() => setActive(0), [query]); const grouped = useMemo(() => { const map = new Map(); filtered.forEach((it) => { const g = it.group ?? "Results"; if (!map.has(g)) map.set(g, []); map.get(g)!.push(it); }); return Array.from(map.entries()); }, [filtered]); const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(filtered.length - 1, a + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); } else if (e.key === "Enter") { e.preventDefault(); const it = filtered[active]; if (it) { it.onSelect(); setOpen(false); } } }; useEffect(() => { if (!open) return; const el = listRef.current?.querySelector(`[data-index="${active}"]`); el?.scrollIntoView({ block: "nearest" }); }, [active, open]); let cursor = 0; // Always-mounted container; pointer events fully disabled when closed so clicks pass through to the page. return (
setOpen(false)} className={cn( "absolute inset-0 bg-black/55 [backdrop-filter:blur(12px)_saturate(140%)] [-webkit-backdrop-filter:blur(12px)_saturate(140%)]", open ? "pointer-events-auto" : "pointer-events-none", )} />
setQuery(e.target.value)} placeholder={placeholder} tabIndex={open ? 0 : -1} className="h-12 flex-1 bg-transparent text-sm text-(--color-fg) placeholder:text-(--color-fg-muted) outline-none" /> ESC
{filtered.length === 0 ? (
{emptyMessage}
) : ( grouped.map(([group, list]) => (
{group}
{list.map((it) => { const idx = cursor++; const isActive = idx === active; const Icon = it.icon; return ( ); })}
)) )}
); }