From 132bb6961d915accae9e07a3513060ff23e32657 Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 19 Mar 2026 19:00:36 +0800 Subject: [PATCH] feat: add empty state and expand overlay qa --- CONTRIBUTING.md | 133 +++++++++++++++ README.md | 106 ++++++++++++ apps/docs/src/components/dialog.stories.tsx | 49 +++++- .../src/components/dropdown-menu.stories.tsx | 51 +++++- .../src/components/empty-state.stories.tsx | 156 ++++++++++++++++++ apps/docs/src/components/popover.stories.tsx | 51 +++++- apps/docs/src/components/sheet.stories.tsx | 50 +++++- apps/docs/src/components/toast.stories.tsx | 50 +++++- apps/docs/src/components/tooltip.stories.tsx | 43 ++++- packages/ui/src/components/dialog.test.tsx | 25 +++ .../ui/src/components/dropdown-menu.test.tsx | 27 +++ .../ui/src/components/empty-state.test.tsx | 59 +++++++ packages/ui/src/components/empty-state.tsx | 123 ++++++++++++++ .../ui/src/components/empty-state.variants.ts | 50 ++++++ packages/ui/src/components/popover.test.tsx | 23 +++ packages/ui/src/components/sheet.test.tsx | 25 +++ packages/ui/src/components/toast.test.tsx | 8 + packages/ui/src/components/tooltip.test.tsx | 26 +++ packages/ui/src/index.ts | 25 +++ tests/e2e/storybook-smoke.spec.ts | 20 +++ 20 files changed, 1094 insertions(+), 6 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 README.md create mode 100644 apps/docs/src/components/empty-state.stories.tsx create mode 100644 packages/ui/src/components/empty-state.test.tsx create mode 100644 packages/ui/src/components/empty-state.tsx create mode 100644 packages/ui/src/components/empty-state.variants.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e022a50 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb9bd2d --- /dev/null +++ b/README.md @@ -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. diff --git a/apps/docs/src/components/dialog.stories.tsx b/apps/docs/src/components/dialog.stories.tsx index b349aa7..d446d55 100644 --- a/apps/docs/src/components/dialog.stories.tsx +++ b/apps/docs/src/components/dialog.stories.tsx @@ -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; export const Playground: Story = { - render: () => + render: () => , + 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 = { diff --git a/apps/docs/src/components/dropdown-menu.stories.tsx b/apps/docs/src/components/dropdown-menu.stories.tsx index f3ad45d..b2cacb7 100644 --- a/apps/docs/src/components/dropdown-menu.stories.tsx +++ b/apps/docs/src/components/dropdown-menu.stories.tsx @@ -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; export const Playground: Story = { - render: () => + render: () => , + 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 = { diff --git a/apps/docs/src/components/empty-state.stories.tsx b/apps/docs/src/components/empty-state.stories.tsx new file mode 100644 index 0000000..7f2cabd --- /dev/null +++ b/apps/docs/src/components/empty-state.stories.tsx @@ -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 ( +
+
+ + +
+
+ + +
+
+ ); +} + +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 ( + + + + + + {eyebrow} + {title} + {description} + + + + + + + ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const Scenarios: Story = { + render: () => ( +
+ + +
+ ) +}; + +export const Anatomy: Story = { + render: () => ( +
+
+

+ Empty state anatomy +

+ +
+

+ data-slot=\"media\" holds the + decorative or explanatory visual. +

+

+ data-slot=\"header\",{" "} + data-slot=\"eyebrow\",{" "} + data-slot=\"label\", and{" "} + data-slot=\"description\"{" "} + structure the framing copy. +

+

+ data-slot=\"actions\" groups + the primary next step and any secondary recovery action. +

+

+ data-tone exposes whether the + surface stays neutral, subtle, or accent-led. +

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

+ Accessibility notes +

+
+

Describe the absence clearly. "No matching releases" is better than "Nothing here".

+

Use actions that recover the user, such as clearing filters or creating the first item.

+

Do not hide critical instructions inside decorative media. The copy should stand on its own.

+
+
+
+ +
+
+ ) +}; diff --git a/apps/docs/src/components/popover.stories.tsx b/apps/docs/src/components/popover.stories.tsx index fb296c1..f370d5a 100644 --- a/apps/docs/src/components/popover.stories.tsx +++ b/apps/docs/src/components/popover.stories.tsx @@ -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; export const Playground: Story = { - render: () => + render: () => , + 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 = { diff --git a/apps/docs/src/components/sheet.stories.tsx b/apps/docs/src/components/sheet.stories.tsx index 5cbdd36..15e54e0 100644 --- a/apps/docs/src/components/sheet.stories.tsx +++ b/apps/docs/src/components/sheet.stories.tsx @@ -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; -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: () => ( diff --git a/apps/docs/src/components/toast.stories.tsx b/apps/docs/src/components/toast.stories.tsx index eaf07d6..85723dc 100644 --- a/apps/docs/src/components/toast.stories.tsx +++ b/apps/docs/src/components/toast.stories.tsx @@ -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; -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: () => ( diff --git a/apps/docs/src/components/tooltip.stories.tsx b/apps/docs/src/components/tooltip.stories.tsx index 7843855..b3f80df 100644 --- a/apps/docs/src/components/tooltip.stories.tsx +++ b/apps/docs/src/components/tooltip.stories.tsx @@ -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; export const Playground: Story = { - render: () => + render: () => , + 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 = { diff --git a/packages/ui/src/components/dialog.test.tsx b/packages/ui/src/components/dialog.test.tsx index 86a2ecc..9dc1289 100644 --- a/packages/ui/src/components/dialog.test.tsx +++ b/packages/ui/src/components/dialog.test.tsx @@ -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( + + Open accessible dialog + + Accessibility + + + ); + + 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(); + }); + }); }); diff --git a/packages/ui/src/components/dropdown-menu.test.tsx b/packages/ui/src/components/dropdown-menu.test.tsx index 34bb693..c2c11e6 100644 --- a/packages/ui/src/components/dropdown-menu.test.tsx +++ b/packages/ui/src/components/dropdown-menu.test.tsx @@ -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( + + Keyboard menu + + Review + + + ); + + 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(); + }); + }); }); diff --git a/packages/ui/src/components/empty-state.test.tsx b/packages/ui/src/components/empty-state.test.tsx new file mode 100644 index 0000000..7328e45 --- /dev/null +++ b/packages/ui/src/components/empty-state.test.tsx @@ -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( + + 0 + + Search + No matching releases + Try another filter or create a new release. + + + + + + ); + + 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( + + + No saved views + + + ); + + 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" + ); + }); +}); diff --git a/packages/ui/src/components/empty-state.tsx b/packages/ui/src/components/empty-state.tsx new file mode 100644 index 0000000..59b5955 --- /dev/null +++ b/packages/ui/src/components/empty-state.tsx @@ -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; + +export const EmptyState = forwardRef(function EmptyState( + { className, tone, ...props }, + ref +) { + return ( +
+ ); +}); + +export type EmptyStateMediaProps = ComponentPropsWithoutRef<"div">; + +export const EmptyStateMedia = forwardRef( + function EmptyStateMedia({ className, ...props }, ref) { + return ( +
+ ); + } +); + +export type EmptyStateHeaderProps = ComponentPropsWithoutRef<"div">; + +export const EmptyStateHeader = forwardRef( + function EmptyStateHeader({ className, ...props }, ref) { + return ( +
+ ); + } +); + +export type EmptyStateEyebrowProps = ComponentPropsWithoutRef<"p">; + +export const EmptyStateEyebrow = forwardRef( + function EmptyStateEyebrow({ className, ...props }, ref) { + return ( +

+ ); + } +); + +export type EmptyStateTitleProps = ComponentPropsWithoutRef<"h3">; + +export const EmptyStateTitle = forwardRef( + function EmptyStateTitle({ className, ...props }, ref) { + return ( +

+ ); + } +); + +export type EmptyStateDescriptionProps = ComponentPropsWithoutRef<"p">; + +export const EmptyStateDescription = forwardRef< + HTMLParagraphElement, + EmptyStateDescriptionProps +>(function EmptyStateDescription({ className, ...props }, ref) { + return ( +

+ ); +}); + +export type EmptyStateActionsProps = ComponentPropsWithoutRef<"div">; + +export const EmptyStateActions = forwardRef( + function EmptyStateActions({ className, ...props }, ref) { + return ( +

+ ); + } +); diff --git a/packages/ui/src/components/empty-state.variants.ts b/packages/ui/src/components/empty-state.variants.ts new file mode 100644 index 0000000..1463052 --- /dev/null +++ b/packages/ui/src/components/empty-state.variants.ts @@ -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" +); diff --git a/packages/ui/src/components/popover.test.tsx b/packages/ui/src/components/popover.test.tsx index dd9a3d4..de32000 100644 --- a/packages/ui/src/components/popover.test.tsx +++ b/packages/ui/src/components/popover.test.tsx @@ -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( + + Open details + Context + + ); + + 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(); + }); + }); }); diff --git a/packages/ui/src/components/sheet.test.tsx b/packages/ui/src/components/sheet.test.tsx index d9efaf7..5cdef11 100644 --- a/packages/ui/src/components/sheet.test.tsx +++ b/packages/ui/src/components/sheet.test.tsx @@ -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( + + Open accessible sheet + + Accessibility + + + ); + + 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(); + }); + }); }); diff --git a/packages/ui/src/components/toast.test.tsx b/packages/ui/src/components/toast.test.tsx index 2615c96..891cd61 100644 --- a/packages/ui/src/components/toast.test.tsx +++ b/packages/ui/src/components/toast.test.tsx @@ -66,4 +66,12 @@ describe("Toast", () => { expect(screen.queryByText("Saved")).not.toBeInTheDocument(); }); }); + + it("exposes the default close label for screen readers", () => { + render(); + + const closeButton = screen.getByRole("button", { name: "Close notification" }); + + expect(closeButton).toHaveAttribute("data-slot", "close"); + }); }); diff --git a/packages/ui/src/components/tooltip.test.tsx b/packages/ui/src/components/tooltip.test.tsx index 658f6a8..8fc2435 100644 --- a/packages/ui/src/components/tooltip.test.tsx +++ b/packages/ui/src/components/tooltip.test.tsx @@ -59,4 +59,30 @@ describe("Tooltip", () => { expect(onOpenChange).toHaveBeenCalledWith(true); }); }); + + it("shows tooltip content on focus and hides it on blur", async () => { + render( + + + Focus help + Focus context + + + ); + + 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(); + }); + }); }); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 4f61b5e..86e6258 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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, diff --git a/tests/e2e/storybook-smoke.spec.ts b/tests/e2e/storybook-smoke.spec.ts index a2b2505..a452bf9 100644 --- a/tests/e2e/storybook-smoke.spec.ts +++ b/tests/e2e/storybook-smoke.spec.ts @@ -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); +});