diff --git a/packages/propel/.storybook/preview.css b/packages/propel/.storybook/preview.css index bc4cabd7..9d76e0bb 100644 --- a/packages/propel/.storybook/preview.css +++ b/packages/propel/.storybook/preview.css @@ -4,7 +4,11 @@ @import "../tailwind.css"; /* Make the preview canvas adapt to the active theme so the toolbar toggle is - visually meaningful (bg + text follow light/dark/contrast). */ + visually meaningful (bg + text follow light/dark/contrast). + + Use `layer-2` (not `canvas`): in light mode `--bg-canvas` === `--bg-layer-3`, so + tertiary button rest fills (and anything else keyed to `layer-3`) vanish on the + default Storybook page — Figma button sheets sit on white/`layer-2`. */ body { - @apply bg-canvas text-primary; + @apply bg-layer-2 text-primary; } diff --git a/packages/propel/AGENTS.md b/packages/propel/AGENTS.md index 391cf703..a408c238 100644 --- a/packages/propel/AGENTS.md +++ b/packages/propel/AGENTS.md @@ -197,9 +197,10 @@ existed). `[&>svg]:size-*`. A family gets its OWN icon part only for genuinely distinct styling (toast/ banner/alert-dialog tones, avatar's scale, autocomplete/combobox focus-brightening); an icon slot that is only a tint/size of the shared chrome is the internal `Icon` (`tint`/`magnitude` axes, - defaulting to `inherit`), composed by the `components` ready-made — icon-shaped controls - (`IconButton`, `Toggle`, `ToolbarButton`, `ToolbarToggle`) wrap their bare svg children in it - themselves, so consumers pass a bare glyph. + defaulting to `inherit`). Ready-mades expose that slot as an `icon` / `startIcon` / `endIcon` + **node prop** — consumers pass the public `` (or another already-wrapped + node). Icon-shaped controls (`IconButton`, `Toggle`, `ToolbarButton`, `ToolbarToggle`) render the + provided node as-is; they do **not** wrap bare lucide glyphs themselves. - **`Indicator`** — a state-reflecting part. Keep Base UI's own names where they exist (`ItemIndicator` for a selected marker, `Tabs`/`Progress`/`Meter`/`Slider` `Indicator` for geometry). Disclosure carets are ONE internal `DisclosureIndicator` (pass a **chevron-down**; @@ -217,7 +218,8 @@ not a part at all — compose the internal `NodeSlot` in `components` instead (` Consumer-provided node **props** follow the same convention: name the prop for the slot it fills, Base UI style (`placeholder`, `title`, `description`), never a `Node` suffix — `icon` fills the family's single `Icon` slot; `startIcon`/`endIcon` fill dual `Icon` slots (bare `start`/`end` is -the logical-direction vocabulary, like Drawer's `side`); `trailing` fills `MenuItemTrailing`; +the logical-direction vocabulary, like Drawer's `side`). Pass the public `` +into those props (ready-mades do not wrap bare glyphs). `trailing` fills `MenuItemTrailing`; `meta` fills `MenuLabelMeta`; a consumer-provided control is named for its role (`decrement`, `increment`). diff --git a/packages/propel/src/components/anchor-button/anchor-button.stories.tsx b/packages/propel/src/components/anchor-button/anchor-button.stories.tsx index 7dfa459d..3c990ff0 100644 --- a/packages/propel/src/components/anchor-button/anchor-button.stories.tsx +++ b/packages/propel/src/components/anchor-button/anchor-button.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { ArrowUpRight, Plus } from "lucide-react"; -import { expect, fn } from "storybook/test"; +import * as React from "react"; +import { expect, fn, userEvent as baseUserEvent, waitFor } from "storybook/test"; import { Icon } from "../icon"; import { AnchorButton, type AnchorButtonMagnitude, type AnchorButtonProminence } from "./index"; @@ -115,8 +116,146 @@ export const WithIcons: Story = { ), }; -/** The `loading` spinner replaces the leading icon and dims the label. */ +/** The `loading` spinner trails the label and mutes via the link chrome. */ export const Loading: Story = { args: { prominence: "primary", magnitude: "md", loading: true }, render: (args) => , }; + +/** + * An action that enters `loading` after click: spinner + `aria-busy`, blocks re-fires, keeps + * keyboard focus while soft-disabled, then settles once the work resolves. + */ +export const AsyncSubmit: Story = { + parameters: { controls: { disable: true } }, + render: function Render(args) { + const [submitting, setSubmitting] = React.useState(false); + return ( + { + setSubmitting(true); + window.setTimeout(() => setSubmitting(false), 1500); + }} + /> + ); + }, +}; + +/** + * Tab moves focus onto the control, then **Enter** activates it (fires `onClick`). Native + * ` ), @@ -58,13 +62,12 @@ export const Default: Story = {}; /** Every prominence (Figma "Type") side by side at the default magnitude. */ export const Prominences: Story = { argTypes: { prominence: { control: false }, children: { control: false } }, - render: ({ tone, magnitude, sizing }) => ( + render: ({ magnitude, sizing }) => (
{PROMINENCES.map((prominence) => ( ))} @@ -121,10 +118,10 @@ export const Stretch: Story = { argTypes: { sizing: { control: false }, children: { control: false } }, render: ({ prominence, tone, magnitude }) => (
- -
@@ -135,8 +132,8 @@ export const Stretch: Story = { * Every visual state of every palette, pinned statically — one row per prominence×tone pairing the * chrome defines. Hover / active / focus-visible are CSS pseudo-classes, forced by the * pseudo-states addon; `disabled` is the native attribute the `disabled:` palette keys off; busy - * pins the `aria-busy` the ready-made Button sets while `loading` (the `ButtonLabel` dims via - * `group-aria-busy:` and the spinner reads as the active affordance). + * pins the `aria-busy` the ready-made Button sets while `loading` (loading mutes via the root + * chrome palette — label and spinner share the same weight). */ export const States: Story = { parameters: { @@ -149,59 +146,43 @@ export const States: Story = { }, render: ({ magnitude, sizing }) => (
- {PALETTES.map(({ prominence, tone }) => ( -
- - -
))} @@ -211,9 +192,9 @@ export const States: Story = { /** * The atomic button is composed from named parts: the internal `Icon` sizes a decorative - * leading/trailing node to the button's `--node-size`, `ButtonLabel` holds the text (and dims under - * `aria-busy`), and the internal `Spinner` is the loading indicator. The busy state is pinned here - * via the `aria-busy`/`aria-disabled` the ready-made Button (Components/Button) sets while + * leading/trailing node to the button's `--node-size`, `ButtonLabel` holds the text, and the + * internal `Spinner` is the loading indicator (trailing, after the label). The busy state is pinned + * here via the `aria-busy`/`aria-disabled` the ready-made Button (Components/Button) sets while * `loading` — that ready-made also lays these parts out for you and adds the soft-disabled * behavior. */ @@ -222,24 +203,23 @@ export const Anatomy: Story = { argTypes: { children: { control: false } }, render: ({ prominence, tone, magnitude, sizing }) => (
-
), diff --git a/packages/propel/src/elements/button/variants.ts b/packages/propel/src/elements/button/variants.ts index 67759af9..44bf4810 100644 --- a/packages/propel/src/elements/button/variants.ts +++ b/packages/propel/src/elements/button/variants.ts @@ -2,7 +2,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { buttonGeometryVariants } from "../../internal/button-geometry"; import { composeVariants } from "../../internal/compose-variants"; -import { controlChromeVariants } from "../../internal/control-chrome"; +import { type ControlChromePair, controlChromeVariants } from "../../internal/control-chrome"; import { type StrictVariantProps } from "../../internal/variant-props"; /** The full Button className: the shared control chrome composed with the label-button geometry. */ @@ -14,11 +14,10 @@ export type ButtonTone = NonNullable; export type ButtonMagnitude = NonNullable; export type ButtonSizing = NonNullable; -// No `defaultVariants` today, so every axis is required. -export type ButtonVariantProps = StrictVariantProps; +// Geometry axes stay required (no defaults); prominence × tone is the closed chrome pair set. +export type ButtonVariantProps = ControlChromePair & + Pick, "magnitude" | "sizing">; -// 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. -export const buttonLabelVariants = cva( - "transition-opacity duration-200 ease-out group-aria-busy:opacity-50", -); +// Anatomy shell only — no label-specific classes today (loading mute lives on the root chrome). +// Keep the cva so a future label token has a home without reshaping the part. +export const buttonLabelVariants = cva(""); diff --git a/packages/propel/src/elements/icon-button/icon-button.stories.tsx b/packages/propel/src/elements/icon-button/icon-button.stories.tsx index 4b434836..2c298433 100644 --- a/packages/propel/src/elements/icon-button/icon-button.stories.tsx +++ b/packages/propel/src/elements/icon-button/icon-button.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { LoaderCircle, Plus } from "lucide-react"; +import type { ControlChromePair } from "../../internal/control-chrome"; import { Icon } from "../../internal/icon"; import { Spinner } from "../../internal/spinner"; import { iconControl } from "../../storybook/icon-control"; @@ -14,16 +15,18 @@ import { const PROMINENCES: IconButtonProminence[] = ["primary", "secondary", "tertiary", "ghost"]; const MAGNITUDES: IconButtonMagnitude[] = ["sm", "md", "lg", "xl"]; -// The prominence×tone pairings the control chrome defines a palette for (danger skips -// tertiary/ghost). -const PALETTES: { prominence: IconButtonProminence; tone: IconButtonTone }[] = [ +// Storybook ArgTypes flatten discriminant unions into independent controls — re-pair for the API. +const chrome = (prominence: IconButtonProminence, tone: IconButtonTone): ControlChromePair => + ({ prominence, tone }) as ControlChromePair; + +const PALETTES = [ { prominence: "primary", tone: "neutral" }, { prominence: "secondary", tone: "neutral" }, { prominence: "tertiary", tone: "neutral" }, { prominence: "ghost", tone: "neutral" }, { prominence: "primary", tone: "danger" }, { prominence: "secondary", tone: "danger" }, -]; +] as const satisfies readonly ControlChromePair[]; // elements-tier story (rule 2b): a pure UI-configuration showcase. `IconButton` is a Base-UI-agnostic // styled square `
+ + + + + + + ), +}; + +/** + * Every prominence (Figma ships primary and secondary only — no tertiary/ghost, no danger tone). + * Primary lays segments out as separate pills with a gap; secondary connects them — the leading + * segment's own end border IS the divider. + */ +export const Prominences: Story = { + render: () => ( +
+ {PROMINENCES.map((prominence) => ( + + + + + + + + + ))} +
+ ), +}; + +/** + * CSS canary (rule 2b): asserts the frame's prominence-specific child selectors compiled — primary + * keeps outer `rounded-md` but soft-squares the facing edges to 2px; secondary flattens the shared + * edge (`rounded-e-none` / `rounded-s-none`) and collapses the trailing start border. Tagged out of + * the sidebar/docs/manifest while still running under the default `test` tag. + */ +export const ProminencesCanary: Story = { + ...Prominences, + tags: ["!dev", "!autodocs", "!manifest"], + play: async ({ canvas }) => { + const [primaryMain, secondaryMain] = canvas.getAllByRole("button", { name: "Button" }); + const primaryTrigger = canvas.getByRole("button", { name: "More options (primary)" }); + const secondaryTrigger = canvas.getByRole("button", { name: "More options (secondary)" }); + + // Primary: two discrete pills — outer corners stay `rounded-md`, split-facing edges are 2px. + await expect(getComputedStyle(primaryMain).borderTopRightRadius).toBe("2px"); + await expect(getComputedStyle(primaryMain).borderTopLeftRadius).not.toBe("0px"); + await expect(getComputedStyle(primaryMain).borderTopLeftRadius).not.toBe("2px"); + await expect(getComputedStyle(primaryTrigger).borderTopLeftRadius).toBe("2px"); + await expect(getComputedStyle(primaryTrigger).borderTopRightRadius).not.toBe("0px"); + await expect(getComputedStyle(primaryTrigger).borderTopRightRadius).not.toBe("2px"); + + // Secondary: connected — flatten shared edge, collapse trailing start border. + await expect(getComputedStyle(secondaryMain).borderTopRightRadius).toBe("0px"); + await expect(getComputedStyle(secondaryTrigger).borderTopLeftRadius).toBe("0px"); + await expect(getComputedStyle(secondaryTrigger).borderLeftWidth).toBe("0px"); + }, +}; + +/** The four size steps — both segments of a split button always share one `magnitude`. */ +export const Magnitudes: Story = { + render: () => ( +
+ {MAGNITUDES.map((magnitude) => ( + + + + + + + + + ))} +
+ ), +}; + +/** + * Both segments pinned disabled (the native `disabled` attribute): the segments show their disabled + * fills. On secondary, the frame also dims its divider (the `:has(:disabled)` end-border tint). + */ +export const Disabled: Story = { + render: () => ( + + + + + + + + + ), +}; diff --git a/packages/propel/src/elements/split-button/split-button.tsx b/packages/propel/src/elements/split-button/split-button.tsx new file mode 100644 index 00000000..b2eecbc4 --- /dev/null +++ b/packages/propel/src/elements/split-button/split-button.tsx @@ -0,0 +1,24 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; + +import { type SplitButtonVariantProps, splitButtonVariants } from "./variants"; + +export type { SplitButtonMagnitude, SplitButtonProminence } from "./variants"; + +export type SplitButtonProps = Omit, "className" | "style"> & + SplitButtonVariantProps; + +/** + * The styled frame around a split button's two segments — the main action button and the + * menu-opening icon button. Primary lays the segments out as separate 1px-gapped pills (2px radius + * on the facing edges); secondary flattens the shared edge and draws a divider so they read as one + * connected outline. Neutral only — there is no danger/error split button. The segments keep their + * own control chrome. Base-UI-agnostic (there is no Base UI split-button primitive); the + * `components` ready-made composes `Button` + `MenuTrigger`-grafted `IconButton` inside it. + */ +export function SplitButton({ prominence, magnitude, render, ...props }: SplitButtonProps) { + const defaultProps: useRender.ElementProps<"div"> = { + className: splitButtonVariants({ prominence, magnitude }), + }; + return useRender({ defaultTagName: "div", render, props: mergeProps(defaultProps, props) }); +} diff --git a/packages/propel/src/elements/split-button/variants.ts b/packages/propel/src/elements/split-button/variants.ts new file mode 100644 index 00000000..ab89ae7b --- /dev/null +++ b/packages/propel/src/elements/split-button/variants.ts @@ -0,0 +1,51 @@ +import { cva, cx, type VariantProps } from "class-variance-authority"; + +import { type StrictVariantProps } from "../../internal/variant-props"; + +// SplitButton is the two-segment frame around a main action button and a menu-opening icon button +// (Figma "Split button"). No `tone` axis — Figma ships only neutral primary/secondary. The segments +// keep their own control chrome; the frame only owns how they sit together — and that layout +// differs by prominence: +// +// - **primary** — two discrete pills 1px apart (`gap-px`). Outer corners keep each segment's +// `rounded-md`; the facing (split) edges are soft-squared to 2px via longhand +// `rounded-e/s-[2px]` (out-cascades the segment shorthand). No divider — canvas shows in the gap. +// - **secondary** — one connected outline frame. Inner-edge radius flattening via child +// selectors (longhand `rounded-s/e-none` intentionally out-cascades a segment's own +// shorthand `rounded-md`), and the doubled border at the junction collapses to one +// (`border-s-0` on trailing children; the leading segment's `border-e` IS the divider). +// +// A focused segment is lifted (`z-10` under `isolate`) so its offset focus ring paints over the +// adjacent segment instead of underneath it. +// +// Child selectors key off `:first-child`/`* + *` and NEVER `:last-child`: while the menu is open, +// Base UI appends focus-guard ``s after the trigger inside the frame, so the trigger is not +// reliably the last child (keying the flattening off `:not(:last-child)` visibly ate the open +// trigger's end radius). +// +// `prominence` is declared for the split-button Types only — primary and secondary; there is no +// tertiary/ghost split button. `magnitude` is color-less: it constrains the composed frame to the +// segments' shared size step (both segments of a split button are always the same magnitude). +export const splitButtonVariants = cva( + cx("isolate inline-flex items-stretch", "[&>*:focus-visible]:z-10"), + { + variants: { + prominence: { + primary: cx("gap-px", "[&>*:first-child]:rounded-e-[2px]", "[&>*+*]:rounded-s-[2px]"), + secondary: cx( + "[&>*+*]:rounded-s-none [&>*:first-child]:rounded-e-none", + "[&>*+*]:border-s-0 [&>*:first-child]:border-e", + "[&:has(:disabled)>*:first-child]:border-e-subtle", + ), + }, + magnitude: { sm: "", md: "", lg: "", xl: "" }, + }, + }, +); + +type SplitButtonVariantConfig = VariantProps; +export type SplitButtonProminence = NonNullable; +export type SplitButtonMagnitude = NonNullable; + +// No `defaultVariants` today, so every axis is required. +export type SplitButtonVariantProps = StrictVariantProps; diff --git a/packages/propel/src/internal/button-geometry.ts b/packages/propel/src/internal/button-geometry.ts index 773d5fda..c0c7caad 100644 --- a/packages/propel/src/internal/button-geometry.ts +++ b/packages/propel/src/internal/button-geometry.ts @@ -1,9 +1,10 @@ import { cva, cx, type VariantProps } from "class-variance-authority"; -// The label-button geometry shared by surfaces that wear the control chrome with a text label: -// `Button` (`