"use client"; import { ChevronRightIcon, CircleCheckIcon, CornerDownLeftIcon, HistoryIcon, SearchIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, type ReactNode, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Kbd, KbdGroup } from "@/components/ui/kbd"; import { cn } from "@/lib/utils"; export interface LauncherAction { id: string; label: string; icon?: ReactNode; group?: string; shortcut?: string[]; keywords?: string[]; page?: string; done?: string; onSelect?: () => void; } export interface LauncherPage { title: string; placeholder?: string; actions: LauncherAction[]; } export interface LauncherProps { actions: LauncherAction[]; pages?: Record; defaultRecent?: string[]; recentLimit?: number; placeholder?: string; triggerLabel?: string; defaultOpen?: boolean; className?: string; } type Section = { title: string; items: LauncherAction[] }; function scrollListOnly(list: HTMLElement, el: HTMLElement) { const top = el.getBoundingClientRect().top - list.getBoundingClientRect().top + list.scrollTop; if (top < list.scrollTop) list.scrollTop = top - 6; else if (top + el.offsetHeight > list.scrollTop + list.clientHeight) list.scrollTop = top + el.offsetHeight - list.clientHeight + 6; } function score(action: LauncherAction, q: string) { const label = action.label.toLowerCase(); if (label.startsWith(q)) return 3; if (label.split(/\s+/).some((w) => w.startsWith(q))) return 2; if (label.includes(q) || action.keywords?.some((k) => k.toLowerCase().includes(q))) return 1; return 0; } function useThemeRadius(scale: number) { const probe = useRef(null); const [radius, setRadius] = useState(scale * 10); useLayoutEffect(() => { const el = probe.current; if (!el) return; const read = () => setRadius(el.offsetWidth); read(); const observer = new ResizeObserver(read); observer.observe(el); return () => observer.disconnect(); }, []); const marker = ( ); return [radius, marker] as const; } export function Launcher({ actions, pages = {}, defaultRecent = [], recentLimit = 3, placeholder = "Type a command or search…", triggerLabel = "Search or jump to…", defaultOpen = false, className, }: LauncherProps) { const [radius, radiusProbe] = useThemeRadius(1); const [open, setOpen] = useState(defaultOpen); const [page, setPage] = useState(null); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const [recent, setRecent] = useState(defaultRecent); const [lastRun, setLastRun] = useState(null); const [direction, setDirection] = useState(1); const reduce = useReducedMotion(); const baseId = useId(); const inputRef = useRef(null); const triggerRef = useRef(null); const listRef = useRef(null); const timer = useRef(undefined); useEffect(() => () => clearTimeout(timer.current), []); const all = useMemo(() => { const map = new Map(); for (const a of actions) map.set(a.id, a); for (const p of Object.values(pages)) for (const a of p.actions) map.set(a.id, a); return map; }, [actions, pages]); const sections: Section[] = useMemo(() => { const q = query.trim().toLowerCase(); const source = page ? (pages[page]?.actions ?? []) : actions; if (q) { const hits = source .map((a) => ({ a, s: score(a, q) })) .filter((x) => x.s > 0) .sort((x, y) => y.s - x.s) .map((x) => x.a); return hits.length ? [{ title: page ? (pages[page]?.title ?? "Results") : "Results", items: hits }] : []; } if (page) return [{ title: pages[page]?.title ?? "", items: source }]; const out: Section[] = []; const recents = recent.map((id) => all.get(id)).filter((a): a is LauncherAction => !!a); if (recents.length) out.push({ title: "Recent", items: recents }); for (const a of source) { const title = a.group ?? "Actions"; const section = out.find((s) => s.title === title && s.title !== "Recent"); if (section) section.items.push(a); else out.push({ title, items: [a] }); } return out; }, [query, page, pages, actions, recent, all]); const flat = sections.flatMap((s) => s.items.map((item) => ({ item, section: s.title }))); const current = flat[Math.min(active, flat.length - 1)]; const optionId = (section: string, id: string) => `${baseId}-${section}-${id}`.replace(/[^a-zA-Z0-9_-]/g, ""); useEffect(() => setActive(0), [query, page]); useEffect(() => { const list = listRef.current; if (!current || !list) return; const el = list.querySelector(`#${CSS.escape(optionId(current.section, current.item.id))}`); if (el) scrollListOnly(list, el); }, [active, current?.item.id]); const [height, setHeight] = useState("auto"); const [more, setMore] = useState(false); const checkMore = () => { const el = listRef.current; if (el) setMore(el.scrollTop + el.clientHeight < el.scrollHeight - 2); }; useLayoutEffect(() => { const el = listRef.current; if (!el) return; const observer = new ResizeObserver(() => { setHeight(Math.min(el.scrollHeight, 296)); checkMore(); }); observer.observe(el); return () => observer.disconnect(); }, [open]); const focusOnOpen = useRef(false); const show = () => { focusOnOpen.current = true; setOpen(true); setLastRun(null); }; useEffect(() => { if (open && focusOnOpen.current) { focusOnOpen.current = false; inputRef.current?.focus(); } }, [open]); const close = () => { setOpen(false); setPage(null); setQuery(""); requestAnimationFrame(() => triggerRef.current?.focus()); }; const goTo = (next: string | null) => { setDirection(next ? 1 : -1); setPage(next); setQuery(""); inputRef.current?.focus(); }; const run = (action: LauncherAction) => { if (action.page) return goTo(action.page); action.onSelect?.(); setRecent((list) => [action.id, ...list.filter((id) => id !== action.id)].slice(0, recentLimit)); setLastRun(action.done ?? action.label); clearTimeout(timer.current); timer.current = window.setTimeout(() => setLastRun(null), 2600); close(); }; const onInputKey = (event: KeyboardEvent) => { const n = flat.length; if (event.key === "ArrowDown" && n) { event.preventDefault(); setActive((i) => (i + 1) % n); } else if (event.key === "ArrowUp" && n) { event.preventDefault(); setActive((i) => (i - 1 + n) % n); } else if (event.key === "Home" && n) { event.preventDefault(); setActive(0); } else if (event.key === "End" && n) { event.preventDefault(); setActive(n - 1); } else if (event.key === "Enter" && current) { event.preventDefault(); run(current.item); } else if (event.key === "Escape") { event.preventDefault(); if (page) goTo(null); else close(); } else if (event.key === "Backspace" && !query && page) { event.preventDefault(); goTo(null); } }; const onWrapperKey = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); if (open) close(); else show(); } }; const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 36 }; const pageTitle = page ? pages[page]?.title : null; return (
{radiusProbe} {open ? (
{pageTitle && ( goTo(null)} title="Back (Backspace)" type="button" > {pageTitle} )} setQuery(e.target.value)} onKeyDown={onInputKey} placeholder={page ? (pages[page]?.placeholder ?? "Search…") : placeholder} ref={inputRef} role="combobox" value={query} /> esc
{sections.length === 0 && (

Nothing for “{query}”.

)} {sections.map((section) => (
{section.title === "Recent" && } {section.title}
{section.items.map((item) => { const isActive = current?.item.id === item.id && current.section === section.title; const index = flat.findIndex((f) => f.item.id === item.id && f.section === section.title); return (
run(item)} onMouseMove={() => index !== active && setActive(index)} role="option" tabIndex={-1} > {isActive && ( )} {item.icon} {item.label} {item.page ? ( ) : ( item.shortcut && ( {item.shortcut.map((k) => ( {k} ))} ) )}
); })}
))}
↑ ↓ navigate {current?.item.page ? "open" : "run"} {page ? "⌫" : "esc"} {page ? "back" : "close"}
) : ( {triggerLabel} ⌘ K )}
{lastRun && !open && ( {lastRun} )}
); }