diff --git a/.gitignore b/.gitignore index 0509f57..fbc903e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,7 @@ coverage .pnpm-store /.storybook .home +.tmp-home +playwright-report +test-results .DS_Store diff --git a/apps/docs/.storybook/preview.ts b/apps/docs/.storybook/preview.ts index e1a57e2..6948ffd 100644 --- a/apps/docs/.storybook/preview.ts +++ b/apps/docs/.storybook/preview.ts @@ -42,7 +42,7 @@ const preview: Preview = { }, parameters: { a11y: { - test: "todo" + test: "error" }, backgrounds: { default: "canvas", diff --git a/apps/docs/src/component-authoring.stories.tsx b/apps/docs/src/component-authoring.stories.tsx new file mode 100644 index 0000000..6155fb7 --- /dev/null +++ b/apps/docs/src/component-authoring.stories.tsx @@ -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 ( +
+
+
+

+ AI UI / Docs Guide +

+

+ Component docs should explain usage, structure, and behavior without drifting + away from the actual source contract. +

+

+ This page turns the repo'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. +

+
+ +
+
+

Minimum Story Recipe

+
+ {docsCoverage.map((item) => ( +
+
+

{item.name}

+ + docs lane + +
+

+ {item.note} +

+
+ ))} +
+
+ +
+

Writing Rules

+
+ {docsWritingRules.map((rule) => ( +
+

{rule}

+
+ ))} +
+
+
+ +
+
+

Component Contract Inputs

+

+ The docs should mirror the same public hooks that the components expose in + source. +

+
+ {authoringChecklist.map((item) => ( +
+

{item}

+
+ ))} +
+
+ +
+

CVA Guardrails

+

+ Docs should reinforce the variant surface that engineering already considers + stable. +

+
+ {cvaConventions.map((item) => ( +
+

{item}

+
+ ))} +
+
+
+ +
+
+

Slots Worth Calling Out

+

+ Anatomy stories should name only the slots a consumer can reasonably style or + inspect in tests. +

+
+ {commonSlotNames.map((item) => ( +
+
+ {`data-slot="${item.slot}"`} + + slot + +
+

+ {item.guidance} +

+
+ ))} +
+
+ +
+

States Worth Explaining

+

+ State stories should match the durable `data-*` surface, not one-off visual + tweaks. +

+
+ {commonStateNames.map((item) => ( +
+
+ {`data-${item.state}`} + + state + +
+

+ {item.guidance} +

+
+ ))} +
+
+
+ +
+

Before Opening The PR

+
+
+

+ Ask if the docs tell a consumer when to choose the component. +

+

+ If the answer is "not really", the component description is still too + generic. +

+
+
+

+ Ask if the anatomy story names the real public styling hooks. +

+

+ If the story only explains visuals, it is not yet useful to another engineer. +

+
+
+

+ Ask if the most failure-prone behavior is explicitly documented. +

+

+ For overlays that usually means accessibility. For animated surfaces it may + be motion. +

+
+
+

+ Ask if the story count stayed disciplined. +

+

+ The docs should feel intentional, not encyclopedic. +

+
+
+
+
+
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Overview: Story = {}; diff --git a/apps/docs/src/components/button.stories.tsx b/apps/docs/src/components/button.stories.tsx index eabe765..fb20f9a 100644 --- a/apps/docs/src/components/button.stories.tsx +++ b/apps/docs/src/components/button.stories.tsx @@ -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; -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: () => ( diff --git a/apps/docs/src/components/dialog.stories.tsx b/apps/docs/src/components/dialog.stories.tsx index d0947d9..b349aa7 100644 --- a/apps/docs/src/components/dialog.stories.tsx +++ b/apps/docs/src/components/dialog.stories.tsx @@ -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 ( + + + + + + + {title} + {description} + + + + + + + + ); +} + 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; export const Playground: Story = { + render: () => +}; + +export const Sizes: Story = { render: () => ( - - - - - - - Launch this release? - - This will notify the routing team and publish the release note to the activity feed. - - - - - - - - +
+ + +
+ ) +}; + +export const Anatomy: Story = { + render: () => ( +
+
+

+ Dialog anatomy +

+ +
+

+ data-slot="overlay" sits + behind the surface and carries the backdrop motion. +

+

+ data-slot="content" wraps + the modal panel and exposes data-size. +

+

+ data-slot="header",{" "} + data-slot="footer",{" "} + data-slot="label", and{" "} + data-slot="description" + provide stable hooks for structure and docs. +

+

+ The close button is built into DialogContent, + so every dialog gets a dismiss affordance even when the footer stays minimal. +

+
+
+
+ ) +}; + +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: () => ( +
+
+

+ Accessibility notes +

+
+

Keep the title outcome-oriented so assistive tech announces the decision clearly.

+

+ Use the description for the consequence or next step, not decorative copy. +

+

+ Keep the trigger specific. "Open approval dialog" is more useful than a generic + "Open". +

+

+ Reserve dialogs for blocking work. If the content should not trap focus, prefer + a popover instead. +

+
+
+
+ +
+
) }; diff --git a/apps/docs/src/components/dropdown-menu.stories.tsx b/apps/docs/src/components/dropdown-menu.stories.tsx index 6d59028..f3ad45d 100644 --- a/apps/docs/src/components/dropdown-menu.stories.tsx +++ b/apps/docs/src/components/dropdown-menu.stories.tsx @@ -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; +type ReleaseMenuProps = { + triggerLabel?: string; +}; -export default meta; - -type Story = StoryObj; - -export const Playground: Story = { - render: () => ( +function ReleaseMenu({ triggerLabel = "Open menu" }: ReleaseMenuProps) { + return ( - + Launch actions @@ -45,6 +36,10 @@ export const Playground: Story = { Share preview S + + Retry checks + ⌘R + Notify stakeholders @@ -62,5 +57,110 @@ export const Playground: Story = { + ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: () => +}; + +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: () => ( +
+ + +
+ ) +}; + +export const Anatomy: Story = { + render: () => ( +
+
+

+ Dropdown menu anatomy +

+ +
+

+ data-slot="content" frames + the floating panel and exposes sizing for denser menus. +

+

+ data-slot="item",{" "} + data-slot="trigger", and{" "} + data-slot="shortcut" map the + action rows, nested trigger, and keyboard hint. +

+

+ data-slot="label",{" "} + data-slot="separator", and{" "} + data-slot="icon" support + grouping, dividers, and selection markers. +

+
+
+
+ ) +}; + +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: () => ( +
+
+

+ Keyboard guidance +

+
+

Use labels and separators to group commands into short scannable clusters.

+

+ Keep checkbox and radio items in menus only when the state change is immediate + and local to the current context. +

+

+ Prefer concise labels. Long explanatory copy belongs in a dialog or popover, + not in a menu row. +

+
+
+
+ +
+
) }; diff --git a/apps/docs/src/components/form.stories.tsx b/apps/docs/src/components/form.stories.tsx index 21272da..73d2c0c 100644 --- a/apps/docs/src/components/form.stories.tsx +++ b/apps/docs/src/components/form.stories.tsx @@ -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(null); const form = useForm({ @@ -184,4 +202,67 @@ export default meta; type Story = StoryObj; -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." + ); + } +}; diff --git a/apps/docs/src/components/popover.stories.tsx b/apps/docs/src/components/popover.stories.tsx index ef8374c..fb296c1 100644 --- a/apps/docs/src/components/popover.stories.tsx +++ b/apps/docs/src/components/popover.stories.tsx @@ -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 ( + + + + + +

Release health

+

+ 12 checks passed, 2 reviewers pending, and rollout is limited to 10% of + traffic. +

+
+ + + +
+ +
+
+ ); +} + 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; export const Playground: Story = { + render: () => +}; + +export const Sizes: Story = { render: () => ( - - - - - -

Release health

-

- 12 checks passed, 2 reviewers pending, and rollout is limited to 10% of traffic. -

- -
-
+
+ + + +
+ ) +}; + +export const Anatomy: Story = { + render: () => ( +
+
+

+ Popover anatomy +

+ +
+

+ data-slot="content" wraps + the floating surface and exposes data-size. +

+

+ data-slot="arrow" visually + connects the surface back to its trigger. +

+

+ 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. +

+
+
+
+ ) +}; + +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: () => ( +
+ + + + +
) }; diff --git a/apps/docs/src/components/select.stories.tsx b/apps/docs/src/components/select.stories.tsx index 3c14c30..8dfaa04 100644 --- a/apps/docs/src/components/select.stories.tsx +++ b/apps/docs/src/components/select.stories.tsx @@ -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 = { Legal review - + - ) + ), + 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 = { diff --git a/apps/docs/src/components/sheet.stories.tsx b/apps/docs/src/components/sheet.stories.tsx new file mode 100644 index 0000000..5cbdd36 --- /dev/null +++ b/apps/docs/src/components/sheet.stories.tsx @@ -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 ( + + + + + + + {side === "bottom" ? "Delivery actions" : "Launch settings"} + + {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."} + + + +
+
+

+ Reviewer routing +

+

+ Keep contextual edits in a side panel instead of interrupting the page with a full + modal flow. +

+
+
+

+ Rollout note +

+

+ Sheets reuse dialog semantics while shifting the visual emphasis toward adjacent, + in-flow work. +

+
+
+ + + + + + + +
+
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const Sides: Story = { + render: () => ( +
+ + + +
+ ) +}; + +export const Sizes: Story = { + render: () => ( +
+ + + +
+ ) +}; + +export const Anatomy: Story = { + render: () => ( +
+
+

+ Sheet anatomy +

+ +
+

+ data-slot="overlay" on the + backdrop layer. +

+

+ data-slot="content" on the + sheet panel itself. +

+

+ data-slot="header",{" "} + data-slot="footer",{" "} + data-slot="label", and{" "} + data-slot="description"{" "} + mirror the dialog contract. +

+

+ data-side and{" "} + data-size expose layout intent + for styling and docs inspection. +

+
+
+
+ ) +}; diff --git a/apps/docs/src/components/tabs.stories.tsx b/apps/docs/src/components/tabs.stories.tsx index 3aa6330..fa094cb 100644 --- a/apps/docs/src/components/tabs.stories.tsx +++ b/apps/docs/src/components/tabs.stories.tsx @@ -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; +type ReleaseTabsProps = { + includeDisabled?: boolean; + orientation?: "horizontal" | "vertical"; +}; -export default meta; - -type Story = StoryObj; - -export const Playground: Story = { - render: () => ( - +function ReleaseTabs({ + includeDisabled = false, + orientation = "horizontal" +}: ReleaseTabsProps) { + return ( + Overview Timeline Audience + {includeDisabled ? Billing : null} High-level release summary, current risk score, and owners. @@ -31,6 +35,103 @@ export const Playground: Story = { Impacted customer groups, internal reviewers, and communication channels. + {includeDisabled ? ( + + This tab stays disabled until the finance review has been scheduled. + + ) : null} + ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: () => +}; + +export const States: Story = { + render: () => +}; + +export const Orientation: Story = { + render: () => +}; + +export const Anatomy: Story = { + render: () => ( +
+
+

+ Tabs anatomy +

+ +
+

+ data-slot="root" carries{" "} + data-orientation so layout + styling can react to horizontal or vertical usage. +

+

+ data-slot="list" groups the + triggers, while data-slot="trigger"{" "} + exposes active and disabled state styling. +

+

+ data-slot="content" frames + the active panel and enters with the same motion language as the rest of the system. +

+
+
+
+ ) +}; + +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: () => ( +
+
+

+ Accessibility notes +

+
+

Use tabs for sibling content, not for unrelated navigation destinations.

+

+ Keep trigger labels brief so keyboard users can scan the set quickly. +

+

+ Disabled tabs should represent unavailable views, not hidden primary steps. +

+
+
+
+ +
+
) }; diff --git a/apps/docs/src/components/toast.stories.tsx b/apps/docs/src/components/toast.stories.tsx index 5f15bfa..eaf07d6 100644 --- a/apps/docs/src/components/toast.stories.tsx +++ b/apps/docs/src/components/toast.stories.tsx @@ -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 ( - - - Release queued - - The rollout has been scheduled and reviewers were notified. - - Open rollout + + + {title} + {description} + {actionLabel} @@ -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; export const Playground: Story = {}; + +export const Variants: Story = { + render: () => ( +
+ + + +
+ ) +}; + +export const Anatomy: Story = { + render: () => ( +
+
+

+ Toast anatomy +

+ +
+

+ data-slot="viewport" owns + the screen edge stacking region for multiple toasts. +

+

+ data-slot="root" exposes{" "} + data-variant for semantic + feedback styling. +

+

+ data-slot="label",{" "} + data-slot="description",{" "} + data-slot="action", and{" "} + data-slot="close" structure + the content and affordances. +

+
+
+
+ ) +}; + +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: () => ( +
+
+

+ Motion behavior +

+
+

Trigger a toast, then dismiss it with the close button or a horizontal swipe.

+

+ The viewport stacks from the lower edge on small screens and tightens into the + lower-right corner on larger canvases. +

+

+ Keep toast actions secondary and fast. Anything that needs a full decision should + escalate into a dialog instead. +

+
+
+
+ +
+
+ ) +}; diff --git a/apps/docs/src/components/tooltip.stories.tsx b/apps/docs/src/components/tooltip.stories.tsx index fbd5ea4..7843855 100644 --- a/apps/docs/src/components/tooltip.stories.tsx +++ b/apps/docs/src/components/tooltip.stories.tsx @@ -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 ( + + + + + + + Inline notes stay terse and avoid blocking the main flow. + + + + + ); +} + 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; export const Playground: Story = { + render: () => +}; + +export const Sizes: Story = { render: () => ( - - - - - - - Inline notes stay terse and avoid blocking the main flow. - - - - +
+ + + +
+ ) +}; + +export const Anatomy: Story = { + render: () => ( +
+
+

+ Tooltip anatomy +

+ +
+

+ data-slot="content" holds + the compact explanatory copy and exposes the size token. +

+

+ data-slot="arrow" anchors + the floating label back to its trigger. +

+

+ Tooltip copy should stay short enough to scan in one glance. It is a hint, not a + mini panel. +

+
+
+
+ ) +}; + +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: () => ( +
+
+

+ Accessibility notes +

+
+

Always keep the trigger label meaningful even without the tooltip.

+

+ Use tooltips to clarify or shorten, not to hide critical onboarding copy. +

+

+ Avoid links, buttons, or form controls inside tooltip content. Those belong in a + popover. +

+
+
+
+ +
+
) }; diff --git a/package.json b/package.json index 9d5239a..0102f9b 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,14 @@ "dev:docs": "pnpm --filter @ai-ui/docs storybook", "lint": "eslint .", "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", "typecheck": "pnpm -r typecheck" }, "devDependencies": { "@eslint/js": "^9.39.4", + "@playwright/test": "^1.55.0", "@storybook/addon-a11y": "^8.6.14", "@storybook/addon-essentials": "^8.6.14", "@storybook/addon-interactions": "^8.6.14", diff --git a/packages/ui/src/components/avatar.test.tsx b/packages/ui/src/components/avatar.test.tsx index 6425630..50ee1e0 100644 --- a/packages/ui/src/components/avatar.test.tsx +++ b/packages/ui/src/components/avatar.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { Avatar, AvatarFallback, AvatarImage } from "./avatar"; +import { Avatar, AvatarFallback } from "./avatar"; describe("Avatar", () => { it("renders root slot metadata and fallback content", async () => { diff --git a/packages/ui/src/components/button.test.tsx b/packages/ui/src/components/button.test.tsx index f389ed1..0006396 100644 --- a/packages/ui/src/components/button.test.tsx +++ b/packages/ui/src/components/button.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { Button } from "./button"; +import { setReducedMotionPreference } from "../test/a11y"; describe("Button", () => { it("renders a native button with root and label slots", () => { @@ -49,4 +50,16 @@ describe("Button", () => { expect(link).toHaveAttribute("data-variant", "ghost"); expect(link).not.toHaveAttribute("type"); }); + + it("preserves the loading contract when reduced motion is preferred", () => { + setReducedMotionPreference(true); + + render(); + + const button = screen.getByRole("button", { name: "Saving" }); + + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("data-loading", ""); + expect(button.querySelector('[data-slot="icon"]')).toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/components/combobox.tsx b/packages/ui/src/components/combobox.tsx index 13fe164..cfe17a3 100644 --- a/packages/ui/src/components/combobox.tsx +++ b/packages/ui/src/components/combobox.tsx @@ -104,6 +104,7 @@ export const Combobox = forwardRef(function Co ref ) { const field = useFieldContext(); + const reactId = useId(); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? ""); const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState(defaultSearchValue); @@ -115,7 +116,7 @@ export const Combobox = forwardRef(function Co const resolvedSearchValue = searchValue ?? uncontrolledSearchValue; const resolvedDisabled = disabled ?? field?.disabled ?? 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( typeof props["aria-describedby"] === "string" ? props["aria-describedby"] : undefined, field?.descriptionId, @@ -212,7 +213,13 @@ export const Combobox = forwardRef(function Co const firstEnabledIndex = filteredItems.findIndex((item) => !item.disabled); const nextIndex = selectedIndex >= 0 ? selectedIndex : firstEnabledIndex; - setActiveIndex(nextIndex); + const frame = requestAnimationFrame(() => { + setActiveIndex(nextIndex); + }); + + return () => { + cancelAnimationFrame(frame); + }; }, [filteredItems, resolvedOpen, resolvedValue]); useEffect(() => { @@ -223,6 +230,10 @@ export const Combobox = forwardRef(function Co itemRefs.current[activeIndex]?.scrollIntoView({ block: "nearest" }); }, [activeIndex]); + useEffect(() => { + itemRefs.current = []; + }, [filteredItems]); + const handleSelect = (item: ComboboxItem) => { if (item.disabled) { return; @@ -273,8 +284,6 @@ export const Combobox = forwardRef(function Co } }; - itemRefs.current = []; - return (
{ 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 ( +
+ undefined)}> + + Email address + + + + We send launch notes here. + + +

Primary contact for release notifications.

+ +
+ + ); + } + + render(); + + 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)); + }); }); diff --git a/packages/ui/src/components/select.test.tsx b/packages/ui/src/components/select.test.tsx index f6c4b85..59ccf36 100644 --- a/packages/ui/src/components/select.test.tsx +++ b/packages/ui/src/components/select.test.tsx @@ -128,4 +128,20 @@ describe("Select", () => { expect(trigger).toBeDisabled(); expect(trigger).toHaveAttribute("data-disabled", ""); }); + + it("opens from the keyboard so combobox interaction stays accessible", async () => { + const user = userEvent.setup(); + + render(); + + 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(); + }); }); diff --git a/packages/ui/src/components/sheet.test.tsx b/packages/ui/src/components/sheet.test.tsx new file mode 100644 index 0000000..d9efaf7 --- /dev/null +++ b/packages/ui/src/components/sheet.test.tsx @@ -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( + + Open sheet + + + Review launch + Confirm the rollout details before publishing. + + + Done + + + + ); + + 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( + + Open controlled sheet + + Controlled + + + ); + + await user.click(screen.getByRole("button", { name: "Open controlled sheet" })); + expect(onOpenChange).toHaveBeenCalledWith(true); + + render( + + Open controlled sheet + + Controlled + + + ); + + await user.keyboard("{Escape}"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("supports bottom sheets while remaining accessible as a dialog", async () => { + const user = userEvent.setup(); + + render( + + Open mobile actions + + + Mobile actions + Choose how to continue this rollout. + + + + ); + + 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( + + Open summary sheet + + + Summary + + + Close + + + + ); + + 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(); + }); +}); diff --git a/packages/ui/src/components/sheet.tsx b/packages/ui/src/components/sheet.tsx new file mode 100644 index 0000000..df7bfc3 --- /dev/null +++ b/packages/ui/src/components/sheet.tsx @@ -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, + ComponentPropsWithoutRef +>(function SheetOverlay({ className, ...props }, ref) { + return ( + + ); +}); + +export type SheetContentProps = ComponentPropsWithoutRef & + VariantProps; + +export const SheetContent = forwardRef< + ElementRef, + SheetContentProps +>(function SheetContent({ children, className, side, size, ...props }, ref) { + const resolvedSide = side ?? "right"; + const resolvedSize = size ?? "md"; + + return ( + + + + {children} + + + + + + ); +}); + +export function SheetHeader({ + className, + ...props +}: ComponentPropsWithoutRef<"div">) { + return ( +
+ ); +} + +export function SheetFooter({ + className, + ...props +}: ComponentPropsWithoutRef<"div">) { + return ( +
+ ); +} + +export const SheetTitle = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(function SheetTitle({ className, ...props }, ref) { + return ( + + ); +}); + +export const SheetDescription = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(function SheetDescription({ className, ...props }, ref) { + return ( + + ); +}); diff --git a/packages/ui/src/components/sheet.variants.ts b/packages/ui/src/components/sheet.variants.ts new file mode 100644 index 0000000..d86ceab --- /dev/null +++ b/packages/ui/src/components/sheet.variants.ts @@ -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" +]); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index e138134..4f61b5e 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -226,6 +226,25 @@ export { } from "./components/select.variants"; export { Separator, type SeparatorProps } from "./components/separator"; 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 { Spinner, type SpinnerProps } from "./components/spinner"; export { Switch, type SwitchProps } from "./components/switch"; diff --git a/packages/ui/src/test/a11y.ts b/packages/ui/src/test/a11y.ts new file mode 100644 index 0000000..e71f17c --- /dev/null +++ b/packages/ui/src/test/a11y.ts @@ -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; +} diff --git a/packages/ui/src/test/setup.ts b/packages/ui/src/test/setup.ts index b84dec5..4134f95 100644 --- a/packages/ui/src/test/setup.ts +++ b/packages/ui/src/test/setup.ts @@ -3,7 +3,10 @@ import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; import { afterEach, vi } from "vitest"; +import { matchesMediaQuery, resetAccessibilityPreferences } from "./a11y"; + afterEach(() => { + resetAccessibilityPreferences(); cleanup(); }); @@ -22,7 +25,7 @@ class PointerEventMock extends MouseEvent { Object.defineProperty(window, "matchMedia", { writable: true, value: vi.fn().mockImplementation((query: string) => ({ - matches: false, + matches: matchesMediaQuery(query), media: query, onchange: null, addEventListener: vi.fn(), diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..4851815 --- /dev/null +++ b/playwright.config.ts @@ -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 + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfcb732..d1507f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@eslint/js': specifier: ^9.39.4 version: 9.39.4 + '@playwright/test': + specifier: ^1.55.0 + version: 1.58.2 '@storybook/addon-a11y': specifier: ^8.6.14 version: 8.6.14(storybook@8.6.14) @@ -748,6 +751,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} 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': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2277,6 +2285,11 @@ packages: react-dom: 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: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2752,6 +2765,16 @@ packages: pkg-types@1.3.1: 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: resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} engines: {node: '>=10'} @@ -3775,6 +3798,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -5404,6 +5431,9 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5822,6 +5852,14 @@ snapshots: mlly: 1.8.1 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: dependencies: '@babel/runtime': 7.29.2 diff --git a/tests/e2e/storybook-smoke.spec.ts b/tests/e2e/storybook-smoke.spec.ts new file mode 100644 index 0000000..a2b2505 --- /dev/null +++ b/tests/e2e/storybook-smoke.spec.ts @@ -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"'); +});