mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-10-16 06:15:43 -04:00
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:
5 files changed
+291
-1266
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Generated
+131
-1031
File diff suppressed because it is too large.
Load diff
+9
-6
@@ -1,10 +1,13 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
- "registry/modules/*"
|
||||
- apps/*
|
||||
- packages/*
|
||||
- registry/modules/*
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- "esbuild"
|
||||
- "better-sqlite3"
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
|
||||
ignoredOptionalDependencies: []
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- msw
|
||||
Reference in new issue
Block a user