feat: add empty state and expand overlay qa
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user