feat: add sheet component and docs qa baseline

This commit is contained in:
2026-03-19 18:46:20 +08:00
parent 71ebb010b9
commit f318f94c9a
28 changed files with 1799 additions and 91 deletions
+23 -1
View File
@@ -1,6 +1,18 @@
import { Button } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function getButtonFromCanvas(canvasElement: HTMLElement, name: string) {
const buttons = canvasElement.querySelectorAll("button, a");
for (const element of buttons) {
if (element.textContent?.trim().includes(name)) {
return element;
}
}
throw new Error(`Expected to find an interactive control containing "${name}".`);
}
const meta = {
title: "Components/Button",
component: Button,
@@ -61,7 +73,17 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Playground: Story = {
play: async ({ canvasElement }) => {
const button = getButtonFromCanvas(canvasElement, "Save changes");
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
if (button instanceof HTMLElement) {
button.focus();
}
}
};
export const Variants: Story = {
render: () => (
+130 -17
View File
@@ -10,10 +10,48 @@ import {
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
type LaunchDialogProps = {
description?: string;
size?: "sm" | "md" | "lg";
title?: string;
triggerLabel?: string;
};
function LaunchDialog({
description = "This will notify the routing team and publish the release note to the activity feed.",
size = "md",
title = "Launch this release?",
triggerLabel = "Open approval dialog"
}: LaunchDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>
<Button>{triggerLabel}</Button>
</DialogTrigger>
<DialogContent size={size}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost">Cancel</Button>
<Button>Confirm launch</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
const meta = {
title: "Components/Dialog",
component: Dialog,
parameters: {
docs: {
description: {
component:
"Dialog is the system's blocking overlay for focused decisions, confirmation flows, and dense tasks that must temporarily interrupt the surrounding page. It ships with a portal, overlay, close affordance, semantic title and description wiring, and token-driven motion on both the surface and backdrop."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -24,23 +62,98 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <LaunchDialog />
};
export const Sizes: Story = {
render: () => (
<Dialog>
<DialogTrigger asChild>
<Button>Open approval dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Launch this release?</DialogTitle>
<DialogDescription>
This will notify the routing team and publish the release note to the activity feed.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost">Cancel</Button>
<Button>Confirm launch</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="grid w-[720px] gap-3 sm:grid-cols-2">
<LaunchDialog
description="Use the compact size for short confirmations that only need a title, one supporting sentence, and one primary action."
size="sm"
title="Publish summary?"
triggerLabel="Compact dialog"
/>
<LaunchDialog
description="Use the large size when the flow needs denser copy, audit context, or multi-step review detail before a final action."
size="lg"
title="Review rollout checklist"
triggerLabel="Large dialog"
/>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[700px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
<div className="space-y-3">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Dialog anatomy
</p>
<LaunchDialog triggerLabel="Preview dialog structure" />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="overlay"</code> sits
behind the surface and carries the backdrop motion.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="content"</code> wraps
the modal panel and exposes <code className="text-[var(--color-foreground)]">data-size</code>.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="header"</code>,{" "}
<code className="text-[var(--color-foreground)]">data-slot="footer"</code>,{" "}
<code className="text-[var(--color-foreground)]">data-slot="label"</code>, and{" "}
<code className="text-[var(--color-foreground)]">data-slot="description"</code>
provide stable hooks for structure and docs.
</p>
<p>
The close button is built into <code className="text-[var(--color-foreground)]">DialogContent</code>,
so every dialog gets a dismiss affordance even when the footer stays minimal.
</p>
</div>
</div>
</div>
)
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Use dialog only when the user must resolve or dismiss a blocking task. Focus is trapped while open, Escape closes the surface, and the title and description are announced through the Radix dialog semantics."
}
}
},
render: () => (
<div className="grid w-[760px] gap-4 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<article 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)]">
Accessibility notes
</h3>
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Keep the title outcome-oriented so assistive tech announces the decision clearly.</p>
<p>
Use the description for the consequence or next step, not decorative copy.
</p>
<p>
Keep the trigger specific. &quot;Open approval dialog&quot; is more useful than a generic
&quot;Open&quot;.
</p>
<p>
Reserve dialogs for blocking work. If the content should not trap focus, prefer
a popover instead.
</p>
</div>
</article>
<div className="flex items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<LaunchDialog
description="Keyboard focus moves into the surface, Escape closes it, and the trigger regains focus after dismissal."
triggerLabel="Open accessible dialog"
/>
</div>
</div>
)
};
@@ -16,24 +16,15 @@ import {
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
const meta = {
title: "Components/DropdownMenu",
component: DropdownMenu,
parameters: {
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof DropdownMenu>;
type ReleaseMenuProps = {
triggerLabel?: string;
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => (
function ReleaseMenu({ triggerLabel = "Open menu" }: ReleaseMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary">Open menu</Button>
<Button variant="secondary">{triggerLabel}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Launch actions</DropdownMenuLabel>
@@ -45,6 +36,10 @@ export const Playground: Story = {
Share preview
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem disabled>
Retry checks
<DropdownMenuShortcut>R</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked>Notify stakeholders</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
@@ -62,5 +57,110 @@ export const Playground: Story = {
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
);
}
const meta = {
title: "Components/DropdownMenu",
component: DropdownMenu,
parameters: {
docs: {
description: {
component:
"DropdownMenu is the compact action surface for contextual commands, quick toggles, and short decision trees. It supports labels, separators, nested submenus, checkbox and radio items, destructive emphasis, and keyboard-first navigation without introducing a separate API style."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof DropdownMenu>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <ReleaseMenu />
};
export const States: Story = {
parameters: {
docs: {
description: {
story:
"Open the menu to inspect the checked checkbox item, the selected radio item, a disabled action, the inset submenu trigger, and the destructive nested action."
}
}
},
render: () => (
<div className="grid w-[680px] gap-3 sm:grid-cols-2">
<ReleaseMenu triggerLabel="Review lane menu" />
<ReleaseMenu triggerLabel="Launch action menu" />
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[720px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
<div className="space-y-3">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Dropdown menu anatomy
</p>
<ReleaseMenu triggerLabel="Inspect menu structure" />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="content"</code> frames
the floating panel and exposes sizing for denser menus.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="item"</code>,{" "}
<code className="text-[var(--color-foreground)]">data-slot="trigger"</code>, and{" "}
<code className="text-[var(--color-foreground)]">data-slot="shortcut"</code> map the
action rows, nested trigger, and keyboard hint.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="label"</code>,{" "}
<code className="text-[var(--color-foreground)]">data-slot="separator"</code>, and{" "}
<code className="text-[var(--color-foreground)]">data-slot="icon"</code> support
grouping, dividers, and selection markers.
</p>
</div>
</div>
</div>
)
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Dropdown menus are optimized for keyboard and pointer parity. Focus moves with arrow keys, typeahead remains available through Radix semantics, and destructive options should stay visually distinct from neutral commands."
}
}
},
render: () => (
<div className="grid w-[760px] gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<article 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)]">
Keyboard guidance
</h3>
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Use labels and separators to group commands into short scannable clusters.</p>
<p>
Keep checkbox and radio items in menus only when the state change is immediate
and local to the current context.
</p>
<p>
Prefer concise labels. Long explanatory copy belongs in a dialog or popover,
not in a menu row.
</p>
</div>
</article>
<div className="flex items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<ReleaseMenu triggerLabel="Open keyboard-friendly menu" />
</div>
</div>
)
};
+82 -1
View File
@@ -26,6 +26,24 @@ type LaunchFormValues = {
summary: string;
};
async function waitForCondition(
predicate: () => boolean,
message: string,
timeoutMs = 2000
) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) {
return;
}
await new Promise((resolve) => window.setTimeout(resolve, 16));
}
throw new Error(message);
}
function LaunchSettingsForm() {
const [submitted, setSubmitted] = useState<LaunchFormValues | null>(null);
const form = useForm<LaunchFormValues>({
@@ -184,4 +202,67 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const LaunchSettings: Story = {};
export const LaunchSettings: Story = {
play: async ({ canvasElement }) => {
const emailInput = canvasElement.querySelector('input[placeholder="team@cadence.dev"]');
if (!(emailInput instanceof HTMLInputElement)) {
throw new Error("Expected the email input to render.");
}
emailInput.focus();
emailInput.value = "team@cadence.dev";
emailInput.dispatchEvent(new Event("input", { bubbles: true }));
emailInput.dispatchEvent(new Event("change", { bubbles: true }));
const roleTrigger = [...canvasElement.querySelectorAll('[data-slot="trigger"]')].find(
(element) => element.textContent?.includes("Design")
);
if (!(roleTrigger instanceof HTMLElement)) {
throw new Error("Expected the role select trigger to render.");
}
roleTrigger.click();
await waitForCondition(
() => document.body.querySelector('[role="listbox"]') instanceof HTMLElement,
"Expected the role select content to open."
);
const legalOption = [...document.body.querySelectorAll('[role="option"]')].find((element) =>
element.textContent?.includes("Legal")
);
if (!(legalOption instanceof HTMLElement)) {
throw new Error("Expected to find the Legal option.");
}
legalOption.click();
const summaryInput = canvasElement.querySelector("textarea");
if (!(summaryInput instanceof HTMLTextAreaElement)) {
throw new Error("Expected the launch summary textarea to render.");
}
summaryInput.value = "This release coordinates approvals, copy, and rollout risks.";
summaryInput.dispatchEvent(new Event("input", { bubbles: true }));
summaryInput.dispatchEvent(new Event("change", { bubbles: true }));
const submitButton = [...canvasElement.querySelectorAll("button")].find((element) =>
element.textContent?.includes("Save settings")
);
if (!(submitButton instanceof HTMLButtonElement)) {
throw new Error("Expected the form submit button to render.");
}
submitButton.click();
await waitForCondition(
() => canvasElement.textContent?.includes('"email": "team@cadence.dev"') ?? false,
"Expected the submitted payload preview to update."
);
}
};
+104 -13
View File
@@ -1,10 +1,58 @@
import { Button, Popover, PopoverArrow, PopoverContent, PopoverTrigger } from "@ai-ui/ui";
import {
Button,
Popover,
PopoverArrow,
PopoverClose,
PopoverContent,
PopoverTrigger
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
type SummaryPopoverProps = {
contentSize?: "sm" | "md" | "lg";
side?: "top" | "right" | "bottom" | "left";
triggerLabel?: string;
};
function SummaryPopover({
contentSize = "md",
side = "bottom",
triggerLabel = "Inspect summary"
}: SummaryPopoverProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="secondary">{triggerLabel}</Button>
</PopoverTrigger>
<PopoverContent className="grid gap-3" side={side} size={contentSize}>
<p className="text-sm font-medium">Release health</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
12 checks passed, 2 reviewers pending, and rollout is limited to 10% of
traffic.
</p>
<div className="flex justify-end">
<PopoverClose asChild>
<Button size="sm" variant="ghost">
Dismiss
</Button>
</PopoverClose>
</div>
<PopoverArrow />
</PopoverContent>
</Popover>
);
}
const meta = {
title: "Components/Popover",
component: Popover,
parameters: {
docs: {
description: {
component:
"Popover is the non-blocking overlay for richer contextual detail, lightweight editing, or secondary actions that should stay attached to a trigger without taking over the page. It shares the system's token and motion language while keeping focus management looser than dialog."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -15,18 +63,61 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <SummaryPopover />
};
export const Sizes: Story = {
render: () => (
<Popover>
<PopoverTrigger asChild>
<Button variant="secondary">Inspect summary</Button>
</PopoverTrigger>
<PopoverContent className="grid gap-3">
<p className="text-sm font-medium">Release health</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
12 checks passed, 2 reviewers pending, and rollout is limited to 10% of traffic.
</p>
<PopoverArrow />
</PopoverContent>
</Popover>
<div className="grid w-[760px] gap-3 sm:grid-cols-3">
<SummaryPopover contentSize="sm" triggerLabel="Compact note" />
<SummaryPopover contentSize="md" triggerLabel="Default note" />
<SummaryPopover contentSize="lg" triggerLabel="Expanded note" />
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[700px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
<div className="space-y-3">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Popover anatomy
</p>
<SummaryPopover triggerLabel="Preview popover structure" />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="content"</code> wraps
the floating surface and exposes <code className="text-[var(--color-foreground)]">data-size</code>.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="arrow"</code> visually
connects the surface back to its trigger.
</p>
<p>
Use the trigger for entry, keep the content concise, and add an explicit close
action only when the popover contains multi-step or form-like controls.
</p>
</div>
</div>
</div>
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Popover content animates from the trigger side using the shared rise-and-exit recipes. The side-specific origin keeps the movement anchored to the point of interaction instead of feeling like a detached modal."
}
}
},
render: () => (
<div className="grid w-[760px] gap-3 sm:grid-cols-2">
<SummaryPopover side="top" triggerLabel="Top-origin popover" />
<SummaryPopover side="right" triggerLabel="Right-origin popover" />
<SummaryPopover side="bottom" triggerLabel="Bottom-origin popover" />
<SummaryPopover side="left" triggerLabel="Left-origin popover" />
</div>
)
};
+49 -2
View File
@@ -1,6 +1,24 @@
import { Field, FieldControl, FieldDescription, FieldError, Label, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
async function waitForCondition(
predicate: () => boolean,
message: string,
timeoutMs = 1500
) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) {
return;
}
await new Promise((resolve) => window.setTimeout(resolve, 16));
}
throw new Error(message);
}
const meta = {
title: "Components/Select",
component: Select,
@@ -29,9 +47,38 @@ export const Playground: Story = {
<SelectItem value="legal">Legal review</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Select>
</div>
)
),
play: async ({ canvasElement }) => {
const trigger = canvasElement.querySelector('[data-slot="trigger"]');
if (!(trigger instanceof HTMLElement)) {
throw new Error("Expected the select trigger to render in the canvas.");
}
trigger.click();
await waitForCondition(
() => document.body.querySelector('[role="listbox"]') instanceof HTMLElement,
"Expected the select content to open."
);
const option = [...document.body.querySelectorAll('[role="option"]')].find((element) =>
element.textContent?.includes("Legal review")
);
if (!(option instanceof HTMLElement)) {
throw new Error("Expected to find the Legal review option.");
}
option.click();
await waitForCondition(
() => trigger.textContent?.includes("Legal review") ?? false,
"Expected the trigger label to update after selection."
);
}
};
export const WithField: Story = {
+142
View File
@@ -0,0 +1,142 @@
import {
Button,
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function SettingsSheetDemo({
side = "right",
size = "md"
}: {
side?: "right" | "left" | "bottom";
size?: "sm" | "md" | "lg";
}) {
return (
<Sheet>
<SheetTrigger asChild>
<Button>{side === "bottom" ? "Open mobile tray" : `Open ${side} sheet`}</Button>
</SheetTrigger>
<SheetContent side={side} size={size}>
<SheetHeader>
<SheetTitle>{side === "bottom" ? "Delivery actions" : "Launch settings"}</SheetTitle>
<SheetDescription>
{side === "bottom"
? "Use a bottom sheet when the actions are short, immediate, and touch friendly."
: "Use a sheet when the task benefits from staying anchored to the current page context."}
</SheetDescription>
</SheetHeader>
<div className="grid gap-4">
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<p className="m-0 text-sm font-medium text-[var(--color-foreground)]">
Reviewer routing
</p>
<p className="m-0 mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Keep contextual edits in a side panel instead of interrupting the page with a full
modal flow.
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
<p className="m-0 text-sm font-medium text-[var(--color-foreground)]">
Rollout note
</p>
<p className="m-0 mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Sheets reuse dialog semantics while shifting the visual emphasis toward adjacent,
in-flow work.
</p>
</div>
</div>
<SheetFooter>
<SheetClose asChild>
<Button variant="ghost">Cancel</Button>
</SheetClose>
<Button>Save changes</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
const meta = {
title: "Components/Sheet",
component: SettingsSheetDemo,
parameters: {
docs: {
description: {
component:
"Sheet is a dialog sibling for contextual work that should stay visually attached to the current page. It reuses the same accessible dialog foundation, but presents content from the right, left, or bottom edge."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof SettingsSheetDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Sides: Story = {
render: () => (
<div className="flex flex-wrap gap-3">
<SettingsSheetDemo side="right" />
<SettingsSheetDemo side="left" />
<SettingsSheetDemo side="bottom" />
</div>
)
};
export const Sizes: Story = {
render: () => (
<div className="flex flex-wrap gap-3">
<SettingsSheetDemo size="sm" />
<SettingsSheetDemo size="md" />
<SettingsSheetDemo size="lg" />
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[720px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
<div className="space-y-4">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Sheet anatomy
</p>
<SettingsSheetDemo />
<div className="grid gap-3 text-sm text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="overlay"</code> on the
backdrop layer.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="content"</code> on the
sheet panel itself.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="header"</code>,{" "}
<code className="text-[var(--color-foreground)]">data-slot="footer"</code>,{" "}
<code className="text-[var(--color-foreground)]">data-slot="label"</code>, and{" "}
<code className="text-[var(--color-foreground)]">data-slot="description"</code>{" "}
mirror the dialog contract.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-side</code> and{" "}
<code className="text-[var(--color-foreground)]">data-size</code> expose layout intent
for styling and docs inspection.
</p>
</div>
</div>
</div>
)
};
+116 -15
View File
@@ -1,26 +1,30 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
const meta = {
title: "Components/Tabs",
component: Tabs,
parameters: {
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof Tabs>;
type ReleaseTabsProps = {
includeDisabled?: boolean;
orientation?: "horizontal" | "vertical";
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => (
<Tabs className="w-[720px]" defaultValue="overview">
function ReleaseTabs({
includeDisabled = false,
orientation = "horizontal"
}: ReleaseTabsProps) {
return (
<Tabs
className={
orientation === "vertical"
? "w-[720px] gap-6 md:flex-row"
: "w-[720px]"
}
defaultValue="overview"
orientation={orientation}
>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="timeline">Timeline</TabsTrigger>
<TabsTrigger value="audience">Audience</TabsTrigger>
{includeDisabled ? <TabsTrigger disabled value="billing">Billing</TabsTrigger> : null}
</TabsList>
<TabsContent value="overview">
High-level release summary, current risk score, and owners.
@@ -31,6 +35,103 @@ export const Playground: Story = {
<TabsContent value="audience">
Impacted customer groups, internal reviewers, and communication channels.
</TabsContent>
{includeDisabled ? (
<TabsContent value="billing">
This tab stays disabled until the finance review has been scheduled.
</TabsContent>
) : null}
</Tabs>
);
}
const meta = {
title: "Components/Tabs",
component: Tabs,
parameters: {
docs: {
description: {
component:
"Tabs is the system's in-place content switcher for peer views that belong to the same context. It exposes stable slots for the root, list, triggers, and panels, supports orientation changes, and keeps active-state styling on the trigger rather than moving users into a new page."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof Tabs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <ReleaseTabs />
};
export const States: Story = {
render: () => <ReleaseTabs includeDisabled />
};
export const Orientation: Story = {
render: () => <ReleaseTabs orientation="vertical" />
};
export const Anatomy: Story = {
render: () => (
<div className="w-[760px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
<div className="space-y-3">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Tabs anatomy
</p>
<ReleaseTabs includeDisabled />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="root"</code> carries{" "}
<code className="text-[var(--color-foreground)]">data-orientation</code> so layout
styling can react to horizontal or vertical usage.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="list"</code> groups the
triggers, while <code className="text-[var(--color-foreground)]">data-slot="trigger"</code>{" "}
exposes active and disabled state styling.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="content"</code> frames
the active panel and enters with the same motion language as the rest of the system.
</p>
</div>
</div>
</div>
)
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Tabs works best when every trigger represents a peer view of the same object. Keep the tab labels short, preserve the active tab in the DOM order, and let Radix handle focus and arrow-key movement between triggers."
}
}
},
render: () => (
<div className="grid w-[760px] gap-4 lg:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
<article 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)]">
Accessibility notes
</h3>
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Use tabs for sibling content, not for unrelated navigation destinations.</p>
<p>
Keep trigger labels brief so keyboard users can scan the set quickly.
</p>
<p>
Disabled tabs should represent unavailable views, not hidden primary steps.
</p>
</div>
</article>
<div className="flex items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<ReleaseTabs includeDisabled />
</div>
</div>
)
};
+119 -8
View File
@@ -11,18 +11,30 @@ import {
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
function ToastDemo() {
type ToastDemoProps = {
actionLabel?: string;
buttonLabel?: string;
description?: string;
title?: string;
variant?: "default" | "success" | "destructive";
};
function ToastDemo({
actionLabel = "Open rollout",
buttonLabel = "Show toast",
description = "The rollout has been scheduled and reviewers were notified.",
title = "Release queued",
variant = "success"
}: ToastDemoProps) {
const [open, setOpen] = useState(false);
return (
<ToastProvider swipeDirection="right">
<Button onClick={() => setOpen(true)}>Show toast</Button>
<Toast onOpenChange={setOpen} open={open} variant="success">
<ToastTitle>Release queued</ToastTitle>
<ToastDescription>
The rollout has been scheduled and reviewers were notified.
</ToastDescription>
<ToastAction altText="Open rollout">Open rollout</ToastAction>
<Button onClick={() => setOpen(true)}>{buttonLabel}</Button>
<Toast onOpenChange={setOpen} open={open} variant={variant}>
<ToastTitle>{title}</ToastTitle>
<ToastDescription>{description}</ToastDescription>
<ToastAction altText={actionLabel}>{actionLabel}</ToastAction>
<ToastClose />
</Toast>
<ToastViewport />
@@ -34,6 +46,12 @@ const meta = {
title: "Components/Toast",
component: ToastDemo,
parameters: {
docs: {
description: {
component:
"Toast is the transient feedback surface for async completion, background status, and lightweight follow-up actions. It supports semantic variants, swipe dismissal, a global viewport, and stable slots for title, description, action, and close affordances."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -44,3 +62,96 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Variants: Story = {
render: () => (
<div className="grid w-[760px] gap-3 sm:grid-cols-3">
<ToastDemo
actionLabel="Review feed"
buttonLabel="Default toast"
description="The draft moved into the shared review queue."
title="Draft updated"
variant="default"
/>
<ToastDemo
actionLabel="Open rollout"
buttonLabel="Success toast"
description="The rollout has been scheduled and reviewers were notified."
title="Release queued"
variant="success"
/>
<ToastDemo
actionLabel="Inspect failure"
buttonLabel="Destructive toast"
description="Two critical checks failed and the launch remains blocked."
title="Launch blocked"
variant="destructive"
/>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[720px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
<div className="space-y-3">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Toast anatomy
</p>
<ToastDemo buttonLabel="Trigger anatomy demo" />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="viewport"</code> owns
the screen edge stacking region for multiple toasts.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="root"</code> exposes{" "}
<code className="text-[var(--color-foreground)]">data-variant</code> for semantic
feedback styling.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="label"</code>,{" "}
<code className="text-[var(--color-foreground)]">data-slot="description"</code>,{" "}
<code className="text-[var(--color-foreground)]">data-slot="action"</code>, and{" "}
<code className="text-[var(--color-foreground)]">data-slot="close"</code> structure
the content and affordances.
</p>
</div>
</div>
</div>
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Toast enters with the shared rise motion, exits faster than it enters, and supports swipe gestures through the Radix state model. That keeps feedback lightweight without feeling abrupt."
}
}
},
render: () => (
<div className="grid w-[760px] gap-4 lg:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
<article 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)]">
Motion behavior
</h3>
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Trigger a toast, then dismiss it with the close button or a horizontal swipe.</p>
<p>
The viewport stacks from the lower edge on small screens and tightens into the
lower-right corner on larger canvases.
</p>
<p>
Keep toast actions secondary and fast. Anything that needs a full decision should
escalate into a dialog instead.
</p>
</div>
</article>
<div className="flex items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<ToastDemo buttonLabel="Trigger motion demo" />
</div>
</div>
)
};
+107 -12
View File
@@ -1,10 +1,47 @@
import { Button, Tooltip, TooltipArrow, TooltipContent, TooltipProvider, TooltipTrigger } from "@ai-ui/ui";
import {
Button,
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
type InlineTooltipProps = {
contentSize?: "sm" | "md" | "lg";
triggerLabel?: string;
};
function InlineTooltip({
contentSize = "md",
triggerLabel = "Hover for note"
}: InlineTooltipProps) {
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost">{triggerLabel}</Button>
</TooltipTrigger>
<TooltipContent size={contentSize}>
Inline notes stay terse and avoid blocking the main flow.
<TooltipArrow />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
const meta = {
title: "Components/Tooltip",
component: Tooltip,
parameters: {
docs: {
description: {
component:
"Tooltip is the system's smallest assistive overlay. Use it for terse clarification, shortcut hints, or icon-only controls that benefit from a compact label on hover and focus. If the content needs interaction or more than a sentence, switch to popover instead."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -15,17 +52,75 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <InlineTooltip />
};
export const Sizes: Story = {
render: () => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost">Hover for note</Button>
</TooltipTrigger>
<TooltipContent>
Inline notes stay terse and avoid blocking the main flow.
<TooltipArrow />
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="grid w-[720px] gap-3 sm:grid-cols-3">
<InlineTooltip contentSize="sm" triggerLabel="Small hint" />
<InlineTooltip contentSize="md" triggerLabel="Default hint" />
<InlineTooltip contentSize="lg" triggerLabel="Longer hint" />
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[700px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
<div className="space-y-3">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Tooltip anatomy
</p>
<InlineTooltip triggerLabel="Preview tooltip structure" />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="content"</code> holds
the compact explanatory copy and exposes the size token.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="arrow"</code> anchors
the floating label back to its trigger.
</p>
<p>
Tooltip copy should stay short enough to scan in one glance. It is a hint, not a
mini panel.
</p>
</div>
</div>
</div>
)
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Tooltips should reinforce an existing control, not carry primary instructions. Because they open on hover and focus, their content should stay concise, non-interactive, and understandable without requiring pointer precision."
}
}
},
render: () => (
<div className="grid w-[760px] gap-4 lg:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
<article 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)]">
Accessibility notes
</h3>
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Always keep the trigger label meaningful even without the tooltip.</p>
<p>
Use tooltips to clarify or shorten, not to hide critical onboarding copy.
</p>
<p>
Avoid links, buttons, or form controls inside tooltip content. Those belong in a
popover.
</p>
</div>
</article>
<div className="flex items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<InlineTooltip triggerLabel="Focus-friendly tooltip" />
</div>
</div>
)
};