232 lines
8.5 KiB
TypeScript
232 lines
8.5 KiB
TypeScript
import { Button } from "@ai-ui/ui";
|
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
|
|
function FavoriteIcon() {
|
|
return (
|
|
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
|
|
<path
|
|
d="m8 2.4 1.67 3.4 3.76.55-2.72 2.64.64 3.73L8 10.98l-3.35 1.74.64-3.73L2.57 6.35l3.76-.55L8 2.4Z"
|
|
stroke="currentColor"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.35"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function getButtonFromCanvas(canvasElement: HTMLElement, name: string) {
|
|
const buttons = canvasElement.querySelectorAll("button, a");
|
|
|
|
for (const element of buttons) {
|
|
if (element.textContent?.trim().includes(name)) {
|
|
return element;
|
|
}
|
|
}
|
|
|
|
throw new Error(`Expected to find an interactive control containing "${name}".`);
|
|
}
|
|
|
|
const meta = {
|
|
title: "Components/Button",
|
|
component: Button,
|
|
args: {
|
|
children: "Save changes",
|
|
size: "md",
|
|
variant: "primary"
|
|
},
|
|
argTypes: {
|
|
asChild: {
|
|
control: "boolean",
|
|
description: "Render through the child element using Radix Slot."
|
|
},
|
|
children: {
|
|
control: "text",
|
|
description: "Primary button label."
|
|
},
|
|
className: {
|
|
control: false
|
|
},
|
|
disabled: {
|
|
control: "boolean",
|
|
description: "Disables interaction and sets `data-disabled`."
|
|
},
|
|
loading: {
|
|
control: "boolean",
|
|
description: "Shows a spinner, disables interaction, and sets `data-loading`."
|
|
},
|
|
size: {
|
|
control: "select",
|
|
options: ["sm", "md", "lg", "icon"],
|
|
description: "Controls density and spacing."
|
|
},
|
|
type: {
|
|
control: "radio",
|
|
options: ["button", "submit", "reset"],
|
|
description: "Native button type when not using `asChild`."
|
|
},
|
|
variant: {
|
|
control: "select",
|
|
options: ["primary", "secondary", "ghost", "subtle", "destructive"],
|
|
description: "Semantic appearance style."
|
|
}
|
|
},
|
|
parameters: {
|
|
docs: {
|
|
description: {
|
|
component:
|
|
"The first production-style component in the system. It demonstrates the Phase 3 pattern: semantic variants, token-driven styling, motion recipes, loading state, stable `data-slot` / `data-*` hooks, and early integration of the `motion` React runtime for refined hover and press animation."
|
|
}
|
|
},
|
|
layout: "centered"
|
|
},
|
|
tags: ["autodocs"]
|
|
} satisfies Meta<typeof Button>;
|
|
|
|
export default meta;
|
|
|
|
type Story = StoryObj<typeof meta>;
|
|
|
|
export const Playground: Story = {
|
|
play: async ({ canvasElement }) => {
|
|
const button = getButtonFromCanvas(canvasElement, "Save changes");
|
|
|
|
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
if (button instanceof HTMLElement) {
|
|
button.focus();
|
|
}
|
|
}
|
|
};
|
|
|
|
export const Variants: Story = {
|
|
render: () => (
|
|
<div className="grid w-[720px] gap-3 sm:grid-cols-2">
|
|
<Button variant="primary">Primary action</Button>
|
|
<Button variant="secondary">Secondary action</Button>
|
|
<Button variant="subtle">Subtle action</Button>
|
|
<Button variant="ghost">Ghost action</Button>
|
|
<Button variant="destructive">Delete item</Button>
|
|
</div>
|
|
)
|
|
};
|
|
|
|
export const Sizes: Story = {
|
|
render: () => (
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Button size="sm">Small</Button>
|
|
<Button size="md">Medium</Button>
|
|
<Button size="lg">Large</Button>
|
|
<Button aria-label="Favorite" size="icon">
|
|
<FavoriteIcon />
|
|
</Button>
|
|
</div>
|
|
)
|
|
};
|
|
|
|
export const States: Story = {
|
|
render: () => (
|
|
<div className="grid w-[720px] gap-3 sm:grid-cols-2">
|
|
<Button>Default</Button>
|
|
<Button disabled>Disabled</Button>
|
|
<Button loading>Saving</Button>
|
|
<Button variant="destructive" loading>
|
|
Deleting
|
|
</Button>
|
|
</div>
|
|
)
|
|
};
|
|
|
|
export const Motion: Story = {
|
|
parameters: {
|
|
docs: {
|
|
description: {
|
|
story:
|
|
"Hover and press these buttons directly in the canvas. Primary and subtle variants now use `motion/react` for a restrained lift-and-settle interaction, while loading keeps the label and spinner transitions smooth."
|
|
}
|
|
}
|
|
},
|
|
render: () => (
|
|
<div className="relative grid w-[840px] gap-5 overflow-hidden rounded-[2.2rem] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_84%,white_16%),color-mix(in_oklch,var(--color-background)_90%,white_10%))] p-6 shadow-[0_24px_72px_color-mix(in_oklch,var(--color-primary)_10%,transparent)] sm:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
|
<div className="pointer-events-none absolute inset-0">
|
|
<div className="motion-drift absolute left-[-2rem] top-[-2rem] h-28 w-28 rounded-full bg-[color-mix(in_oklch,var(--color-primary-container)_62%,transparent)] blur-3xl" />
|
|
<div className="motion-breathe absolute right-0 top-10 h-24 w-24 rounded-full bg-[color-mix(in_oklch,var(--color-tertiary-container)_58%,transparent)] blur-3xl" />
|
|
</div>
|
|
|
|
<div className="relative grid gap-4">
|
|
<div className="space-y-2">
|
|
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
|
Material motion deck
|
|
</p>
|
|
<h3 className="max-w-md text-3xl font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
|
|
Buttons should feel like touchable capsules floating over tinted light.
|
|
</h3>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<Button>Premium primary</Button>
|
|
<Button variant="subtle">Subtle surface</Button>
|
|
<Button variant="secondary">Secondary action</Button>
|
|
<Button loading>Saving changes</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative flex items-center justify-center">
|
|
<div className="motion-float absolute left-5 top-8 rounded-full border border-white/45 bg-[color-mix(in_oklch,var(--color-surface-container-low)_78%,white_22%)] px-4 py-2 text-xs font-medium tracking-[0.14em] text-[var(--color-muted-foreground)] shadow-[0_12px_30px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]">
|
|
SOFT LIFT
|
|
</div>
|
|
<div className="motion-float-delayed absolute bottom-6 right-6 rounded-full bg-[var(--color-primary-container)] px-4 py-2 text-sm font-medium text-[var(--color-on-primary-container)] shadow-[0_14px_28px_color-mix(in_oklch,var(--color-primary)_12%,transparent)]">
|
|
PRESSED
|
|
</div>
|
|
<div className="grid w-full max-w-[16rem] gap-3 rounded-[2rem] border border-white/40 bg-[color-mix(in_oklch,var(--color-surface-container-low)_82%,white_18%)] p-4 shadow-[0_24px_60px_color-mix(in_oklch,var(--color-primary)_12%,transparent)]">
|
|
<div className="h-28 rounded-[1.5rem] bg-[linear-gradient(165deg,color-mix(in_oklch,var(--color-primary-container)_88%,white_12%),color-mix(in_oklch,var(--color-tertiary-container)_82%,white_18%))]" />
|
|
<Button>Shop set</Button>
|
|
<Button variant="ghost">Maybe later</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
};
|
|
|
|
export const AsLink: Story = {
|
|
render: () => (
|
|
<Button asChild variant="ghost">
|
|
<a href="https://example.com">Read release notes</a>
|
|
</Button>
|
|
)
|
|
};
|
|
|
|
export const Anatomy: Story = {
|
|
render: () => (
|
|
<div className="w-[680px] 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)]">
|
|
Button anatomy
|
|
</p>
|
|
<Button loading variant="secondary">
|
|
Save draft
|
|
</Button>
|
|
<div className="grid gap-3 text-sm text-[var(--color-muted-foreground)]">
|
|
<p>
|
|
<code className="text-[var(--color-foreground)]">data-slot="root"</code> on the
|
|
interactive surface.
|
|
</p>
|
|
<p>
|
|
<code className="text-[var(--color-foreground)]">data-slot="icon"</code> on the
|
|
loading spinner when present.
|
|
</p>
|
|
<p>
|
|
<code className="text-[var(--color-foreground)]">data-slot="label"</code> on the
|
|
visible button text.
|
|
</p>
|
|
<p>
|
|
<code className="text-[var(--color-foreground)]">data-loading</code>,{" "}
|
|
<code className="text-[var(--color-foreground)]">data-disabled</code>,{" "}
|
|
<code className="text-[var(--color-foreground)]">data-size</code>, and{" "}
|
|
<code className="text-[var(--color-foreground)]">data-variant</code> drive stateful
|
|
styling.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
};
|