diff --git a/packages/propel/src/components/button/button.stories.tsx b/packages/propel/src/components/button/button.stories.tsx
index 68466269..03fc7315 100644
--- a/packages/propel/src/components/button/button.stories.tsx
+++ b/packages/propel/src/components/button/button.stories.tsx
@@ -1,9 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Plus, Search, Settings } from "lucide-react";
-import { expect, fn } from "storybook/test";
+import { expect, fn, userEvent as baseUserEvent } from "storybook/test";
import { iconControl } from "../../storybook/icon-control";
-import { Button, type ButtonMagnitude, type ButtonVariant } from "./index";
+import {
+ Button,
+ ButtonIcon,
+ ButtonLabel,
+ type ButtonMagnitude,
+ ButtonSpinner,
+ type ButtonVariant,
+} from "./index";
const VARIANTS: ButtonVariant[] = ["primary", "secondary", "tertiary", "ghost", "link"];
const MAGNITUDES: ButtonMagnitude[] = ["sm", "md", "lg", "xl"];
@@ -11,6 +18,8 @@ const MAGNITUDES: ButtonMagnitude[] = ["sm", "md", "lg", "xl"];
const meta = {
title: "Components/Button",
component: Button,
+ // Anatomy parts the ready-made Button composes (UI tier).
+ subcomponents: { ButtonIcon, ButtonLabel, ButtonSpinner },
// Icon picker controls for the two icon slots.
argTypes: { inlineStartNode: iconControl, inlineEndNode: iconControl },
parameters: {
@@ -128,7 +137,7 @@ export const WithIcons: Story = {
),
};
-/** The loading state shows a spinner, sets `aria-busy`, and blocks interaction. */
+/** The loading state shows a spinner, sets `aria-busy`, and blocks interaction. The label dims. */
export const Loading: Story = {
parameters: { controls: { disable: true } },
render: (args) => (
@@ -146,6 +155,21 @@ export const Loading: Story = {
),
};
+/** `stretch="full"` fills the container (e.g. a form row or mobile CTA). */
+export const Stretch: Story = {
+ parameters: { controls: { disable: true } },
+ render: (args) => (
+
+
+
+
+ ),
+};
+
/**
* Clicking a button fires `onClick`. Tagged `!dev`/`!autodocs`/`!manifest` so it's hidden from the
* sidebar, docs, and AI manifest — it's a behavior test, not an example — but still runs under the
@@ -194,10 +218,14 @@ export const SpaceActivates: Story = {
export const DisabledBlocksClick: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
args: { onClick: fn(), disabled: true },
- play: async ({ args, canvas, userEvent }) => {
+ play: async ({ args, canvas }) => {
const button = canvas.getByRole("button", { name: "Button" });
await expect(button).toBeDisabled();
- await userEvent.click(button);
+ // A disabled button sets `pointer-events: none`, so the default user-event guard
+ // refuses to click it. Disable that guard so the click is dispatched at the element;
+ // the native disabled button must still ignore it and never fire `onClick`.
+ const user = baseUserEvent.setup({ pointerEventsCheck: 0 });
+ await user.click(button);
await expect(args.onClick).not.toHaveBeenCalled();
},
};
diff --git a/packages/propel/src/components/button/button.tsx b/packages/propel/src/components/button/button.tsx
index 61368feb..787bc95c 100644
--- a/packages/propel/src/components/button/button.tsx
+++ b/packages/propel/src/components/button/button.tsx
@@ -1,8 +1,13 @@
import { LoaderCircle } from "lucide-react";
import type * as React from "react";
-import { NodeSlot } from "../../internal/node-slot";
-import { Button as ButtonRoot, type ButtonProps as ButtonRootProps } from "../../ui/button";
+import {
+ Button as ButtonRoot,
+ ButtonIcon,
+ ButtonLabel,
+ type ButtonProps as ButtonRootProps,
+ ButtonSpinner,
+} from "../../ui/button";
export type ButtonProps = ButtonRootProps & {
/**
@@ -43,12 +48,14 @@ export function Button({
aria-busy={loading ? true : undefined}
>
{loading ? (
-
+
+
+
) : inlineStartNode ? (
- {inlineStartNode}
+ {inlineStartNode}
) : null}
- {children}
- {!loading && inlineEndNode ? {inlineEndNode} : null}
+ {children}
+ {!loading && inlineEndNode ? {inlineEndNode} : null}
);
}
diff --git a/packages/propel/src/components/button/index.tsx b/packages/propel/src/components/button/index.tsx
index 0ce3df37..9d05e38f 100644
--- a/packages/propel/src/components/button/index.tsx
+++ b/packages/propel/src/components/button/index.tsx
@@ -1,9 +1,16 @@
export { Button, type ButtonProps } from "./button";
-// Re-export the atomic button's variant types + `buttonVariants` so the full button surface is
-// importable from this convenience.
+// Re-export the atomic button's parts, variant types, and `buttonVariants` so the full button
+// surface is importable from this convenience.
export {
+ ButtonIcon,
+ type ButtonIconProps,
+ ButtonLabel,
+ type ButtonLabelProps,
+ ButtonSpinner,
+ type ButtonSpinnerProps,
type ButtonEmphasis,
type ButtonMagnitude,
+ type ButtonStretch,
type ButtonTone,
type ButtonVariant,
buttonVariants,
diff --git a/packages/propel/src/ui/button/button-icon.tsx b/packages/propel/src/ui/button/button-icon.tsx
new file mode 100644
index 00000000..ffde33ac
--- /dev/null
+++ b/packages/propel/src/ui/button/button-icon.tsx
@@ -0,0 +1,14 @@
+import type * as React from "react";
+
+import { buttonIconVariants } from "./variants";
+
+export type ButtonIconProps = Omit, "className" | "style">;
+
+/**
+ * A decorative node beside the label (Figma leading/trailing icon). Sizes its single child to the
+ * button's `--node-size`, so callers pass a bare icon/avatar. Decorative — the button's label
+ * carries the accessible name — so it is `aria-hidden`.
+ */
+export function ButtonIcon(props: ButtonIconProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/button/button-label.tsx b/packages/propel/src/ui/button/button-label.tsx
new file mode 100644
index 00000000..8f89187b
--- /dev/null
+++ b/packages/propel/src/ui/button/button-label.tsx
@@ -0,0 +1,14 @@
+import type * as React from "react";
+
+import { buttonLabelVariants } from "./variants";
+
+export type ButtonLabelProps = Omit, "className" | "style">;
+
+/**
+ * The button's text label. When the root button is `aria-busy` (loading) it dims via the
+ * `group-aria-busy:` sibling of the `group` class the root carries, so the spinner reads as the
+ * active affordance while the label fades.
+ */
+export function ButtonLabel(props: ButtonLabelProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/button/button-spinner.tsx b/packages/propel/src/ui/button/button-spinner.tsx
new file mode 100644
index 00000000..bcffe23a
--- /dev/null
+++ b/packages/propel/src/ui/button/button-spinner.tsx
@@ -0,0 +1,18 @@
+import type * as React from "react";
+
+import { buttonSpinnerVariants } from "./variants";
+
+export type ButtonSpinnerProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/**
+ * The loading indicator shown in place of the inline-start node while the button is busy. A pure
+ * slot: it sizes its single child to the button's `--node-size` and spins it via `animate-spin`,
+ * but bakes no glyph — callers pass the spinner icon as `children`. Decorative (the root carries
+ * `aria-busy`), so it is `aria-hidden`.
+ */
+export function ButtonSpinner(props: ButtonSpinnerProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/button/button.stories.tsx b/packages/propel/src/ui/button/button.stories.tsx
index 6e55ae54..1e8882dc 100644
--- a/packages/propel/src/ui/button/button.stories.tsx
+++ b/packages/propel/src/ui/button/button.stories.tsx
@@ -1,7 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { expect, fn } from "storybook/test";
+import { LoaderCircle, Plus } from "lucide-react";
+import { expect, fn, userEvent as baseUserEvent } from "storybook/test";
-import { Button, type ButtonMagnitude, type ButtonVariant } from "./index";
+import {
+ Button,
+ ButtonIcon,
+ ButtonLabel,
+ type ButtonMagnitude,
+ ButtonSpinner,
+ type ButtonVariant,
+} from "./index";
const VARIANTS: ButtonVariant[] = ["primary", "secondary", "tertiary", "ghost", "link"];
const MAGNITUDES: ButtonMagnitude[] = ["sm", "md", "lg", "xl"];
@@ -13,6 +21,8 @@ const MAGNITUDES: ButtonMagnitude[] = ["sm", "md", "lg", "xl"];
const meta = {
title: "UI/Button",
component: Button,
+ // The button's anatomy parts; the ready-made Button (Components/Button) composes them.
+ subcomponents: { ButtonIcon, ButtonLabel, ButtonSpinner },
args: {
children: "Button",
variant: "primary",
@@ -97,6 +107,50 @@ export const Magnitudes: Story = {
),
};
+/** `stretch="full"` fills the container (e.g. a form row or mobile CTA). */
+export const Stretch: Story = {
+ argTypes: { stretch: { control: false }, children: { control: false } },
+ render: (args) => (
+
+
+
+
+ ),
+};
+
+/**
+ * The atomic button is composed from named parts: `ButtonIcon` sizes a decorative leading/trailing
+ * node to the button's `--node-size`, `ButtonLabel` holds the text (and dims under `aria-busy`),
+ * and `ButtonSpinner` is the loading indicator. The ready-made `Button` (Components/Button) lays
+ * these out for you; here they are composed by hand.
+ */
+export const Anatomy: Story = {
+ args: { children: undefined },
+ argTypes: { children: { control: false } },
+ render: (args) => (
+
+
+ {/* The busy state mirrors the ready-made Button: it is `aria-busy` AND soft-disabled
+ (Base UI `disabled` + `focusableWhenDisabled`), so the disabled palette applies. */}
+
+
+ ),
+};
+
/**
* Clicking the button fires `onClick`. Tagged out of the sidebar/docs/manifest but still runs under
* the default `test` tag.
@@ -115,10 +169,14 @@ export const ClickFiresOnClick: Story = {
export const DisabledBlocksClick: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
args: { onClick: fn(), disabled: true },
- play: async ({ args, canvas, userEvent }) => {
+ play: async ({ args, canvas }) => {
const button = canvas.getByRole("button", { name: "Button" });
await expect(button).toBeDisabled();
- await userEvent.click(button);
+ // A disabled button sets `pointer-events: none`, so the default user-event guard
+ // refuses to click it. Disable that guard so the click is dispatched at the element;
+ // the native disabled button must still ignore it and never fire `onClick`.
+ const user = baseUserEvent.setup({ pointerEventsCheck: 0 });
+ await user.click(button);
await expect(args.onClick).not.toHaveBeenCalled();
},
};
diff --git a/packages/propel/src/ui/button/button.tsx b/packages/propel/src/ui/button/button.tsx
index 313eeb08..c3deacc0 100644
--- a/packages/propel/src/ui/button/button.tsx
+++ b/packages/propel/src/ui/button/button.tsx
@@ -6,11 +6,15 @@ import { buttonVariants } from "./variants";
// Re-exported so `buttonVariants` stays part of the button entry's public surface
// (e.g. `icon-button` composes it).
export { buttonVariants } from "./variants";
+export { ButtonIcon, type ButtonIconProps } from "./button-icon";
+export { ButtonLabel, type ButtonLabelProps } from "./button-label";
+export { ButtonSpinner, type ButtonSpinnerProps } from "./button-spinner";
export type ButtonVariant = NonNullable["variant"]>;
export type ButtonTone = NonNullable["tone"]>;
export type ButtonMagnitude = NonNullable["magnitude"]>;
export type ButtonEmphasis = NonNullable["emphasis"]>;
+export type ButtonStretch = NonNullable["stretch"]>;
type ButtonOwnProps = {
variant: ButtonVariant;
@@ -22,6 +26,12 @@ type ButtonOwnProps = {
* default and every other `variant` ignores it.
*/
emphasis?: ButtonEmphasis;
+ /**
+ * Layout axis (Figma "Full width"). `auto` keeps the button inline-sized (default); `full`
+ * stretches it to fill its container (`w-full`). Pass `stretch="full"` for forms or full-width
+ * call-to-action placements.
+ */
+ stretch?: ButtonStretch;
};
export type ButtonProps = Omit & ButtonOwnProps;
@@ -30,20 +40,22 @@ export type ButtonProps = Omit & Button
* A plain accessible button built on propel's design tokens. Pick a look with `variant` (Figma
* Type), select the error palette with `tone`, and size it with `magnitude` — all required, so
* consumers choose explicitly. For `variant="link"` only, optionally choose `solid` (blue) or
- * `subtle` (gray) with `emphasis`. `children` is passed through; it is not a variant.
+ * `subtle` (gray) with `emphasis`. Use `stretch="full"` for full-width placements. `children` is
+ * passed through; it is not a variant.
*/
export function Button({
variant,
tone,
magnitude,
emphasis,
+ stretch,
type = "button",
...props
}: ButtonProps) {
return (
);
diff --git a/packages/propel/src/ui/button/variants.ts b/packages/propel/src/ui/button/variants.ts
index 11aa8987..b1e32d5f 100644
--- a/packages/propel/src/ui/button/variants.ts
+++ b/packages/propel/src/ui/button/variants.ts
@@ -1,17 +1,22 @@
import { cva, cx } from "class-variance-authority";
+import { nodeSlotClass } from "../../internal/node-slot";
+
// Magnitudes follow the Figma "Buttons" Size scale. Figma ships S/Base/L/XL; those
// map to sm/md/lg/xl by their px heights (20/24/28/32). Per Figma:
// S -> text-12/px-1.5; Base & L -> text-13; XL -> text-14. All chrome'd magnitudes
// use leading-none so the flex-centered label sits dead-center in the fixed height.
export const buttonVariants = cva(
- // Shared chrome: inline flex row, centered, focus-visible ring on the brand
- // accent token, real disabled affordance, and a snug medium label.
+ // Shared chrome: inline flex row, centered, focus-visible ring (with 1px offset)
+ // on the brand accent token, real disabled affordance, and a snug medium label.
+ // `group` enables `group-aria-busy:` on child elements (e.g. the label span).
cx(
- "relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-1 rounded-md font-medium",
+ "group relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-1 rounded-md font-medium",
"whitespace-nowrap transition-colors outline-none",
- "focus-visible:ring-2 focus-visible:ring-accent-strong",
- "disabled:cursor-not-allowed aria-busy:cursor-default",
+ "focus-visible:ring-2 focus-visible:ring-accent-strong focus-visible:ring-offset-1",
+ // Disabled: cursor, no pointer events (covers both native disabled and aria-disabled).
+ "disabled:pointer-events-none disabled:cursor-not-allowed",
+ "aria-busy:cursor-default",
),
{
variants: {
@@ -54,6 +59,12 @@ export const buttonVariants = cva(
lg: "h-7 min-w-12 px-2 text-13 leading-none [--node-size:1rem]",
xl: "h-8 min-w-13 px-2 text-14 leading-none [--node-size:1rem]",
},
+ // Layout axis (Figma "Full width" spec item). `auto` is the default inline
+ // size; `full` stretches to fill the container (`w-full`).
+ stretch: {
+ auto: "",
+ full: "w-full",
+ },
},
compoundVariants: [
// ----- Neutral solid (Figma Type=Primary) -----
@@ -149,3 +160,17 @@ export const buttonVariants = cva(
],
},
);
+
+// The text label inside a Button. When the parent button is `aria-busy` (loading)
+// it dims via the `group-aria-busy:` sibling of the `group` class on the root: the
+// spinner replaces the inline-start node, and this fades the text alongside it.
+export const buttonLabelVariants = cva("group-aria-busy:opacity-50");
+
+// A decorative node beside the label. Reuses the shared node-slot chrome so its
+// single child is sized to the button's inherited `--node-size`.
+export const buttonIconVariants = cva(nodeSlotClass);
+
+// The loading indicator that replaces the inline-start node while busy. A pure slot:
+// reuses the shared node-slot chrome to size its single child to the button's
+// `--node-size`, and spins the wrapper (and thus the child) via `animate-spin`.
+export const buttonSpinnerVariants = cva(cx(nodeSlotClass, "animate-spin"));