feat: add empty state and expand overlay qa
This commit is contained in:
+133
@@ -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
|
||||
@@ -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.
|
||||
@@ -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. "No matching releases" is better than "Nothing here".</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>
|
||||
)
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: () => (
|
||||
|
||||
@@ -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: () => (
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user