Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions packages/propel/src/components/button/button.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
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"];

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: {
Expand Down Expand Up @@ -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) => (
Expand All @@ -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) => (
<div className="flex w-64 flex-col gap-2">
<Button {...args} stretch="full">
Full-width
</Button>
<Button {...args} variant="secondary" stretch="full">
Full-width outline
</Button>
</div>
),
};

/**
* 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
Expand Down Expand Up @@ -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();
},
};
Expand Down
19 changes: 13 additions & 6 deletions packages/propel/src/components/button/button.tsx
Original file line number Diff line number Diff line change
@@ -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 & {
/**
Expand Down Expand Up @@ -43,12 +48,14 @@ export function Button({
aria-busy={loading ? true : undefined}
>
{loading ? (
<LoaderCircle aria-hidden className="size-(--node-size) animate-spin" />
<ButtonSpinner>
<LoaderCircle />
</ButtonSpinner>
) : inlineStartNode ? (
<NodeSlot aria-hidden>{inlineStartNode}</NodeSlot>
<ButtonIcon>{inlineStartNode}</ButtonIcon>
) : null}
{children}
{!loading && inlineEndNode ? <NodeSlot aria-hidden>{inlineEndNode}</NodeSlot> : null}
<ButtonLabel>{children}</ButtonLabel>
{!loading && inlineEndNode ? <ButtonIcon>{inlineEndNode}</ButtonIcon> : null}
</ButtonRoot>
);
}
11 changes: 9 additions & 2 deletions packages/propel/src/components/button/index.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
14 changes: 14 additions & 0 deletions packages/propel/src/ui/button/button-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type * as React from "react";

import { buttonIconVariants } from "./variants";

export type ButtonIconProps = Omit<React.ComponentPropsWithoutRef<"span">, "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 <span aria-hidden className={buttonIconVariants()} {...props} />;
}
14 changes: 14 additions & 0 deletions packages/propel/src/ui/button/button-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type * as React from "react";

import { buttonLabelVariants } from "./variants";

export type ButtonLabelProps = Omit<React.ComponentPropsWithoutRef<"span">, "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 <span className={buttonLabelVariants()} {...props} />;
}
18 changes: 18 additions & 0 deletions packages/propel/src/ui/button/button-spinner.tsx
Original file line number Diff line number Diff line change
@@ -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 <span aria-hidden className={buttonSpinnerVariants()} {...props} />;
}
66 changes: 62 additions & 4 deletions packages/propel/src/ui/button/button.stories.tsx
Original file line number Diff line number Diff line change
@@ -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"];
Expand All @@ -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",
Expand Down Expand Up @@ -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) => (
<div className="flex w-64 flex-col gap-2">
<Button {...args} stretch="auto">
Auto width
</Button>
<Button {...args} stretch="full">
Full width
</Button>
</div>
),
};

/**
* 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) => (
<div className="flex items-center gap-3">
<Button {...args}>
<ButtonIcon>
<Plus />
</ButtonIcon>
<ButtonLabel>With icon</ButtonLabel>
</Button>
{/* The busy state mirrors the ready-made Button: it is `aria-busy` AND soft-disabled
(Base UI `disabled` + `focusableWhenDisabled`), so the disabled palette applies. */}
<Button {...args} aria-busy disabled focusableWhenDisabled>
<ButtonSpinner>
<LoaderCircle />
</ButtonSpinner>
<ButtonLabel>Loading</ButtonLabel>
</Button>
</div>
),
};

/**
* Clicking the button fires `onClick`. Tagged out of the sidebar/docs/manifest but still runs under
* the default `test` tag.
Expand All @@ -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();
},
};
16 changes: 14 additions & 2 deletions packages/propel/src/ui/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<VariantProps<typeof buttonVariants>["variant"]>;
export type ButtonTone = NonNullable<VariantProps<typeof buttonVariants>["tone"]>;
export type ButtonMagnitude = NonNullable<VariantProps<typeof buttonVariants>["magnitude"]>;
export type ButtonEmphasis = NonNullable<VariantProps<typeof buttonVariants>["emphasis"]>;
export type ButtonStretch = NonNullable<VariantProps<typeof buttonVariants>["stretch"]>;

type ButtonOwnProps = {
variant: ButtonVariant;
Expand All @@ -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<BaseButton.Props, "className" | "style"> & ButtonOwnProps;
Expand All @@ -30,20 +40,22 @@ export type ButtonProps = Omit<BaseButton.Props, "className" | "style"> & 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 (
<BaseButton
type={type}
className={buttonVariants({ variant, tone, magnitude, emphasis })}
className={buttonVariants({ variant, tone, magnitude, emphasis, stretch })}
{...props}
/>
);
Expand Down
35 changes: 30 additions & 5 deletions packages/propel/src/ui/button/variants.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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) -----
Expand Down Expand Up @@ -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"));