Skip to content

Commit 4d6a25e

Browse files
Align button to architecture goals (#201)
* Align button to architecture goals per issue #122 spec - Focus ring: add ring-offset-1 (focus ring style and offset is always the same per spec) - Disabled: add pointer-events-none to base (disabled state always blocks pointer events per spec) - Stretch: add stretch variant (auto/full) for full-width layout axis (adjustable per spec); expose as optional ButtonStretch prop on both ui and components tiers; add Stretch stories - Loading label: add buttonLabelClass + group to root so the label span dims (group-aria-busy:opacity-50) when the button is aria-busy, matching "label may dim" in spec; wrap children in <span> in components/button to pick up the class * Extract button anatomy parts; clear styling out of the components tier The components-tier Button was composing raw elements with baked-in classes — a `<span>` carrying buttonLabelClass, a LoaderCircle with size/spin classes, and a bare NodeSlot — so styling leaked into the composition layer and the ui parts stopped at the single root <button>. Add three single-element ui parts (each with its own cva in ui/button/variants.ts): ButtonIcon (decorative leading/trailing node slot, sized to --node-size), ButtonLabel (the text, dims under aria-busy), and ButtonSpinner (the loading indicator). The components-tier Button now only composes these — no className, cx, or cva anywhere under components/button. Register the new parts as story subcomponents and add a ui Anatomy story that composes them by hand. * Make the button spinner a single-element slot ButtonSpinner baked a LoaderCircle glyph; it is now a pure node-slot span that sizes and spins its single child, with the default icon moved to the components-tier Button (and to the ui Anatomy story) as explicit children.
1 parent e4f4bc1 commit 4d6a25e

9 files changed

Lines changed: 207 additions & 24 deletions

File tree

packages/propel/src/components/button/button.stories.tsx

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
import type { Meta, StoryObj } from "@storybook/react-vite";
22
import { Plus, Search, Settings } from "lucide-react";
3-
import { expect, fn } from "storybook/test";
3+
import { expect, fn, userEvent as baseUserEvent } from "storybook/test";
44

55
import { iconControl } from "../../storybook/icon-control";
6-
import { Button, type ButtonMagnitude, type ButtonVariant } from "./index";
6+
import {
7+
Button,
8+
ButtonIcon,
9+
ButtonLabel,
10+
type ButtonMagnitude,
11+
ButtonSpinner,
12+
type ButtonVariant,
13+
} from "./index";
714

815
const VARIANTS: ButtonVariant[] = ["primary", "secondary", "tertiary", "ghost", "link"];
916
const MAGNITUDES: ButtonMagnitude[] = ["sm", "md", "lg", "xl"];
1017

1118
const meta = {
1219
title: "Components/Button",
1320
component: Button,
21+
// Anatomy parts the ready-made Button composes (UI tier).
22+
subcomponents: { ButtonIcon, ButtonLabel, ButtonSpinner },
1423
// Icon picker controls for the two icon slots.
1524
argTypes: { inlineStartNode: iconControl, inlineEndNode: iconControl },
1625
parameters: {
@@ -128,7 +137,7 @@ export const WithIcons: Story = {
128137
),
129138
};
130139

131-
/** The loading state shows a spinner, sets `aria-busy`, and blocks interaction. */
140+
/** The loading state shows a spinner, sets `aria-busy`, and blocks interaction. The label dims. */
132141
export const Loading: Story = {
133142
parameters: { controls: { disable: true } },
134143
render: (args) => (
@@ -146,6 +155,21 @@ export const Loading: Story = {
146155
),
147156
};
148157

158+
/** `stretch="full"` fills the container (e.g. a form row or mobile CTA). */
159+
export const Stretch: Story = {
160+
parameters: { controls: { disable: true } },
161+
render: (args) => (
162+
<div className="flex w-64 flex-col gap-2">
163+
<Button {...args} stretch="full">
164+
Full-width
165+
</Button>
166+
<Button {...args} variant="secondary" stretch="full">
167+
Full-width outline
168+
</Button>
169+
</div>
170+
),
171+
};
172+
149173
/**
150174
* Clicking a button fires `onClick`. Tagged `!dev`/`!autodocs`/`!manifest` so it's hidden from the
151175
* 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 = {
194218
export const DisabledBlocksClick: Story = {
195219
tags: ["!dev", "!autodocs", "!manifest"],
196220
args: { onClick: fn(), disabled: true },
197-
play: async ({ args, canvas, userEvent }) => {
221+
play: async ({ args, canvas }) => {
198222
const button = canvas.getByRole("button", { name: "Button" });
199223
await expect(button).toBeDisabled();
200-
await userEvent.click(button);
224+
// A disabled button sets `pointer-events: none`, so the default user-event guard
225+
// refuses to click it. Disable that guard so the click is dispatched at the element;
226+
// the native disabled button must still ignore it and never fire `onClick`.
227+
const user = baseUserEvent.setup({ pointerEventsCheck: 0 });
228+
await user.click(button);
201229
await expect(args.onClick).not.toHaveBeenCalled();
202230
},
203231
};

packages/propel/src/components/button/button.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { LoaderCircle } from "lucide-react";
22
import type * as React from "react";
33

4-
import { NodeSlot } from "../../internal/node-slot";
5-
import { Button as ButtonRoot, type ButtonProps as ButtonRootProps } from "../../ui/button";
4+
import {
5+
Button as ButtonRoot,
6+
ButtonIcon,
7+
ButtonLabel,
8+
type ButtonProps as ButtonRootProps,
9+
ButtonSpinner,
10+
} from "../../ui/button";
611

712
export type ButtonProps = ButtonRootProps & {
813
/**
@@ -43,12 +48,14 @@ export function Button({
4348
aria-busy={loading ? true : undefined}
4449
>
4550
{loading ? (
46-
<LoaderCircle aria-hidden className="size-(--node-size) animate-spin" />
51+
<ButtonSpinner>
52+
<LoaderCircle />
53+
</ButtonSpinner>
4754
) : inlineStartNode ? (
48-
<NodeSlot aria-hidden>{inlineStartNode}</NodeSlot>
55+
<ButtonIcon>{inlineStartNode}</ButtonIcon>
4956
) : null}
50-
{children}
51-
{!loading && inlineEndNode ? <NodeSlot aria-hidden>{inlineEndNode}</NodeSlot> : null}
57+
<ButtonLabel>{children}</ButtonLabel>
58+
{!loading && inlineEndNode ? <ButtonIcon>{inlineEndNode}</ButtonIcon> : null}
5259
</ButtonRoot>
5360
);
5461
}

packages/propel/src/components/button/index.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
export { Button, type ButtonProps } from "./button";
2-
// Re-export the atomic button's variant types + `buttonVariants` so the full button surface is
3-
// importable from this convenience.
2+
// Re-export the atomic button's parts, variant types, and `buttonVariants` so the full button
3+
// surface is importable from this convenience.
44
export {
5+
ButtonIcon,
6+
type ButtonIconProps,
7+
ButtonLabel,
8+
type ButtonLabelProps,
9+
ButtonSpinner,
10+
type ButtonSpinnerProps,
511
type ButtonEmphasis,
612
type ButtonMagnitude,
13+
type ButtonStretch,
714
type ButtonTone,
815
type ButtonVariant,
916
buttonVariants,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type * as React from "react";
2+
3+
import { buttonIconVariants } from "./variants";
4+
5+
export type ButtonIconProps = Omit<React.ComponentPropsWithoutRef<"span">, "className" | "style">;
6+
7+
/**
8+
* A decorative node beside the label (Figma leading/trailing icon). Sizes its single child to the
9+
* button's `--node-size`, so callers pass a bare icon/avatar. Decorative — the button's label
10+
* carries the accessible name — so it is `aria-hidden`.
11+
*/
12+
export function ButtonIcon(props: ButtonIconProps) {
13+
return <span aria-hidden className={buttonIconVariants()} {...props} />;
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type * as React from "react";
2+
3+
import { buttonLabelVariants } from "./variants";
4+
5+
export type ButtonLabelProps = Omit<React.ComponentPropsWithoutRef<"span">, "className" | "style">;
6+
7+
/**
8+
* The button's text label. When the root button is `aria-busy` (loading) it dims via the
9+
* `group-aria-busy:` sibling of the `group` class the root carries, so the spinner reads as the
10+
* active affordance while the label fades.
11+
*/
12+
export function ButtonLabel(props: ButtonLabelProps) {
13+
return <span className={buttonLabelVariants()} {...props} />;
14+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type * as React from "react";
2+
3+
import { buttonSpinnerVariants } from "./variants";
4+
5+
export type ButtonSpinnerProps = Omit<
6+
React.ComponentPropsWithoutRef<"span">,
7+
"className" | "style"
8+
>;
9+
10+
/**
11+
* The loading indicator shown in place of the inline-start node while the button is busy. A pure
12+
* slot: it sizes its single child to the button's `--node-size` and spins it via `animate-spin`,
13+
* but bakes no glyph — callers pass the spinner icon as `children`. Decorative (the root carries
14+
* `aria-busy`), so it is `aria-hidden`.
15+
*/
16+
export function ButtonSpinner(props: ButtonSpinnerProps) {
17+
return <span aria-hidden className={buttonSpinnerVariants()} {...props} />;
18+
}

packages/propel/src/ui/button/button.stories.tsx

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import type { Meta, StoryObj } from "@storybook/react-vite";
2-
import { expect, fn } from "storybook/test";
2+
import { LoaderCircle, Plus } from "lucide-react";
3+
import { expect, fn, userEvent as baseUserEvent } from "storybook/test";
34

4-
import { Button, type ButtonMagnitude, type ButtonVariant } from "./index";
5+
import {
6+
Button,
7+
ButtonIcon,
8+
ButtonLabel,
9+
type ButtonMagnitude,
10+
ButtonSpinner,
11+
type ButtonVariant,
12+
} from "./index";
513

614
const VARIANTS: ButtonVariant[] = ["primary", "secondary", "tertiary", "ghost", "link"];
715
const MAGNITUDES: ButtonMagnitude[] = ["sm", "md", "lg", "xl"];
@@ -13,6 +21,8 @@ const MAGNITUDES: ButtonMagnitude[] = ["sm", "md", "lg", "xl"];
1321
const meta = {
1422
title: "UI/Button",
1523
component: Button,
24+
// The button's anatomy parts; the ready-made Button (Components/Button) composes them.
25+
subcomponents: { ButtonIcon, ButtonLabel, ButtonSpinner },
1626
args: {
1727
children: "Button",
1828
variant: "primary",
@@ -97,6 +107,50 @@ export const Magnitudes: Story = {
97107
),
98108
};
99109

110+
/** `stretch="full"` fills the container (e.g. a form row or mobile CTA). */
111+
export const Stretch: Story = {
112+
argTypes: { stretch: { control: false }, children: { control: false } },
113+
render: (args) => (
114+
<div className="flex w-64 flex-col gap-2">
115+
<Button {...args} stretch="auto">
116+
Auto width
117+
</Button>
118+
<Button {...args} stretch="full">
119+
Full width
120+
</Button>
121+
</div>
122+
),
123+
};
124+
125+
/**
126+
* The atomic button is composed from named parts: `ButtonIcon` sizes a decorative leading/trailing
127+
* node to the button's `--node-size`, `ButtonLabel` holds the text (and dims under `aria-busy`),
128+
* and `ButtonSpinner` is the loading indicator. The ready-made `Button` (Components/Button) lays
129+
* these out for you; here they are composed by hand.
130+
*/
131+
export const Anatomy: Story = {
132+
args: { children: undefined },
133+
argTypes: { children: { control: false } },
134+
render: (args) => (
135+
<div className="flex items-center gap-3">
136+
<Button {...args}>
137+
<ButtonIcon>
138+
<Plus />
139+
</ButtonIcon>
140+
<ButtonLabel>With icon</ButtonLabel>
141+
</Button>
142+
{/* The busy state mirrors the ready-made Button: it is `aria-busy` AND soft-disabled
143+
(Base UI `disabled` + `focusableWhenDisabled`), so the disabled palette applies. */}
144+
<Button {...args} aria-busy disabled focusableWhenDisabled>
145+
<ButtonSpinner>
146+
<LoaderCircle />
147+
</ButtonSpinner>
148+
<ButtonLabel>Loading</ButtonLabel>
149+
</Button>
150+
</div>
151+
),
152+
};
153+
100154
/**
101155
* Clicking the button fires `onClick`. Tagged out of the sidebar/docs/manifest but still runs under
102156
* the default `test` tag.
@@ -115,10 +169,14 @@ export const ClickFiresOnClick: Story = {
115169
export const DisabledBlocksClick: Story = {
116170
tags: ["!dev", "!autodocs", "!manifest"],
117171
args: { onClick: fn(), disabled: true },
118-
play: async ({ args, canvas, userEvent }) => {
172+
play: async ({ args, canvas }) => {
119173
const button = canvas.getByRole("button", { name: "Button" });
120174
await expect(button).toBeDisabled();
121-
await userEvent.click(button);
175+
// A disabled button sets `pointer-events: none`, so the default user-event guard
176+
// refuses to click it. Disable that guard so the click is dispatched at the element;
177+
// the native disabled button must still ignore it and never fire `onClick`.
178+
const user = baseUserEvent.setup({ pointerEventsCheck: 0 });
179+
await user.click(button);
122180
await expect(args.onClick).not.toHaveBeenCalled();
123181
},
124182
};

packages/propel/src/ui/button/button.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@ import { buttonVariants } from "./variants";
66
// Re-exported so `buttonVariants` stays part of the button entry's public surface
77
// (e.g. `icon-button` composes it).
88
export { buttonVariants } from "./variants";
9+
export { ButtonIcon, type ButtonIconProps } from "./button-icon";
10+
export { ButtonLabel, type ButtonLabelProps } from "./button-label";
11+
export { ButtonSpinner, type ButtonSpinnerProps } from "./button-spinner";
912

1013
export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>;
1114
export type ButtonTone = NonNullable<VariantProps<typeof buttonVariants>["tone"]>;
1215
export type ButtonMagnitude = NonNullable<VariantProps<typeof buttonVariants>["magnitude"]>;
1316
export type ButtonEmphasis = NonNullable<VariantProps<typeof buttonVariants>["emphasis"]>;
17+
export type ButtonStretch = NonNullable<VariantProps<typeof buttonVariants>["stretch"]>;
1418

1519
type ButtonOwnProps = {
1620
variant: ButtonVariant;
@@ -22,6 +26,12 @@ type ButtonOwnProps = {
2226
* default and every other `variant` ignores it.
2327
*/
2428
emphasis?: ButtonEmphasis;
29+
/**
30+
* Layout axis (Figma "Full width"). `auto` keeps the button inline-sized (default); `full`
31+
* stretches it to fill its container (`w-full`). Pass `stretch="full"` for forms or full-width
32+
* call-to-action placements.
33+
*/
34+
stretch?: ButtonStretch;
2535
};
2636

2737
export type ButtonProps = Omit<BaseButton.Props, "className" | "style"> & ButtonOwnProps;
@@ -30,20 +40,22 @@ export type ButtonProps = Omit<BaseButton.Props, "className" | "style"> & Button
3040
* A plain accessible button built on propel's design tokens. Pick a look with `variant` (Figma
3141
* Type), select the error palette with `tone`, and size it with `magnitude` — all required, so
3242
* consumers choose explicitly. For `variant="link"` only, optionally choose `solid` (blue) or
33-
* `subtle` (gray) with `emphasis`. `children` is passed through; it is not a variant.
43+
* `subtle` (gray) with `emphasis`. Use `stretch="full"` for full-width placements. `children` is
44+
* passed through; it is not a variant.
3445
*/
3546
export function Button({
3647
variant,
3748
tone,
3849
magnitude,
3950
emphasis,
51+
stretch,
4052
type = "button",
4153
...props
4254
}: ButtonProps) {
4355
return (
4456
<BaseButton
4557
type={type}
46-
className={buttonVariants({ variant, tone, magnitude, emphasis })}
58+
className={buttonVariants({ variant, tone, magnitude, emphasis, stretch })}
4759
{...props}
4860
/>
4961
);

packages/propel/src/ui/button/variants.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import { cva, cx } from "class-variance-authority";
22

3+
import { nodeSlotClass } from "../../internal/node-slot";
4+
35
// Magnitudes follow the Figma "Buttons" Size scale. Figma ships S/Base/L/XL; those
46
// map to sm/md/lg/xl by their px heights (20/24/28/32). Per Figma:
57
// S -> text-12/px-1.5; Base & L -> text-13; XL -> text-14. All chrome'd magnitudes
68
// use leading-none so the flex-centered label sits dead-center in the fixed height.
79
export const buttonVariants = cva(
8-
// Shared chrome: inline flex row, centered, focus-visible ring on the brand
9-
// accent token, real disabled affordance, and a snug medium label.
10+
// Shared chrome: inline flex row, centered, focus-visible ring (with 1px offset)
11+
// on the brand accent token, real disabled affordance, and a snug medium label.
12+
// `group` enables `group-aria-busy:` on child elements (e.g. the label span).
1013
cx(
11-
"relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-1 rounded-md font-medium",
14+
"group relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-1 rounded-md font-medium",
1215
"whitespace-nowrap transition-colors outline-none",
13-
"focus-visible:ring-2 focus-visible:ring-accent-strong",
14-
"disabled:cursor-not-allowed aria-busy:cursor-default",
16+
"focus-visible:ring-2 focus-visible:ring-accent-strong focus-visible:ring-offset-1",
17+
// Disabled: cursor, no pointer events (covers both native disabled and aria-disabled).
18+
"disabled:pointer-events-none disabled:cursor-not-allowed",
19+
"aria-busy:cursor-default",
1520
),
1621
{
1722
variants: {
@@ -54,6 +59,12 @@ export const buttonVariants = cva(
5459
lg: "h-7 min-w-12 px-2 text-13 leading-none [--node-size:1rem]",
5560
xl: "h-8 min-w-13 px-2 text-14 leading-none [--node-size:1rem]",
5661
},
62+
// Layout axis (Figma "Full width" spec item). `auto` is the default inline
63+
// size; `full` stretches to fill the container (`w-full`).
64+
stretch: {
65+
auto: "",
66+
full: "w-full",
67+
},
5768
},
5869
compoundVariants: [
5970
// ----- Neutral solid (Figma Type=Primary) -----
@@ -149,3 +160,17 @@ export const buttonVariants = cva(
149160
],
150161
},
151162
);
163+
164+
// The text label inside a Button. When the parent button is `aria-busy` (loading)
165+
// it dims via the `group-aria-busy:` sibling of the `group` class on the root: the
166+
// spinner replaces the inline-start node, and this fades the text alongside it.
167+
export const buttonLabelVariants = cva("group-aria-busy:opacity-50");
168+
169+
// A decorative node beside the label. Reuses the shared node-slot chrome so its
170+
// single child is sized to the button's inherited `--node-size`.
171+
export const buttonIconVariants = cva(nodeSlotClass);
172+
173+
// The loading indicator that replaces the inline-start node while busy. A pure slot:
174+
// reuses the shared node-slot chrome to size its single child to the button's
175+
// `--node-size`, and spins the wrapper (and thus the child) via `animate-spin`.
176+
export const buttonSpinnerVariants = cva(cx(nodeSlotClass, "animate-spin"));

0 commit comments

Comments
 (0)