Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 17 additions & 18 deletions packages/ui/src/components/callout.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 },
};
Expand All @@ -32,21 +33,19 @@ export function Callout({ tone = "note", title, className, children, ...props }:
return (
<div
role="note"
className={cn("flex gap-3 rounded-xl border p-4", className)}
style={{
// tone-tinted surface + left accent, expressed off the tone color
background: `color-mix(in oklab, ${t.color} 9%, transparent)`,
borderColor: `color-mix(in oklab, ${t.color} 25%, transparent)`,
borderLeft: `3px solid ${t.color}`,
}}
className={cn("flex flex-col gap-2 py-2 pl-4 pr-2", className)}
style={{ borderLeft: `3px solid ${t.color}` }}
{...props}
>
<Icon className="mt-0.5 h-[18px] w-[18px] shrink-0" style={{ color: t.color } as React.CSSProperties} />
<div className="min-w-0">
<div className="mb-1 text-sm font-bold text-foreground">{title ?? t.label}</div>
<div className="text-sm leading-relaxed text-muted-foreground [&_code]:font-mono [&_code]:text-[var(--primary-emphasis)]">
{children}
</div>
<div
className="flex items-center gap-2 text-sm font-semibold leading-none"
style={{ color: t.color }}
>
<Icon className="h-4 w-4 shrink-0" />
{title ?? t.label}
</div>
<div className="text-sm leading-relaxed text-foreground [&_code]:font-mono [&_code]:text-[var(--primary-emphasis)]">
{children}
</div>
</div>
);
Expand Down
173 changes: 173 additions & 0 deletions packages/ui/src/components/command-palette.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop className="fixed inset-0 z-50 bg-black/50 supports-backdrop-filter:backdrop-blur-[2px] data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0" />
<DialogPrimitive.Popup
aria-label="Command palette"
onKeyDown={onKeyDown}
className={cn(
"fixed left-1/2 top-24 z-50 w-[560px] max-w-[calc(100%-2rem)] -translate-x-1/2",
"overflow-hidden rounded-2xl border border-border-strong bg-popover",
"shadow-[0_30px_70px_-20px_rgba(0,0,0,0.8)] outline-none",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
)}
>
<div className="flex items-center gap-3 border-b border-border px-[18px] py-[15px]">
<Search className="h-[18px] w-[18px] shrink-0 text-subtle-foreground" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
aria-label={placeholder}
className="flex-1 bg-transparent text-[15px] text-foreground outline-none placeholder:text-subtle-foreground"
/>
<kbd className="rounded-md border border-border px-2 py-0.5 font-mono text-[11px] text-subtle-foreground">
esc
</kbd>
</div>

<div className="flex max-h-[320px] flex-col gap-0.5 overflow-y-auto p-2">
{flat.length === 0 ? (
<div className="px-2.5 py-6 text-center text-[13.5px] text-muted-foreground">
{emptyMessage}
</div>
) : (
filtered.map((group) => (
<div key={group.heading}>
<div className={groupHeadingCls}>{group.heading}</div>
{group.items.map((item) => {
const idx = flat.indexOf(item);
const isActive = idx === active;
return (
<button
key={item.id}
type="button"
onMouseMove={() => setActive(idx)}
onClick={() => select(item)}
className={cn(
"flex w-full items-center gap-[11px] rounded-[9px] px-2.5 py-[9px] text-left",
isActive && "[background:var(--primary-soft)]",
)}
>
<span
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center [&_svg]:h-4 [&_svg]:w-4",
isActive ? "text-primary" : "text-muted-foreground",
)}
>
{item.icon}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-semibold text-foreground">
{item.title}
</span>
{item.subtitle ? (
<span className="block truncate text-[11.5px] text-subtle-foreground">
{item.subtitle}
</span>
) : null}
</span>
{isActive ? (
<CornerDownLeft className="h-3.5 w-3.5 shrink-0 text-primary" />
) : null}
</button>
);
})}
</div>
))
)}
</div>
</DialogPrimitive.Popup>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
6 changes: 6 additions & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
106 changes: 106 additions & 0 deletions packages/ui/src/stories/CommandPalette.stories.tsx
Original file line number Diff line number Diff line change
@@ -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: <FileText />,
keywords: "sqlite javascript database",
},
{
id: "sqlite-host",
title: "SQLite host module — elide:sqlite",
subtitle: "API Reference · Host APIs",
icon: <FileCode />,
keywords: "sqlite host module",
},
{
id: "sqlite-ex",
title: "Example: JavaScript with SQLite",
subtitle: "Code Samples",
icon: <Sparkles />,
keywords: "sqlite example javascript",
},
],
},
{
heading: "Actions",
items: [
{ id: "ask-ai", title: "Ask AI about “sqlite”", icon: <Sparkles />, keywords: "ask ai sqlite" },
],
},
];

const meta = {
title: "Docs/CommandPalette",
component: CommandPalette,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof CommandPalette>;

export default meta;
type Story = StoryObj<typeof meta>;

/** Open by default, matching the mockup's ⌘K overlay state. */
export const Default: Story = {
render: () => {
const [open, setOpen] = React.useState(true);
return (
<div style={{ minHeight: "100vh", padding: 24 }}>
<button
type="button"
onClick={() => setOpen(true)}
style={{ font: "500 13px/1 'Inter',sans-serif" }}
>
Open palette (⌘K)
</button>
<CommandPalette open={open} onOpenChange={setOpen} groups={groups} />
</div>
);
},
// 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 (
<div style={{ minHeight: "100vh", padding: 24 }}>
<p style={{ font: "400 14px/1.6 'Inter',sans-serif" }}>
Press <kbd>⌘K</kbd> (or <kbd>Ctrl+K</kbd>) to toggle the palette.
</p>
<CommandPalette open={open} onOpenChange={setOpen} groups={groups} />
</div>
);
},
play: async () => {
await userEvent.keyboard("{Meta>}k{/Meta}");
const dialog = await screen.findByRole("dialog", { name: "Command palette" });
await expect(dialog).toBeInTheDocument();
},
};
Loading