79 lines
2.7 KiB
TypeScript
Executable File
79 lines
2.7 KiB
TypeScript
Executable File
import * as React from "react";
|
|
import { Command } from "cmdk";
|
|
import { Search } from "lucide-react";
|
|
import { cn } from "../lib/cn";
|
|
|
|
export interface CommandAction {
|
|
id: string;
|
|
label: string;
|
|
group?: string;
|
|
icon?: React.ReactNode;
|
|
onSelect: () => void;
|
|
}
|
|
|
|
/** ⌘K palette. Opens on Cmd/Ctrl+K by default; big perceived-quality boost on any tool. */
|
|
export function CommandPalette({
|
|
actions,
|
|
placeholder = "Type a command or search…",
|
|
}: {
|
|
actions: CommandAction[];
|
|
placeholder?: string;
|
|
}) {
|
|
const [open, setOpen] = React.useState(false);
|
|
React.useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
setOpen((o) => !o);
|
|
}
|
|
};
|
|
document.addEventListener("keydown", onKey);
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
}, []);
|
|
|
|
const groups = React.useMemo(() => {
|
|
const map = new Map<string, CommandAction[]>();
|
|
for (const a of actions) {
|
|
const g = a.group ?? "Actions";
|
|
map.set(g, [...(map.get(g) ?? []), a]);
|
|
}
|
|
return [...map.entries()];
|
|
}, [actions]);
|
|
|
|
return (
|
|
<Command.Dialog
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
label="Command palette"
|
|
overlayClassName="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
|
|
contentClassName="fixed left-1/2 top-[18vh] z-50 w-[calc(100%-2rem)] max-w-xl -translate-x-1/2"
|
|
className={cn(
|
|
"overflow-hidden rounded-lg border border-border bg-surface shadow-lg",
|
|
"data-[state=open]:animate-scale-in"
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2 border-b border-border px-4">
|
|
<Search className="size-4 text-muted" />
|
|
<Command.Input placeholder={placeholder} className="h-12 flex-1 bg-transparent text-sm text-text outline-none placeholder:text-muted" />
|
|
</div>
|
|
<Command.List className="max-h-80 overflow-auto p-2">
|
|
<Command.Empty className="py-6 text-center text-sm text-muted">No results.</Command.Empty>
|
|
{groups.map(([group, items]) => (
|
|
<Command.Group key={group} heading={group} className="px-1 py-1 text-xs font-semibold uppercase tracking-wide text-muted [&_[cmdk-group-items]]:mt-1">
|
|
{items.map((a) => (
|
|
<Command.Item
|
|
key={a.id}
|
|
onSelect={() => { a.onSelect(); setOpen(false); }}
|
|
className="flex cursor-pointer items-center gap-2 rounded px-2 py-2 text-sm text-text aria-selected:bg-surface-2 [&_svg]:size-4"
|
|
>
|
|
{a.icon}
|
|
{a.label}
|
|
</Command.Item>
|
|
))}
|
|
</Command.Group>
|
|
))}
|
|
</Command.List>
|
|
</Command.Dialog>
|
|
);
|
|
}
|