feat(ui): add navigation and picker primitives

This commit is contained in:
2026-03-22 23:38:31 +08:00
parent a8c1d3f256
commit 4d67f4ad76
22 changed files with 2805 additions and 0 deletions
@@ -0,0 +1,147 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Badge,
Button
} from "@ai-ui/ui";
function AccordionPlayground() {
return (
<div className="w-full max-w-3xl">
<Accordion collapsible>
<AccordionItem value="editorial">
<AccordionTrigger>Editorial review</AccordionTrigger>
<AccordionContent>
The editorial lane is ready for the final release note pass and legal cross-check.
</AccordionContent>
</AccordionItem>
<AccordionItem value="engineering">
<AccordionTrigger>Engineering canary</AccordionTrigger>
<AccordionContent>
Canary thresholds are green and the 10% wave can begin after routing sign-off.
</AccordionContent>
</AccordionItem>
<AccordionItem value="support">
<AccordionTrigger>Support queue</AccordionTrigger>
<AccordionContent>
Customer macros are staged and only need one more quiet-hour review.
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}
function AccordionFaq() {
return (
<div className="w-full max-w-3xl">
<Accordion type="multiple">
<AccordionItem value="why">
<AccordionTrigger>Why use Accordion instead of Tabs?</AccordionTrigger>
<AccordionContent>
Use Accordion when people need to compare or progressively reveal multiple sections in
the same reading flow. Tabs hide sibling content; Accordion keeps the page narrative in
one vertical surface.
</AccordionContent>
</AccordionItem>
<AccordionItem value="where">
<AccordionTrigger>Where does it fit best?</AccordionTrigger>
<AccordionContent>
Settings groups, FAQ sections, filter drawers, release notes, audit explanations, and
inspector panels all benefit from lightweight disclosure.
</AccordionContent>
</AccordionItem>
<AccordionItem value="motion">
<AccordionTrigger>How does motion behave?</AccordionTrigger>
<AccordionContent>
The content region uses the shared motion contract for expansion and still respects the
static motion mode when the user needs a quieter interface.
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}
function AccordionControlPanel() {
return (
<div className="w-full max-w-4xl">
<Accordion type="multiple">
<AccordionItem value="release-state">
<AccordionTrigger>
<div className="flex min-w-0 items-center gap-3">
<Badge size="sm" variant="outline">
Release
</Badge>
<span>Wave rollout controls</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Stage a 10% wave, keep rollback thresholds visible, and hold broader rollout until
the support digest is approved.
</p>
<div className="flex flex-wrap gap-2">
<Button size="sm">Start 10% wave</Button>
<Button size="sm" variant="secondary">
Hold rollout
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="audit-context">
<AccordionTrigger>
<div className="flex min-w-0 items-center gap-3">
<Badge size="sm" tone="warning" variant="outline">
Audit
</Badge>
<span>Open outstanding review notes</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="grid gap-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Legal footnote still needs one sentence tightened before public launch.</p>
<p>Customer support messaging is approved but waiting on the rollout window.</p>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}
const meta = {
title: "Components/Accordion",
component: AccordionPlayground,
parameters: {
docs: {
description: {
component:
"A lightweight disclosure surface for FAQ sections, filter groups, settings, and inspector panels. Use it when content should remain in one vertical reading flow instead of moving into tabs or overlays."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof AccordionPlayground>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Faq: Story = {
render: () => <AccordionFaq />
};
export const ControlPanel: Story = {
render: () => <AccordionControlPanel />
};
@@ -0,0 +1,128 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
Badge,
Breadcrumb,
BreadcrumbCurrent,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@ai-ui/ui";
function WorkspaceBreadcrumbShowcase() {
return (
<div className="grid gap-6 p-6">
<Card className="max-w-4xl">
<CardHeader>
<CardTitle>Workflow navigation</CardTitle>
<CardDescription>
Breadcrumb should stabilize layered navigation across runs, threads, queues,
and operator workspaces without competing with page headings.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-8">
<section className="grid gap-3">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Run detail path
</p>
<Breadcrumb aria-label="Run detail path">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#runs">Runs</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#run-42">run-42</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbCurrent>Thread timeline</BreadcrumbCurrent>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</section>
<section className="grid gap-3">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Environment drill-down
</p>
<Breadcrumb aria-label="Environment drill-down">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#org">Cadence Labs</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#project">Agent platform</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#env">Production</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbCurrent>Deploy guardrails</BreadcrumbCurrent>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</section>
<section className="grid gap-3">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Mixed actions
</p>
<Breadcrumb aria-label="Escalation trail">
<BreadcrumbList>
<BreadcrumbItem>
<Badge size="sm" variant="outline">
Holding
</Badge>
</BreadcrumbItem>
<BreadcrumbSeparator></BreadcrumbSeparator>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Button size="sm" variant="ghost">
Escalations
</Button>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbCurrent>Review queue</BreadcrumbCurrent>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</section>
</CardContent>
</Card>
</div>
);
}
const meta = {
title: "Components/Breadcrumb",
component: WorkspaceBreadcrumbShowcase,
parameters: {
layout: "padded",
docs: {
description: {
component:
"A lightweight breadcrumb family for layered operator and admin navigation. Use it to establish context across list → detail → nested object flows without overloading the page chrome."
}
}
},
tags: ["autodocs"]
} satisfies Meta<typeof WorkspaceBreadcrumbShowcase>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
@@ -0,0 +1,229 @@
import {
Badge,
Button,
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function FileIcon() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
<path
d="M4 2.75h5.2l2.8 2.8v7.7H4z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.3"
/>
<path d="M9.2 2.75v2.8H12" stroke="currentColor" strokeWidth="1.3" />
</svg>
);
}
function EyeIcon() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
<path
d="M1.75 8s2.2-3.25 6.25-3.25S14.25 8 14.25 8 12.05 11.25 8 11.25 1.75 8 1.75 8Z"
stroke="currentColor"
strokeWidth="1.3"
/>
<circle cx="8" cy="8" r="1.85" stroke="currentColor" strokeWidth="1.3" />
</svg>
);
}
function FileRowContextMenu({ label = "Open file menu" }: { label?: string }) {
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="grid gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-5 shadow-[var(--shadow-sm)]">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="inline-flex size-10 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<FileIcon />
</span>
<div className="grid gap-1">
<p className="text-sm font-medium text-[var(--color-foreground)]">release-plan.md</p>
<p className="text-xs text-[var(--color-muted-foreground)]">
Right click to open contextual actions.
</p>
</div>
</div>
<Badge size="sm" variant="outline">
docs
</Badge>
</div>
<span className="sr-only">{label}</span>
</div>
</ContextMenuTrigger>
<ContextMenuContent size="xl">
<ContextMenuLabel inset>release-plan.md</ContextMenuLabel>
<ContextMenuItem
description="Open the document in a side-by-side preview."
leading={<EyeIcon />}
shortcut="P"
>
Preview file
</ContextMenuItem>
<ContextMenuItem
description="Reveal the document inside the release workspace."
leading={<FileIcon />}
shortcut="R"
>
Reveal in workspace
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuCheckboxItem
checked
description="Keep this file pinned in the review queue."
shortcut="K"
>
Keep pinned
</ContextMenuCheckboxItem>
<ContextMenuRadioGroup value="write">
<ContextMenuRadioItem
description="Allow direct edits before the next checkpoint."
shortcut="W"
value="write"
>
Write access
</ContextMenuRadioItem>
<ContextMenuRadioItem
description="Review the file without mutating it."
shortcut="R"
value="read"
>
Read-only access
</ContextMenuRadioItem>
</ContextMenuRadioGroup>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger description="Secondary actions with lower urgency.">
More actions
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem description="Duplicate this file into the next release branch.">
Duplicate file
</ContextMenuItem>
<ContextMenuItem
description="Move the file out of the active release without deleting it."
variant="destructive"
>
Archive file
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>
);
}
const meta = {
title: "Components/ContextMenu",
component: ContextMenu,
parameters: {
docs: {
description: {
component:
"ContextMenu extends the menu contract to right-click and long-press surfaces. It matches the DropdownMenu visual language while supporting richer item rows, nested submenus, toggles, and destructive actions for row-level workflows."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof ContextMenu>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <FileRowContextMenu />
};
export const FileRowWorkflow: Story = {
render: () => (
<div className="grid w-[720px] gap-4">
<FileRowContextMenu label="Open release file context menu" />
<div className="rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3 text-sm text-[var(--color-muted-foreground)]">
This example is designed for real workflow rows. Right click the file card to reveal
preview, workspace, pinning, permission, and archive actions.
</div>
</div>
)
};
export const DensePanels: Story = {
render: () => (
<div className="grid w-[960px] gap-6 lg:grid-cols-2">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
File row context menu
</h3>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
This menu mirrors the richer rows from DropdownMenu, but the trigger is a context
surface instead of a button.
</p>
<div className="mt-6">
<FileRowContextMenu />
</div>
</div>
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Data table row actions
</h3>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Use a context menu when table rows need denser actions than an inline action column can
comfortably show.
</p>
<div className="mt-6">
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="grid gap-1 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-[var(--color-foreground)]">
Run 184 · release wave
</span>
<Badge size="sm" tone="warning" variant="outline">
blocked
</Badge>
</div>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Right click this row to route the issue, open the thread, or escalate the blocker.
</p>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem description="Open the current run details in a side panel.">
Open run detail
</ContextMenuItem>
<ContextMenuItem description="Jump directly to the latest blocked thread.">
Open blocked thread
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
description="Escalate this row into the operator blocker queue."
variant="destructive"
>
Escalate blocker
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
</div>
</div>
)
};
@@ -0,0 +1,95 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@ai-ui/ui";
import { DatePicker } from "../../../../packages/ui/src/components/date-picker";
function DatePickerPlayground() {
const [value, setValue] = useState<Date | undefined>(new Date(2026, 3, 18));
return (
<div className="grid w-full max-w-3xl gap-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-3">
<div className="space-y-2">
<CardTitle>Launch scheduling</CardTitle>
<CardDescription>
Pick a single launch date from a lightweight calendar surface.
</CardDescription>
</div>
<Badge size="sm" variant="outline">
single date
</Badge>
</div>
</CardHeader>
<CardContent>
<DatePicker
aria-label="Launch date"
onValueChange={setValue}
value={value}
/>
</CardContent>
</Card>
</div>
);
}
function DatePickerScenarios() {
return (
<div className="grid w-full max-w-4xl gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Empty state</CardTitle>
<CardDescription>Use the field as a clean trigger for a future date choice.</CardDescription>
</CardHeader>
<CardContent>
<DatePicker aria-label="Review date" placeholder="Select review date" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Guardrailed window</CardTitle>
<CardDescription>
Limit choices to a narrow release window without turning the API into a range picker.
</CardDescription>
</CardHeader>
<CardContent>
<DatePicker
aria-label="Launch window"
defaultMonth={new Date(2026, 4, 1)}
defaultValue={new Date(2026, 4, 14)}
maxDate={new Date(2026, 4, 20)}
minDate={new Date(2026, 4, 10)}
/>
</CardContent>
</Card>
</div>
);
}
const meta = {
title: "Components/DatePicker",
component: DatePickerPlayground,
parameters: {
docs: {
description: {
component:
"A single-date picker for launch windows, review deadlines, and operator scheduling surfaces. This first slice stays intentionally narrow: one date, one popover calendar, no range or timezone API."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof DatePickerPlayground>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Scenarios: Story = {
render: () => <DatePickerScenarios />
};