From 7083978ef7cc1e23ef8a74a0e050337630ce92eb Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Tue, 14 Jul 2026 13:59:50 +0530 Subject: [PATCH 01/10] Add ButtonGroup and SplitButton components with context support - Introduced `ButtonGroup` and `ButtonGroupButton` components to create a connected button group with shared magnitude via context. - Implemented `SplitButton` component, combining a main action button with a menu-triggering icon button, maintaining consistent styling and behavior. - Added stories for both components to demonstrate various states, including interaction tests and size variations, ensuring comprehensive coverage for UI configurations. - Established context for managing button magnitudes within the `ButtonGroup`, enhancing flexibility and usability across button segments. --- .../button-group/button-group-button.tsx | 67 ++++++ .../button-group/button-group-context.ts | 12 ++ .../button-group/button-group.stories.tsx | 132 ++++++++++++ .../components/button-group/button-group.tsx | 26 +++ .../src/components/button-group/index.tsx | 2 + .../src/components/split-button/index.tsx | 1 + .../split-button/split-button.stories.tsx | 191 ++++++++++++++++++ .../components/split-button/split-button.tsx | 98 +++++++++ .../button-group/button-group-button.tsx | 26 +++ .../button-group/button-group.stories.tsx | 144 +++++++++++++ .../elements/button-group/button-group.tsx | 17 ++ .../src/elements/button-group/index.tsx | 2 + .../src/elements/button-group/variants.ts | 47 +++++ .../src/elements/split-button/index.tsx | 1 + .../split-button/split-button.stories.tsx | 175 ++++++++++++++++ .../elements/split-button/split-button.tsx | 23 +++ .../src/elements/split-button/variants.ts | 51 +++++ 17 files changed, 1015 insertions(+) create mode 100644 packages/propel/src/components/button-group/button-group-button.tsx create mode 100644 packages/propel/src/components/button-group/button-group-context.ts create mode 100644 packages/propel/src/components/button-group/button-group.stories.tsx create mode 100644 packages/propel/src/components/button-group/button-group.tsx create mode 100644 packages/propel/src/components/button-group/index.tsx create mode 100644 packages/propel/src/components/split-button/index.tsx create mode 100644 packages/propel/src/components/split-button/split-button.stories.tsx create mode 100644 packages/propel/src/components/split-button/split-button.tsx create mode 100644 packages/propel/src/elements/button-group/button-group-button.tsx create mode 100644 packages/propel/src/elements/button-group/button-group.stories.tsx create mode 100644 packages/propel/src/elements/button-group/button-group.tsx create mode 100644 packages/propel/src/elements/button-group/index.tsx create mode 100644 packages/propel/src/elements/button-group/variants.ts create mode 100644 packages/propel/src/elements/split-button/index.tsx create mode 100644 packages/propel/src/elements/split-button/split-button.stories.tsx create mode 100644 packages/propel/src/elements/split-button/split-button.tsx create mode 100644 packages/propel/src/elements/split-button/variants.ts diff --git a/packages/propel/src/components/button-group/button-group-button.tsx b/packages/propel/src/components/button-group/button-group-button.tsx new file mode 100644 index 00000000..6c16fc8d --- /dev/null +++ b/packages/propel/src/components/button-group/button-group-button.tsx @@ -0,0 +1,67 @@ +import { Button as BaseButton } from "@base-ui/react/button"; +import * as React from "react"; + +import { + ButtonGroupButton as ButtonGroupButtonElement, + type ButtonGroupButtonMagnitude, + type ButtonGroupButtonProps as ButtonGroupButtonElementProps, +} from "../../elements/button-group"; +import { ButtonGroupContext } from "./button-group-context"; + +export type ButtonGroupButtonProps = Omit< + ButtonGroupButtonElementProps, + "children" | "magnitude" +> & { + /** + * Set `false` when `render` swaps the underlying tag away from ` + + + + + + + ), +}; + +/** + * The frame's full `prominence` × `tone` matrix (Figma ships primary and secondary split buttons + * only — no tertiary/ghost). Primary frames tint the divider onto the accent/danger fill; a + * secondary frame's divider is the leading segment's own end border. + */ +export const ProminencesAndTones: Story = { + render: () => ( +
+ {TONES.map((tone) => ( +
+ {PROMINENCES.map((prominence) => ( + + + + + + + + + ))} +
+ ))} +
+ ), +}; + +/** + * CSS canary (rule 2b): asserts the frame's child selectors compiled — the leading segment's inner + * corners flatten (`rounded-e-none` out-cascades the segment's own `rounded-md`) while its outer + * corners stay rounded, and the trailing segment loses its start border so the junction is a single + * line. Tagged out of the sidebar/docs/manifest while still running under the default `test` tag. + */ +export const ProminencesAndTonesCanary: Story = { + ...ProminencesAndTones, + tags: ["!dev", "!autodocs", "!manifest"], + play: async ({ canvas }) => { + // Four frames render a main segment named "Button"; the first is primary/neutral. + const [main] = canvas.getAllByRole("button", { name: "Button" }); + const trigger = canvas.getByRole("button", { name: "More options (primary neutral)" }); + await expect(getComputedStyle(main).borderTopRightRadius).toBe("0px"); + await expect(getComputedStyle(main).borderTopLeftRadius).not.toBe("0px"); + + const secondaryTrigger = canvas.getByRole("button", { + name: "More options (secondary neutral)", + }); + await expect(getComputedStyle(secondaryTrigger).borderLeftWidth).toBe("0px"); + await expect(getComputedStyle(trigger).borderTopLeftRadius).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 and the frame dims its divider (`has-[:disabled]:divide-subtle`). + */ +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..2dd531a0 --- /dev/null +++ b/packages/propel/src/elements/split-button/split-button.tsx @@ -0,0 +1,23 @@ +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, SplitButtonTone } from "./variants"; + +export type SplitButtonProps = Omit, "className" | "style"> & + SplitButtonVariantProps; + +/** + * The styled connected frame around a split button's two segments — the main action button and the + * menu-opening icon button. It flattens the segments' shared edge and draws the divider between + * them; 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, tone, magnitude, render, ...props }: SplitButtonProps) { + const defaultProps: useRender.ElementProps<"div"> = { + className: splitButtonVariants({ prominence, tone, 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..16909df9 --- /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 connected two-segment frame around a main action button and a menu-opening +// icon button (Figma "Split button"). The container owns segmentation only — the segments keep +// their own control chrome; the frame straightens their shared edge and draws the divider: +// +// - Inner-edge radius flattening via child selectors (longhand `rounded-s/e-none` intentionally +// out-cascades a segment's own shorthand `rounded-md`). +// - The doubled border at the junction of self-bordered (secondary) segments collapses to one +// (`border-s-0` on the trailing segment; the leading segment's end border is the divider). +// - Borderless (primary) segments get their divider from `divide-x`, tinted per tone so it reads +// on the accent fill; `divide-x`'s zero-specificity `:where()` never touches a segment that +// draws its own border. +// - A focused segment is lifted (`z-10` under `isolate`) so its offset focus ring paints over +// the adjacent segment instead of underneath it. +// +// `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", + "divide-x has-[:disabled]:divide-subtle", + "[&>*+*]:rounded-s-none [&>*:not(:last-child)]:rounded-e-none", + "[&>*+*]:border-s-0", + "[&>*:focus-visible]:z-10", + ), + { + variants: { + prominence: { primary: "", secondary: "" }, + tone: { neutral: "", danger: "" }, + magnitude: { sm: "", md: "", lg: "", xl: "" }, + }, + compoundVariants: [ + // The pressed-fill background tokens double as the divider tint (there is no border token + // for a line on the accent/danger fill), read via the var shorthand like circular-progress. + { prominence: "primary", tone: "neutral", className: "divide-(--bg-accent-primary-active)" }, + { prominence: "primary", tone: "danger", className: "divide-(--bg-danger-primary-active)" }, + ], + }, +); + +type SplitButtonVariantConfig = VariantProps; +export type SplitButtonProminence = NonNullable; +export type SplitButtonTone = NonNullable; +export type SplitButtonMagnitude = NonNullable; + +// No `defaultVariants` today, so every axis is required. +export type SplitButtonVariantProps = StrictVariantProps; From 21d3ca19ce1313d9528fccc44e293fb0fcda8abe Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Tue, 14 Jul 2026 14:16:06 +0530 Subject: [PATCH 02/10] Update SplitButton stories and variants for improved alignment and styling - Adjusted `MenuContent` alignment to "end" in multiple SplitButton stories for consistent presentation. - Enhanced interaction tests to ensure proper focus handling and visual consistency of the SplitButton trigger. - Updated comments in the variants file to clarify styling behavior related to disabled states and border handling. - Refined variant classes to ensure proper border and divider behavior for primary and secondary segments. --- .../split-button/split-button.stories.tsx | 17 ++++++---- .../split-button/split-button.stories.tsx | 2 +- .../src/elements/split-button/variants.ts | 32 +++++++++++++------ 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/propel/src/components/split-button/split-button.stories.tsx b/packages/propel/src/components/split-button/split-button.stories.tsx index e204dd45..ac6fbf37 100644 --- a/packages/propel/src/components/split-button/split-button.stories.tsx +++ b/packages/propel/src/components/split-button/split-button.stories.tsx @@ -33,7 +33,7 @@ export const Default: Story = { render: (args) => ( } /> - + @@ -55,10 +55,15 @@ export const DefaultInteraction: Story = { await expect(args.onClick).toHaveBeenCalledTimes(1); await expect(menuPopup()).not.toBeInTheDocument(); - await userEvent.click(canvas.getByRole("button", { name: "More options" })); + const trigger = canvas.getByRole("button", { name: "More options" }); + await userEvent.click(trigger); await waitFor(() => expect(menuPopup()).toBeInTheDocument()); await expect(args.onClick).toHaveBeenCalledTimes(1); + // Regression: while open, Base UI appends focus-guard spans inside the frame after the + // trigger; the frame's child selectors must not eat the open trigger's end radius. + await expect(getComputedStyle(trigger).borderTopRightRadius).not.toBe("0px"); + const item = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find((el) => el.textContent?.includes("Create from template"), ) as HTMLElement; @@ -87,7 +92,7 @@ export const ProminencesAndTones: Story = { startIcon={} menuLabel={`More options (${prominence} ${tone})`} /> - + @@ -112,7 +117,7 @@ export const Magnitudes: Story = { startIcon={} menuLabel={`More options (${magnitude})`} /> - + @@ -127,7 +132,7 @@ export const Disabled: Story = { render: (args) => ( } /> - + @@ -162,7 +167,7 @@ export const Loading: Story = { render: (args) => ( } /> - + diff --git a/packages/propel/src/elements/split-button/split-button.stories.tsx b/packages/propel/src/elements/split-button/split-button.stories.tsx index bb51481b..b1c6f47a 100644 --- a/packages/propel/src/elements/split-button/split-button.stories.tsx +++ b/packages/propel/src/elements/split-button/split-button.stories.tsx @@ -148,7 +148,7 @@ export const Magnitudes: Story = { /** * Both segments pinned disabled (the native `disabled` attribute): the segments show their disabled - * fills and the frame dims its divider (`has-[:disabled]:divide-subtle`). + * fills and the frame dims its divider (the `:has(:disabled)` end-border tint). */ export const Disabled: Story = { render: () => ( diff --git a/packages/propel/src/elements/split-button/variants.ts b/packages/propel/src/elements/split-button/variants.ts index 16909df9..3542e9ae 100644 --- a/packages/propel/src/elements/split-button/variants.ts +++ b/packages/propel/src/elements/split-button/variants.ts @@ -8,23 +8,26 @@ import { type StrictVariantProps } from "../../internal/variant-props"; // // - Inner-edge radius flattening via child selectors (longhand `rounded-s/e-none` intentionally // out-cascades a segment's own shorthand `rounded-md`). -// - The doubled border at the junction of self-bordered (secondary) segments collapses to one -// (`border-s-0` on the trailing segment; the leading segment's end border is the divider). -// - Borderless (primary) segments get their divider from `divide-x`, tinted per tone so it reads -// on the accent fill; `divide-x`'s zero-specificity `:where()` never touches a segment that -// draws its own border. +// - The divider is the FIRST segment's end border (`border-e`, tinted per tone on the primary +// fill; a self-bordered secondary segment already draws it). The doubled border at the +// junction of self-bordered segments collapses to one (`border-s-0` on trailing children). // - A focused segment is lifted (`z-10` under `isolate`) so its offset focus ring paints over // the adjacent segment instead of underneath it. // +// Every child selector keys 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", - "divide-x has-[:disabled]:divide-subtle", - "[&>*+*]:rounded-s-none [&>*:not(:last-child)]:rounded-e-none", - "[&>*+*]:border-s-0", + "[&>*+*]:rounded-s-none [&>*:first-child]:rounded-e-none", + "[&>*+*]:border-s-0 [&>*:first-child]:border-e", + "[&:has(:disabled)>*:first-child]:border-e-subtle", "[&>*:focus-visible]:z-10", ), { @@ -36,8 +39,17 @@ export const splitButtonVariants = cva( compoundVariants: [ // The pressed-fill background tokens double as the divider tint (there is no border token // for a line on the accent/danger fill), read via the var shorthand like circular-progress. - { prominence: "primary", tone: "neutral", className: "divide-(--bg-accent-primary-active)" }, - { prominence: "primary", tone: "danger", className: "divide-(--bg-danger-primary-active)" }, + // Secondary needs no tint: its main segment's own border-strong end border IS the divider. + { + prominence: "primary", + tone: "neutral", + className: "[&>*:first-child]:border-e-(--bg-accent-primary-active)", + }, + { + prominence: "primary", + tone: "danger", + className: "[&>*:first-child]:border-e-(--bg-danger-primary-active)", + }, ], }, ); From 61a1ad7242472f3e6ab998085ce804915568df0d Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Tue, 14 Jul 2026 14:38:02 +0530 Subject: [PATCH 03/10] Update spinner documentation to clarify glyph choice and animation behavior - Added detailed comments regarding the selection of the `LoaderCircle` glyph over the radial spinner from Figma, emphasizing adherence to using lucide icons. - Enhanced documentation on the spinner's animation behavior, specifying the purpose of the `--animate-spinner-reveal` and perpetual `spin` animations for improved clarity. --- packages/propel/src/internal/spinner.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/propel/src/internal/spinner.tsx b/packages/propel/src/internal/spinner.tsx index f6042f9a..e1864f76 100644 --- a/packages/propel/src/internal/spinner.tsx +++ b/packages/propel/src/internal/spinner.tsx @@ -9,6 +9,12 @@ import { nodeSlotClass } from "./node-slot"; * child (pass a `LoaderCircle`) to the inherited `--node-size`. Replaces the byte-identical * Button/IconButton/Pill spinner parts. * + * Glyph choice — decided with the designers (2026-07): the loading glyph is lucide's circular + * `LoaderCircle`, NOT the radial ("spokes") spinner shown in Figma. Figma's radial spinner is not a + * lucide icon, and we only ship lucide glyphs here, so we intentionally diverge from the design and + * use the circular one. Do not hand-roll a custom radial SVG to match Figma — keep passing + * `LoaderCircle`. + * * Two animations run on mount: the `--animate-spinner-reveal` one-shot grows the slot from zero * width and fades it in (so a control entering its loading state widens smoothly instead of * snapping), while the perpetual `spin` rotates the glyph. They live on separate elements because From a1f5e7bc1f5da53d7e7f4f32c648da654caf480d Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Tue, 14 Jul 2026 16:11:26 +0530 Subject: [PATCH 04/10] Refactor button and anchor-button components for improved loading state handling - Updated loading state behavior in `Button` and `AnchorButton` components to display a trailing spinner instead of replacing the label icon. - Enhanced documentation to clarify the loading state effects on the button and anchor button labels. - Adjusted styles in `preview.css` to ensure consistent background and text colors in light and dark modes. - Improved story examples for both components to reflect the new loading state behavior and ensure clarity in usage. --- packages/propel/.storybook/preview.css | 8 +- .../anchor-button/anchor-button.stories.tsx | 2 +- .../anchor-button/anchor-button.tsx | 6 +- .../src/components/button/button.stories.tsx | 2 +- .../propel/src/components/button/button.tsx | 10 +- .../anchor-button/anchor-button-label.tsx | 4 +- .../anchor-button/anchor-button.stories.tsx | 29 ++-- .../src/elements/anchor-button/variants.ts | 6 +- .../src/elements/button/button-label.tsx | 5 +- .../src/elements/button/button.stories.tsx | 146 +++++++++++++++++- .../propel/src/elements/button/variants.ts | 8 +- .../propel/src/internal/control-chrome.ts | 40 ++++- packages/propel/src/internal/link-chrome.ts | 17 +- 13 files changed, 227 insertions(+), 56 deletions(-) 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/src/components/anchor-button/anchor-button.stories.tsx b/packages/propel/src/components/anchor-button/anchor-button.stories.tsx index 7dfa459d..42a3e3a9 100644 --- a/packages/propel/src/components/anchor-button/anchor-button.stories.tsx +++ b/packages/propel/src/components/anchor-button/anchor-button.stories.tsx @@ -115,7 +115,7 @@ 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) => , diff --git a/packages/propel/src/components/anchor-button/anchor-button.tsx b/packages/propel/src/components/anchor-button/anchor-button.tsx index 931ad4b3..69f83b38 100644 --- a/packages/propel/src/components/anchor-button/anchor-button.tsx +++ b/packages/propel/src/components/anchor-button/anchor-button.tsx @@ -68,15 +68,15 @@ export function AnchorButton({ focusableWhenDisabled={loading ? true : undefined} aria-busy={loading ? true : undefined} > + {!loading ? startIcon : null} + {label} {loading ? ( ) : ( - startIcon + endIcon )} - {label} - {!loading ? endIcon : null} ); } diff --git a/packages/propel/src/components/button/button.stories.tsx b/packages/propel/src/components/button/button.stories.tsx index e2bc1b9d..5fa990cd 100644 --- a/packages/propel/src/components/button/button.stories.tsx +++ b/packages/propel/src/components/button/button.stories.tsx @@ -103,7 +103,7 @@ export const WithIcons: Story = { ), }; -/** The loading state shows a spinner, sets `aria-busy`, and blocks interaction. The label dims. */ +/** The loading state shows a trailing spinner, sets `aria-busy`, and blocks interaction. */ export const Loading: Story = { parameters: { controls: { disable: true } }, render: (args) => ( diff --git a/packages/propel/src/components/button/button.tsx b/packages/propel/src/components/button/button.tsx index c55c50e2..9ba4a5e5 100644 --- a/packages/propel/src/components/button/button.tsx +++ b/packages/propel/src/components/button/button.tsx @@ -27,8 +27,8 @@ export type ButtonProps = Omit & { /** * The ready-made `Button`: grafts Base UI's `Button` behavior onto the styled `Button` element and - * lays out an optional `startIcon`/`endIcon` beside the label plus a `loading` spinner. Content — - * the label, inline nodes, and `loading` state — is not a variant. + * lays out an optional `startIcon`/`endIcon` beside the label, swapping them for a trailing + * `loading` spinner. Content — the label, inline nodes, and `loading` state — is not a variant. */ export function Button({ prominence, @@ -66,15 +66,15 @@ export function Button({ focusableWhenDisabled={loading ? true : undefined} aria-busy={loading ? true : undefined} > + {!loading ? startIcon : null} + {label} {loading ? ( ) : ( - startIcon + endIcon )} - {label} - {!loading ? endIcon : null} ); } diff --git a/packages/propel/src/elements/anchor-button/anchor-button-label.tsx b/packages/propel/src/elements/anchor-button/anchor-button-label.tsx index a3c09a21..525292ab 100644 --- a/packages/propel/src/elements/anchor-button/anchor-button-label.tsx +++ b/packages/propel/src/elements/anchor-button/anchor-button-label.tsx @@ -6,8 +6,8 @@ import { anchorButtonLabelVariants } from "./variants"; export type AnchorButtonLabelProps = Omit, "className" | "style">; /** - * The text label inside an `AnchorButton`. Dims while the parent is `aria-busy` (loading), reading - * the `group` class on the root — mirrors `ButtonLabel`. + * The text label inside an `AnchorButton`. Owns the underline (icons stay un-underlined) and mutes + * with the root chrome while loading — mirrors `ButtonLabel`. */ export function AnchorButtonLabel({ render, ...props }: AnchorButtonLabelProps) { const defaultProps: useRender.ElementProps<"span"> = { diff --git a/packages/propel/src/elements/anchor-button/anchor-button.stories.tsx b/packages/propel/src/elements/anchor-button/anchor-button.stories.tsx index 1fde2da9..bdfd1e9c 100644 --- a/packages/propel/src/elements/anchor-button/anchor-button.stories.tsx +++ b/packages/propel/src/elements/anchor-button/anchor-button.stories.tsx @@ -1,6 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { AnchorButton, type AnchorButtonMagnitude, type AnchorButtonProminence } from "./index"; +import { + AnchorButton, + AnchorButtonLabel, + type AnchorButtonMagnitude, + type AnchorButtonProminence, +} from "./index"; const PROMINENCES: AnchorButtonProminence[] = ["primary", "secondary"]; const MAGNITUDES: AnchorButtonMagnitude[] = ["sm", "md", "lg", "xl"]; @@ -14,7 +19,11 @@ const MAGNITUDES: AnchorButtonMagnitude[] = ["sm", "md", "lg", "xl"]; const meta = { title: "Elements/AnchorButton", component: AnchorButton, - args: { children: "Show more", prominence: "primary", magnitude: "md" }, + args: { + children: Show more, + prominence: "primary", + magnitude: "md", + }, } satisfies Meta; export default meta; @@ -29,7 +38,7 @@ export const Prominences: Story = {
{PROMINENCES.map((prominence) => ( - {prominence} action + {prominence} action ))}
@@ -43,7 +52,7 @@ export const Magnitudes: Story = {
{MAGNITUDES.map((magnitude) => ( - {magnitude} + {magnitude} ))}
@@ -54,7 +63,7 @@ export const Magnitudes: Story = { * Every visual state of the link chrome, per prominence, pinned statically: hover recolors the text * (forced via the pseudo-states addon), focus-visible draws the accent ring (also forced), native * `disabled` — what Base UI's `Button` sets by default — and `aria-disabled="true"` — the - * soft-disabled state it sets under `focusableWhenDisabled` — both drop the underline and dim to + * soft-disabled state it sets under `focusableWhenDisabled` — both keep the underline and mute to * the disabled text color with `cursor-not-allowed`. */ export const States: Story = { @@ -70,27 +79,27 @@ export const States: Story = { {PROMINENCES.map((prominence) => (
- Default + Default - Hover + Hover - Focus visible + Focus visible - Disabled + Disabled - Soft-disabled + Soft-disabled
))} diff --git a/packages/propel/src/elements/anchor-button/variants.ts b/packages/propel/src/elements/anchor-button/variants.ts index 6510e1f2..f8ba77a1 100644 --- a/packages/propel/src/elements/anchor-button/variants.ts +++ b/packages/propel/src/elements/anchor-button/variants.ts @@ -13,6 +13,6 @@ export type AnchorButtonMagnitude = NonNullable; -// The text label inside an AnchorButton. When the parent is `aria-busy` (loading) it dims via the -// `group-aria-busy:` sibling of the `group` class on the root (mirrors ButtonLabel). -export const anchorButtonLabelVariants = cva("group-aria-busy:opacity-50"); +// The text label inside an AnchorButton. Carries the underline (Figma: underline spans the text +// only, not the flanking icons). Loading mutes via the root chrome — same weight as any spinner. +export const anchorButtonLabelVariants = cva("underline underline-offset-2"); diff --git a/packages/propel/src/elements/button/button-label.tsx b/packages/propel/src/elements/button/button-label.tsx index e816fe5b..36da1ff8 100644 --- a/packages/propel/src/elements/button/button-label.tsx +++ b/packages/propel/src/elements/button/button-label.tsx @@ -6,9 +6,8 @@ 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. + * The button's text label. Loading mutes with the root chrome (`aria-busy` / disabled palette) — + * label and spinner share the same weight (Figma). */ export function ButtonLabel({ render, ...props }: ButtonLabelProps) { const defaultProps: useRender.ElementProps<"span"> = { className: buttonLabelVariants() }; diff --git a/packages/propel/src/elements/button/button.stories.tsx b/packages/propel/src/elements/button/button.stories.tsx index 37a85f19..535d5b83 100644 --- a/packages/propel/src/elements/button/button.stories.tsx +++ b/packages/propel/src/elements/button/button.stories.tsx @@ -3,6 +3,7 @@ import { LoaderCircle, Plus } from "lucide-react"; import { Icon } from "../../internal/icon"; import { Spinner } from "../../internal/spinner"; +import { AnchorButton, AnchorButtonLabel } from "../anchor-button/index"; import { Button, ButtonLabel, @@ -135,8 +136,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: { @@ -198,10 +199,10 @@ export const States: Story = { aria-busy aria-disabled > + Busy - Busy ))} @@ -211,12 +212,143 @@ 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. */ +// --------------------------------------------------------------------------- +// TEMPORARY debug sheet (mirrors the Figma "Buttons" master sheet). Every +// palette × magnitude × state at once, plus the text-link buttons. Excluded +// from autodocs and the test run — DELETE once the state debugging is done. +// --------------------------------------------------------------------------- + +const DEBUG_SECTIONS: { title: string; prominence: ButtonProminence; tone: ButtonTone }[] = [ + { title: "Primary buttons", prominence: "primary", tone: "neutral" }, + { title: "Error buttons (fill)", prominence: "primary", tone: "danger" }, + { title: "Secondary buttons", prominence: "secondary", tone: "neutral" }, + { title: "Error buttons (outline)", prominence: "secondary", tone: "danger" }, + { title: "Tertiary buttons", prominence: "tertiary", tone: "neutral" }, + { title: "Ghost buttons", prominence: "ghost", tone: "neutral" }, +]; + +const DEBUG_STATES = ["default", "hover", "active", "focus", "disabled", "busy"] as const; +const DEBUG_LINK_PROMINENCES = [ + { title: "Default", prominence: "primary" }, + { title: "Subtle", prominence: "secondary" }, +] as const; + +function debugButtonIds(state: string) { + return DEBUG_SECTIONS.flatMap(({ prominence, tone }) => + MAGNITUDES.map((magnitude) => `#dbg-${prominence}-${tone}-${magnitude}-${state}`), + ); +} +function debugLinkIds(state: string) { + return DEBUG_LINK_PROMINENCES.flatMap(({ prominence }) => + MAGNITUDES.map((magnitude) => `#dbg-link-${prominence}-${magnitude}-${state}`), + ); +} + +/** + * TEMPORARY: the full debug sheet — every palette (primary/secondary/tertiary/ghost + the two + * danger palettes) × every magnitude (columns) × every state (rows: rest, hover, active, + * focus-visible, disabled, busy), each with start+end icons, plus the text-link buttons + * (`AnchorButton`, Default/Subtle). Hover/active/focus are forced by the pseudo-states addon. + * Delete this story once state debugging is done. + */ +export const AllVariantsDebug: Story = { + tags: ["!autodocs", "!test"], + parameters: { + controls: { disable: true }, + pseudo: { + hover: [...debugButtonIds("hover"), ...debugLinkIds("hover")], + active: debugButtonIds("active"), + focusVisible: [...debugButtonIds("focus"), ...debugLinkIds("focus")], + }, + }, + render: () => ( +
+ {DEBUG_SECTIONS.map(({ title, prominence, tone }) => ( +
+

{title}

+ {DEBUG_STATES.map((state) => { + const pinned = state === "hover" || state === "active" || state === "focus"; + return ( +
+ {MAGNITUDES.map((magnitude) => ( + + ))} +
+ ); + })} +
+ ))} +
+

Text link button

+
+ {DEBUG_LINK_PROMINENCES.map(({ title, prominence }) => ( +
+

{title}

+ {(["default", "hover", "focus", "disabled"] as const).map((state) => ( +
+ {MAGNITUDES.map((magnitude) => ( + + + + + Button + + + + + ))} +
+ ))} +
+ ))} +
+
+
+ ), +}; + export const Anatomy: Story = { args: { children: undefined }, argTypes: { children: { control: false } }, @@ -236,10 +368,10 @@ export const Anatomy: Story = { aria-busy aria-disabled > + Loading - Loading ), diff --git a/packages/propel/src/elements/button/variants.ts b/packages/propel/src/elements/button/variants.ts index 67759af9..b65728cd 100644 --- a/packages/propel/src/elements/button/variants.ts +++ b/packages/propel/src/elements/button/variants.ts @@ -17,8 +17,6 @@ export type ButtonSizing = NonNullable; // No `defaultVariants` today, so every axis is required. export type ButtonVariantProps = StrictVariantProps; -// 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", -); +// The text label inside a Button. Loading mutes via the root's disabled/`aria-busy` chrome +// palette (Figma: label + spinner share the same muted weight — no extra opacity fade). +export const buttonLabelVariants = cva(""); diff --git a/packages/propel/src/internal/control-chrome.ts b/packages/propel/src/internal/control-chrome.ts index ef016677..4bb0b456 100644 --- a/packages/propel/src/internal/control-chrome.ts +++ b/packages/propel/src/internal/control-chrome.ts @@ -6,6 +6,12 @@ import { cva, cx, type VariantProps } from "class-variance-authority"; * ring, disabled affordance, shape, transition) and the neutral/danger fill + border + text palette * per `prominence`. Each surface's geometry (label padding vs square box) is its own local concern. * Compose this with a surface's local cva via `composeVariants`. + * + * Disabled / loading (Figma): filled primary (and filled danger) swap to the solid `layer-disabled` + * pill; secondary / outline keep their surface and only mute border+text; tertiary / ghost drop the + * fill entirely (transparent + muted text). Soft-disabled loading (`focusableWhenDisabled`) lands + * `aria-disabled`/`aria-busy`, not native `disabled`, so every disabled palette key is mirrored on + * those attrs too. */ export const controlChromeVariants = cva( cx( @@ -13,7 +19,8 @@ export const controlChromeVariants = cva( "transition-all duration-200 ease-out outline-none", "focus-visible:ring-2 focus-visible:ring-accent-strong focus-visible:ring-offset-1", "disabled:pointer-events-none disabled:cursor-not-allowed", - "aria-busy:cursor-default", + "aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed", + "aria-busy:pointer-events-none aria-busy:cursor-default", ), { variants: { @@ -28,6 +35,8 @@ export const controlChromeVariants = cva( "bg-accent-primary text-inverse", "hover:bg-accent-primary-hover active:bg-accent-primary-active", "disabled:bg-layer-disabled disabled:text-on-color-disabled", + "aria-disabled:bg-layer-disabled aria-disabled:text-on-color-disabled", + "aria-busy:bg-layer-disabled aria-busy:text-on-color-disabled", ), }, { @@ -36,7 +45,11 @@ export const controlChromeVariants = cva( className: cx( "border border-strong bg-layer-2 text-secondary", "hover:bg-layer-2-hover active:bg-layer-2-active", - "disabled:border-disabled disabled:bg-layer-disabled disabled:text-disabled disabled:shadow-none", + // Figma: outlined pill stays. Use `border-subtle` (not `border-disabled`) — disabled + // token is the same as `--bg-canvas`, so the stroke vanishes on the Storybook/page canvas. + "disabled:border-subtle disabled:bg-layer-2 disabled:text-disabled disabled:shadow-none", + "aria-disabled:border-subtle aria-disabled:bg-layer-2 aria-disabled:text-disabled aria-disabled:shadow-none", + "aria-busy:border-subtle aria-busy:bg-layer-2 aria-busy:text-disabled aria-busy:shadow-none", ), }, { @@ -45,16 +58,23 @@ export const controlChromeVariants = cva( className: cx( "bg-layer-3 text-secondary", "hover:bg-layer-3-hover active:bg-layer-3-active", - "disabled:bg-layer-disabled disabled:text-disabled", + // Figma: disabled/loading drop the fill entirely — transparent + muted text. + "disabled:bg-transparent disabled:text-disabled", + "aria-disabled:bg-transparent aria-disabled:text-disabled", + "aria-busy:bg-transparent aria-busy:text-disabled", ), }, { prominence: "ghost", tone: "neutral", className: cx( + // Figma: transparent rest; hover/active use the same solid light-grey ramp as tertiary + // (not `layer-transparent-hover` — 4%/6% black alpha reads as “no fill” on white). "bg-layer-transparent text-secondary", - "hover:bg-layer-transparent-hover active:bg-layer-transparent-active", + "hover:bg-layer-3 active:bg-layer-3-hover", "disabled:bg-transparent disabled:text-disabled", + "aria-disabled:bg-transparent aria-disabled:text-disabled", + "aria-busy:bg-transparent aria-busy:text-disabled", ), }, { @@ -64,15 +84,21 @@ export const controlChromeVariants = cva( "bg-danger-primary text-on-color", "hover:bg-danger-primary-hover active:bg-danger-primary-active", "disabled:bg-layer-disabled disabled:text-on-color-disabled", + "aria-disabled:bg-layer-disabled aria-disabled:text-on-color-disabled", + "aria-busy:bg-layer-disabled aria-busy:text-on-color-disabled", ), }, { prominence: "secondary", tone: "danger", className: cx( - "border border-danger-strong bg-layer-2 text-danger-secondary", - "hover:bg-danger-subtle active:border-danger-subtle active:bg-danger-subtle-active", - "disabled:border-disabled disabled:bg-layer-disabled disabled:text-disabled disabled:shadow-none", + // Figma outline error: transparent rest, soft fill on hover/active; border stays danger. + "border border-danger-strong bg-transparent text-danger-secondary", + "hover:bg-danger-subtle active:bg-danger-subtle-active", + // Same canvas clash as neutral secondary — `border-subtle` keeps the outline visible. + "disabled:border-subtle disabled:bg-transparent disabled:text-disabled disabled:shadow-none", + "aria-disabled:border-subtle aria-disabled:bg-transparent aria-disabled:text-disabled aria-disabled:shadow-none", + "aria-busy:border-subtle aria-busy:bg-transparent aria-busy:text-disabled aria-busy:shadow-none", ), }, ], diff --git a/packages/propel/src/internal/link-chrome.ts b/packages/propel/src/internal/link-chrome.ts index 7dbf281f..4cf39224 100644 --- a/packages/propel/src/internal/link-chrome.ts +++ b/packages/propel/src/internal/link-chrome.ts @@ -1,16 +1,19 @@ import { cva, cx, type VariantProps } from "class-variance-authority"; // The inline text-link look (Figma "Link"): `AnchorButton` wears it — a `
- ))} - - ); - })} - - ))} -
-

Text link button

-
- {DEBUG_LINK_PROMINENCES.map(({ title, prominence }) => ( -
-

{title}

- {(["default", "hover", "focus", "disabled"] as const).map((state) => ( -
- {MAGNITUDES.map((magnitude) => ( - - - - - Button - - - - - ))} -
- ))} -
- ))} -
-
- - ), -}; - export const Anatomy: Story = { args: { children: undefined }, argTypes: { children: { control: false } }, From 0bb825ad518975dcfe5bd6608b326648b1069e84 Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Tue, 14 Jul 2026 17:10:11 +0530 Subject: [PATCH 06/10] Refactor button and icon-button components to unify control chrome handling - Introduced a `controlChromePair` utility to standardize the application of prominence and tone across button and icon-button components. - Updated button and icon-button stories to reflect the new control chrome structure, ensuring consistent styling and behavior. - Enhanced loading state handling in button components, maintaining focus and accessibility during async operations. - Improved documentation and comments for clarity on component behavior and usage. --- packages/propel/AGENTS.md | 10 +- .../anchor-button/anchor-button.stories.tsx | 141 +++++++++++++++++- .../src/components/button/button.stories.tsx | 17 ++- .../propel/src/components/button/button.tsx | 4 +- .../icon-button/icon-button.stories.tsx | 33 ++++ .../components/icon-button/icon-button.tsx | 8 +- .../anchor-button/anchor-button.stories.tsx | 16 +- .../elements/anchor-button/anchor-button.tsx | 9 +- .../src/elements/anchor-button/variants.ts | 5 +- .../src/elements/button/button.stories.tsx | 76 ++++------ .../propel/src/elements/button/variants.ts | 11 +- .../icon-button/icon-button.stories.tsx | 68 ++++----- .../src/elements/icon-button/icon-button.tsx | 5 +- .../src/elements/icon-button/variants.ts | 7 +- .../split-button/split-button.stories.tsx | 10 +- .../propel/src/internal/button-geometry.ts | 9 +- .../propel/src/internal/control-chrome.ts | 29 +++- 17 files changed, 325 insertions(+), 133 deletions(-) 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 42a3e3a9..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"; @@ -120,3 +121,141 @@ 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 }) => (
- -
@@ -149,55 +146,39 @@ export const States: Story = { }, render: ({ magnitude, sizing }) => (
- {PALETTES.map(({ prominence, tone }) => ( -
- - - @@ -74,7 +74,7 @@ export const ProminencesAndTones: Story = { - Button + Button - Button + Button - Button + Button `) and `AnchorButton` (``). Owns the label row (gap/font), the per-magnitude -// height/min-width/padding/text + `--node-size` glyph scale (Figma "Buttons" Size), and full-width -// `sizing`. Compose with `controlChromeVariants` via `composeVariants`. +// The label-button geometry for surfaces that wear control chrome with a text label: `Button`. +// Owns the label row (gap/font), the per-magnitude height/min-width/padding/text + `--node-size` +// glyph scale (Figma "Buttons" Size), and full-width `sizing`. Compose with `controlChromeVariants` +// via `composeVariants`. (The inline text-link look is `linkChromeVariants` on `AnchorButton` — +// not this geometry.) export const buttonGeometryVariants = cva(cx("gap-1 font-medium whitespace-nowrap"), { variants: { magnitude: { diff --git a/packages/propel/src/internal/control-chrome.ts b/packages/propel/src/internal/control-chrome.ts index 4bb0b456..a3b80707 100644 --- a/packages/propel/src/internal/control-chrome.ts +++ b/packages/propel/src/internal/control-chrome.ts @@ -2,10 +2,11 @@ import { cva, cx, type VariantProps } from "class-variance-authority"; /** * The control chrome shared by the button-look surfaces built on Figma's button tokens — `Button` - * (` - - - - - - - ))} -
+
+ {PROMINENCES.map((prominence) => ( + + + + + + + + ))}
), }; /** - * CSS canary (rule 2b): asserts the frame's child selectors compiled — the leading segment's inner - * corners flatten (`rounded-e-none` out-cascades the segment's own `rounded-md`) while its outer - * corners stay rounded, and the trailing segment loses its start border so the junction is a single - * line. Tagged out of the sidebar/docs/manifest while still running under the default `test` tag. + * 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 ProminencesAndTonesCanary: Story = { - ...ProminencesAndTones, +export const ProminencesCanary: Story = { + ...Prominences, tags: ["!dev", "!autodocs", "!manifest"], play: async ({ canvas }) => { - // Four frames render a main segment named "Button"; the first is primary/neutral. - const [main] = canvas.getAllByRole("button", { name: "Button" }); - const trigger = canvas.getByRole("button", { name: "More options (primary neutral)" }); - await expect(getComputedStyle(main).borderTopRightRadius).toBe("0px"); - await expect(getComputedStyle(main).borderTopLeftRadius).not.toBe("0px"); + 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"); - const secondaryTrigger = canvas.getByRole("button", { - name: "More options (secondary neutral)", - }); + // 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"); - await expect(getComputedStyle(trigger).borderTopLeftRadius).toBe("0px"); }, }; @@ -123,7 +121,7 @@ export const Magnitudes: Story = { render: () => (
{MAGNITUDES.map((magnitude) => ( - +