"use client"; import { AnimatePresence, 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 { useOnOpen } from "@/lib/hooks/use-on-open"; import { useRowCursor } from "@/lib/hooks/use-row-cursor"; import { useTouchCapable } from "@/lib/hooks/use-touch-capable"; import { PresenceGate } from "@/lib/presence-gate"; import { cn } from "@/lib/utils"; import { searchCommands } from "@/lib/command-search"; 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; } // 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(""); // 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 canTouch = useTouchCapable(); 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) return; const root = document.documentElement; const previousRootOverflow = root.style.overflow; const previousBodyOverflow = document.body.style.overflow; root.style.overflow = "hidden"; document.body.style.overflow = "hidden"; return () => { root.style.overflow = previousRootOverflow; document.body.style.overflow = previousBodyOverflow; }; }, [open]); const filtered = useMemo(() => searchCommands(items, query), [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]); // Grouping reorders the list, so the rendered order is not the filtered // order whenever two groups interleave. Everything that has to agree on // "which row" — the highlight, the ids, Enter, the scroll — reads this one // array, so they cannot drift apart. const rows = useMemo(() => grouped.flatMap(([, list]) => list), [grouped]); const { activeIndex: active, moveTo, moveActive } = useRowCursor(rows, query); // Clearing the query would drop the cursor on its own, but only if it had // changed; `moveTo(null)` covers reopening on an already-empty query. useOnOpen(open, () => { setQuery(""); moveTo(null); }); useEffect(() => { if (!open) return; const frame = requestAnimationFrame(() => inputRef.current?.focus()); return () => cancelAnimationFrame(frame); }, [open]); const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); moveActive(1); } else if (e.key === "ArrowUp") { e.preventDefault(); moveActive(-1); } else if (e.key === "Enter") { e.preventDefault(); const it = rows[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]); if (!mounted) return null; // Portaled to so ancestors with transforms, filters, or fixed // positioning can't trap the overlay in their stacking context, and mounted // only while open. The chrome is two fixed siblings rather than one wrapper: // the backdrop spans the viewport edges but carries the scrim colour, and the // layer positioning the panel is inset off every edge. Both hang off // `PresenceGate`, so interaction releases in the same commit that starts the // exit rather than when it ends — `open` is already false for those frames. // See tests/fixed-overlay-edge-sampling.test.tsx. return createPortal( {open ? ( {({ gate }) => ( setOpen(false)} className="pointer-events-auto fixed inset-0 z-[100] bg-background/5 [backdrop-filter:blur(12px)_saturate(140%)] [-webkit-backdrop-filter:blur(12px)_saturate(140%)]" /> )} ) : null} {open ? ( {({ isPresent, gate }) => ( // The layer itself never takes pointer events, so it carries // `inert` alone rather than the gate's pointer-events value.
setQuery(e.target.value)} placeholder={placeholder} role="combobox" // The field only exists while the palette is open. aria-expanded="true" aria-controls={`${uid}-list`} aria-activedescendant={ rows.length > 0 ? `${uid}-opt-${active}` : undefined } aria-autocomplete="list" className={cn( "h-12 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none", // The palette focuses this field the moment it opens, and iOS // zooms the page in on a focused field under 16px: the fixed // overlay is magnified off-center — clipped leading edge, half // an icon column — and the zoom outlives the palette. 16px on // touch keeps the page at scale 1; pointer devices keep 14px. canTouch && "text-base", )} /> ESC
{rows.length === 0 ? (
{emptyMessage}
) : ( grouped.map(([group, list]) => (
{group}
{list.map((it) => { // `rows` holds these very objects, in render order. const idx = rows.indexOf(it); const isActive = idx === active; const Icon = it.icon; return ( ); })}
)) )}
)}
) : null}
, document.body, ); }