"use client";
import * as React from "react";
export interface CommandItem {
/** Stable, unique id. */
id: string;
label: string;
/** Secondary text shown after the label. */
description?: string;
icon?: React.ReactNode;
/** Keys shown on the right, e.g. ["⌘", "N"]. Display only. */
shortcut?: string[];
/** Extra search terms that match without being highlighted. */
keywords?: string[];
disabled?: boolean;
/** Runs when the command is chosen. */
onSelect?: () => void;
}
export interface CommandGroup {
heading: string;
items: CommandItem[];
}
export interface CommandMenuProps {
groups: CommandGroup[];
/** "dialog" opens a modal palette; "inline" renders the palette in place. */
variant?: "dialog" | "inline";
/** Open state (controlled, dialog variant). */
open?: boolean;
/** Initial open state (uncontrolled, dialog variant). */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Called after any command runs. */
onSelect?: (item: CommandItem) => void;
/** Key pressed with ⌘ / Ctrl to toggle the dialog (or focus the inline input). */
hotkey?: string;
disableHotkey?: boolean;
placeholder?: string;
emptyMessage?: string;
/** Accessible name of the palette. */
label?: string;
/** Accent colour for highlights, the active row marker and focus ring. */
accent?: string;
/** Highlight the matched characters in labels. */
highlightMatches?: boolean;
/** Close the dialog after a command runs. */
closeOnSelect?: boolean;
/** Show keyboard shortcut hints on the right of each row. */
showShortcuts?: boolean;
/** Maximum height of the results list in px. */
maxListHeight?: number;
className?: string;
}
/* ---------------------------------- search --------------------------------- */
interface Match {
score: number;
/** Indexes of matched characters in the label, for highlighting. */
indices: number[];
}
const isBoundary = (text: string, i: number) => i === 0 || /[\s\-_/.:]/.test(text[i - 1]);
/**
* Fuzzy-ish matching: a contiguous substring wins (more so at a word start); otherwise every
* query character must appear in order, rewarding consecutive runs and word starts.
*/
function fuzzyMatch(text: string, query: string): Match | null {
const haystack = text.toLowerCase();
const needle = query.toLowerCase().replace(/\s+/g, " ").trim();
if (!needle) return { score: 0, indices: [] };
const at = haystack.indexOf(needle);
if (at !== -1) {
return {
score: 1000 - at + (isBoundary(haystack, at) ? 200 : 0),
indices: Array.from({ length: needle.length }, (_, i) => at + i),
};
}
const indices: number[] = [];
let from = 0;
let score = 0;
for (const char of needle) {
if (char === " ") continue;
const found = haystack.indexOf(char, from);
if (found === -1) return null;
score += found === indices[indices.length - 1] + 1 ? 12 : 1;
if (isBoundary(haystack, found)) score += 8;
indices.push(found);
from = found + 1;
}
// Prefer tight matches over scattered ones.
return { score: score - (indices[indices.length - 1] - indices[0]) * 0.5, indices };
}
function matchItem(item: CommandItem, query: string): Match | null {
const label = fuzzyMatch(item.label, query);
if (label) return label;
const keywords = item.keywords?.join(" ");
const keyword = keywords ? fuzzyMatch(keywords, query) : null;
return keyword ? { score: keyword.score * 0.5, indices: [] } : null;
}
function Highlight({ text, indices }: { text: string; indices: number[] }) {
if (!indices.length) return <>{text}</>;
const marked = new Set(indices);
const parts: { text: string; match: boolean }[] = [];
for (let i = 0; i < text.length; i++) {
const match = marked.has(i);
const last = parts[parts.length - 1];
if (last && last.match === match) last.text += text[i];
else parts.push({ text: text[i], match });
}
return (
<>
{parts.map((part, i) =>
part.match ? (
<mark
key={i}
className="rounded-[3px] bg-[color-mix(in_oklab,var(--cm-accent)_24%,transparent)] text-inherit"
>
{part.text}
</mark>
) : (
<React.Fragment key={i}>{part.text}</React.Fragment>
),
)}
</>
);
}
/* -------------------------------- component -------------------------------- */
const domId = (value: string) => value.replace(/[^\w-]/g, "_");
/**
* A searchable command palette with grouped commands, fuzzy filtering and match highlighting.
* The dialog variant uses a native modal <dialog> (top layer, inert background, focus return).
*/
export function CommandMenu({
groups,
variant = "dialog",
open,
defaultOpen = false,
onOpenChange,
onSelect,
hotkey = "k",
disableHotkey = false,
placeholder = "Type a command or search…",
emptyMessage = "No matching commands.",
label = "Command menu",
accent = "#FF7448",
highlightMatches = true,
closeOnSelect = true,
showShortcuts = true,
maxListHeight = 320,
className = "",
}: CommandMenuProps) {
const id = React.useId();
const listId = `${id}-list`;
const dialogRef = React.useRef<HTMLDialogElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const returnFocusRef = React.useRef<HTMLElement | null>(null);
const [query, setQuery] = React.useState("");
const [activeIndex, setActiveIndex] = React.useState(0);
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const isDialog = variant === "dialog";
const isOpen = isDialog ? (open ?? internalOpen) : true;
const setOpen = (next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
};
/* ----------------------------- filtered results ---------------------------- */
const searching = query.trim() !== "";
const matchedGroups = groups
.map((group, g) => {
const entries = group.items
.map((item) => ({ item, match: matchItem(item, query) }))
.filter((entry): entry is { item: CommandItem; match: Match } => entry.match !== null);
if (searching) entries.sort((a, b) => b.match.score - a.match.score);
return { heading: group.heading, headingId: `${id}-group-${g}`, entries, best: entries[0]?.match.score ?? 0 };
})
.filter((group) => group.entries.length > 0);
// While searching, the group holding the best match comes first.
if (searching) matchedGroups.sort((a, b) => b.best - a.best);
const flat: CommandItem[] = [];
const results = matchedGroups.map((group) => ({
...group,
entries: group.entries.map((entry) => ({ ...entry, index: flat.push(entry.item) - 1 })),
}));
const active = flat.length ? Math.min(activeIndex, flat.length - 1) : -1;
const activeItem = active >= 0 ? flat[active] : undefined;
const optionId = (item: CommandItem) => `${id}-option-${domId(item.id)}`;
/* --------------------------------- effects --------------------------------- */
// Open / close the native dialog in sync with state.
React.useEffect(() => {
const dialog = dialogRef.current;
if (!isDialog || !dialog) return;
if (isOpen && !dialog.open) {
returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
dialog.showModal();
inputRef.current?.focus();
} else if (!isOpen && dialog.open) {
dialog.close();
}
}, [isOpen, isDialog]);
// Keep the active option visible by scrolling the list only (scrollIntoView could scroll the page).
React.useEffect(() => {
const list = listRef.current;
if (!activeItem || !list) return;
const option = list.querySelector<HTMLElement>(`[data-command-id="${CSS.escape(activeItem.id)}"]`);
if (!option) return;
const listRect = list.getBoundingClientRect();
const optionRect = option.getBoundingClientRect();
// Convert from screen px to layout px in case an ancestor is scaled.
const scale = list.offsetHeight ? listRect.height / list.offsetHeight : 1;
const pad = 8 * scale;
if (optionRect.top < listRect.top + pad) list.scrollTop -= (listRect.top + pad - optionRect.top) / scale;
else if (optionRect.bottom > listRect.bottom - pad) list.scrollTop += (optionRect.bottom - listRect.bottom + pad) / scale;
}, [activeItem]);
const toggleFromHotkey = React.useEffectEvent(() => {
if (isDialog) setOpen(!isOpen);
else inputRef.current?.focus();
});
React.useEffect(() => {
if (disableHotkey || !hotkey) return;
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
if (event.key.toLowerCase() !== hotkey.toLowerCase()) return;
event.preventDefault();
toggleFromHotkey();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [hotkey, disableHotkey]);
/* --------------------------------- actions --------------------------------- */
const run = (item: CommandItem | undefined) => {
if (!item || item.disabled) return;
item.onSelect?.();
onSelect?.(item);
if (isDialog && closeOnSelect) setOpen(false);
};
const move = (step: 1 | -1) => {
if (!flat.length) return;
let next = active;
// Walk past disabled commands, wrapping at both ends.
for (let i = 0; i < flat.length; i++) {
next = (next + step + flat.length) % flat.length;
if (!flat[next].disabled) break;
}
setActiveIndex(next);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
move(1);
break;
case "ArrowUp":
event.preventDefault();
move(-1);
break;
case "Enter":
event.preventDefault();
run(activeItem);
break;
case "Escape":
// First Escape clears the query; the next one closes the dialog.
if (query) {
event.preventDefault();
event.stopPropagation();
setQuery("");
setActiveIndex(0);
} else if (isDialog) {
event.preventDefault();
setOpen(false);
}
break;
}
};
/* --------------------------------- render --------------------------------- */
const panel = (
<div
className={`flex w-full max-w-[560px] flex-col overflow-hidden rounded-2xl border border-zinc-900/10 bg-white text-left text-zinc-900 shadow-[0_24px_64px_-24px_rgba(0,0,0,0.35),0_2px_6px_-2px_rgba(0,0,0,0.08)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:shadow-[0_24px_64px_-24px_rgba(0,0,0,0.9)] ${
isDialog
? "transition-[opacity,translate,scale] duration-200 ease-out motion-reduce:transition-none starting:-translate-y-2 starting:scale-[0.98] starting:opacity-0"
: ""
} ${className}`}
style={{ ["--cm-accent" as string]: accent }}
>
<div className="flex items-center gap-3 border-b border-zinc-900/[0.08] px-4 dark:border-white/[0.08]">
<svg viewBox="0 0 16 16" className="h-4 w-4 shrink-0 text-zinc-400" aria-hidden="true">
<circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path d="m10.5 10.5 3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<input
ref={inputRef}
type="text"
role="combobox"
aria-label={label}
aria-expanded={isOpen}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={activeItem ? optionId(activeItem) : undefined}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
value={query}
placeholder={placeholder}
onChange={(event) => {
setQuery(event.target.value);
setActiveIndex(0);
}}
onKeyDown={handleKeyDown}
className="h-12 min-w-0 flex-1 bg-transparent text-[15px] tracking-tight text-zinc-950 placeholder:text-zinc-400 focus:outline-none dark:text-zinc-50 dark:placeholder:text-zinc-500"
/>
{isDialog && (
<kbd className="rounded border border-zinc-900/10 px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:border-white/10 dark:text-zinc-400">
Esc
</kbd>
)}
</div>
<div
ref={listRef}
id={listId}
role="listbox"
aria-label={label}
className="scroll-py-2 overflow-y-auto overscroll-contain p-2"
style={{ maxHeight: maxListHeight }}
>
{results.map((group) => (
<div key={group.headingId} role="group" aria-labelledby={group.headingId} className="pb-1">
<div
id={group.headingId}
className="px-2.5 pb-1.5 pt-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400"
>
{group.heading}
</div>
{group.entries.map(({ item, match, index }) => {
const isActive = index === active;
return (
<div
key={item.id}
id={optionId(item)}
data-command-id={item.id}
role="option"
aria-selected={isActive}
aria-disabled={item.disabled || undefined}
data-active={isActive || undefined}
onPointerMove={() => {
if (!item.disabled && index !== active) setActiveIndex(index);
}}
// Keep focus in the input when clicking an option.
onMouseDown={(event) => event.preventDefault()}
onClick={() => run(item)}
className="group/option relative flex h-11 cursor-pointer select-none items-center gap-3 rounded-lg px-2.5 text-sm data-[active]:bg-zinc-900/[0.05] aria-disabled:cursor-not-allowed aria-disabled:opacity-40 dark:data-[active]:bg-white/[0.06]"
>
<span
aria-hidden="true"
className="absolute bottom-2.5 left-0 top-2.5 w-[2px] rounded-full bg-[var(--cm-accent)] opacity-0 transition-opacity group-data-[active]/option:opacity-100"
/>
{item.icon && (
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-zinc-900/10 bg-zinc-50 text-zinc-600 group-data-[active]/option:text-zinc-950 dark:border-white/10 dark:bg-white/[0.04] dark:text-zinc-400 dark:group-data-[active]/option:text-white [&_svg]:h-3.5 [&_svg]:w-3.5"
>
{item.icon}
</span>
)}
<span className="min-w-0 truncate">
<span className="font-medium tracking-tight">
{highlightMatches ? <Highlight text={item.label} indices={match.indices} /> : item.label}
</span>
{item.description && (
<span className="ml-2 text-zinc-500 dark:text-zinc-400">{item.description}</span>
)}
</span>
{showShortcuts && item.shortcut && (
<span aria-hidden="true" className="ml-auto flex shrink-0 gap-1">
{item.shortcut.map((key, k) => (
<kbd
key={k}
className="flex h-5 min-w-5 items-center justify-center rounded border border-zinc-900/10 bg-white px-1 font-mono text-[10px] text-zinc-500 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-400"
>
{key}
</kbd>
))}
</span>
)}
</div>
);
})}
</div>
))}
</div>
{!flat.length && (
<p className="px-4 pb-8 pt-4 text-center text-sm text-zinc-500 dark:text-zinc-400">{emptyMessage}</p>
)}
{/* Announces the number of results as the query changes. */}
<div role="status" aria-live="polite" className="sr-only">
{query ? `${flat.length} ${flat.length === 1 ? "result" : "results"}` : ""}
</div>
<div className="flex items-center gap-4 border-t border-zinc-900/[0.08] px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:border-white/[0.08] dark:text-zinc-400">
<span>↑↓ Navigate</span>
<span>↵ Run</span>
{isDialog && <span>Esc Close</span>}
</div>
</div>
);
if (!isDialog) return panel;
return (
<dialog
ref={dialogRef}
aria-label={label}
onCancel={(event) => {
// Escape outside the input: keep React state as the source of truth.
event.preventDefault();
setOpen(false);
}}
onClose={() => {
setQuery("");
setActiveIndex(0);
returnFocusRef.current?.focus();
returnFocusRef.current = null;
}}
className="m-0 h-full max-h-none w-full max-w-none overflow-hidden border-0 bg-transparent p-0 backdrop:bg-zinc-950/40 backdrop:backdrop-blur-[2px]"
>
<div
className="flex h-full w-full items-start justify-center px-4 pt-[12vh]"
onClick={(event) => {
// Clicks on the overlay (outside the panel) close the palette.
if (event.target === event.currentTarget) setOpen(false);
}}
>
{panel}
</div>
</dialog>
);
}