From 8a435d19a70f63881913810b7e5ea7a74fd3446b Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 16:09:47 +0700 Subject: [PATCH 1/3] 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 in components/button to pick up the class --- .../src/components/button/button.stories.tsx | 17 ++++++++++++- .../propel/src/components/button/button.tsx | 4 +++- .../propel/src/components/button/index.tsx | 1 + .../propel/src/ui/button/button.stories.tsx | 15 ++++++++++++ packages/propel/src/ui/button/button.tsx | 13 ++++++++-- packages/propel/src/ui/button/variants.ts | 24 +++++++++++++++---- 6 files changed, 65 insertions(+), 9 deletions(-) diff --git a/packages/propel/src/components/button/button.stories.tsx b/packages/propel/src/components/button/button.stories.tsx index 68466269..d7947299 100644 --- a/packages/propel/src/components/button/button.stories.tsx +++ b/packages/propel/src/components/button/button.stories.tsx @@ -128,7 +128,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 +146,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 diff --git a/packages/propel/src/components/button/button.tsx b/packages/propel/src/components/button/button.tsx index 61368feb..045057eb 100644 --- a/packages/propel/src/components/button/button.tsx +++ b/packages/propel/src/components/button/button.tsx @@ -3,6 +3,7 @@ import type * as React from "react"; import { NodeSlot } from "../../internal/node-slot"; import { Button as ButtonRoot, type ButtonProps as ButtonRootProps } from "../../ui/button"; +import { buttonLabelClass } from "../../ui/button/variants"; export type ButtonProps = ButtonRootProps & { /** @@ -47,7 +48,8 @@ export function Button({ ) : inlineStartNode ? ( {inlineStartNode} ) : null} - {children} + {/* buttonLabelClass dims the label when the root is aria-busy (loading). */} + {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..cf625592 100644 --- a/packages/propel/src/components/button/index.tsx +++ b/packages/propel/src/components/button/index.tsx @@ -4,6 +4,7 @@ export { Button, type ButtonProps } from "./button"; export { type ButtonEmphasis, type ButtonMagnitude, + type ButtonStretch, type ButtonTone, type ButtonVariant, buttonVariants, diff --git a/packages/propel/src/ui/button/button.stories.tsx b/packages/propel/src/ui/button/button.stories.tsx index 6e55ae54..837f228b 100644 --- a/packages/propel/src/ui/button/button.stories.tsx +++ b/packages/propel/src/ui/button/button.stories.tsx @@ -97,6 +97,21 @@ 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) => ( +
+ + +
+ ), +}; + /** * Clicking the button fires `onClick`. Tagged out of the sidebar/docs/manifest but still runs under * the default `test` tag. diff --git a/packages/propel/src/ui/button/button.tsx b/packages/propel/src/ui/button/button.tsx index 313eeb08..1270cf43 100644 --- a/packages/propel/src/ui/button/button.tsx +++ b/packages/propel/src/ui/button/button.tsx @@ -11,6 +11,7 @@ export type ButtonVariant = NonNullable["var 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 +23,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 +37,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..5f43a522 100644 --- a/packages/propel/src/ui/button/variants.ts +++ b/packages/propel/src/ui/button/variants.ts @@ -5,13 +5,16 @@ import { cva, cx } from "class-variance-authority"; // 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 +57,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 +158,8 @@ export const buttonVariants = cva( ], }, ); + +// The label element inside a Button (components tier). 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 icon slot; this class fades the text. +export const buttonLabelClass = cx("group-aria-busy:opacity-50"); From a18ba09d08d29c869602948e63e5d1967e29ad2b Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 16:44:31 +0700 Subject: [PATCH 2/3] Extract button anatomy parts; clear styling out of the components tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The components-tier Button was composing raw elements with baked-in classes — a `` 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 + {/* 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. @@ -130,10 +167,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 1270cf43..c3deacc0 100644 --- a/packages/propel/src/ui/button/button.tsx +++ b/packages/propel/src/ui/button/button.tsx @@ -6,6 +6,9 @@ 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"]>; diff --git a/packages/propel/src/ui/button/variants.ts b/packages/propel/src/ui/button/variants.ts index 5f43a522..fce8063b 100644 --- a/packages/propel/src/ui/button/variants.ts +++ b/packages/propel/src/ui/button/variants.ts @@ -1,5 +1,7 @@ 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 @@ -159,7 +161,15 @@ export const buttonVariants = cva( }, ); -// The label element inside a Button (components tier). 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 icon slot; this class fades the text. -export const buttonLabelClass = cx("group-aria-busy:opacity-50"); +// 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. Sized to +// the button's `--node-size` and spun; `shrink-0` keeps it from collapsing. +export const buttonSpinnerVariants = cva("size-(--node-size) shrink-0 animate-spin"); From ff32afb52b0a2f787c252278cb7b4cc74a244cc5 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 17:27:20 +0700 Subject: [PATCH 3/3] 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. --- packages/propel/src/components/button/button.tsx | 5 ++++- packages/propel/src/ui/button/button-spinner.tsx | 12 ++++++------ packages/propel/src/ui/button/button.stories.tsx | 6 ++++-- packages/propel/src/ui/button/variants.ts | 7 ++++--- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/propel/src/components/button/button.tsx b/packages/propel/src/components/button/button.tsx index 955334c6..787bc95c 100644 --- a/packages/propel/src/components/button/button.tsx +++ b/packages/propel/src/components/button/button.tsx @@ -1,3 +1,4 @@ +import { LoaderCircle } from "lucide-react"; import type * as React from "react"; import { @@ -47,7 +48,9 @@ export function Button({ aria-busy={loading ? true : undefined} > {loading ? ( - + + + ) : inlineStartNode ? ( {inlineStartNode} ) : null} diff --git a/packages/propel/src/ui/button/button-spinner.tsx b/packages/propel/src/ui/button/button-spinner.tsx index 1d2c8540..bcffe23a 100644 --- a/packages/propel/src/ui/button/button-spinner.tsx +++ b/packages/propel/src/ui/button/button-spinner.tsx @@ -1,18 +1,18 @@ -import { LoaderCircle } from "lucide-react"; import type * as React from "react"; import { buttonSpinnerVariants } from "./variants"; export type ButtonSpinnerProps = Omit< - React.ComponentPropsWithoutRef, - "className" + React.ComponentPropsWithoutRef<"span">, + "className" | "style" >; /** - * The loading indicator shown in place of the inline-start node while the button is busy. Sized to - * the button's `--node-size` and spun via `animate-spin`. Decorative (the root carries + * 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 ; + return ; } diff --git a/packages/propel/src/ui/button/button.stories.tsx b/packages/propel/src/ui/button/button.stories.tsx index dbab17a5..1e8882dc 100644 --- a/packages/propel/src/ui/button/button.stories.tsx +++ b/packages/propel/src/ui/button/button.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Plus } from "lucide-react"; +import { LoaderCircle, Plus } from "lucide-react"; import { expect, fn, userEvent as baseUserEvent } from "storybook/test"; import { @@ -142,7 +142,9 @@ export const Anatomy: Story = { {/* The busy state mirrors the ready-made Button: it is `aria-busy` AND soft-disabled (Base UI `disabled` + `focusableWhenDisabled`), so the disabled palette applies. */} diff --git a/packages/propel/src/ui/button/variants.ts b/packages/propel/src/ui/button/variants.ts index fce8063b..b1e32d5f 100644 --- a/packages/propel/src/ui/button/variants.ts +++ b/packages/propel/src/ui/button/variants.ts @@ -170,6 +170,7 @@ export const buttonLabelVariants = cva("group-aria-busy:opacity-50"); // 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. Sized to -// the button's `--node-size` and spun; `shrink-0` keeps it from collapsing. -export const buttonSpinnerVariants = cva("size-(--node-size) shrink-0 animate-spin"); +// 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"));