feat: add empty state and expand overlay qa

This commit is contained in:
2026-03-19 19:00:36 +08:00
parent f318f94c9a
commit 132bb6961d
20 changed files with 1094 additions and 6 deletions
+133
View File
@@ -0,0 +1,133 @@
# Contributing
This repo treats components as source-owned product infrastructure, not generated vendor
artifacts. Changes should preserve the existing token system, component contract, and docs
discipline instead of introducing parallel patterns.
## Before you start
Read the current contract and docs baseline first:
- `roadmap.md`
- `packages/ui/src/lib/contracts.ts`
- `apps/docs/src/component-authoring.stories.tsx`
Then inspect the closest existing component before adding a new one.
## Default workflow
1. Confirm the component or change fits the current system layers.
2. Reuse the existing contract helpers, slot names, state naming, and variant conventions.
3. Add or update Storybook stories so behavior is reviewable.
4. Add or update tests before treating the component as done.
5. Run the relevant validation commands locally.
## Authoring rules
These are the baseline rules for public components in `packages/ui`:
- Expose `className` on every styled public component.
- Forward `ref` on every focusable or measurable public component.
- Use `asChild` only when the root is intentionally polymorphic.
- Prefer controlled and uncontrolled APIs together when the component manages user state.
- Represent boolean UI states with empty-string `data-*` attributes.
- Represent finite machine states with stable `data-state="..."` values.
- Name stylable internal parts with `data-slot`.
- Keep `variant` semantic and `size` meaningful; do not add one-off booleans that fragment the API.
## Styling and token rules
- Consume tokens and motion recipes instead of hardcoded brand values.
- Prefer semantic roles such as `primary`, `muted`, `destructive`, `surface`, and `card`.
- Keep shared layout, focus, and interaction primitives in the CVA base string.
- Avoid `transition-all`.
- Prefer animating `transform` and `opacity`.
- Use `data-state` driven animation where possible.
## Theme and reduced motion expectations
Every meaningful UI change should be reviewed under:
- the default theme
- alternate themes when contrast or surface depth could shift
- reduced motion
Practical expectations:
- The component should remain usable when motion is reduced.
- Motion should communicate state or hierarchy, not hide missing feedback.
- Theme differences should come from tokens, not conditional component styling forks.
## Storybook expectations
Storybook is not just a gallery. It is the review surface for API, anatomy, and behavior.
Minimum story recipe:
- `Playground`: one opinionated default example
- `States`: only when the component has meaningful state comparisons
- `Anatomy`: document stable slots and public `data-*` hooks
- `Accessibility` or `Motion`: choose whichever behavior is easiest to misunderstand
Writing rules:
- Use `docs.description.component` to explain when to choose the component.
- Use real product language instead of filler copy.
- Keep examples narrow and intentional.
- If a story exists only to explain slots, accessibility, or motion, say that directly.
## Testing and QA expectations
Component work is not done until the behavior is covered at the right level.
Use the following baseline:
- Unit and interaction tests in `packages/ui/src/components/*.test.tsx`
- Storybook interaction coverage where a representative `play` flow adds signal
- Playwright smoke coverage for high-value cross-component flows
Common things to cover:
- trigger and close behavior
- keyboard behavior
- controlled and uncontrolled state
- slot and `data-*` attributes that consumers rely on
- invalid, disabled, loading, or required state where relevant
- reduced motion behavior when the component has meaningful animation
## Validation commands
Run the narrowest useful set while working, then the broader set before opening a PR.
Core checks:
```bash
pnpm lint
pnpm typecheck
pnpm test
```
Docs and smoke checks:
```bash
pnpm dev:docs
pnpm build:docs
pnpm test:e2e:smoke
```
## Practical repo guidance
- Keep shared integration points small. If you only need a new component, avoid unrelated changes.
- Treat `packages/ui/src/index.ts` as a shared export surface and change it deliberately.
- Prefer adding a sibling pattern over mutating an existing component unless the API itself is wrong.
- If a change needs a new dependency, justify it against the repo's current stack and complexity budget.
## Definition of done
A component or pattern change is ready when:
- the implementation uses tokens and follows the current contract
- the docs explain when to use it and how it is structured
- tests cover the important behavior
- accessibility and reduced motion were considered explicitly
- the repo's standard validation commands pass
+106
View File
@@ -0,0 +1,106 @@
# Cadence UI
Cadence UI is a source-owned React component system built in a `pnpm` workspace.
The repo keeps the `Radix + Tailwind + source-owned components` model, but replaces
default styling with its own tokens, motion recipes, and component contract.
## What this repo contains
- `packages/tokens`: theme tokens, motion tokens, and theme helpers
- `packages/ui`: component source, variants, contracts, and tests
- `apps/docs`: Storybook docs and usage reference
- `tests/e2e`: Playwright smoke coverage for high-value Storybook flows
## System principles
- Source owned: components live in this repo and are modified directly.
- Token first: colors, type, radius, shadow, and motion decisions come from tokens.
- Component contract over component count: stable APIs matter more than shipping many one-off parts.
- Accessibility by default: keyboard, focus, ARIA, and reduced motion are baseline expectations.
- Motion with purpose: animation should communicate state and hierarchy, not decorate at random.
## Getting started
Requirements:
- `node >= 24`
- `pnpm >= 10`
Install dependencies:
```bash
pnpm install
```
Start Storybook:
```bash
pnpm dev:docs
```
Build the packages:
```bash
pnpm build
```
Build Storybook:
```bash
pnpm build:docs
```
Run tests:
```bash
pnpm test
pnpm test:e2e:smoke
```
Run lint and typecheck:
```bash
pnpm lint
pnpm typecheck
```
## Workspace structure
```txt
apps/
docs/ Storybook docs and interaction examples
packages/
tokens/ Theme and motion tokens
ui/ Component source, variants, tests, and contracts
tests/
e2e/ Playwright smoke specs
```
## How the component system is organized
The system is layered:
1. Tokens define semantic color, type, surface, radius, shadow, and motion values.
2. Primitives build on Radix where accessibility and interaction behavior matter.
3. Motion recipes provide reusable transition patterns instead of ad hoc animation rules.
4. Components compose tokens, primitives, and recipes into the public API.
The current public component layer lives in `packages/ui/src/components`, with shared
helpers in `packages/ui/src/lib`.
## Docs and QA
Storybook is the main usage reference and review surface. Component stories are expected
to document more than the default playground when behavior is non-trivial. The repo also
uses:
- Vitest + Testing Library for unit and interaction coverage
- Storybook interaction coverage for representative examples
- Playwright smoke coverage for core Storybook flows
- Storybook a11y checks as part of the docs review surface
## Contributing
Read [CONTRIBUTING.md](/Users/xd/project/cadence-ui/CONTRIBUTING.md) before adding or
changing components. It documents the component contract, story expectations, reduced
motion and theme requirements, and the minimum validation workflow.
+48 -1
View File
@@ -10,6 +10,24 @@ import {
} 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);
}
type LaunchDialogProps = {
description?: string;
size?: "sm" | "md" | "lg";
@@ -62,7 +80,36 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <LaunchDialog />
render: () => <LaunchDialog />,
play: async ({ canvasElement }) => {
const trigger = [...canvasElement.querySelectorAll("button")].find((element) =>
element.textContent?.includes("Open approval dialog")
);
if (!(trigger instanceof HTMLButtonElement)) {
throw new Error("Expected the dialog trigger to render.");
}
trigger.click();
await waitForCondition(
() => document.body.querySelector('[role="dialog"]') instanceof HTMLElement,
"Expected the dialog to open."
);
const closeButton = document.body.querySelector('[aria-label="Close dialog"]');
if (!(closeButton instanceof HTMLButtonElement)) {
throw new Error("Expected the dialog close control to render.");
}
closeButton.click();
await waitForCondition(
() => document.body.querySelector('[role="dialog"]') === null,
"Expected the dialog to close."
);
}
};
export const Sizes: Story = {
@@ -16,6 +16,24 @@ import {
} 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);
}
type ReleaseMenuProps = {
triggerLabel?: string;
};
@@ -80,7 +98,38 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <ReleaseMenu />
render: () => <ReleaseMenu />,
play: async ({ canvasElement }) => {
const trigger = [...canvasElement.querySelectorAll("button")].find((element) =>
element.textContent?.includes("Open menu")
);
if (!(trigger instanceof HTMLButtonElement)) {
throw new Error("Expected the dropdown trigger to render.");
}
trigger.click();
await waitForCondition(
() => document.body.querySelector('[role="menu"]') instanceof HTMLElement,
"Expected the dropdown menu to open."
);
const item = [...document.body.querySelectorAll('[role="menuitem"]')].find((element) =>
element.textContent?.includes("Review summary")
);
if (!(item instanceof HTMLElement)) {
throw new Error("Expected the Review summary item to render.");
}
item.click();
await waitForCondition(
() => document.body.querySelector('[role="menu"]') === null,
"Expected the dropdown menu to close after selection."
);
}
};
export const States: Story = {
@@ -0,0 +1,156 @@
import {
Button,
EmptyState,
EmptyStateActions,
EmptyStateDescription,
EmptyStateEyebrow,
EmptyStateHeader,
EmptyStateMedia,
EmptyStateTitle
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function EmptyStateGlyph() {
return (
<div className="grid gap-2">
<div className="grid grid-cols-[1.3rem_3.4rem] gap-2">
<span className="h-5 rounded-[var(--radius-sm)] bg-[color-mix(in_oklch,var(--color-primary)_18%,var(--color-card))]" />
<span className="h-5 rounded-[var(--radius-sm)] bg-[var(--color-surface-strong)]" />
</div>
<div className="grid grid-cols-[4.8rem] gap-2">
<span className="h-2 rounded-[var(--radius-full)] bg-[var(--color-border-strong)]" />
<span className="h-2 rounded-[var(--radius-full)] bg-[var(--color-border)]" />
</div>
</div>
);
}
function ReleaseEmptyState({
description = "Adjust the current filters or create a new release to start routing work.",
eyebrow = "No results",
tone = "default",
title = "No matching releases"
}: {
description?: string;
eyebrow?: string;
title?: string;
tone?: "default" | "subtle" | "accent";
}) {
return (
<EmptyState className="w-[min(100%,42rem)]" tone={tone}>
<EmptyStateMedia>
<EmptyStateGlyph />
</EmptyStateMedia>
<EmptyStateHeader>
<EmptyStateEyebrow>{eyebrow}</EmptyStateEyebrow>
<EmptyStateTitle>{title}</EmptyStateTitle>
<EmptyStateDescription>{description}</EmptyStateDescription>
</EmptyStateHeader>
<EmptyStateActions>
<Button>Create release</Button>
<Button variant="ghost">Reset filters</Button>
</EmptyStateActions>
</EmptyState>
);
}
const meta = {
title: "Components/EmptyState",
component: ReleaseEmptyState,
parameters: {
docs: {
description: {
component:
"EmptyState is the system surface for no-results, first-run, and no-content moments that should still feel intentional. It gives teams one stable composition for media, framing copy, and next-step actions instead of improvising ad hoc placeholder cards."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof ReleaseEmptyState>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Scenarios: Story = {
render: () => (
<div className="grid w-[860px] gap-4 lg:grid-cols-2">
<ReleaseEmptyState />
<ReleaseEmptyState
description="Invite teammates and define the first approval lane to turn this empty project into a reusable workflow."
eyebrow="First run"
title="This workspace is ready for its first rollout"
tone="accent"
/>
</div>
)
};
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-4">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Empty state anatomy
</p>
<ReleaseEmptyState />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot=\"media\"</code> holds the
decorative or explanatory visual.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot=\"header\"</code>,{" "}
<code className="text-[var(--color-foreground)]">data-slot=\"eyebrow\"</code>,{" "}
<code className="text-[var(--color-foreground)]">data-slot=\"label\"</code>, and{" "}
<code className="text-[var(--color-foreground)]">data-slot=\"description\"</code>{" "}
structure the framing copy.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot=\"actions\"</code> groups
the primary next step and any secondary recovery action.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-tone</code> exposes whether the
surface stays neutral, subtle, or accent-led.
</p>
</div>
</div>
</div>
)
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Use empty states to explain what happened and what the user can do next. Keep the title concrete, the description actionable, and ensure the primary action is a real next step rather than decorative reassurance."
}
}
},
render: () => (
<div className="grid w-[840px] 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)]">
Accessibility notes
</h3>
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Describe the absence clearly. &quot;No matching releases&quot; is better than &quot;Nothing here&quot;.</p>
<p>Use actions that recover the user, such as clearing filters or creating the first item.</p>
<p>Do not hide critical instructions inside decorative media. The copy should stand on its own.</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">
<ReleaseEmptyState
description="Clear the current filters or create a release with a broader audience to repopulate this view."
title="No results for this routing lane"
tone="subtle"
/>
</div>
</div>
)
};
+50 -1
View File
@@ -8,6 +8,24 @@ import {
} 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);
}
type SummaryPopoverProps = {
contentSize?: "sm" | "md" | "lg";
side?: "top" | "right" | "bottom" | "left";
@@ -63,7 +81,38 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <SummaryPopover />
render: () => <SummaryPopover />,
play: async ({ canvasElement }) => {
const trigger = [...canvasElement.querySelectorAll("button")].find((element) =>
element.textContent?.includes("Inspect summary")
);
if (!(trigger instanceof HTMLButtonElement)) {
throw new Error("Expected the popover trigger to render.");
}
trigger.click();
await waitForCondition(
() => document.body.textContent?.includes("Release health") ?? false,
"Expected the popover content to open."
);
const dismiss = [...document.body.querySelectorAll("button")].find((element) =>
element.textContent?.includes("Dismiss")
);
if (!(dismiss instanceof HTMLButtonElement)) {
throw new Error("Expected the popover dismiss button to render.");
}
dismiss.click();
await waitForCondition(
() => !(document.body.textContent?.includes("Release health") ?? false),
"Expected the popover content to close."
);
}
};
export const Sizes: Story = {
+49 -1
View File
@@ -11,6 +11,24 @@ import {
} 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);
}
function SettingsSheetDemo({
side = "right",
size = "md"
@@ -84,7 +102,37 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Playground: Story = {
play: async ({ canvasElement }) => {
const trigger = [...canvasElement.querySelectorAll("button")].find((element) =>
element.textContent?.includes("Open right sheet")
);
if (!(trigger instanceof HTMLButtonElement)) {
throw new Error("Expected the sheet trigger to render.");
}
trigger.click();
await waitForCondition(
() => document.body.querySelector('[role="dialog"]') instanceof HTMLElement,
"Expected the sheet to open."
);
const closeButton = document.body.querySelector('[aria-label="Close sheet"]');
if (!(closeButton instanceof HTMLButtonElement)) {
throw new Error("Expected the sheet close control to render.");
}
closeButton.click();
await waitForCondition(
() => document.body.querySelector('[role="dialog"]') === null,
"Expected the sheet to close."
);
}
};
export const Sides: Story = {
render: () => (
+49 -1
View File
@@ -11,6 +11,24 @@ import {
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "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);
}
type ToastDemoProps = {
actionLabel?: string;
buttonLabel?: string;
@@ -61,7 +79,37 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Playground: Story = {
play: async ({ canvasElement }) => {
const trigger = [...canvasElement.querySelectorAll("button")].find((element) =>
element.textContent?.includes("Show toast")
);
if (!(trigger instanceof HTMLButtonElement)) {
throw new Error("Expected the toast trigger to render.");
}
trigger.click();
await waitForCondition(
() => document.body.textContent?.includes("Release queued") ?? false,
"Expected the toast to open."
);
const close = document.body.querySelector('[aria-label="Close notification"]');
if (!(close instanceof HTMLButtonElement)) {
throw new Error("Expected the toast close button to render.");
}
close.click();
await waitForCondition(
() => !(document.body.textContent?.includes("Release queued") ?? false),
"Expected the toast to close."
);
}
};
export const Variants: Story = {
render: () => (
+42 -1
View File
@@ -8,6 +8,24 @@ import {
} 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);
}
type InlineTooltipProps = {
contentSize?: "sm" | "md" | "lg";
triggerLabel?: string;
@@ -52,7 +70,30 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <InlineTooltip />
render: () => <InlineTooltip />,
play: async ({ canvasElement }) => {
const trigger = [...canvasElement.querySelectorAll("button")].find((element) =>
element.textContent?.includes("Hover for note")
);
if (!(trigger instanceof HTMLButtonElement)) {
throw new Error("Expected the tooltip trigger to render.");
}
trigger.focus();
await waitForCondition(
() => document.body.querySelector('[role="tooltip"]') instanceof HTMLElement,
"Expected the tooltip content to open on focus."
);
trigger.blur();
await waitForCondition(
() => document.body.querySelector('[role="tooltip"]') === null,
"Expected the tooltip content to close on blur."
);
}
};
export const Sizes: Story = {
@@ -105,4 +105,29 @@ describe("Dialog", () => {
expect(within(dialog).getByText("Summary").closest('[data-slot="header"]')).toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: "Close" }).closest('[data-slot="footer"]')).toBeInTheDocument();
});
it("returns focus to the trigger after Escape closes the dialog", async () => {
const user = userEvent.setup();
render(
<Dialog>
<DialogTrigger>Open accessible dialog</DialogTrigger>
<DialogContent>
<DialogTitle>Accessibility</DialogTitle>
</DialogContent>
</Dialog>
);
const trigger = screen.getByRole("button", { name: "Open accessible dialog" });
await user.click(trigger);
expect(await screen.findByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
});
});
@@ -88,4 +88,31 @@ describe("DropdownMenu", () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
it("opens from the keyboard and returns focus to the trigger on Escape", async () => {
const user = userEvent.setup();
render(
<DropdownMenu>
<DropdownMenuTrigger>Keyboard menu</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Review</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
const trigger = screen.getByRole("button", { name: "Keyboard menu" });
trigger.focus();
await user.keyboard("{Enter}");
expect(await screen.findByRole("menu")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
});
});
@@ -0,0 +1,59 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Button } from "./button";
import {
EmptyState,
EmptyStateActions,
EmptyStateDescription,
EmptyStateEyebrow,
EmptyStateHeader,
EmptyStateMedia,
EmptyStateTitle
} from "./empty-state";
describe("EmptyState", () => {
it("renders semantic slots and tone metadata", () => {
render(
<EmptyState tone="accent">
<EmptyStateMedia>0</EmptyStateMedia>
<EmptyStateHeader>
<EmptyStateEyebrow>Search</EmptyStateEyebrow>
<EmptyStateTitle>No matching releases</EmptyStateTitle>
<EmptyStateDescription>Try another filter or create a new release.</EmptyStateDescription>
</EmptyStateHeader>
<EmptyStateActions>
<Button size="sm">Create release</Button>
</EmptyStateActions>
</EmptyState>
);
const root = screen.getByText("No matching releases").closest('[data-slot="root"]');
expect(root).toHaveAttribute("data-tone", "accent");
expect(screen.getByText("0")).toHaveAttribute("data-slot", "media");
expect(screen.getByText("Search")).toHaveAttribute("data-slot", "eyebrow");
expect(screen.getByText("No matching releases")).toHaveAttribute("data-slot", "label");
expect(screen.getByText("Try another filter or create a new release.")).toHaveAttribute(
"data-slot",
"description"
);
expect(screen.getByRole("button", { name: "Create release" }).closest('[data-slot="actions"]')).toBeInTheDocument();
});
it("supports className overrides on sub-slots", () => {
render(
<EmptyState data-testid="empty-state" tone="subtle">
<EmptyStateHeader className="items-start text-left">
<EmptyStateTitle className="text-left">No saved views</EmptyStateTitle>
</EmptyStateHeader>
</EmptyState>
);
expect(screen.getByTestId("empty-state")).toHaveAttribute("data-tone", "subtle");
expect(screen.getByText("No saved views")).toHaveClass("text-left");
expect(screen.getByText("No saved views").closest('[data-slot="header"]')).toHaveClass(
"items-start"
);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import {
emptyStateActionsVariants,
emptyStateDescriptionVariants,
emptyStateEyebrowVariants,
emptyStateHeaderVariants,
emptyStateMediaVariants,
emptyStateTitleVariants,
emptyStateVariants
} from "./empty-state.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type EmptyStateProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof emptyStateVariants>;
export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(function EmptyState(
{ className, tone, ...props },
ref
) {
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({ tone })}
className={cn(emptyStateVariants({ tone }), className)}
ref={ref}
/>
);
});
export type EmptyStateMediaProps = ComponentPropsWithoutRef<"div">;
export const EmptyStateMedia = forwardRef<HTMLDivElement, EmptyStateMediaProps>(
function EmptyStateMedia({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("media")}
className={cn(emptyStateMediaVariants(), className)}
ref={ref}
/>
);
}
);
export type EmptyStateHeaderProps = ComponentPropsWithoutRef<"div">;
export const EmptyStateHeader = forwardRef<HTMLDivElement, EmptyStateHeaderProps>(
function EmptyStateHeader({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(emptyStateHeaderVariants(), className)}
ref={ref}
/>
);
}
);
export type EmptyStateEyebrowProps = ComponentPropsWithoutRef<"p">;
export const EmptyStateEyebrow = forwardRef<HTMLParagraphElement, EmptyStateEyebrowProps>(
function EmptyStateEyebrow({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("eyebrow")}
className={cn(emptyStateEyebrowVariants(), className)}
ref={ref}
/>
);
}
);
export type EmptyStateTitleProps = ComponentPropsWithoutRef<"h3">;
export const EmptyStateTitle = forwardRef<HTMLHeadingElement, EmptyStateTitleProps>(
function EmptyStateTitle({ className, ...props }, ref) {
return (
<h3
{...props}
{...createSlot("label")}
className={cn(emptyStateTitleVariants(), className)}
ref={ref}
/>
);
}
);
export type EmptyStateDescriptionProps = ComponentPropsWithoutRef<"p">;
export const EmptyStateDescription = forwardRef<
HTMLParagraphElement,
EmptyStateDescriptionProps
>(function EmptyStateDescription({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("description")}
className={cn(emptyStateDescriptionVariants(), className)}
ref={ref}
/>
);
});
export type EmptyStateActionsProps = ComponentPropsWithoutRef<"div">;
export const EmptyStateActions = forwardRef<HTMLDivElement, EmptyStateActionsProps>(
function EmptyStateActions({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("actions")}
className={cn(emptyStateActionsVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,50 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const emptyStateVariants = cva(
[
"grid gap-6 rounded-[var(--radius-lg)] border p-8 shadow-[var(--shadow-sm)] sm:p-10",
"justify-items-center text-center text-[var(--color-card-foreground)]",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
tone: {
default: "border-[var(--color-border)] bg-[var(--color-card)]",
subtle:
"border-[color-mix(in_oklch,var(--color-border)_82%,transparent)] bg-[var(--color-surface)] shadow-[var(--shadow-xs)]",
accent:
"border-[color-mix(in_oklch,var(--color-primary)_24%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-primary)_8%,var(--color-card))]"
}
},
defaultVariants: {
tone: "default"
}
}
);
export const emptyStateMediaVariants = cva(
[
"grid min-h-20 min-w-20 place-items-center rounded-[var(--radius-lg)] border p-4",
"border-[color-mix(in_oklch,var(--color-border)_88%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-background)_72%,white_28%),var(--color-surface))]",
"text-[var(--color-foreground)] shadow-[var(--shadow-xs)]"
]
);
export const emptyStateHeaderVariants = cva("grid max-w-[34rem] gap-2 justify-items-center");
export const emptyStateEyebrowVariants = cva(
"text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
);
export const emptyStateTitleVariants = cva(
"text-2xl font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]"
);
export const emptyStateDescriptionVariants = cva(
"max-w-[30rem] text-sm leading-6 text-[var(--color-muted-foreground)]"
);
export const emptyStateActionsVariants = cva(
"flex flex-wrap items-center justify-center gap-3"
);
@@ -54,4 +54,27 @@ describe("Popover", () => {
await user.click(screen.getByRole("button", { name: "Open details" }));
expect(onOpenChange).toHaveBeenCalledWith(true);
});
it("closes on Escape and returns focus to the trigger", async () => {
const user = userEvent.setup();
render(
<Popover>
<PopoverTrigger>Open details</PopoverTrigger>
<PopoverContent>Context</PopoverContent>
</Popover>
);
const trigger = screen.getByRole("button", { name: "Open details" });
await user.click(trigger);
expect(await screen.findByText("Context")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByText("Context")).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
});
});
+25
View File
@@ -130,4 +130,29 @@ describe("Sheet", () => {
within(sheet).getByRole("button", { name: "Close" }).closest('[data-slot="footer"]')
).toBeInTheDocument();
});
it("returns focus to the trigger after Escape closes the sheet", async () => {
const user = userEvent.setup();
render(
<Sheet>
<SheetTrigger>Open accessible sheet</SheetTrigger>
<SheetContent side="right">
<SheetTitle>Accessibility</SheetTitle>
</SheetContent>
</Sheet>
);
const trigger = screen.getByRole("button", { name: "Open accessible sheet" });
await user.click(trigger);
expect(await screen.findByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
});
});
@@ -66,4 +66,12 @@ describe("Toast", () => {
expect(screen.queryByText("Saved")).not.toBeInTheDocument();
});
});
it("exposes the default close label for screen readers", () => {
render(<StatefulToast />);
const closeButton = screen.getByRole("button", { name: "Close notification" });
expect(closeButton).toHaveAttribute("data-slot", "close");
});
});
@@ -59,4 +59,30 @@ describe("Tooltip", () => {
expect(onOpenChange).toHaveBeenCalledWith(true);
});
});
it("shows tooltip content on focus and hides it on blur", async () => {
render(
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger>Focus help</TooltipTrigger>
<TooltipContent>Focus context</TooltipContent>
</Tooltip>
</TooltipProvider>
);
const trigger = screen.getByRole("button", { name: "Focus help" });
trigger.focus();
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toHaveTextContent("Focus context");
expect(trigger).toHaveFocus();
trigger.blur();
await waitFor(() => {
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});
});
});
+25
View File
@@ -145,6 +145,31 @@ export {
dropdownMenuLabelVariants,
dropdownMenuSeparatorVariants
} from "./components/dropdown-menu.variants";
export {
EmptyState,
EmptyStateActions,
EmptyStateDescription,
EmptyStateEyebrow,
EmptyStateHeader,
EmptyStateMedia,
EmptyStateTitle,
type EmptyStateActionsProps,
type EmptyStateDescriptionProps,
type EmptyStateEyebrowProps,
type EmptyStateHeaderProps,
type EmptyStateMediaProps,
type EmptyStateProps,
type EmptyStateTitleProps
} from "./components/empty-state";
export {
emptyStateActionsVariants,
emptyStateDescriptionVariants,
emptyStateEyebrowVariants,
emptyStateHeaderVariants,
emptyStateMediaVariants,
emptyStateTitleVariants,
emptyStateVariants
} from "./components/empty-state.variants";
export {
Field,
FieldControl,
+20
View File
@@ -30,3 +30,23 @@ test("storybook button, select, and reduced-motion form stories stay interactive
await page.getByRole("button", { name: "Save settings" }).click();
await expect(page.locator("pre code").last()).toContainText('"role": "legal"');
});
test("storybook overlay stories stay interactive", async ({ page }) => {
await page.goto("/iframe.html?id=components-dialog--playground&viewMode=story");
await page.getByRole("button", { name: "Open approval dialog" }).click();
await expect(page.getByRole("dialog", { name: "Launch this release?" })).toBeVisible();
await page.getByRole("button", { name: "Close dialog" }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await page.goto("/iframe.html?id=components-popover--playground&viewMode=story");
await page.getByRole("button", { name: "Inspect summary" }).click();
await expect(page.getByText("Release health")).toBeVisible();
await page.getByRole("button", { name: "Dismiss" }).click();
await expect(page.getByText("Release health")).toHaveCount(0);
await page.goto("/iframe.html?id=components-sheet--playground&viewMode=story");
await page.getByRole("button", { name: "Open right sheet" }).click();
await expect(page.getByRole("dialog", { name: "Launch settings" })).toBeVisible();
await page.getByRole("button", { name: "Close sheet" }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
});