f6bf91951e
- no-outline-none (2): add focus-visible outline to inline-styled inputs - no-redundant-roles (2): drop role="list" on <ul> - click-events-have-key-events (2): convert div backdrops to buttons - no-static-element-interactions (2): add role+tabIndex+onKeyDown to clickable divs - rendering-svg-precision (2): round SVG path decimals - no-tiny-text (2): 11px → 12px on CommandPalette hints - js-set-map-lookups (4→1): Sets for visibleItems, regex for keyword match - no-gray-on-colored-background (4): use color-matched text instead of stone-400/900 - no-transition-all (4): list specific properties in transition shorthand
457 lines
14 KiB
TypeScript
457 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; } }
|
|
}
|
|
`}</style>
|
|
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
zIndex: 60,
|
|
backgroundColor: "rgba(26, 24, 20, 0.4)",
|
|
backdropFilter: "blur(4px)",
|
|
WebkitBackdropFilter: "blur(4px)",
|
|
display: "flex",
|
|
alignItems: "flex-start",
|
|
justifyContent: "center",
|
|
paddingTop: "15vh",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: "100%",
|
|
maxWidth: "36rem",
|
|
margin: "0 1rem",
|
|
backgroundColor: "var(--admin-card-bg)",
|
|
border: "1px solid var(--admin-border)",
|
|
borderRadius: "var(--admin-radius-lg)",
|
|
boxShadow: "var(--admin-shadow-lg)",
|
|
overflow: "hidden",
|
|
animation: "cp-scale-in 180ms ease-out",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
{/* 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 */}
|
|
<div
|
|
role="listbox"
|
|
aria-label="Results"
|
|
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 “{query}”
|
|
</div>
|
|
) : (
|
|
results.map((entry, i) => {
|
|
const Icon = ICON_MAP[entry.iconName] ?? Search;
|
|
const isSelected = i === safeSelected;
|
|
return (
|
|
<button
|
|
key={entry.id}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={isSelected}
|
|
onClick={() => navigate(entry.href)}
|
|
onMouseEnter={() => setSelected(i)}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.75rem",
|
|
width: "100%",
|
|
padding: "0.625rem 0.75rem",
|
|
borderRadius: "var(--admin-radius-md)",
|
|
border: "none",
|
|
background: isSelected
|
|
? "var(--admin-primary-soft)"
|
|
: "transparent",
|
|
color: isSelected
|
|
? "var(--admin-primary)"
|
|
: "var(--admin-text-primary)",
|
|
cursor: "pointer",
|
|
textAlign: "left",
|
|
fontSize: "0.875rem",
|
|
lineHeight: 1.3,
|
|
fontFamily: "inherit",
|
|
transition:
|
|
"background-color 80ms ease-out, color 80ms ease-out",
|
|
}}
|
|
>
|
|
<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>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* 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 · ↵ to select · esc to close
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
);
|
|
}
|