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
+3
View File
@@ -6,4 +6,7 @@ coverage
.pnpm-store .pnpm-store
/.storybook /.storybook
.home .home
.tmp-home
playwright-report
test-results
.DS_Store .DS_Store
+1 -1
View File
@@ -42,7 +42,7 @@ const preview: Preview = {
}, },
parameters: { parameters: {
a11y: { a11y: {
test: "todo" test: "error"
}, },
backgrounds: { backgrounds: {
default: "canvas", default: "canvas",
@@ -0,0 +1,253 @@
import {
authoringChecklist,
commonSlotNames,
commonStateNames,
cvaConventions
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
const docsCoverage = [
{
name: "Playground",
note: "One opinionated default example that shows the component in its most typical role."
},
{
name: "States",
note: "Only when the component has meaningful disabled, invalid, checked, selected, or open-state behavior worth comparing."
},
{
name: "Anatomy",
note: "Name the stable slots and public data attributes that designers and engineers can style against."
},
{
name: "Accessibility or Motion",
note: "Choose the dimension that is most likely to be misunderstood by contributors and consumers."
}
] as const;
const docsWritingRules = [
"Use `docs.description.component` to explain when the component should be chosen, not to restate its name.",
"Show real product language instead of lorem ipsum so states and hierarchy feel intentional.",
"Keep examples narrow. One good scenario teaches more than six generic permutations.",
"If a story exists only to explain slots or motion, say that directly in the story description."
] as const;
function ComponentAuthoringGuide() {
return (
<div className="min-h-screen bg-[var(--color-background)] px-6 py-10 text-[var(--color-foreground)] sm:px-10">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
<header className="max-w-4xl space-y-3">
<p className="text-sm uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
AI UI / Docs Guide
</p>
<h1
className="font-semibold tracking-[var(--tracking-tight)]"
style={{
fontFamily: "var(--font-display)",
fontSize: "var(--text-4xl)",
lineHeight: "var(--leading-tight)"
}}
>
Component docs should explain usage, structure, and behavior without drifting
away from the actual source contract.
</h1>
<p className="text-[var(--text-lg)] leading-[var(--leading-loose)] text-[var(--color-muted-foreground)]">
This page turns the repo&apos;s component contract into a repeatable Storybook
recipe. The goal is not maximum story count. The goal is a small set of stories
that makes API, anatomy, and behavioral intent obvious to the next contributor.
</p>
</header>
<section className="grid gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 className="text-2xl font-semibold">Minimum Story Recipe</h2>
<div className="mt-5 grid gap-3">
{docsCoverage.map((item) => (
<div
key={item.name}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3"
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">{item.name}</p>
<span className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
docs lane
</span>
</div>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
{item.note}
</p>
</div>
))}
</div>
</article>
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 className="text-2xl font-semibold">Writing Rules</h2>
<div className="mt-5 grid gap-3">
{docsWritingRules.map((rule) => (
<div
key={rule}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3"
>
<p className="text-sm leading-6 text-[var(--color-foreground)]">{rule}</p>
</div>
))}
</div>
</article>
</section>
<section className="grid gap-4 lg:grid-cols-2">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 className="text-2xl font-semibold">Component Contract Inputs</h2>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
The docs should mirror the same public hooks that the components expose in
source.
</p>
<div className="mt-5 grid gap-3">
{authoringChecklist.map((item) => (
<div
key={item}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3"
>
<p className="text-sm leading-6 text-[var(--color-foreground)]">{item}</p>
</div>
))}
</div>
</article>
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 className="text-2xl font-semibold">CVA Guardrails</h2>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
Docs should reinforce the variant surface that engineering already considers
stable.
</p>
<div className="mt-5 grid gap-3">
{cvaConventions.map((item) => (
<div
key={item}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3"
>
<p className="text-sm leading-6 text-[var(--color-foreground)]">{item}</p>
</div>
))}
</div>
</article>
</section>
<section className="grid gap-4 lg:grid-cols-2">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 className="text-2xl font-semibold">Slots Worth Calling Out</h2>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
Anatomy stories should name only the slots a consumer can reasonably style or
inspect in tests.
</p>
<div className="mt-5 grid gap-3">
{commonSlotNames.map((item) => (
<div
key={item.slot}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3"
>
<div className="flex items-center justify-between gap-3">
<code className="text-sm font-medium">{`data-slot="${item.slot}"`}</code>
<span className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
slot
</span>
</div>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
{item.guidance}
</p>
</div>
))}
</div>
</article>
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 className="text-2xl font-semibold">States Worth Explaining</h2>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
State stories should match the durable `data-*` surface, not one-off visual
tweaks.
</p>
<div className="mt-5 grid gap-3">
{commonStateNames.map((item) => (
<div
key={item.state}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3"
>
<div className="flex items-center justify-between gap-3">
<code className="text-sm font-medium">{`data-${item.state}`}</code>
<span className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
state
</span>
</div>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
{item.guidance}
</p>
</div>
))}
</div>
</article>
</section>
<section className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 className="text-2xl font-semibold">Before Opening The PR</h2>
<div className="mt-5 grid gap-3 md:grid-cols-2">
<div className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3">
<p className="text-sm font-medium text-[var(--color-foreground)]">
Ask if the docs tell a consumer when to choose the component.
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
If the answer is &quot;not really&quot;, the component description is still too
generic.
</p>
</div>
<div className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3">
<p className="text-sm font-medium text-[var(--color-foreground)]">
Ask if the anatomy story names the real public styling hooks.
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
If the story only explains visuals, it is not yet useful to another engineer.
</p>
</div>
<div className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3">
<p className="text-sm font-medium text-[var(--color-foreground)]">
Ask if the most failure-prone behavior is explicitly documented.
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
For overlays that usually means accessibility. For animated surfaces it may
be motion.
</p>
</div>
<div className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3">
<p className="text-sm font-medium text-[var(--color-foreground)]">
Ask if the story count stayed disciplined.
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
The docs should feel intentional, not encyclopedic.
</p>
</div>
</div>
</section>
</div>
</div>
);
}
const meta = {
title: "Foundation/Component Authoring",
component: ComponentAuthoringGuide,
parameters: {
docs: {
description: {
component:
"Use this page as the Storybook-side companion to the repo's component contract. It defines the minimum documentation recipe for a new component and keeps docs work aligned with slots, states, variants, and motion semantics that already exist in source."
}
},
layout: "fullscreen"
}
} satisfies Meta<typeof ComponentAuthoringGuide>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Overview: Story = {};
+23 -1
View File
@@ -1,6 +1,18 @@
import { Button } from "@ai-ui/ui"; import { Button } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react"; 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 = { const meta = {
title: "Components/Button", title: "Components/Button",
component: Button, component: Button,
@@ -61,7 +73,17 @@ export default meta;
type Story = StoryObj<typeof 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 = { export const Variants: Story = {
render: () => ( render: () => (
+130 -17
View File
@@ -10,10 +10,48 @@ import {
} from "@ai-ui/ui"; } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react"; 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 = { const meta = {
title: "Components/Dialog", title: "Components/Dialog",
component: Dialog, component: Dialog,
parameters: { 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" layout: "centered"
}, },
tags: ["autodocs"] tags: ["autodocs"]
@@ -24,23 +62,98 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Playground: Story = { export const Playground: Story = {
render: () => <LaunchDialog />
};
export const Sizes: Story = {
render: () => ( render: () => (
<Dialog> <div className="grid w-[720px] gap-3 sm:grid-cols-2">
<DialogTrigger asChild> <LaunchDialog
<Button>Open approval dialog</Button> description="Use the compact size for short confirmations that only need a title, one supporting sentence, and one primary action."
</DialogTrigger> size="sm"
<DialogContent> title="Publish summary?"
<DialogHeader> triggerLabel="Compact dialog"
<DialogTitle>Launch this release?</DialogTitle> />
<DialogDescription> <LaunchDialog
This will notify the routing team and publish the release note to the activity feed. description="Use the large size when the flow needs denser copy, audit context, or multi-step review detail before a final action."
</DialogDescription> size="lg"
</DialogHeader> title="Review rollout checklist"
<DialogFooter> triggerLabel="Large dialog"
<Button variant="ghost">Cancel</Button> />
<Button>Confirm launch</Button> </div>
</DialogFooter> )
</DialogContent> };
</Dialog>
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"; } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
const meta = { type ReleaseMenuProps = {
title: "Components/DropdownMenu", triggerLabel?: string;
component: DropdownMenu, };
parameters: {
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof DropdownMenu>;
export default meta; function ReleaseMenu({ triggerLabel = "Open menu" }: ReleaseMenuProps) {
return (
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="secondary">Open menu</Button> <Button variant="secondary">{triggerLabel}</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuLabel>Launch actions</DropdownMenuLabel> <DropdownMenuLabel>Launch actions</DropdownMenuLabel>
@@ -45,6 +36,10 @@ export const Playground: Story = {
Share preview Share preview
<DropdownMenuShortcut>S</DropdownMenuShortcut> <DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem disabled>
Retry checks
<DropdownMenuShortcut>R</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked>Notify stakeholders</DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem checked>Notify stakeholders</DropdownMenuCheckboxItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -62,5 +57,110 @@ export const Playground: Story = {
</DropdownMenuSub> </DropdownMenuSub>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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; 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() { function LaunchSettingsForm() {
const [submitted, setSubmitted] = useState<LaunchFormValues | null>(null); const [submitted, setSubmitted] = useState<LaunchFormValues | null>(null);
const form = useForm<LaunchFormValues>({ const form = useForm<LaunchFormValues>({
@@ -184,4 +202,67 @@ export default meta;
type Story = StoryObj<typeof 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"; 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 = { const meta = {
title: "Components/Popover", title: "Components/Popover",
component: Popover, component: Popover,
parameters: { 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" layout: "centered"
}, },
tags: ["autodocs"] tags: ["autodocs"]
@@ -15,18 +63,61 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Playground: Story = { export const Playground: Story = {
render: () => <SummaryPopover />
};
export const Sizes: Story = {
render: () => ( render: () => (
<Popover> <div className="grid w-[760px] gap-3 sm:grid-cols-3">
<PopoverTrigger asChild> <SummaryPopover contentSize="sm" triggerLabel="Compact note" />
<Button variant="secondary">Inspect summary</Button> <SummaryPopover contentSize="md" triggerLabel="Default note" />
</PopoverTrigger> <SummaryPopover contentSize="lg" triggerLabel="Expanded note" />
<PopoverContent className="grid gap-3"> </div>
<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> export const Anatomy: Story = {
<PopoverArrow /> render: () => (
</PopoverContent> <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)]">
</Popover> <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 { Field, FieldControl, FieldDescription, FieldError, Label, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react"; 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 = { const meta = {
title: "Components/Select", title: "Components/Select",
component: Select, component: Select,
@@ -29,9 +47,38 @@ export const Playground: Story = {
<SelectItem value="legal">Legal review</SelectItem> <SelectItem value="legal">Legal review</SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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 = { 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
const meta = { type ReleaseTabsProps = {
title: "Components/Tabs", includeDisabled?: boolean;
component: Tabs, orientation?: "horizontal" | "vertical";
parameters: { };
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof Tabs>;
export default meta; function ReleaseTabs({
includeDisabled = false,
type Story = StoryObj<typeof meta>; orientation = "horizontal"
}: ReleaseTabsProps) {
export const Playground: Story = { return (
render: () => ( <Tabs
<Tabs className="w-[720px]" defaultValue="overview"> className={
orientation === "vertical"
? "w-[720px] gap-6 md:flex-row"
: "w-[720px]"
}
defaultValue="overview"
orientation={orientation}
>
<TabsList> <TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger> <TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="timeline">Timeline</TabsTrigger> <TabsTrigger value="timeline">Timeline</TabsTrigger>
<TabsTrigger value="audience">Audience</TabsTrigger> <TabsTrigger value="audience">Audience</TabsTrigger>
{includeDisabled ? <TabsTrigger disabled value="billing">Billing</TabsTrigger> : null}
</TabsList> </TabsList>
<TabsContent value="overview"> <TabsContent value="overview">
High-level release summary, current risk score, and owners. High-level release summary, current risk score, and owners.
@@ -31,6 +35,103 @@ export const Playground: Story = {
<TabsContent value="audience"> <TabsContent value="audience">
Impacted customer groups, internal reviewers, and communication channels. Impacted customer groups, internal reviewers, and communication channels.
</TabsContent> </TabsContent>
{includeDisabled ? (
<TabsContent value="billing">
This tab stays disabled until the finance review has been scheduled.
</TabsContent>
) : null}
</Tabs> </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 type { Meta, StoryObj } from "@storybook/react";
import { useState } from "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); const [open, setOpen] = useState(false);
return ( return (
<ToastProvider swipeDirection="right"> <ToastProvider swipeDirection="right">
<Button onClick={() => setOpen(true)}>Show toast</Button> <Button onClick={() => setOpen(true)}>{buttonLabel}</Button>
<Toast onOpenChange={setOpen} open={open} variant="success"> <Toast onOpenChange={setOpen} open={open} variant={variant}>
<ToastTitle>Release queued</ToastTitle> <ToastTitle>{title}</ToastTitle>
<ToastDescription> <ToastDescription>{description}</ToastDescription>
The rollout has been scheduled and reviewers were notified. <ToastAction altText={actionLabel}>{actionLabel}</ToastAction>
</ToastDescription>
<ToastAction altText="Open rollout">Open rollout</ToastAction>
<ToastClose /> <ToastClose />
</Toast> </Toast>
<ToastViewport /> <ToastViewport />
@@ -34,6 +46,12 @@ const meta = {
title: "Components/Toast", title: "Components/Toast",
component: ToastDemo, component: ToastDemo,
parameters: { 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" layout: "centered"
}, },
tags: ["autodocs"] tags: ["autodocs"]
@@ -44,3 +62,96 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Playground: Story = {}; 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"; 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 = { const meta = {
title: "Components/Tooltip", title: "Components/Tooltip",
component: Tooltip, component: Tooltip,
parameters: { 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" layout: "centered"
}, },
tags: ["autodocs"] tags: ["autodocs"]
@@ -15,17 +52,75 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Playground: Story = { export const Playground: Story = {
render: () => <InlineTooltip />
};
export const Sizes: Story = {
render: () => ( render: () => (
<TooltipProvider> <div className="grid w-[720px] gap-3 sm:grid-cols-3">
<Tooltip> <InlineTooltip contentSize="sm" triggerLabel="Small hint" />
<TooltipTrigger asChild> <InlineTooltip contentSize="md" triggerLabel="Default hint" />
<Button variant="ghost">Hover for note</Button> <InlineTooltip contentSize="lg" triggerLabel="Longer hint" />
</TooltipTrigger> </div>
<TooltipContent> )
Inline notes stay terse and avoid blocking the main flow. };
<TooltipArrow />
</TooltipContent> export const Anatomy: Story = {
</Tooltip> render: () => (
</TooltipProvider> <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>
) )
}; };
+3
View File
@@ -12,11 +12,14 @@
"dev:docs": "pnpm --filter @ai-ui/docs storybook", "dev:docs": "pnpm --filter @ai-ui/docs storybook",
"lint": "eslint .", "lint": "eslint .",
"test": "pnpm --filter @ai-ui/ui test", "test": "pnpm --filter @ai-ui/ui test",
"test:e2e": "playwright test",
"test:e2e:smoke": "playwright test tests/e2e/storybook-smoke.spec.ts",
"test:watch": "pnpm --filter @ai-ui/ui test:watch", "test:watch": "pnpm --filter @ai-ui/ui test:watch",
"typecheck": "pnpm -r typecheck" "typecheck": "pnpm -r typecheck"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.55.0",
"@storybook/addon-a11y": "^8.6.14", "@storybook/addon-a11y": "^8.6.14",
"@storybook/addon-essentials": "^8.6.14", "@storybook/addon-essentials": "^8.6.14",
"@storybook/addon-interactions": "^8.6.14", "@storybook/addon-interactions": "^8.6.14",
+1 -1
View File
@@ -1,7 +1,7 @@
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { Avatar, AvatarFallback, AvatarImage } from "./avatar"; import { Avatar, AvatarFallback } from "./avatar";
describe("Avatar", () => { describe("Avatar", () => {
it("renders root slot metadata and fallback content", async () => { it("renders root slot metadata and fallback content", async () => {
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { Button } from "./button"; import { Button } from "./button";
import { setReducedMotionPreference } from "../test/a11y";
describe("Button", () => { describe("Button", () => {
it("renders a native button with root and label slots", () => { it("renders a native button with root and label slots", () => {
@@ -49,4 +50,16 @@ describe("Button", () => {
expect(link).toHaveAttribute("data-variant", "ghost"); expect(link).toHaveAttribute("data-variant", "ghost");
expect(link).not.toHaveAttribute("type"); expect(link).not.toHaveAttribute("type");
}); });
it("preserves the loading contract when reduced motion is preferred", () => {
setReducedMotionPreference(true);
render(<Button loading>Saving</Button>);
const button = screen.getByRole("button", { name: "Saving" });
expect(button).toBeDisabled();
expect(button).toHaveAttribute("data-loading", "");
expect(button.querySelector('[data-slot="icon"]')).toBeInTheDocument();
});
}); });
+13 -4
View File
@@ -104,6 +104,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
ref ref
) { ) {
const field = useFieldContext(); const field = useFieldContext();
const reactId = useId();
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? ""); const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "");
const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState(defaultSearchValue); const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState(defaultSearchValue);
@@ -115,7 +116,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
const resolvedSearchValue = searchValue ?? uncontrolledSearchValue; const resolvedSearchValue = searchValue ?? uncontrolledSearchValue;
const resolvedDisabled = disabled ?? field?.disabled ?? false; const resolvedDisabled = disabled ?? field?.disabled ?? false;
const resolvedInvalid = invalid ?? field?.invalid ?? false; const resolvedInvalid = invalid ?? field?.invalid ?? false;
const controlId = id ?? props.name ?? field?.inputId ?? `combobox-${useId().replace(/:/g, "")}`; const controlId = id ?? props.name ?? field?.inputId ?? `combobox-${reactId.replace(/:/g, "")}`;
const describedBy = mergeIds( const describedBy = mergeIds(
typeof props["aria-describedby"] === "string" ? props["aria-describedby"] : undefined, typeof props["aria-describedby"] === "string" ? props["aria-describedby"] : undefined,
field?.descriptionId, field?.descriptionId,
@@ -212,7 +213,13 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
const firstEnabledIndex = filteredItems.findIndex((item) => !item.disabled); const firstEnabledIndex = filteredItems.findIndex((item) => !item.disabled);
const nextIndex = selectedIndex >= 0 ? selectedIndex : firstEnabledIndex; const nextIndex = selectedIndex >= 0 ? selectedIndex : firstEnabledIndex;
setActiveIndex(nextIndex); const frame = requestAnimationFrame(() => {
setActiveIndex(nextIndex);
});
return () => {
cancelAnimationFrame(frame);
};
}, [filteredItems, resolvedOpen, resolvedValue]); }, [filteredItems, resolvedOpen, resolvedValue]);
useEffect(() => { useEffect(() => {
@@ -223,6 +230,10 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
itemRefs.current[activeIndex]?.scrollIntoView({ block: "nearest" }); itemRefs.current[activeIndex]?.scrollIntoView({ block: "nearest" });
}, [activeIndex]); }, [activeIndex]);
useEffect(() => {
itemRefs.current = [];
}, [filteredItems]);
const handleSelect = (item: ComboboxItem) => { const handleSelect = (item: ComboboxItem) => {
if (item.disabled) { if (item.disabled) {
return; return;
@@ -273,8 +284,6 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
} }
}; };
itemRefs.current = [];
return ( return (
<div <div
{...createSlot("root")} {...createSlot("root")}
+45
View File
@@ -193,4 +193,49 @@ describe("Form", () => {
expect(await screen.findByText("Saved successfully.")).toBeInTheDocument(); expect(await screen.findByText("Saved successfully.")).toBeInTheDocument();
}); });
it("merges caller-provided aria-describedby ids with generated field messaging", async () => {
const user = userEvent.setup();
function DescribedByPreview() {
const form = useForm<{ email: string }>({
defaultValues: {
email: ""
}
});
return (
<Form {...form}>
<form noValidate onSubmit={form.handleSubmit(() => undefined)}>
<FormItem name="email">
<FormLabel>Email address</FormLabel>
<FormControl aria-describedby="supporting-note">
<Input
{...form.register("email", {
required: "Email is required."
})}
/>
</FormControl>
<FormDescription>We send launch notes here.</FormDescription>
<FormMessage />
</FormItem>
<p id="supporting-note">Primary contact for release notifications.</p>
<Button type="submit">Save</Button>
</form>
</Form>
);
}
render(<DescribedByPreview />);
await user.click(screen.getByRole("button", { name: "Save" }));
const input = screen.getByRole("textbox", { name: "Email address" });
const description = screen.getByText("We send launch notes here.");
const message = await screen.findByText("Email is required.");
expect(input).toHaveAttribute("aria-describedby", expect.stringContaining("supporting-note"));
expect(input).toHaveAttribute("aria-describedby", expect.stringContaining(description.id));
expect(input).toHaveAttribute("aria-describedby", expect.stringContaining(message.id));
});
}); });
@@ -128,4 +128,20 @@ describe("Select", () => {
expect(trigger).toBeDisabled(); expect(trigger).toBeDisabled();
expect(trigger).toHaveAttribute("data-disabled", ""); expect(trigger).toHaveAttribute("data-disabled", "");
}); });
it("opens from the keyboard so combobox interaction stays accessible", async () => {
const user = userEvent.setup();
render(<ReviewLaneSelect defaultValue="editorial" />);
const trigger = screen.getByRole("combobox", { name: "Review lane" });
trigger.focus();
await user.keyboard("{ArrowDown}");
const listbox = await screen.findByRole("listbox");
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(within(listbox).getByRole("option", { name: "Editorial review" })).toBeInTheDocument();
});
}); });
+133
View File
@@ -0,0 +1,133 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger
} from "./sheet";
describe("Sheet", () => {
it("opens from the trigger and closes from the close control", async () => {
const user = userEvent.setup();
render(
<Sheet>
<SheetTrigger>Open sheet</SheetTrigger>
<SheetContent side="right" size="lg">
<SheetHeader>
<SheetTitle>Review launch</SheetTitle>
<SheetDescription>Confirm the rollout details before publishing.</SheetDescription>
</SheetHeader>
<SheetFooter>
<SheetClose>Done</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Open sheet" }));
const sheet = await screen.findByRole("dialog");
expect(sheet).toHaveAttribute("data-slot", "content");
expect(sheet).toHaveAttribute("data-side", "right");
expect(sheet).toHaveAttribute("data-size", "lg");
expect(screen.getByText("Review launch")).toHaveAttribute("data-slot", "label");
expect(screen.getByText("Confirm the rollout details before publishing.")).toHaveAttribute(
"data-slot",
"description"
);
expect(document.querySelector('[data-slot="overlay"]')).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Close sheet" }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
it("notifies controlled open state changes from trigger and Escape", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(
<Sheet open={false} onOpenChange={onOpenChange}>
<SheetTrigger>Open controlled sheet</SheetTrigger>
<SheetContent side="left">
<SheetTitle>Controlled</SheetTitle>
</SheetContent>
</Sheet>
);
await user.click(screen.getByRole("button", { name: "Open controlled sheet" }));
expect(onOpenChange).toHaveBeenCalledWith(true);
render(
<Sheet open onOpenChange={onOpenChange}>
<SheetTrigger>Open controlled sheet</SheetTrigger>
<SheetContent side="left">
<SheetTitle>Controlled</SheetTitle>
</SheetContent>
</Sheet>
);
await user.keyboard("{Escape}");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("supports bottom sheets while remaining accessible as a dialog", async () => {
const user = userEvent.setup();
render(
<Sheet>
<SheetTrigger>Open mobile actions</SheetTrigger>
<SheetContent side="bottom">
<SheetHeader>
<SheetTitle>Mobile actions</SheetTitle>
<SheetDescription>Choose how to continue this rollout.</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>
);
await user.click(screen.getByRole("button", { name: "Open mobile actions" }));
const sheet = await screen.findByRole("dialog", { name: "Mobile actions" });
expect(sheet).toHaveAttribute("data-side", "bottom");
expect(sheet).toHaveAttribute("data-size", "md");
});
it("renders header and footer slots when provided", async () => {
const user = userEvent.setup();
render(
<Sheet>
<SheetTrigger>Open summary sheet</SheetTrigger>
<SheetContent side="left" size="sm">
<SheetHeader>
<SheetTitle>Summary</SheetTitle>
</SheetHeader>
<SheetFooter>
<SheetClose>Close</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
);
await user.click(screen.getByRole("button", { name: "Open summary sheet" }));
const sheet = await screen.findByRole("dialog");
expect(within(sheet).getByText("Summary").closest('[data-slot="header"]')).toBeInTheDocument();
expect(
within(sheet).getByRole("button", { name: "Close" }).closest('[data-slot="footer"]')
).toBeInTheDocument();
});
});
+128
View File
@@ -0,0 +1,128 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
import {
sheetContentVariants,
sheetFooterVariants,
sheetHeaderVariants,
sheetOverlayVariants
} from "./sheet.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export const Sheet = DialogPrimitive.Root;
export const SheetTrigger = DialogPrimitive.Trigger;
export const SheetPortal = DialogPrimitive.Portal;
export const SheetClose = DialogPrimitive.Close;
export const SheetOverlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(function SheetOverlay({ className, ...props }, ref) {
return (
<DialogPrimitive.Overlay
{...props}
{...createSlot("overlay")}
className={cn(sheetOverlayVariants(), className)}
ref={ref}
/>
);
});
export type SheetContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
VariantProps<typeof sheetContentVariants>;
export const SheetContent = forwardRef<
ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(function SheetContent({ children, className, side, size, ...props }, ref) {
const resolvedSide = side ?? "right";
const resolvedSize = size ?? "md";
return (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
{...props}
{...createSlot("content")}
{...createDataAttributes({
side: resolvedSide,
size: resolvedSize
})}
className={cn(
sheetContentVariants({
side: resolvedSide,
size: resolvedSize
}),
className
)}
ref={ref}
>
{children}
<DialogPrimitive.Close
aria-label="Close sheet"
className="absolute right-4 top-4 inline-flex size-9 items-center justify-center rounded-[var(--radius-full)] text-[var(--color-muted-foreground)] outline-none transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)] hover:bg-[var(--color-surface)] hover:text-[var(--color-foreground)] focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-card)]"
>
<span aria-hidden="true" className="text-lg leading-none">
×
</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
);
});
export function SheetHeader({
className,
...props
}: ComponentPropsWithoutRef<"div">) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(sheetHeaderVariants(), className)}
/>
);
}
export function SheetFooter({
className,
...props
}: ComponentPropsWithoutRef<"div">) {
return (
<div
{...props}
{...createSlot("footer")}
className={cn(sheetFooterVariants(), className)}
/>
);
}
export const SheetTitle = forwardRef<
ElementRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(function SheetTitle({ className, ...props }, ref) {
return (
<DialogPrimitive.Title
{...props}
{...createSlot("label")}
className={cn("pr-10 text-xl font-semibold tracking-tight", className)}
ref={ref}
/>
);
});
export const SheetDescription = forwardRef<
ElementRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(function SheetDescription({ className, ...props }, ref) {
return (
<DialogPrimitive.Description
{...props}
{...createSlot("description")}
className={cn("text-sm leading-6 text-[var(--color-muted-foreground)]", className)}
ref={ref}
/>
);
});
@@ -0,0 +1,79 @@
import { cva } from "../lib/cva";
import { dialogOverlayVariants } from "./dialog.variants";
export const sheetOverlayVariants = dialogOverlayVariants;
export const sheetContentVariants = cva(
[
"fixed z-50 grid gap-5 overflow-y-auto",
"border bg-[var(--color-card)] text-[var(--color-card-foreground)] shadow-[var(--shadow-md)] outline-none",
"transition-[transform,opacity,box-shadow] duration-[var(--dur-slow)] ease-[var(--ease-emphasized)]",
"data-[state=open]:opacity-100 data-[state=closed]:opacity-0"
],
{
variants: {
side: {
right: [
"inset-y-0 right-0 h-full rounded-l-[var(--radius-lg)] border-l border-y-0 border-r-0",
"data-[state=open]:translate-x-0 data-[state=closed]:translate-x-full"
],
left: [
"inset-y-0 left-0 h-full rounded-r-[var(--radius-lg)] border-r border-y-0 border-l-0",
"data-[state=open]:translate-x-0 data-[state=closed]:-translate-x-full"
],
bottom: [
"bottom-0 left-1/2 max-h-[min(85vh,42rem)] w-[min(calc(100vw-1rem),52rem)] -translate-x-1/2",
"rounded-t-[var(--radius-lg)] border-b-0",
"data-[state=open]:translate-y-0 data-[state=closed]:translate-y-full"
]
},
size: {
sm: "",
md: "",
lg: ""
}
},
compoundVariants: [
{
side: ["left", "right"],
size: "sm",
class: "w-[min(calc(100vw-1rem),22rem)]"
},
{
side: ["left", "right"],
size: "md",
class: "w-[min(calc(100vw-1rem),28rem)]"
},
{
side: ["left", "right"],
size: "lg",
class: "w-[min(calc(100vw-1rem),36rem)]"
},
{
side: "bottom",
size: "sm",
class: "pb-5 px-5 pt-6 sm:px-6"
},
{
side: "bottom",
size: "md",
class: "pb-5 px-5 pt-6 sm:px-6"
},
{
side: "bottom",
size: "lg",
class: "pb-6 px-5 pt-6 sm:px-6"
}
],
defaultVariants: {
side: "right",
size: "md"
}
}
);
export const sheetHeaderVariants = cva(["flex flex-col gap-2 text-left"]);
export const sheetFooterVariants = cva([
"flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"
]);
+19
View File
@@ -226,6 +226,25 @@ export {
} from "./components/select.variants"; } from "./components/select.variants";
export { Separator, type SeparatorProps } from "./components/separator"; export { Separator, type SeparatorProps } from "./components/separator";
export { separatorVariants } from "./components/separator.variants"; export { separatorVariants } from "./components/separator.variants";
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
type SheetContentProps
} from "./components/sheet";
export {
sheetContentVariants,
sheetFooterVariants,
sheetHeaderVariants,
sheetOverlayVariants
} from "./components/sheet.variants";
export { Skeleton, type SkeletonProps } from "./components/skeleton"; export { Skeleton, type SkeletonProps } from "./components/skeleton";
export { Spinner, type SpinnerProps } from "./components/spinner"; export { Spinner, type SpinnerProps } from "./components/spinner";
export { Switch, type SwitchProps } from "./components/switch"; export { Switch, type SwitchProps } from "./components/switch";
+15
View File
@@ -0,0 +1,15 @@
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
let prefersReducedMotion = false;
export function matchesMediaQuery(query: string) {
return query === REDUCED_MOTION_QUERY ? prefersReducedMotion : false;
}
export function resetAccessibilityPreferences() {
prefersReducedMotion = false;
}
export function setReducedMotionPreference(nextValue: boolean) {
prefersReducedMotion = nextValue;
}
+4 -1
View File
@@ -3,7 +3,10 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest"; import { afterEach, vi } from "vitest";
import { matchesMediaQuery, resetAccessibilityPreferences } from "./a11y";
afterEach(() => { afterEach(() => {
resetAccessibilityPreferences();
cleanup(); cleanup();
}); });
@@ -22,7 +25,7 @@ class PointerEventMock extends MouseEvent {
Object.defineProperty(window, "matchMedia", { Object.defineProperty(window, "matchMedia", {
writable: true, writable: true,
value: vi.fn().mockImplementation((query: string) => ({ value: vi.fn().mockImplementation((query: string) => ({
matches: false, matches: matchesMediaQuery(query),
media: query, media: query,
onchange: null, onchange: null,
addEventListener: vi.fn(), addEventListener: vi.fn(),
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
use: {
baseURL: "http://127.0.0.1:6006",
trace: "retain-on-failure"
},
webServer: {
command: "pnpm --filter @ai-ui/docs storybook",
port: 6006,
reuseExistingServer: !process.env.CI,
timeout: 120_000
}
});
+38
View File
@@ -11,6 +11,9 @@ importers:
'@eslint/js': '@eslint/js':
specifier: ^9.39.4 specifier: ^9.39.4
version: 9.39.4 version: 9.39.4
'@playwright/test':
specifier: ^1.55.0
version: 1.58.2
'@storybook/addon-a11y': '@storybook/addon-a11y':
specifier: ^8.6.14 specifier: ^8.6.14
version: 8.6.14(storybook@8.6.14) version: 8.6.14(storybook@8.6.14)
@@ -748,6 +751,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@playwright/test@1.58.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'}
hasBin: true
'@radix-ui/number@1.1.1': '@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -2277,6 +2285,11 @@ packages:
react-dom: react-dom:
optional: true optional: true
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2752,6 +2765,16 @@ packages:
pkg-types@1.3.1: pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
playwright-core@1.58.2:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.58.2:
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
engines: {node: '>=18'}
hasBin: true
polished@4.3.1: polished@4.3.1:
resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3775,6 +3798,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
'@playwright/test@1.58.2':
dependencies:
playwright: 1.58.2
'@radix-ui/number@1.1.1': {} '@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {} '@radix-ui/primitive@1.1.3': {}
@@ -5404,6 +5431,9 @@ snapshots:
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -5822,6 +5852,14 @@ snapshots:
mlly: 1.8.1 mlly: 1.8.1
pathe: 2.0.3 pathe: 2.0.3
playwright-core@1.58.2: {}
playwright@1.58.2:
dependencies:
playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
polished@4.3.1: polished@4.3.1:
dependencies: dependencies:
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.2
+32
View File
@@ -0,0 +1,32 @@
import { expect, test } from "@playwright/test";
test("storybook button, select, and reduced-motion form stories stay interactive", async ({
page
}) => {
await page.goto("/");
await expect(page).toHaveTitle(/storybook/i);
await page.goto("/iframe.html?id=components-button--playground&viewMode=story");
const button = page.getByRole("button", { name: "Save changes" });
await expect(button).toBeVisible();
await button.focus();
await expect(button).toBeFocused();
await page.goto("/iframe.html?id=components-select--playground&viewMode=story");
const selectTrigger = page.locator('[data-slot="trigger"]').first();
await expect(selectTrigger).toBeVisible();
await selectTrigger.click();
await expect(page.getByRole("option", { name: "Legal review" })).toBeVisible();
await page.goto(
"/iframe.html?id=components-form--launch-settings&viewMode=story&globals=motion:reduced"
);
await page.getByRole("textbox", { name: "Email address" }).fill("team@cadence.dev");
await page.getByRole("combobox", { name: "Review lane" }).click();
await page.getByRole("option", { name: "Legal" }).click();
await page.getByRole("textbox", { name: "Launch summary" }).fill(
"This release coordinates approvals, copy, and rollout risks."
);
await page.getByRole("button", { name: "Save settings" }).click();
await expect(page.locator("pre code").last()).toContainText('"role": "legal"');
});