Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronRight, CornerDownLeft, Loader2, Search, SearchX } from "lucide-react";
import { cn } from "@/lib/utils";

/* ----------------------------------------------------------------------------
 * Types
 * ------------------------------------------------------------------------- */

export type CommandIcon = React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>;

export type CommandPage = {
  id: string;
  title: string;
  placeholder?: string;
  groups: CommandGroup[];
};

export type CommandItem = {
  id: string;
  label: string;
  icon?: CommandIcon;
  /** Secondary text shown on the right (e.g. a path or a type). */
  hint?: string;
  /** Keys shown as keycaps, e.g. ["⌘", "B"]. */
  shortcut?: string[];
  /** Extra words that should match the search (not highlighted). */
  keywords?: string[];
  /** Opening this item pushes a nested page instead of running it. */
  page?: CommandPage;
  onSelect?: () => void;
  disabled?: boolean;
};

export type CommandGroup = { id: string; heading: string; items: CommandItem[] };

export interface CommandPaletteProps {
  groups?: CommandGroup[];
  /** Simulated or real async search, merged below local results. */
  asyncSearch?: (query: string, signal: AbortSignal) => Promise<CommandItem[]>;
  asyncHeading?: string;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Called for every executed (non-page) item. */
  onSelect?: (item: CommandItem) => void;
  /** Letter used with ⌘/Ctrl to toggle the palette. `null` disables the hotkey. */
  hotkey?: string | null;
  placeholder?: string;
  /** Renders the "Search…" trigger button. */
  showTrigger?: boolean;
  recentLimit?: number;
  /** Item ids shown under "Recent" before anything was run. */
  defaultRecent?: string[];
  /** localStorage key for recents (best effort). */
  storageKey?: string;
  className?: string;
}

/* ----------------------------------------------------------------------------
 * Fuzzy matching
 * ------------------------------------------------------------------------- */

type Match = { score: number; indices: number[] };

export function fuzzyMatch(query: string, text: string): Match | null {
  const q = query.toLowerCase().replace(/\s+/g, "");
  if (!q) return { score: 0, indices: [] };
  const t = text.toLowerCase();

  // Contiguous substring wins big (prefer word starts)
  const sub = t.indexOf(q);
  if (sub !== -1) {
    const atWord = sub === 0 || /[\s\-_/.]/.test(t[sub - 1]);
    return {
      score: 100 + (atWord ? 40 : 0) - sub - (t.length - q.length) * 0.2,
      indices: Array.from({ length: q.length }, (_, i) => sub + i),
    };
  }

  // Subsequence: prefer word-boundary hits, reward runs
  const indices: number[] = [];
  let score = 0;
  let from = 0;
  for (let i = 0; i < q.length; i++) {
    const ch = q[i];
    let at = -1;
    for (let j = from; j < t.length; j++) {
      if (t[j] !== ch) continue;
      const boundary = j === 0 || /[\s\-_/.]/.test(t[j - 1]);
      if (boundary) {
        at = j;
        break;
      }
      if (at === -1) at = j;
    }
    if (at === -1) return null;
    const prev = indices[indices.length - 1];
    const boundary = at === 0 || /[\s\-_/.]/.test(t[at - 1]);
    score += 1 + (boundary ? 8 : 0) + (prev !== undefined && at === prev + 1 ? 5 : 0) - Math.min(4, at - from) * 0.5;
    indices.push(at);
    from = at + 1;
  }
  return { score, indices };
}

function Highlight({ text, indices }: { text: string; indices: number[] }) {
  if (!indices.length) return <>{text}</>;
  const set = new Set(indices);
  const out: React.ReactNode[] = [];
  let buf = "";
  let on = false;
  const flush = (k: number) => {
    if (!buf) return;
    out.push(
      on ? (
        <mark key={k} className="rounded-[3px] bg-primary/12 px-px font-semibold text-foreground dark:bg-primary/25">
          {buf}
        </mark>
      ) : (
        <span key={k}>{buf}</span>
      ),
    );
    buf = "";
  };
  for (let i = 0; i < text.length; i++) {
    const hit = set.has(i);
    if (hit !== on) {
      flush(i);
      on = hit;
    }
    buf += text[i];
  }
  flush(text.length);
  return <>{out}</>;
}

/* ----------------------------------------------------------------------------
 * Helpers
 * ------------------------------------------------------------------------- */

const noopSubscribe = () => () => {};
function useIsMac() {
  return React.useSyncExternalStore(
    noopSubscribe,
    () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent),
    () => true,
  );
}

function Kbd({ children, className }: { children: React.ReactNode; className?: string }) {
  return (
    <kbd
      className={cn(
        "inline-flex h-5 min-w-5 items-center justify-center rounded-md border border-b-2 bg-background px-1 font-sans text-[10.5px] font-medium text-muted-foreground",
        className,
      )}
    >
      {children}
    </kbd>
  );
}

type Row = { item: CommandItem; group: string; match: Match };
type ResultGroup = { id: string; heading: string; rows: Row[]; loading?: boolean };

/* ----------------------------------------------------------------------------
 * Component
 * ------------------------------------------------------------------------- */

export function CommandPalette({
  groups = [],
  asyncSearch,
  asyncHeading = "Search results",
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  onSelect,
  hotkey = "k",
  placeholder = "Type a command or search…",
  showTrigger = true,
  recentLimit = 3,
  defaultRecent = [],
  storageKey,
  className,
}: CommandPaletteProps) {
  const uid = React.useId();
  const reduce = useReducedMotion();
  const isMac = useIsMac();
  const [openInner, setOpenInner] = React.useState(defaultOpen);
  const open = openProp ?? openInner;
  const setOpen = React.useCallback(
    (v: boolean) => {
      if (openProp === undefined) setOpenInner(v);
      onOpenChange?.(v);
    },
    [openProp, onOpenChange],
  );

  const [pages, setPages] = React.useState<CommandPage[]>([]);
  const [pageDir, setPageDir] = React.useState(1);
  const [query, setQuery] = React.useState("");
  const [activeId, setActiveId] = React.useState<string | null>(null);
  const [recent, setRecent] = React.useState<string[]>(defaultRecent);

  /* restore persisted recents after mount (keeps SSR markup stable) */
  React.useEffect(() => {
    if (!storageKey) return;
    try {
      const saved = JSON.parse(localStorage.getItem(storageKey) ?? "null") as unknown;
      if (Array.isArray(saved)) setRecent(saved.filter((x): x is string => typeof x === "string"));
    } catch {
      /* storage unavailable */
    }
  }, [storageKey]);
  const [asyncResult, setAsyncResult] = React.useState<{ query: string; items: CommandItem[] } | null>(null);
  const [pulse, setPulse] = React.useState<string | null>(null);

  const inputRef = React.useRef<HTMLInputElement>(null);
  const listRef = React.useRef<HTMLDivElement>(null);
  const restoreRef = React.useRef<HTMLElement | null>(null);

  const page = pages[pages.length - 1];
  const sourceGroups = page ? page.groups : groups;
  const trimmed = query.trim();
  const asyncOn = !!asyncSearch && !page && trimmed.length >= 2;

  /* all items reachable from root (for recents lookup) */
  const byId = React.useMemo(() => {
    const m = new Map<string, CommandItem>();
    const walk = (gs: CommandGroup[]) =>
      gs.forEach((g) =>
        g.items.forEach((it) => {
          m.set(it.id, it);
          if (it.page) walk(it.page.groups);
        }),
      );
    walk(groups);
    return m;
  }, [groups]);

  /* results */
  const results: ResultGroup[] = React.useMemo(() => {
    const out: ResultGroup[] = [];
    if (!trimmed && !page && recent.length) {
      const rows = recent
        .map((id) => byId.get(id))
        .filter((x): x is CommandItem => !!x)
        .slice(0, recentLimit)
        .map((item) => ({ item, group: "recent", match: { score: 0, indices: [] } }));
      if (rows.length) out.push({ id: "recent", heading: "Recent", rows });
    }
    for (const g of sourceGroups) {
      const rows: Row[] = [];
      for (const item of g.items) {
        if (!trimmed) {
          rows.push({ item, group: g.id, match: { score: 0, indices: [] } });
          continue;
        }
        const m = fuzzyMatch(trimmed, item.label);
        const kw = !m && item.keywords?.some((k) => fuzzyMatch(trimmed, k));
        if (m) rows.push({ item, group: g.id, match: m });
        else if (kw) rows.push({ item, group: g.id, match: { score: 1, indices: [] } });
      }
      if (trimmed) rows.sort((a, b) => b.match.score - a.match.score);
      if (rows.length) out.push({ id: g.id, heading: g.heading, rows });
    }
    if (trimmed) out.sort((a, b) => (b.rows[0]?.match.score ?? 0) - (a.rows[0]?.match.score ?? 0));
    if (asyncOn) {
      const ready = asyncResult?.query === trimmed;
      out.push({
        id: "async",
        heading: asyncHeading,
        loading: !ready,
        rows: ready
          ? asyncResult.items.map((item) => ({ item, group: "async", match: fuzzyMatch(trimmed, item.label) ?? { score: 0, indices: [] } }))
          : [],
      });
    }
    return out;
  }, [trimmed, page, recent, byId, recentLimit, sourceGroups, asyncOn, asyncResult, asyncHeading]);

  const flat = React.useMemo(() => results.flatMap((g) => g.rows.filter((r) => !r.item.disabled)), [results]);
  const rowKey = (r: Row) => `${r.group}:${r.item.id}`;
  const active = flat.find((r) => rowKey(r) === activeId) ?? flat[0];
  const activeKey = active ? rowKey(active) : null;
  const optionId = (key: string) => `${uid}-opt-${key.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
  const hasAnyRows = results.some((g) => g.rows.length);
  const loadingAsync = results.some((g) => g.loading);

  /* async search (state only written from async callbacks) */
  React.useEffect(() => {
    if (!asyncOn || !asyncSearch) return;
    const ctrl = new AbortController();
    const t = window.setTimeout(() => {
      asyncSearch(trimmed, ctrl.signal)
        .then((items) => {
          if (!ctrl.signal.aborted) setAsyncResult({ query: trimmed, items });
        })
        .catch(() => {
          if (!ctrl.signal.aborted) setAsyncResult({ query: trimmed, items: [] });
        });
    }, 220);
    return () => {
      ctrl.abort();
      window.clearTimeout(t);
    };
  }, [asyncOn, asyncSearch, trimmed]);

  /* global hotkey */
  React.useEffect(() => {
    if (!hotkey) return;
    const onKey = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === hotkey.toLowerCase()) {
        e.preventDefault();
        if (!open) {
          restoreRef.current = document.activeElement as HTMLElement | null;
          setPages([]);
          setQuery("");
          setActiveId(null);
        }
        setOpen(!open);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [hotkey, open, setOpen]);

  /* focus management + scroll lock */
  React.useEffect(() => {
    if (!open) return;
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    const id = window.requestAnimationFrame(() => inputRef.current?.focus());
    return () => {
      window.cancelAnimationFrame(id);
      document.body.style.overflow = prev;
    };
  }, [open]);

  /* keep active option visible */
  React.useEffect(() => {
    if (!activeKey) return;
    document.getElementById(optionId(activeKey))?.scrollIntoView({ block: "nearest" });
    // eslint-disable-next-line react-hooks/exhaustive-deps -- optionId is derived from uid
  }, [activeKey]);

  const show = () => {
    restoreRef.current = document.activeElement as HTMLElement | null;
    setPages([]);
    setQuery("");
    setActiveId(null);
    setOpen(true);
  };
  const hide = () => {
    setOpen(false);
    const el = restoreRef.current;
    window.setTimeout(() => el?.focus?.(), 0);
  };

  const pushPage = (p: CommandPage) => {
    setPageDir(1);
    setPages((s) => [...s, p]);
    setQuery("");
    setActiveId(null);
  };
  const popPage = () => {
    setPageDir(-1);
    setPages((s) => s.slice(0, -1));
    setQuery("");
    setActiveId(null);
  };

  const run = (row: Row) => {
    const { item } = row;
    if (item.disabled) return;
    if (item.page) return pushPage(item.page);
    setPulse(rowKey(row));
    setRecent((r) => {
      const next = [item.id, ...r.filter((x) => x !== item.id)].slice(0, 8);
      if (storageKey) {
        try {
          localStorage.setItem(storageKey, JSON.stringify(next));
        } catch {
          /* storage unavailable */
        }
      }
      return next;
    });
    item.onSelect?.();
    onSelect?.(item);
    window.setTimeout(() => {
      setPulse(null);
      hide();
    }, reduce ? 0 : 140);
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    const i = active ? flat.indexOf(active) : -1;
    const go = (n: number) => {
      if (!flat.length) return;
      setActiveId(rowKey(flat[(n + flat.length) % flat.length]));
    };
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        go(i + 1);
        break;
      case "ArrowUp":
        e.preventDefault();
        go(i - 1);
        break;
      case "Home":
        if (!query) {
          e.preventDefault();
          go(0);
        }
        break;
      case "End":
        if (!query) {
          e.preventDefault();
          go(flat.length - 1);
        }
        break;
      case "PageDown":
        e.preventDefault();
        go(Math.min(flat.length - 1, i + 5));
        break;
      case "PageUp":
        e.preventDefault();
        go(Math.max(0, i - 5));
        break;
      case "Enter":
        e.preventDefault();
        if (active) run(active);
        break;
      case "ArrowRight":
        if (active?.item.page && e.currentTarget.selectionStart === query.length) {
          e.preventDefault();
          pushPage(active.item.page);
        }
        break;
      case "Backspace":
        if (!query && pages.length) {
          e.preventDefault();
          popPage();
        }
        break;
      case "Escape":
        e.preventDefault();
        e.stopPropagation();
        if (query) setQuery("");
        else if (pages.length) popPage();
        else hide();
        break;
      case "Tab":
        e.preventDefault(); // focus stays in the palette (options are driven by aria-activedescendant)
        break;
    }
  };

  const mod = isMac ? "⌘" : "Ctrl";

  return (
    <>
      {showTrigger && (
        <button
          type="button"
          onClick={show}
          aria-haspopup="dialog"
          aria-expanded={open}
          className={cn(
            "group inline-flex h-10 w-full max-w-xs items-center gap-2 rounded-xl border bg-background pl-3 pr-2 text-sm text-muted-foreground shadow-xs outline-none transition hover:border-foreground/20 hover:text-foreground focus-visible:ring-4 focus-visible:ring-ring/20",
            className,
          )}
        >
          <Search className="size-4" aria-hidden />
          <span className="flex-1 text-left">Search or jump to…</span>
          <span className="flex gap-1" aria-hidden>
            <Kbd>{mod}</Kbd>
            {hotkey && <Kbd>{hotkey.toUpperCase()}</Kbd>}
          </span>
        </button>
      )}

      <AnimatePresence>
        {open && (
          <div className="fixed inset-0 z-50" onKeyDown={(e) => e.key === "Escape" && hide()}>
            <motion.div
              aria-hidden
              className="absolute inset-0 bg-background/60 backdrop-blur-[3px] dark:bg-black/55"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.18 }}
              onClick={hide}
            />
            <motion.div
              role="dialog"
              aria-modal="true"
              aria-label="Command palette"
              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -12 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: -8, transition: { duration: 0.14 } }}
              transition={{ type: "spring", stiffness: 480, damping: 34 }}
              className="absolute inset-x-0 top-[max(1rem,10vh)] mx-auto flex max-h-[min(560px,calc(100dvh-max(1rem,10vh)-1rem))] w-[min(640px,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-2xl shadow-black/15 ring-1 ring-black/5 dark:shadow-black/60 dark:ring-white/5"
            >
              {/* input row */}
              <div className="flex items-center gap-2 border-b px-4">
                {loadingAsync ? (
                  <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
                ) : (
                  <Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
                )}
                <AnimatePresence initial={false}>
                  {pages.map((p) => (
                    <motion.button
                      key={p.id}
                      type="button"
                      tabIndex={-1}
                      layout
                      initial={{ opacity: 0, scale: 0.8 }}
                      animate={{ opacity: 1, scale: 1 }}
                      exit={{ opacity: 0, scale: 0.8 }}
                      onClick={popPage}
                      className="shrink-0 rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-foreground"
                      aria-label={`${p.title} (press Backspace to go back)`}
                    >
                      {p.title}
                    </motion.button>
                  ))}
                </AnimatePresence>
                <input
                  ref={inputRef}
                  role="combobox"
                  aria-expanded
                  aria-controls={`${uid}-list`}
                  aria-autocomplete="list"
                  aria-activedescendant={activeKey ? optionId(activeKey) : undefined}
                  aria-label={page ? page.title : "Search commands"}
                  value={query}
                  onChange={(e) => {
                    setQuery(e.target.value);
                    setActiveId(null);
                  }}
                  onKeyDown={onKeyDown}
                  placeholder={page?.placeholder ?? placeholder}
                  spellCheck={false}
                  autoComplete="off"
                  className="h-14 min-w-0 flex-1 bg-transparent text-[15px] text-foreground outline-none placeholder:text-muted-foreground/70"
                />
                <Kbd className="hidden sm:inline-flex">esc</Kbd>
              </div>

              {/* results */}
              <div ref={listRef} id={`${uid}-list`} role="listbox" tabIndex={-1} aria-label="Commands" className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain p-2 [scrollbar-width:thin]">
                <AnimatePresence mode="popLayout" initial={false} custom={pageDir}>
                  <motion.div
                    key={page?.id ?? "root"}
                    custom={pageDir}
                    variants={{
                      enter: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * 24 }),
                      center: { opacity: 1, x: 0 },
                      exit: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * -24 }),
                    }}
                    initial="enter"
                    animate="center"
                    exit="exit"
                    transition={{ type: "spring", stiffness: 500, damping: 40 }}
                  >
                    {results.map((g) => (
                      <div key={g.id} role="group" aria-labelledby={`${uid}-g-${g.id}`} className="mb-1 last:mb-0">
                        <div id={`${uid}-g-${g.id}`} className="px-2.5 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/80">
                          {g.heading}
                        </div>
                        {g.loading &&
                          [0, 1].map((i) => (
                            <div key={i} className="flex h-10 items-center gap-3 px-2.5" aria-hidden>
                              <span className="size-5 animate-pulse rounded-md bg-muted" />
                              <span className="h-3 animate-pulse rounded bg-muted" style={{ width: `${48 - i * 14}%` }} />
                            </div>
                          ))}
                        {g.rows.map((row) => {
                          const k = rowKey(row);
                          const isActive = k === activeKey;
                          const Icon = row.item.icon;
                          return (
                            <div
                              key={k}
                              id={optionId(k)}
                              role="option"
                              aria-selected={isActive}
                              aria-disabled={row.item.disabled || undefined}
                              onPointerMove={() => !row.item.disabled && activeId !== k && setActiveId(k)}
                              onClick={() => run(row)}
                              className={cn(
                                "relative flex h-10 cursor-pointer select-none items-center gap-3 rounded-lg px-2.5 text-sm",
                                row.item.disabled ? "cursor-not-allowed opacity-40" : isActive ? "text-foreground" : "text-foreground/80",
                              )}
                            >
                              {isActive && (
                                <motion.span
                                  layoutId={`${uid}-active`}
                                  className="absolute inset-0 rounded-lg bg-accent dark:bg-accent/70"
                                  transition={{ type: "spring", stiffness: 700, damping: 45 }}
                                />
                              )}
                              {pulse === k && (
                                <motion.span
                                  className="absolute inset-0 rounded-lg bg-primary/20"
                                  initial={{ opacity: 1, scale: 0.98 }}
                                  animate={{ opacity: 0, scale: 1.02 }}
                                  transition={{ duration: 0.3 }}
                                />
                              )}
                              <span className="relative grid size-6 shrink-0 place-items-center rounded-md border bg-background text-muted-foreground shadow-xs">
                                {Icon ? <Icon className="size-3.5" aria-hidden /> : <Search className="size-3.5" aria-hidden />}
                              </span>
                              <span className="relative min-w-0 flex-1 truncate">
                                <Highlight text={row.item.label} indices={row.match.indices} />
                              </span>
                              {row.item.hint && <span className="relative hidden truncate text-xs text-muted-foreground sm:block">{row.item.hint}</span>}
                              {row.item.shortcut && (
                                <span className="relative hidden gap-1 sm:flex" aria-label={`Shortcut ${row.item.shortcut.join(" ")}`}>
                                  {row.item.shortcut.map((s) => (
                                    <Kbd key={s}>{s}</Kbd>
                                  ))}
                                </span>
                              )}
                              {row.item.page && <ChevronRight className="relative size-4 text-muted-foreground" aria-hidden />}
                              {isActive && !row.item.page && (
                                <CornerDownLeft className="relative size-3.5 text-muted-foreground" aria-hidden />
                              )}
                            </div>
                          );
                        })}
                      </div>
                    ))}

                    {!hasAnyRows && !loadingAsync && (
                      <motion.div
                        initial={{ opacity: 0, y: 6 }}
                        animate={{ opacity: 1, y: 0 }}
                        className="flex flex-col items-center gap-2 px-6 py-12 text-center"
                        role="status"
                      >
                        <span className="grid size-11 place-items-center rounded-2xl border bg-muted/60">
                          <SearchX className="size-5 text-muted-foreground" aria-hidden />
                        </span>
                        <p className="text-sm font-medium">No results for “{trimmed}”</p>
                        <p className="max-w-xs text-xs text-muted-foreground">Try a different spelling, or fewer letters — matching is fuzzy, so “nwpr” finds “New project”.</p>
                      </motion.div>
                    )}
                  </motion.div>
                </AnimatePresence>
              </div>

              {/* footer */}
              <div className="flex items-center gap-4 border-t bg-muted/40 px-4 py-2 text-[11px] text-muted-foreground">
                <span className="flex items-center gap-1.5">
                  <Kbd>↑</Kbd>
                  <Kbd>↓</Kbd> navigate
                </span>
                <span className="flex items-center gap-1.5">
                  <Kbd>↵</Kbd> {active?.item.page ? "open" : "select"}
                </span>
                <span className="ml-auto hidden items-center gap-1.5 sm:flex">
                  {pages.length ? (
                    <>
                      <Kbd>⌫</Kbd> back
                    </>
                  ) : (
                    <>
                      <Kbd>esc</Kbd> close
                    </>
                  )}
                </span>
                <span className="sr-only" aria-live="polite">
                  {loadingAsync ? "Searching…" : trimmed ? `${flat.length} results` : ""}
                </span>
              </div>
            </motion.div>
          </div>
        )}
      </AnimatePresence>
    </>
  );
}

More in Navigation

View all →