Files
route-commerce/src/components/admin/CommandPalette.tsx
T

458 lines
14 KiB
TypeScript

"use client";
/**
* CommandPalette — Cmd+K / Ctrl+K global quick-launcher for the admin.
*
* Behavior:
* - Toggled with Cmd+K (mac) or Ctrl+K (win/linux) on `document`.
* - Closes on Escape, on backdrop click, or after a navigation.
* - Fuzzy case-insensitive match on `label` / `category` / `keywords`.
* - Top 8 results. Renders null when closed (no DOM noise).
* - Keyboard: ↑/↓ to move, Enter to select, type to filter.
*
* Self-contained: does not read from the sidebar, does not depend on any
* server data. The static list lives in `./command-palette-data.ts`.
*
* The component is intentionally NOT mounted in the admin layout by this
* file. The main thread / sidebar agent wires it in once it's reviewed.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
BarChart3,
Clock,
Command,
CreditCard,
Droplets,
FileText,
LayoutDashboard,
LucideIcon,
Map as MapIcon,
MapPin,
Megaphone,
Package,
Plug,
PlusCircle,
Puzzle,
Receipt,
RefreshCw,
Route,
ScrollText,
Search,
Send,
Settings,
ShoppingCart,
Sparkles,
Store,
Tag,
Truck,
Upload,
UserPlus,
Users,
Wallet,
} from "lucide-react";
import { PALETTE_ENTRIES, type PaletteEntry } from "./command-palette-data";
const MAX_RESULTS = 8;
// ---------------------------------------------------------------------------
// Icon registry
// ---------------------------------------------------------------------------
const ICON_MAP: Record<string, LucideIcon> = {
// Pages
LayoutDashboard,
Command,
ShoppingCart,
MapPin,
Package,
Truck,
Send,
Megaphone,
FileText,
Users,
ScrollText,
Store,
Upload,
Sparkles,
Clock,
Droplets,
Route,
BarChart3,
Receipt,
Settings,
Tag,
CreditCard,
Plug,
Wallet,
Puzzle,
// Quick actions
PlusCircle,
UserPlus,
RefreshCw,
// Misc / fallbacks
Search,
Map: MapIcon,
};
// ---------------------------------------------------------------------------
// Filtering / scoring
// ---------------------------------------------------------------------------
function scoreEntry(entry: PaletteEntry, query: string): number {
if (!query) return 1;
const q = query.toLowerCase();
const label = entry.label.toLowerCase();
const category = entry.category.toLowerCase();
let score = 0;
if (label === q) score += 100;
else if (label.startsWith(q)) score += 50;
else if (label.includes(q)) score += 25;
if (category.toLowerCase().includes(q)) score += 10;
if (entry.keywords?.some((k) => k.toLowerCase().includes(q))) score += 5;
// Quick actions get a small boost when no specific match (so they surface
// on the empty query list, alongside the first batch of pages).
if (!query && entry.type === "action") score = 0.5;
if (!query && entry.type === "page") score = 1 - entry.label.length * 0.001;
return score;
}
function filterEntries(query: string): PaletteEntry[] {
const scored: { entry: PaletteEntry; score: number }[] = [];
for (const e of PALETTE_ENTRIES) {
const score = scoreEntry(e, query);
if (score > 0) {
scored.push({ entry: e, score });
}
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, MAX_RESULTS).map((r) => r.entry);
}
// ---------------------------------------------------------------------------
// Highlight helper
// ---------------------------------------------------------------------------
function HighlightedText({ text, query }: { text: string; query: string }) {
if (!query) return <>{text}</>;
const q = query.toLowerCase();
const idx = text.toLowerCase().indexOf(q);
if (idx === -1) return <>{text}</>;
return (
<>
{text.slice(0, idx)}
<mark
style={{
background: "transparent",
color: "var(--admin-primary)",
fontWeight: 600,
padding: 0,
}}
>
{text.slice(idx, idx + query.length)}
</mark>
{text.slice(idx + query.length)}
</>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function CommandPalette() {
const [open, setOpen] = useState(false);
const [toggleCount, setToggleCount] = useState(0);
// Open/close on Cmd+K / Ctrl+K. Toggle so the same shortcut closes it.
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((o) => !o);
setToggleCount((c) => c + 1);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
if (!open) return null;
return <CommandPaletteDialog key={toggleCount} onClose={() => setOpen(false)} />;
}
function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const [query, setQuery] = useState("");
const [selected, setSelected] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
const router = useRouter();
// Native <dialog> handles focus trapping, ESC, and the backdrop.
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const raf = requestAnimationFrame(() => inputRef.current?.focus());
const onCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", onCancel);
return () => {
cancelAnimationFrame(raf);
dialog.removeEventListener("cancel", onCancel);
};
}, [onClose]);
// Filtering
const results = useMemo(() => filterEntries(query), [query]);
// Always render a valid selection index (clamped to results length).
const safeSelected =
results.length === 0 ? 0 : Math.min(selected, results.length - 1);
const navigate = useCallback(
(href: string) => {
onClose();
router.push(href);
},
[router, onClose]
);
// In-input keyboard nav. Attached to the input itself so it works whether
// the user is typing or the input just has focus.
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (results.length === 0) return;
setSelected((s) => (s + 1) % results.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (results.length === 0) return;
setSelected((s) => (s - 1 + results.length) % results.length);
} else if (e.key === "Enter") {
e.preventDefault();
const entry = results[safeSelected];
if (entry) navigate(entry.href);
}
};
return (
<dialog
ref={dialogRef}
aria-label="Command palette"
className="m-0 p-0 w-full h-full max-w-none max-h-full bg-transparent"
style={{
backgroundColor: "transparent",
animation: "cp-fade-in 180ms ease-out",
}}
>
<style>{`
@keyframes cp-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes cp-scale-in {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
@keyframes cp-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes cp-scale-in { from { opacity: 0; } to { opacity: 1; } }
}
.cp-backdrop {
position: fixed;
inset: 0;
z-index: 60;
background-color: rgba(26, 24, 20, 0.4);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
}
.cp-panel {
width: 100%;
max-width: 36rem;
margin: 0 1rem;
background-color: var(--admin-card-bg);
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius-lg);
box-shadow: var(--admin-shadow-lg);
overflow: hidden;
animation: cp-scale-in 180ms ease-out;
display: flex;
flex-direction: column;
}
.cp-result-btn {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.75rem;
border-radius: var(--admin-radius-md);
border: none;
cursor: pointer;
text-align: left;
font-size: 0.875rem;
line-height: 1.3;
font-family: inherit;
transition: background-color 80ms ease-out, color 80ms ease-out;
}
.cp-result-btn[data-selected="true"] {
background: var(--admin-primary-soft);
color: var(--admin-primary);
}
.cp-result-btn[data-selected="false"] {
background: transparent;
color: var(--admin-text-primary);
}
`}</style>
<div className="cp-backdrop">
<div className="cp-panel">
{/* Search input row */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.875rem 1rem",
borderBottom: "1px solid var(--admin-border)",
}}
>
<Search
size={18}
style={{ color: "var(--admin-text-muted)", flexShrink: 0 }}
aria-hidden="true"
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder="Search pages, actions, anything…"
aria-label="Search command palette"
spellCheck={false}
autoComplete="off"
className="flex-1 min-w-0 border-none outline-none focus-visible:outline-2 focus-visible:outline-[var(--color-accent)] focus-visible:outline-offset-2 bg-transparent text-base"
style={{
lineHeight: 1.4,
color: "var(--admin-text-primary)",
fontFamily: "inherit",
}}
// Restore the focus ring only for keyboard users. Mouse clicks
// hide it; tabbing into the input keeps a visible outline.
onFocus={(e) => {
e.currentTarget.style.outline = "2px solid var(--admin-accent)";
e.currentTarget.style.outlineOffset = "2px";
}}
onBlur={(e) => {
e.currentTarget.style.outline = "none";
e.currentTarget.style.outlineOffset = "0";
}}
/>
</div>
{/* Results */}
<ul
aria-label="Results"
className="list-none p-0 m-0"
style={{
maxHeight: "60vh",
overflowY: "auto",
padding: "0.375rem",
}}
>
{results.length === 0 ? (
<div
style={{
padding: "2rem 1rem",
textAlign: "center",
color: "var(--admin-text-muted)",
fontSize: "0.875rem",
}}
>
No results for &ldquo;{query}&rdquo;
</div>
) : (
results.map((entry, i) => {
const Icon = ICON_MAP[entry.iconName] ?? Search;
const isSelected = i === safeSelected;
return (
<li key={entry.id} className="list-none">
<button
type="button"
aria-current={isSelected ? "true" : undefined}
onClick={() => navigate(entry.href)}
onMouseEnter={() => setSelected(i)}
data-selected={isSelected ? "true" : "false"}
className="cp-result-btn"
>
<Icon
size={16}
style={{
flexShrink: 0,
opacity: isSelected ? 1 : 0.7,
}}
aria-hidden="true"
/>
<span
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: 500,
}}
>
<HighlightedText text={entry.label} query={query} />
</span>
<span
style={{
fontSize: "0.75rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--admin-text-muted)",
fontWeight: 500,
flexShrink: 0,
}}
>
{entry.category}
</span>
</button>
</li>
);
})
)}
</ul>
{/* Footer hint */}
<div
style={{
padding: "0.5rem 1rem 0.625rem",
borderTop: "1px solid var(--admin-border)",
fontSize: "0.75rem",
color: "var(--admin-text-muted)",
textAlign: "center",
letterSpacing: "0.02em",
userSelect: "none",
}}
>
to navigate &nbsp;·&nbsp; to select &nbsp;·&nbsp; esc to close
</div>
</div>
</div>
</dialog>
);
}