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"));