"use client"; import { motion, useReducedMotion } from "motion/react"; import { Search, type LucideIcon } from "lucide-react"; import { type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type CommandItem = { id: string; label: string; group?: string; hint?: string; keywords?: string[]; icon?: LucideIcon; badge?: ReactNode; 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; } // Opened via a keyboard shortcut many times a day — entrance must read as // instant. Tight spring, even faster exit. const PANEL_SPRING = { type: "spring", stiffness: 560, damping: 40, mass: 0.5, } 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 = useCallback( (v: boolean) => { if (!controlled) setInternalOpen(v); onOpenChange?.(v); }, [controlled, onOpenChange], ); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); // Portal target only exists client-side; render nothing during SSR/hydration. const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); const uid = useId(); const reduce = useReducedMotion(); const updateQuery = useCallback((value: string) => { setQuery(value); setActive(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, setOpen]); useEffect(() => { if (open) { updateQuery(""); setActive(0); requestAnimationFrame(() => inputRef.current?.focus()); } }, [open, updateQuery]); 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]); // Reserve the icon column only when at least one item brings an icon, so // icon-less lists don't render a dead gap before every label. const hasIcons = useMemo(() => items.some((it) => it.icon), [items]); const grouped = useMemo(() => { const map = new Map(); filtered.forEach((it) => { const g = it.group ?? "Results"; const groupItems = map.get(g) ?? []; groupItems.push(it); map.set(g, groupItems); }); 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; if (!mounted) return null; // Always-mounted container; pointer events fully disabled when closed so clicks // pass through to the page. Portaled to so ancestors with transforms, // filters, or fixed positioning can't trap the overlay in their stacking context. return createPortal(
setOpen(false)} className={cn( "absolute inset-0 bg-background/5 [backdrop-filter:blur(12px)_saturate(140%)] [-webkit-backdrop-filter:blur(12px)_saturate(140%)]", open ? "pointer-events-auto" : "pointer-events-none", )} />
updateQuery(e.target.value)} placeholder={placeholder} tabIndex={open ? 0 : -1} role="combobox" aria-expanded={open} aria-controls={`${uid}-list`} aria-activedescendant={ filtered.length > 0 ? `${uid}-opt-${active}` : undefined } aria-autocomplete="list" className="h-12 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground 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 ( ); })}
)) )}
, document.body, ); }