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",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-devtools": "^0.10.5",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-router": "^1.170.7",
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/react-router-ssr-query": "^1.167.0",
@@ -31,10 +32,9 @@
"@vercel/og": "^0.11.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"fumadocs-core": "^16.9.0",
"fumadocs-mdx": "^15.0.7",
"fumadocs-ui": "^16.9.0",
"fumadocs-ui": "npm:@fumadocs/base-ui@^16.9.0",
"nitro": "^3.0.260429-beta",
"react": "^19.2.6",
"react-dom": "^19.2.6",
+149 -46
View File
@@ -1,46 +1,65 @@
import type { ModuleSummary, RegistryIndex } from "@stanza/registry";
import { groupLabel, moduleGroup } from "@stanza/registry";
import { IconSearch } from "@tabler/icons-react";
import { formatForDisplay, useHotkey } from "@tanstack/react-hotkeys";
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 { Button } from "@/components/ui/button";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Kbd } from "@/components/ui/kbd";
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
* header and by `⌘K` / `Ctrl-K`. cmdk filters the registry index client-side,
* groups by slot, and navigates to `/m/$slot/$id` on selection.
* header and by `⌘K` / `Ctrl-K` (resolved per-platform by `@tanstack/react-hotkeys`).
* 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 }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const navigate = useNavigate();
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
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);
}, []);
useHotkey(HOTKEY, () => setOpen((o) => !o));
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 go = useCallback(
const filtered = useMemo(
() => 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) => {
setOpen(false);
void navigate({ to: "/m/$slot/$id", params: { slot: moduleGroup(summary), id: summary.id } });
@@ -48,48 +67,132 @@ export function SiteSearch({ index }: { index: RegistryIndex }) {
[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 (
<>
<Button
variant="outline"
onClick={openSearch}
onClick={() => onOpenChange(true)}
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"
>
<IconSearch data-icon="inline-start" />
<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>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Search modules…" />
<CommandList>
<CommandEmpty>No matches.</CommandEmpty>
{groups.map(({ group, modules }) => (
<CommandGroup key={group} heading={groupLabel(group)}>
{modules.map((m) => (
<SearchResult key={`${moduleGroup(m)}:${m.id}`} summary={m} onGo={go} />
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="top-1/3 translate-y-0 gap-0 overflow-hidden rounded-none p-0"
showCloseButton={false}
>
<DialogHeader className="sr-only">
<DialogTitle>Search modules</DialogTitle>
<DialogDescription>Search the registry for a module to add.</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 border-b px-3">
<IconSearch className="size-4 shrink-0 opacity-50" />
<input
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({
summary,
onGo,
index,
active,
onSelect,
onActivate,
}: {
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 (
<CommandItem
value={`${moduleGroup(summary)} ${summary.id} ${summary.label} ${summary.description}`}
onSelect={onSelect}
className="cursor-pointer"
<button
type="button"
data-index={index}
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" />
<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">
{moduleGroup(summary)}/{summary.id}
</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:
- "apps/*"
- "packages/*"
- "registry/modules/*"
- apps/*
- packages/*
- registry/modules/*
onlyBuiltDependencies:
- "esbuild"
- "better-sqlite3"
ignoredBuiltDependencies:
- sharp
ignoredOptionalDependencies: []
onlyBuiltDependencies:
- esbuild
- msw