207 lines
6.9 KiB
TypeScript
207 lines
6.9 KiB
TypeScript
import {
|
|
Button,
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger
|
|
} 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";
|
|
title?: string;
|
|
triggerLabel?: string;
|
|
};
|
|
|
|
function LaunchDialog({
|
|
description = "This will notify the routing team and publish the release note to the activity feed.",
|
|
size = "md",
|
|
title = "Launch this release?",
|
|
triggerLabel = "Open approval dialog"
|
|
}: LaunchDialogProps) {
|
|
return (
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<Button>{triggerLabel}</Button>
|
|
</DialogTrigger>
|
|
<DialogContent size={size}>
|
|
<DialogHeader>
|
|
<DialogTitle>{title}</DialogTitle>
|
|
<DialogDescription>{description}</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="ghost">Cancel</Button>
|
|
<Button>Confirm launch</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
const meta = {
|
|
title: "Components/Dialog",
|
|
component: Dialog,
|
|
parameters: {
|
|
docs: {
|
|
description: {
|
|
component:
|
|
"Dialog is the system's blocking overlay for focused decisions, confirmation flows, and dense tasks that must temporarily interrupt the surrounding page. It ships with a portal, overlay, close affordance, semantic title and description wiring, and token-driven motion on both the surface and backdrop."
|
|
}
|
|
},
|
|
layout: "centered"
|
|
},
|
|
tags: ["autodocs"]
|
|
} satisfies Meta<typeof Dialog>;
|
|
|
|
export default meta;
|
|
|
|
type Story = StoryObj<typeof meta>;
|
|
|
|
export const Playground: Story = {
|
|
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 = {
|
|
render: () => (
|
|
<div className="grid w-[720px] gap-3 sm:grid-cols-2">
|
|
<LaunchDialog
|
|
description="Use the compact size for short confirmations that only need a title, one supporting sentence, and one primary action."
|
|
size="sm"
|
|
title="Publish summary?"
|
|
triggerLabel="Compact dialog"
|
|
/>
|
|
<LaunchDialog
|
|
description="Use the large size when the flow needs denser copy, audit context, or multi-step review detail before a final action."
|
|
size="lg"
|
|
title="Review rollout checklist"
|
|
triggerLabel="Large dialog"
|
|
/>
|
|
</div>
|
|
)
|
|
};
|
|
|
|
export const Anatomy: Story = {
|
|
render: () => (
|
|
<div className="w-[700px] 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-3">
|
|
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
|
Dialog anatomy
|
|
</p>
|
|
<LaunchDialog triggerLabel="Preview dialog structure" />
|
|
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
<p>
|
|
<code className="text-[var(--color-foreground)]">data-slot="overlay"</code> sits
|
|
behind the surface and carries the backdrop motion.
|
|
</p>
|
|
<p>
|
|
<code className="text-[var(--color-foreground)]">data-slot="content"</code> wraps
|
|
the modal panel and exposes <code className="text-[var(--color-foreground)]">data-size</code>.
|
|
</p>
|
|
<p>
|
|
<code className="text-[var(--color-foreground)]">data-slot="header"</code>,{" "}
|
|
<code className="text-[var(--color-foreground)]">data-slot="footer"</code>,{" "}
|
|
<code className="text-[var(--color-foreground)]">data-slot="label"</code>, and{" "}
|
|
<code className="text-[var(--color-foreground)]">data-slot="description"</code>
|
|
provide stable hooks for structure and docs.
|
|
</p>
|
|
<p>
|
|
The close button is built into <code className="text-[var(--color-foreground)]">DialogContent</code>,
|
|
so every dialog gets a dismiss affordance even when the footer stays minimal.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
};
|
|
|
|
export const Accessibility: Story = {
|
|
parameters: {
|
|
docs: {
|
|
description: {
|
|
story:
|
|
"Use dialog only when the user must resolve or dismiss a blocking task. Focus is trapped while open, Escape closes the surface, and the title and description are announced through the Radix dialog semantics."
|
|
}
|
|
}
|
|
},
|
|
render: () => (
|
|
<div className="grid w-[760px] gap-4 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
|
<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>Keep the title outcome-oriented so assistive tech announces the decision clearly.</p>
|
|
<p>
|
|
Use the description for the consequence or next step, not decorative copy.
|
|
</p>
|
|
<p>
|
|
Keep the trigger specific. "Open approval dialog" is more useful than a generic
|
|
"Open".
|
|
</p>
|
|
<p>
|
|
Reserve dialogs for blocking work. If the content should not trap focus, prefer
|
|
a popover instead.
|
|
</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">
|
|
<LaunchDialog
|
|
description="Keyboard focus moves into the surface, Escape closes it, and the trigger regains focus after dismissal."
|
|
triggerLabel="Open accessible dialog"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
};
|