feat: replace cmdk-backed CommandDialog with a bespoke search dialog and @tanstack/react-hotkeys

- Remove `cmdk` and the generated `src/components/ui/command.tsx` shadcn primitive; replace `CommandDialog` / `CommandInput` / `CommandList` / `CommandItem` in `SiteSearch` with a plain `Dialog` + `<input>` + `<button>` elements so the component owns its own rendering without the cmdk abstraction layer
- Add `@tanstack/react-hotkeys` and replace the hand-rolled `keydown` listener with `useHotkey(HOTKEY, …)`; use `formatForDisplay` (deferred to mount to avoid SSR hydration mismatch) so the `<Kbd>` badge shows `⌘K` on macOS and `Ctrl+K` on Windows/Linux
- Implement client-side fuzzy filtering via a local `matches` helper (splits query on whitespace, checks every term against a joined haystack of group/id/label/description) and drive the list from derived `filtered` / `groups` / `flat` memos
- Add keyboard navigation — `ArrowDown`/`ArrowUp` cycle `activeIndex` through the flat item list, `Enter` selects; `onPointerMove` on each row syncs hover → `activeIndex`; the active row scrolls into view via a `data-index` attribute + `scrollIntoView`
- Reset `query` and `activeIndex` when the dialog opens; reset `activeIndex` whenever `query` changes
- Pin `fumadocs-ui` to `npm:@fumadocs/base-ui@^16.9.0`
This commit is contained in:
jake committed 2026-05-21 19:26:48 -04:00
1 parent 27f90c1765
commit 1eb0b9c5dd
5 files changed
+291 -1266

No files matched your search

+2 -2
View File
@@ -22,6 +22,7 @@
"@tabler/icons-react": "^3.44.0", "@tabler/icons-react": "^3.44.0",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@tanstack/react-devtools": "^0.10.5", "@tanstack/react-devtools": "^0.10.5",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-router": "^1.170.7", "@tanstack/react-router": "^1.170.7",
"@tanstack/react-router-devtools": "^1.167.0", "@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/react-router-ssr-query": "^1.167.0", "@tanstack/react-router-ssr-query": "^1.167.0",
@@ -31,10 +32,9 @@
"@vercel/og": "^0.11.1", "@vercel/og": "^0.11.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"fumadocs-core": "^16.9.0", "fumadocs-core": "^16.9.0",
"fumadocs-mdx": "^15.0.7", "fumadocs-mdx": "^15.0.7",
"fumadocs-ui": "^16.9.0", "fumadocs-ui": "npm:@fumadocs/base-ui@^16.9.0",
"nitro": "^3.0.260429-beta", "nitro": "^3.0.260429-beta",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
+149 -46
View File
@@ -1,46 +1,65 @@
import type { ModuleSummary, RegistryIndex } from "@stanza/registry"; import type { ModuleSummary, RegistryIndex } from "@stanza/registry";
import { groupLabel, moduleGroup } from "@stanza/registry"; import { groupLabel, moduleGroup } from "@stanza/registry";
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { formatForDisplay, useHotkey } from "@tanstack/react-hotkeys";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ModuleLogo } from "@/components/module-logo"; import { ModuleLogo } from "@/components/module-logo";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
CommandDialog, Dialog,
CommandEmpty, DialogContent,
CommandGroup, DialogDescription,
CommandInput, DialogHeader,
CommandItem, DialogTitle,
CommandList, } from "@/components/ui/dialog";
} from "@/components/ui/command";
import { Kbd } from "@/components/ui/kbd"; import { Kbd } from "@/components/ui/kbd";
import { groupBySlot } from "@/lib/module-search"; import { groupBySlot } from "@/lib/module-search";
import { cn } from "@/lib/utils";
const HOTKEY = "Mod+K";
function matches(m: ModuleSummary, query: string): boolean {
if (!query) return true;
const haystack = `${moduleGroup(m)} ${m.id} ${m.label} ${m.description}`.toLowerCase();
return query
.toLowerCase()
.split(/\s+/)
.filter(Boolean)
.every((term) => haystack.includes(term));
}
/** /**
* Global module search. Triggered by a search-input-looking button in the * Global module search. Triggered by a search-input-looking button in the
* header and by `⌘K` / `Ctrl-K`. cmdk filters the registry index client-side, * header and by `⌘K` / `Ctrl-K` (resolved per-platform by `@tanstack/react-hotkeys`).
* groups by slot, and navigates to `/m/$slot/$id` on selection. * Filters the registry index client-side, groups by slot, and navigates to
* `/m/$slot/$id` on selection. Arrow keys move the highlight; Enter selects.
*/ */
export function SiteSearch({ index }: { index: RegistryIndex }) { export function SiteSearch({ index }: { index: RegistryIndex }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const navigate = useNavigate(); const navigate = useNavigate();
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => { useHotkey(HOTKEY, () => setOpen((o) => !o));
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((o) => !o);
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const groups = useMemo(() => groupBySlot(index.modules), [index.modules]); // Resolved client-side: the platform-aware label differs between server and
// client, so render nothing until mount to avoid a hydration mismatch.
const [hotkeyLabel, setHotkeyLabel] = useState<string | null>(null);
useEffect(() => setHotkeyLabel(formatForDisplay(HOTKEY)), []);
const openSearch = useCallback(() => setOpen(true), []); const filtered = useMemo(
const go = useCallback( () => index.modules.filter((m) => matches(m, query)),
[index.modules, query],
);
const groups = useMemo(() => groupBySlot(filtered), [filtered]);
const flat = useMemo(() => groups.flatMap((g) => g.modules), [groups]);
useEffect(() => setActiveIndex(0), [query]);
const selectModule = useCallback(
(summary: ModuleSummary) => { (summary: ModuleSummary) => {
setOpen(false); setOpen(false);
void navigate({ to: "/m/$slot/$id", params: { slot: moduleGroup(summary), id: summary.id } }); void navigate({ to: "/m/$slot/$id", params: { slot: moduleGroup(summary), id: summary.id } });
@@ -48,48 +67,132 @@ export function SiteSearch({ index }: { index: RegistryIndex }) {
[navigate], [navigate],
); );
const onOpenChange = useCallback((next: boolean) => {
setOpen(next);
if (next) {
setQuery("");
setActiveIndex(0);
}
}, []);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => (flat.length ? (i + 1) % flat.length : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => (flat.length ? (i - 1 + flat.length) % flat.length : 0));
} else if (e.key === "Enter") {
e.preventDefault();
const m = flat[activeIndex];
if (m) selectModule(m);
}
},
[flat, activeIndex, selectModule],
);
useEffect(() => {
if (!open) return;
const node = listRef.current?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`);
node?.scrollIntoView({ block: "nearest" });
}, [activeIndex, open]);
let flatIndex = 0;
return ( return (
<> <>
<Button <Button
variant="outline" variant="outline"
onClick={openSearch} onClick={() => onOpenChange(true)}
aria-label="Search modules" aria-label="Search modules"
className="gap-2 bg-background px-2 text-muted-foreground hover:bg-muted hover:text-foreground sm:min-w-[180px] sm:px-2.5" className="gap-2 bg-background px-2 text-muted-foreground hover:bg-muted hover:text-foreground sm:min-w-[180px] sm:px-2.5"
> >
<IconSearch data-icon="inline-start" /> <IconSearch data-icon="inline-start" />
<span className="hidden flex-1 text-left sm:inline">Search modules…</span> <span className="hidden flex-1 text-left sm:inline">Search modules…</span>
<Kbd className="hidden sm:inline">⌘K</Kbd> {hotkeyLabel && <Kbd className="hidden sm:inline">{hotkeyLabel}</Kbd>}
</Button> </Button>
<CommandDialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={onOpenChange}>
<CommandInput placeholder="Search modules…" /> <DialogContent
<CommandList> className="top-1/3 translate-y-0 gap-0 overflow-hidden rounded-none p-0"
<CommandEmpty>No matches.</CommandEmpty> showCloseButton={false}
{groups.map(({ group, modules }) => ( >
<CommandGroup key={group} heading={groupLabel(group)}> <DialogHeader className="sr-only">
{modules.map((m) => ( <DialogTitle>Search modules</DialogTitle>
<SearchResult key={`${moduleGroup(m)}:${m.id}`} summary={m} onGo={go} /> <DialogDescription>Search the registry for a module to add.</DialogDescription>
))} </DialogHeader>
</CommandGroup> <div className="flex items-center gap-2 border-b px-3">
))} <IconSearch className="size-4 shrink-0 opacity-50" />
</CommandList> <input
</CommandDialog> autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Search modules…"
aria-label="Search modules"
className="h-10 w-full bg-transparent text-xs outline-hidden placeholder:text-muted-foreground"
/>
</div>
<div
ref={listRef}
className="no-scrollbar max-h-72 overflow-x-hidden overflow-y-auto p-1"
>
{flat.length === 0 ? (
<div className="py-6 text-center text-xs text-muted-foreground">No matches.</div>
) : (
groups.map(({ group, modules }) => (
<div key={group} className="overflow-hidden">
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{groupLabel(group)}
</div>
{modules.map((m) => {
const i = flatIndex++;
return (
<SearchResult
key={`${moduleGroup(m)}:${m.id}`}
summary={m}
index={i}
active={i === activeIndex}
onSelect={selectModule}
onActivate={setActiveIndex}
/>
);
})}
</div>
))
)}
</div>
</DialogContent>
</Dialog>
</> </>
); );
} }
function SearchResult({ function SearchResult({
summary, summary,
onGo, index,
active,
onSelect,
onActivate,
}: { }: {
summary: ModuleSummary; summary: ModuleSummary;
onGo: (summary: ModuleSummary) => void; index: number;
active: boolean;
onSelect: (summary: ModuleSummary) => void;
onActivate: (index: number) => void;
}) { }) {
const onSelect = useCallback(() => onGo(summary), [onGo, summary]); const handleSelect = useCallback(() => onSelect(summary), [onSelect, summary]);
const handlePointerMove = useCallback(() => onActivate(index), [onActivate, index]);
return ( return (
<CommandItem <button
value={`${moduleGroup(summary)} ${summary.id} ${summary.label} ${summary.description}`} type="button"
onSelect={onSelect} data-index={index}
className="cursor-pointer" data-selected={active || undefined}
onClick={handleSelect}
onPointerMove={handlePointerMove}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-none px-2 py-2 text-left text-xs outline-hidden",
active && "bg-muted text-foreground",
)}
> >
<ModuleLogo logo={summary.logo} label={summary.label} size="sm" /> <ModuleLogo logo={summary.logo} label={summary.label} size="sm" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -99,6 +202,6 @@ function SearchResult({
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/60"> <span className="shrink-0 font-mono text-[10px] text-muted-foreground/60">
{moduleGroup(summary)}/{summary.id} {moduleGroup(summary)}/{summary.id}
</span> </span>
</CommandItem> </button>
); );
} }
-181
View File
@@ -1,181 +0,0 @@
"use client";
import { IconSearch, IconCheck } from "@tabler/icons-react";
import { Command as CommandPrimitive } from "cmdk";
import * as React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
import { cn } from "@/lib/utils";
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-none bg-popover text-popover-foreground",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
children: React.ReactNode;
}) {
return (
<Dialog {...props}>
<DialogContent
className={cn("top-1/3 translate-y-0 overflow-hidden rounded-none p-0", className)}
showCloseButton={showCloseButton}
>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Command>{children}</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="border-b pb-0">
<InputGroup className="h-8 border-none border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-xs outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
<InputGroupAddon>
<IconSearch className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
);
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-0 overflow-x-hidden overflow-y-auto outline-none",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-xs", className)}
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
);
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-none px-2 py-2 text-xs outline-hidden select-none in-data-[slot=dialog-content]:rounded-none! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className,
)}
{...props}
>
{children}
<IconCheck className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
);
}
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+131 -1031
View File
File diff suppressed because it is too large. Load diff
+9 -6
View File
@@ -1,10 +1,13 @@
packages: packages:
- "apps/*" - apps/*
- "packages/*" - packages/*
- "registry/modules/*" - registry/modules/*
onlyBuiltDependencies: ignoredBuiltDependencies:
- "esbuild" - sharp
- "better-sqlite3"
ignoredOptionalDependencies: [] ignoredOptionalDependencies: []
onlyBuiltDependencies:
- esbuild
- msw