From 7ff473a6c0b782d592b2dba7a9298ed757e4f24a Mon Sep 17 00:00:00 2001 From: Srikar Sunchu Date: Mon, 6 Jul 2026 20:54:22 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(ui):=20CommandPalette=20=E2=80=94=20th?= =?UTF-8?q?e=20=E2=8C=98K=20spotlight=20search=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the command palette from the 1b mockup on the Base UI Dialog primitive (no new deps): grouped results, query filtering, arrow-key navigation, Enter/click select. Stories cover the default open state (with a11y play test) and a global ⌘K/Ctrl+K shortcut wiring. Co-authored-by: Cursor --- .../ui/src/components/command-palette.tsx | 173 ++++++++++++++++++ packages/ui/src/index.ts | 6 + .../ui/src/stories/CommandPalette.stories.tsx | 106 +++++++++++ 3 files changed, 285 insertions(+) create mode 100644 packages/ui/src/components/command-palette.tsx create mode 100644 packages/ui/src/stories/CommandPalette.stories.tsx diff --git a/packages/ui/src/components/command-palette.tsx b/packages/ui/src/components/command-palette.tsx new file mode 100644 index 0000000..4f9900c --- /dev/null +++ b/packages/ui/src/components/command-palette.tsx @@ -0,0 +1,173 @@ +import * as React from "react"; +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; +import { Search, CornerDownLeft } from "lucide-react"; +import { cn } from "../lib/utils"; + +/** + * CommandPalette — the ⌘K spotlight search overlay (direction 1b in the docs + * mockups). Built on the Base UI Dialog primitive (the repo's overlay base) and + * themed from `@elide/tokens`. Presentational + keyboard-navigable: it filters, + * arrow-keys through a flat list, and selects on Enter/click. Wiring a global + * ⌘K shortcut is left to the consumer so the primitive stays reusable. + */ +export interface CommandItem { + id: string; + title: React.ReactNode; + subtitle?: string; + icon?: React.ReactNode; + /** Plain text used for filtering (falls back to `title` when it's a string). */ + keywords?: string; + onSelect?: () => void; +} + +export interface CommandGroup { + heading: string; + items: CommandItem[]; +} + +export interface CommandPaletteProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; + groups: CommandGroup[]; + placeholder?: string; + emptyMessage?: string; + /** Filter items by the query against title/keywords/subtitle. Default true. */ + filter?: boolean; +} + +const groupHeadingCls = + "px-2.5 pt-1.5 pb-1 font-mono text-[10.5px] font-semibold uppercase tracking-[0.08em] text-subtle-foreground"; + +export function CommandPalette({ + open, + onOpenChange, + groups, + placeholder = "Search docs, commands, APIs…", + emptyMessage = "No results found.", + filter = true, +}: CommandPaletteProps) { + const [query, setQuery] = React.useState(""); + + const filtered = React.useMemo(() => { + if (!filter || !query.trim()) return groups; + const q = query.toLowerCase(); + const match = (i: CommandItem) => { + const hay = i.keywords ?? (typeof i.title === "string" ? i.title : ""); + return `${hay} ${i.subtitle ?? ""}`.toLowerCase().includes(q); + }; + return groups + .map((g) => ({ ...g, items: g.items.filter(match) })) + .filter((g) => g.items.length > 0); + }, [groups, query, filter]); + + // Flatten for keyboard navigation across groups. + const flat = React.useMemo(() => filtered.flatMap((g) => g.items), [filtered]); + const [active, setActive] = React.useState(0); + React.useEffect(() => setActive(0), [query, open]); + + const select = (item: CommandItem | undefined) => { + if (!item) return; + item.onSelect?.(); + onOpenChange?.(false); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (flat.length === 0) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((i) => (i + 1) % flat.length); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((i) => (i - 1 + flat.length) % flat.length); + } else if (e.key === "Enter") { + e.preventDefault(); + select(flat[active]); + } + }; + + return ( + + + + +
+ + setQuery(e.target.value)} + placeholder={placeholder} + aria-label={placeholder} + className="flex-1 bg-transparent text-[15px] text-foreground outline-none placeholder:text-subtle-foreground" + /> + + esc + +
+ +
+ {flat.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + filtered.map((group) => ( +
+
{group.heading}
+ {group.items.map((item) => { + const idx = flat.indexOf(item); + const isActive = idx === active; + return ( + + ); + })} +
+ )) + )} +
+
+
+
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 66ee118..009fd88 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -2,6 +2,12 @@ export { Button, buttonVariants, type ButtonProps } from "./components/button"; export { Badge, badgeVariants, type BadgeProps } from "./components/badge"; export { Callout, type CalloutProps } from "./components/callout"; export { CodeBlock, CopyButton, type CodeBlockProps } from "./components/code-block"; +export { + CommandPalette, + type CommandPaletteProps, + type CommandGroup, + type CommandItem, +} from "./components/command-palette"; export { cn } from "./lib/utils"; export { ThemeProvider, useTheme, type Theme, type ThemeProviderProps } from "./components/theme-provider"; export { Breadcrumbs, type BreadcrumbSegment, type BreadcrumbsProps } from "./components/breadcrumbs"; diff --git a/packages/ui/src/stories/CommandPalette.stories.tsx b/packages/ui/src/stories/CommandPalette.stories.tsx new file mode 100644 index 0000000..b7c6814 --- /dev/null +++ b/packages/ui/src/stories/CommandPalette.stories.tsx @@ -0,0 +1,106 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import * as React from "react"; +import { expect, screen, userEvent } from "storybook/test"; +import { FileText, FileCode, Sparkles } from "lucide-react"; +import { CommandPalette, type CommandGroup } from "../components/command-palette"; + +const groups: CommandGroup[] = [ + { + heading: "Pages", + items: [ + { + id: "sqlite-js", + title: "SQLite in JavaScript", + subtitle: "API Reference · Database Modules", + icon: , + keywords: "sqlite javascript database", + }, + { + id: "sqlite-host", + title: "SQLite host module — elide:sqlite", + subtitle: "API Reference · Host APIs", + icon: , + keywords: "sqlite host module", + }, + { + id: "sqlite-ex", + title: "Example: JavaScript with SQLite", + subtitle: "Code Samples", + icon: , + keywords: "sqlite example javascript", + }, + ], + }, + { + heading: "Actions", + items: [ + { id: "ask-ai", title: "Ask AI about “sqlite”", icon: , keywords: "ask ai sqlite" }, + ], + }, +]; + +const meta = { + title: "Docs/CommandPalette", + component: CommandPalette, + parameters: { layout: "fullscreen" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Open by default, matching the mockup's ⌘K overlay state. */ +export const Default: Story = { + render: () => { + const [open, setOpen] = React.useState(true); + return ( +
+ + +
+ ); + }, + // a11y regression guard: the overlay must be a labelled dialog and the search + // field must have an accessible name (both required for screen-reader use). + play: async () => { + const dialog = await screen.findByRole("dialog", { name: "Command palette" }); + await expect(dialog).toBeInTheDocument(); + const input = screen.getByRole("textbox"); + await expect(input).toHaveAccessibleName(); + }, +}; + +/** Wires a global ⌘K / Ctrl+K listener the way a real app would. */ +export const WithGlobalShortcut: Story = { + render: () => { + const [open, setOpen] = React.useState(false); + React.useEffect(() => { + const 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); + }, []); + return ( +
+

+ Press ⌘K (or Ctrl+K) to toggle the palette. +

+ +
+ ); + }, + play: async () => { + await userEvent.keyboard("{Meta>}k{/Meta}"); + const dialog = await screen.findByRole("dialog", { name: "Command palette" }); + await expect(dialog).toBeInTheDocument(); + }, +}; From 2b4b53089f3cd5ca85279e92aba056b048517760 Mon Sep 17 00:00:00 2001 From: Srikar Sunchu Date: Mon, 6 Jul 2026 20:54:30 -0700 Subject: [PATCH 2/2] feat(ui): restyle Callout to GitHub admonition anatomy Drops the tinted surface and outer border for a plain 3px left rule; the icon + label line is tinted in the tone color and the body renders in the regular foreground. Important now uses the speech-bubble icon (MessageSquareWarning) instead of duplicating Info. API unchanged. Co-authored-by: Cursor --- packages/ui/src/components/callout.tsx | 35 +++++++++++++------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/components/callout.tsx b/packages/ui/src/components/callout.tsx index 771d0d8..aab4ebb 100644 --- a/packages/ui/src/components/callout.tsx +++ b/packages/ui/src/components/callout.tsx @@ -1,11 +1,12 @@ import * as React from "react"; -import { Info, Lightbulb, TriangleAlert, OctagonAlert } from "lucide-react"; +import { Info, Lightbulb, MessageSquareWarning, TriangleAlert, OctagonAlert } from "lucide-react"; import { cn } from "../lib/utils"; /** - * Callout — composite. GFM-style admonition matching the Elide docs content - * (Note / Tip / Important / Warning / Caution). Each tone carries its own hue, - * left accent rule, and icon. Body accepts arbitrary rich content. + * Callout — composite. GitHub-style admonition (Note / Tip / Important / + * Warning / Caution): a plain left rule with the icon + label line tinted in + * the tone color, no surface fill or outer border. Body accepts arbitrary + * rich content and renders in the regular foreground color. */ type CalloutTone = "note" | "tip" | "important" | "warning" | "caution"; @@ -15,7 +16,7 @@ const TONES: Record< > = { note: { label: "Note", color: "var(--eld-info)", icon: Info }, tip: { label: "Tip", color: "var(--eld-success)", icon: Lightbulb }, - important: { label: "Important", color: "var(--eld-magenta-500)", icon: Info }, + important: { label: "Important", color: "var(--eld-magenta-500)", icon: MessageSquareWarning }, warning: { label: "Warning", color: "var(--eld-warning)", icon: TriangleAlert }, caution: { label: "Caution", color: "var(--eld-danger)", icon: OctagonAlert }, }; @@ -32,21 +33,19 @@ export function Callout({ tone = "note", title, className, children, ...props }: return (
- -
-
{title ?? t.label}
-
- {children} -
+
+ + {title ?? t.label} +
+
+ {children}
);