From 6e507f30e972cbcc18aa180d75526747e780623c Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Fri, 5 Jun 2026 11:40:55 +0200 Subject: [PATCH 1/5] feat(ui): add ButtonGroup layout primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ButtonGroup is a low-level layout primitive for arranging action buttons: alignment, gap, orientation, responsive stacking (viewport or container query), full-width-on-stack and reverseOnStack. It owns only arrangement — not footer/header chrome or button size. Also adds ButtonGroupSeparator (logical divider) and ButtonGroupOverflow (width-driven collapse into a "more" ellipsis menu, built on OverflowList), plus autodocs, an MDX page, and behaviors stories. Co-Authored-By: Claude Opus 4.8 --- .../react/src/ui/ButtonGroup/ButtonGroup.mdx | 108 ++++ .../react/src/ui/ButtonGroup/ButtonGroup.tsx | 72 +++ .../ui/ButtonGroup/ButtonGroupOverflow.tsx | 109 ++++ .../ButtonGroup.behaviors.stories.tsx | 469 ++++++++++++++++++ .../__stories__/ButtonGroup.stories.tsx | 89 ++++ packages/react/src/ui/ButtonGroup/index.ts | 10 + packages/react/src/ui/ButtonGroup/variants.ts | 96 ++++ 7 files changed, 953 insertions(+) create mode 100644 packages/react/src/ui/ButtonGroup/ButtonGroup.mdx create mode 100644 packages/react/src/ui/ButtonGroup/ButtonGroup.tsx create mode 100644 packages/react/src/ui/ButtonGroup/ButtonGroupOverflow.tsx create mode 100644 packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.behaviors.stories.tsx create mode 100644 packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.stories.tsx create mode 100644 packages/react/src/ui/ButtonGroup/index.ts create mode 100644 packages/react/src/ui/ButtonGroup/variants.ts diff --git a/packages/react/src/ui/ButtonGroup/ButtonGroup.mdx b/packages/react/src/ui/ButtonGroup/ButtonGroup.mdx new file mode 100644 index 0000000000..1bc02af7ce --- /dev/null +++ b/packages/react/src/ui/ButtonGroup/ButtonGroup.mdx @@ -0,0 +1,108 @@ +import { Canvas, Meta, Controls } from "@storybook/addon-docs/blocks" + +import * as ButtonGroupStories from "./__stories__/ButtonGroup.stories" +import * as Patterns from "./__stories__/ButtonGroup.behaviors.stories" + + + +# ButtonGroup + +`ButtonGroup` is a low-level layout primitive that arranges a group of action +buttons. It is intentionally **dumb about its children**: it only controls how +they are arranged — alignment, gap, and responsive stacking. + +Across F0, the action rows that are hand-rolled per surface reduce to just **three +layout patterns** — Trailing, Split, and Reflowing — plus a few orthogonal +**composition** pieces you drop into any of them. Documenting them by pattern +(rather than per component) is what lets those surfaces share one primitive without +changing what users see. + + + + +## At a glance + +| Pattern | Recipe | Responsive | +| ------------- | ------------------------------------------------------- | --------------------------------------------- | +| **Trailing** | `align="end" stack="none"` | none — always a row | +| **Split** | `align="between" stack="container-md" fullWidthOnStack` | full-width column when the surface is narrow | +| **Reflowing** | two groups (one per breakpoint) | stacks **and** reorders across the breakpoint | + +## Trailing actions + +Actions packed at the trailing (end) edge, primary last, no stacking — the most +common pattern. _Recognized in: dialog footers, dialog header actions, one-liner +cards._ + +It's the same recipe in every case; what changes is what you **compose** into it — +a `ButtonGroupSeparator`, an icon-only button (`hideLabel`), an `F0ButtonDropdown` +for an action array, or an ellipsis "more" menu. The one-liner is this recipe too; +only its surrounding chrome toggles by container width. + + + +## Split actions + +Secondary and primary pushed to opposite edges (`align="between"`), collapsing to a +full-width column when the **surface itself** is narrow (container `@md`). +_Recognized in: card footers._ Button size stays constant — it can't follow a +container query. + + + +## Reflowing actions + +The actions **reorder** between compact and wide: wide is +`… → secondary → divider → primary`; compact promotes the primary to the top of a +full-width column. _Recognized in: page / resource headers._ + +The reorder itself fits a **single** group via **`reverseOnStack`** (below). What +forces this example to use **two** groups is everything a flex container can't +switch on its own across the breakpoint: the button **`size`** (`lg` stacked → +`md` row — a React prop, see [What ButtonGroup does NOT own](#what-buttongroup-does-not-own)), +and the overflow **menu component** (a desktop `Dropdown` popover ↔ a mobile +`MobileDropdown` drawer). With a constant size and one menu, a single +`stack="md" reverseOnStack fullWidthOnStack` group would do. + + + +### `reverseOnStack` + +Reverses the **stacked (column) order** so the last child — the primary — lands on +top when the group stacks, while the row order at/above the breakpoint is +unchanged. It flips the _whole_ column, so multiple secondaries reverse too +(usually fine). No effect when `stack="none"`. + +## Composition building blocks + +Orthogonal to the patterns — drop these into any of them. They stay outside +`ButtonGroup` because the decisions behind them are domain logic, not arrangement. + +- **Split button** — an action _array_ collapses to a split / menu button via + `F0ButtonDropdown`. +- **Overflow menu** — extra actions collapse under an ellipsis "⋯". Either + width-driven (`ButtonGroupOverflow`, wrapping `OverflowList`) or a fixed, + count-independent bucket (the experimental `Dropdown` / `MobileDropdown`). +- **Separator** — a `ButtonGroupSeparator` hairline divides logical groups. +- **Icon-only** — `hideLabel` on a button (a per-button decision). + + + +## What ButtonGroup does NOT own + +- **Chrome** — borders, padding, and background belong to the surrounding surface. +- **Button `size`** — a React prop, so it **can't follow a container query**; drive + it from a viewport `useMediaQuery` for viewport-based layouts, or keep it constant + under a container query. +- **Collapse decisions** — "is this action critical?", "which secondary is first?", + "should this array become a dropdown?" are domain logic. Compose the building + blocks above instead of adding a configuration enum that accretes every special + case. + +## Viewport vs container + +Stacking can react to the **viewport** (`stack="sm" | "md"`, via Tailwind `sm:` / +`md:`) or to the **container** (`stack="container-md"`, via `@md` = 28rem / 448px on +the nearest `@container` ancestor). They are **not** interchangeable — a card footer +should stack on its own width (container), not the viewport. `ButtonGroup` surfaces +this choice explicitly rather than hiding it behind a single boolean. diff --git a/packages/react/src/ui/ButtonGroup/ButtonGroup.tsx b/packages/react/src/ui/ButtonGroup/ButtonGroup.tsx new file mode 100644 index 0000000000..2a191d58dd --- /dev/null +++ b/packages/react/src/ui/ButtonGroup/ButtonGroup.tsx @@ -0,0 +1,72 @@ +import { type VariantProps } from "cva" + +import { cn } from "@/lib/utils" + +import { buttonGroupVariants } from "./variants" + +export interface ButtonGroupProps + extends + React.HTMLAttributes, + VariantProps {} + +/** + * Low-level layout primitive that arranges a group of action buttons. + * + * It controls ONLY arrangement: alignment, orientation, gap, responsive + * stacking (viewport via `stack="sm"|"md"` or container via + * `stack="container-md"`), full-width-on-stack, and stacked-order reversal + * (`reverseOnStack`, to promote the primary to the top when stacked). + * + * It does NOT own footer/header chrome (borders, padding), button `size`, or + * collapse-into-dropdown behavior. Compose `F0ButtonDropdown` (array → split + * button), an experimental `Dropdown` ("more" menu), or `ButtonGroupOverflow` + * (generic width-driven overflow) as children for those. See `ButtonGroup.mdx` + * for the per-component behavior matrix. + * + * Children render left→right, so pass the secondary action(s) first and the + * primary last; with the default `align="end"` the primary ends up rightmost. + * Insert a `` between children to divide logical groups. + */ +export function ButtonGroup({ + align, + stack, + fullWidthOnStack, + reverseOnStack, + wrap, + className, + ...props +}: ButtonGroupProps) { + return ( +
+ ) +} + +/** + * Vertical hairline that divides logical groups inside a **row-layout** + * `ButtonGroup` — e.g. the actions/close divider in a dialog header, or the + * otherActions/primary divider in a page header. It is intended for horizontal + * layouts and renders unconditionally, so don't place it inside a group that + * stacks into a column (it would float oddly mid-column). + */ +export function ButtonGroupSeparator() { + return ( +
+ ) +} diff --git a/packages/react/src/ui/ButtonGroup/ButtonGroupOverflow.tsx b/packages/react/src/ui/ButtonGroup/ButtonGroupOverflow.tsx new file mode 100644 index 0000000000..bf5fdcfc2f --- /dev/null +++ b/packages/react/src/ui/ButtonGroup/ButtonGroupOverflow.tsx @@ -0,0 +1,109 @@ +import { F0Button } from "@/components/F0Button" +import { + type ButtonSize, + type ButtonVariant, +} from "@/components/F0Button/types" +import { type IconType } from "@/components/F0Icon" +import { Dropdown, type DropdownItem } from "@/experimental/Navigation/Dropdown" +import { Ellipsis } from "@/icons/app" +import { cn } from "@/lib/utils" +import { useOverflowCalculation } from "@/ui/OverflowList/useOverflowCalculation" + +export interface ButtonGroupOverflowAction { + /** Stable identifier, used as the React key. */ + id: string + label: string + icon?: IconType + onClick: () => void + variant?: ButtonVariant + disabled?: boolean +} + +export interface ButtonGroupOverflowProps { + actions: ButtonGroupOverflowAction[] + /** Size applied to every rendered button and the "more" trigger. @default "md" */ + size?: ButtonSize + /** Variant for actions that don't set their own. @default "outline" */ + variant?: ButtonVariant + /** Pixel gap between visible buttons. @default 8 */ + gap?: number + className?: string +} + +/** + * Composition helper that lays out a row of action buttons and sheds the ones + * that don't fit into a real ellipsis ("⋯") **`Dropdown`** menu — a proper button + * that closes on select — driven by the measurement-based {@link useOverflowCalculation}. + * + * Use it for **width-driven** overflow (e.g. a toolbar / action row that should + * collapse as space shrinks). This is distinct from a fixed, count-independent + * "more" bucket (use an experimental `Dropdown` directly for that), and it is NOT + * part of `ButtonGroup`'s layout job — drop it in as a child of a `ButtonGroup` + * or use it standalone. The host must constrain the width (this fills it with + * `w-full`) for the overflow to engage. + */ +export function ButtonGroupOverflow({ + actions, + size = "md", + variant = "outline", + gap = 8, + className, +}: ButtonGroupOverflowProps) { + const { + containerRef, + measurementContainerRef, + customOverflowIndicatorRef, + visibleItems, + overflowItems, + isInitialized, + } = useOverflowCalculation(actions, gap) + + // Before the first measurement, optimistically show everything so the row + // doesn't flash empty; the measured split takes over once initialized. + const shown = isInitialized ? visibleItems : actions + const overflowed = isInitialized ? overflowItems : [] + + const menuItems: DropdownItem[] = overflowed.map((action) => ({ + label: action.label, + icon: action.icon, + onClick: action.onClick, + })) + + const renderButton = (action: ButtonGroupOverflowAction, index: number) => ( + + ) + + return ( +
+ {/* Hidden full-width measurement copy, used to compute the visible split. */} + + + {shown.map(renderButton)} + + {menuItems.length > 0 && ( +
+ +
+ )} +
+ ) +} diff --git a/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.behaviors.stories.tsx b/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.behaviors.stories.tsx new file mode 100644 index 0000000000..f504a13465 --- /dev/null +++ b/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.behaviors.stories.tsx @@ -0,0 +1,469 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { F0Button } from "@/components/F0Button" +import { F0ButtonDropdown } from "@/components/F0ButtonDropdown" +import { + Dropdown, + type DropdownItem, + MobileDropdown, +} from "@/experimental/Navigation/Dropdown" +import { DropdownInternal } from "@/experimental/Navigation/Dropdown/internal" +import { + Archive, + ArrowUp, + Cross, + Delete, + Download, + Ellipsis, + LayersFront, + Pencil, + Plus, + Share, +} from "@/icons/app" +import { cn } from "@/lib/utils" + +import { ButtonGroup, ButtonGroupSeparator } from "../ButtonGroup" +import { ButtonGroupOverflow } from "../ButtonGroupOverflow" + +const noop = () => {} + +/** + * The action layouts across F0 reconcile to THREE agnostic patterns — **Trailing**, + * **Split**, and **Reflowing** — plus orthogonal **composition** pieces (split + * button, overflow menu, separator, icon-only). Each example is reconstructed with + * `ButtonGroup` on a minimal mock surface, without importing the real component. + * + * See `ButtonGroup.mdx` for the at-a-glance matrix. + */ +const meta = { + title: "ButtonGroup/Patterns", + component: ButtonGroup, + parameters: { layout: "padded" }, + tags: ["experimental"], +} satisfies Meta + +export default meta +type Story = StoryObj + +// --- Captioned case + minimal mock surfaces --------------------------------- +// The buttons are the point; the surfaces are just clean white frames (footer +// bar, header bar, card) so examples don't float on the canvas. ButtonGroup owns +// none of this chrome. + +const Case = ({ + caption, + children, +}: { + caption: string + children: React.ReactNode +}) => ( +
+ {caption} + {children} +
+) + +const Cases = ({ children }: { children: React.ReactNode }) => ( +
{children}
+) + +const surface = + "rounded-lg border border-solid border-f1-border-secondary bg-f1-background" + +/** A footer surface: empty body, then a bottom action bar. */ +const MockFooter = ({ children }: { children: React.ReactNode }) => ( +
+
+
+ {children} +
+
+) + +/** A header surface: a top action bar, then an empty body. */ +const MockHeader = ({ children }: { children: React.ReactNode }) => ( +
+
+ {children} +
+
+
+) + +/** A page-header / card surface holding the (right-aligned) action cluster. */ +const MockPageHeader = ({ children }: { children: React.ReactNode }) => ( +
{children}
+) + +// ============================================================================= +// Pattern 1 — Trailing actions +// align="end" stack="none": actions packed at the trailing edge, primary last, +// no stacking. Recognized in dialog footers, dialog header actions, one-liners. +// ============================================================================= + +/** One-liner card: actions inline when wide, stacked under the text when the card + * is narrow. Same trailing recipe — only the surrounding chrome toggles by the + * CONTAINER width (`@md`). */ +const OneLinerRow = ({ width }: { width: number }) => ( +
+
+
+
+ Enable two-factor authentication +
+ + + + +
+
+
+) + +export const TrailingActions: Story = { + name: "Trailing actions", + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ ), +} + +// ============================================================================= +// Pattern 2 — Split actions +// align="between" stack="container-md" fullWidthOnStack: secondary and primary at +// opposite edges, collapsing to a full-width column when the surface is narrow. +// Recognized in card footers. +// ============================================================================= + +const SplitRow = ({ width }: { width: number }) => ( +
+
+
+ + + + + + + +
+
+
+) + +export const SplitActions: Story = { + name: "Split actions", + render: () => ( + + + + + + + + + ), +} + +// ============================================================================= +// Pattern 3 — Reflowing actions +// The actions REORDER between compact and wide. The order alone fits one group +// (stack + reverseOnStack); two groups are used here only because the button +// SIZE and the overflow-menu component also change across the breakpoint. +// Recognized in page / resource headers. +// ============================================================================= + +const reflowOverflow: DropdownItem[] = [ + { label: "Archive", icon: Archive, onClick: noop }, + { label: "Copy URL", icon: LayersFront, onClick: noop }, + { type: "separator" }, + { label: "Unlist", icon: Delete, critical: true, onClick: noop }, +] + +/** The wide cluster: more → secondary → divider → primary, right-aligned. */ +const WideCluster = ({ + withOverflow = true, + primaryAsDropdown = false, + exportAsDropdown = false, +}: { + withOverflow?: boolean + primaryAsDropdown?: boolean + exportAsDropdown?: boolean +}) => ( + + {withOverflow && } + + {exportAsDropdown ? ( + + ) : ( + + )} + + + {primaryAsDropdown ? ( + + ) : ( + + )} + +) + +export const ReflowingActions: Story = { + name: "Reflowing actions", + render: () => ( +
+ + + {/* Wide (≥ md) */} +
+ +
+ {/* Compact (< md): full-width column, size lg, primary first. + The order alone could be one group via `reverseOnStack`; two are + used because the size (lg→md) and the overflow component (Dropdown + ↔ MobileDropdown) also change here. */} +
+ + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + +
+ ), +} + +// ============================================================================= +// Composition — Overflow menu (width-driven) +// ============================================================================= + +const toolbarActions = [ + { id: "edit", label: "Edit", icon: Pencil, onClick: noop }, + { id: "share", label: "Share", icon: Share, onClick: noop }, + { id: "duplicate", label: "Duplicate", icon: Plus, onClick: noop }, + { id: "delete", label: "Delete", icon: Delete, onClick: noop }, +] + +/** + * `ButtonGroupOverflow` sheds the buttons that don't fit under a real ellipsis + * "⋯" `Dropdown` (closes on select), measured by `useOverflowCalculation`. + * Width-driven — distinct from a fixed count-independent bucket. Narrow the + * canvas to watch buttons collapse; shown here at three fixed widths. + */ +export const OverflowMenu: Story = { + name: "Overflow menu (width-driven)", + render: () => ( +
+ {[560, 360, 240].map((w) => ( +
+ + {w}px — buttons that don't fit collapse under the ellipsis + +
+ +
+
+ ))} +
+ ), +} diff --git a/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.stories.tsx b/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.stories.tsx new file mode 100644 index 0000000000..8e96d49669 --- /dev/null +++ b/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { F0Button } from "@/components/F0Button" + +import { ButtonGroup, ButtonGroupSeparator } from "../ButtonGroup" + +const meta = { + title: "ButtonGroup", + component: ButtonGroup, + parameters: { + layout: "padded", + }, + tags: ["autodocs", "experimental"], + argTypes: { + align: { + control: "inline-radio", + options: ["end", "between"], + }, + stack: { + control: "inline-radio", + options: ["none", "sm", "md", "container-md"], + }, + fullWidthOnStack: { control: "boolean" }, + reverseOnStack: { control: "boolean" }, + wrap: { control: "boolean" }, + }, + args: { + align: "end", + stack: "none", + fullWidthOnStack: false, + reverseOnStack: false, + wrap: false, + }, + decorators: [ + // `@container` so the `stack="container-md"` option reacts to this box's + // width (resize the canvas); the viewport `sm`/`md` options react to the + // browser width instead. + (Story) => ( +
+ +
+ ), + ], + render: (args) => ( + + {}} /> + {}} /> + + ), +} satisfies Meta + +export default meta +type Story = StoryObj + +/** Default: right-aligned row, primary (last child) rightmost. */ +export const Default: Story = {} + +export const SpaceBetween: Story = { + args: { align: "between" }, +} + +/** + * Stack into a full-width column below the viewport `sm` breakpoint; resize the + * canvas across 640px to see it become a row. + */ +export const StackOnMobile: Story = { + args: { stack: "sm", fullWidthOnStack: true }, +} + +/** + * `reverseOnStack` flips the stacked column order so the primary (last child) + * sits on top when stacked, while the row order is unchanged at/above the + * breakpoint. Resize across 640px: below it the primary is on top; above it the + * primary is rightmost. (The whole column reverses, so any secondaries flip too.) + */ +export const StackReversed: Story = { + args: { stack: "sm", fullWidthOnStack: true, reverseOnStack: true }, +} + +/** A `ButtonGroupSeparator` divides logical groups (hidden when stacked). */ +export const WithSeparator: Story = { + render: (args) => ( + + {}} /> + + {}} /> + + ), +} diff --git a/packages/react/src/ui/ButtonGroup/index.ts b/packages/react/src/ui/ButtonGroup/index.ts new file mode 100644 index 0000000000..3bf371c1dc --- /dev/null +++ b/packages/react/src/ui/ButtonGroup/index.ts @@ -0,0 +1,10 @@ +export { + ButtonGroup, + ButtonGroupSeparator, + type ButtonGroupProps, +} from "./ButtonGroup" +export { + ButtonGroupOverflow, + type ButtonGroupOverflowAction, + type ButtonGroupOverflowProps, +} from "./ButtonGroupOverflow" diff --git a/packages/react/src/ui/ButtonGroup/variants.ts b/packages/react/src/ui/ButtonGroup/variants.ts new file mode 100644 index 0000000000..4eefe11057 --- /dev/null +++ b/packages/react/src/ui/ButtonGroup/variants.ts @@ -0,0 +1,96 @@ +import { cva } from "cva" + +/** + * Layout variants for {@link ButtonGroup}. This primitive controls ONLY the + * arrangement of a group of action buttons (alignment, orientation, gap, + * responsive stacking, full-width-on-stack). It deliberately does NOT own: + * + * - footer/header chrome (borders, padding) — that stays on the surrounding container, + * - button `size` — a React prop that can't be switched by a container query, so it + * stays a consumer concern, + * - collapse decisions (array→dropdown, count thresholds, hide-label-after-first) — + * those are domain logic; compose `F0ButtonDropdown` / `ButtonGroupOverflow` instead. + * + * Every generated class is a static string so Tailwind's JIT can see it. + */ +export const buttonGroupVariants = cva({ + // Gap is a fixed `gap-md` (8px) for every button group — a constant rhythm + // across surfaces, not a per-call knob. + base: "flex gap-md", + variants: { + // Action groups are never left-aligned (a left-positioned primary is an + // anti-pattern), so only `end` (default, right-aligned) and `between` + // (secondary ‖ primary) are exposed. + align: { + end: "justify-end", + between: "justify-between", + }, + /** + * Orientation + responsive stacking, encoded as ONE axis: + * - `none`: always a horizontal row. + * - `sm` / `md`: stack as a column below the named *viewport* breakpoint and + * become a row at/above it (matches Card footer `sm`, ResourceHeader `md`). + * - `container-md`: stack below the *container* `@md` breakpoint (28rem / 448px); + * requires an ancestor with `@container` (matches the Card oneLiner). + */ + stack: { + none: "flex-row items-center", + sm: "flex-col items-stretch sm:flex-row sm:items-center", + md: "flex-col items-stretch md:flex-row md:items-center", + "container-md": "flex-col items-stretch @md:flex-row @md:items-center", + }, + /** + * When stacked, stretch every direct child to full width; revert to auto + * width once the row layout kicks in. No-op when `stack="none"`. + */ + fullWidthOnStack: { + true: "", + false: "", + }, + /** + * Reverse the *stacked* (column) order so the LAST child renders on top, + * then restore source order once the row layout kicks in. Use it to promote + * the primary (passed last) above the secondaries when the group stacks. + * Note: this flips the WHOLE column, so secondary buttons also reverse. + * No-op when `stack="none"`. Resolved via compound variants below. + */ + reverseOnStack: { + true: "", + false: "", + }, + wrap: { + true: "flex-wrap", + false: "", + }, + }, + compoundVariants: [ + // full-width only while stacked, released at the row breakpoint + { + stack: "sm", + fullWidthOnStack: true, + class: "[&>*]:w-full sm:[&>*]:w-auto", + }, + { + stack: "md", + fullWidthOnStack: true, + class: "[&>*]:w-full md:[&>*]:w-auto", + }, + { + stack: "container-md", + fullWidthOnStack: true, + class: "[&>*]:w-full @md:[&>*]:w-auto", + }, + // reverse only the stacked (column) part; `flex-col-reverse` wins over the + // stack variant's `flex-col` via tailwind-merge, leaving the row class intact + { stack: "sm", reverseOnStack: true, class: "flex-col-reverse" }, + { stack: "md", reverseOnStack: true, class: "flex-col-reverse" }, + { stack: "container-md", reverseOnStack: true, class: "flex-col-reverse" }, + ], + defaultVariants: { + align: "end", + stack: "none", + fullWidthOnStack: false, + reverseOnStack: false, + wrap: false, + }, +}) From 4cc791fb933ee8cd52fa7ebba61ec8e2ba243288 Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Mon, 8 Jun 2026 10:17:48 +0200 Subject: [PATCH 2/5] fix(ui): mark ButtonGroup measurement container as inert The hidden measurement copy is purely for layout sizing, so make it fully non-interactive and remove its children from the tab order. Co-Authored-By: Claude Opus 4.8 --- packages/react/src/ui/ButtonGroup/ButtonGroupOverflow.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/src/ui/ButtonGroup/ButtonGroupOverflow.tsx b/packages/react/src/ui/ButtonGroup/ButtonGroupOverflow.tsx index bf5fdcfc2f..0bb552b4b6 100644 --- a/packages/react/src/ui/ButtonGroup/ButtonGroupOverflow.tsx +++ b/packages/react/src/ui/ButtonGroup/ButtonGroupOverflow.tsx @@ -91,6 +91,7 @@ export function ButtonGroupOverflow({
@@ -130,90 +118,91 @@ export const TrailingActions: Story = { - - - - + - + - - - - + ], + onClick: noop, + }} + /> - - - - - - + - - - - - + @@ -242,28 +231,12 @@ const SplitRow = ({ width }: { width: number }) => ( align="between" stack="container-md" fullWidthOnStack - className="w-full" - > - - - - - - + secondaryActions={[ + { id: "discard", label: "Discard", icon: Delete, onClick: noop }, + { id: "preview", label: "Preview", icon: Share, onClick: noop }, + ]} + primaryAction={{ id: "save", label: "Save changes", onClick: noop }} + />
@@ -285,9 +258,10 @@ export const SplitActions: Story = { // ============================================================================= // Pattern 3 — Reflowing actions -// The actions REORDER between compact and wide. The order alone fits one group -// (stack + reverseOnStack); two groups are used here only because the button -// SIZE and the overflow-menu component also change across the breakpoint. +// One ButtonGroup that REORDERS across the breakpoint: wide is a row +// (more → secondary → divider → primary, size md, ⋯ popover); compact is a +// full-width column with the primary on top (size lg, ⋯ mobile drawer). The +// size swap and the Dropdown↔MobileDropdown swap are handled internally. // Recognized in page / resource headers. // ============================================================================= @@ -298,132 +272,88 @@ const reflowOverflow: DropdownItem[] = [ { label: "Unlist", icon: Delete, critical: true, onClick: noop }, ] -/** The wide cluster: more → secondary → divider → primary, right-aligned. */ -const WideCluster = ({ - withOverflow = true, +const ReflowCluster = ({ primaryAsDropdown = false, exportAsDropdown = false, + withOverflow = true, }: { - withOverflow?: boolean primaryAsDropdown?: boolean exportAsDropdown?: boolean + withOverflow?: boolean }) => ( - - {withOverflow && } - - {exportAsDropdown ? ( - - ) : ( - - )} - - - {primaryAsDropdown ? ( - - ) : ( - - )} - + ) export const ReflowingActions: Story = { name: "Reflowing actions", render: () => (
- + - {/* Wide (≥ md) */} -
- -
- {/* Compact (< md): full-width column, size lg, primary first. - The order alone could be one group via `reverseOnStack`; two are - used because the size (lg→md) and the overflow component (Dropdown - ↔ MobileDropdown) also change here. */} -
- - - - - - - -
+
- + - + - +
@@ -442,10 +372,9 @@ const toolbarActions = [ ] /** - * `ButtonGroupOverflow` sheds the buttons that don't fit under a real ellipsis - * "⋯" `Dropdown` (closes on select), measured by `useOverflowCalculation`. - * Width-driven — distinct from a fixed count-independent bucket. Narrow the - * canvas to watch buttons collapse; shown here at three fixed widths. + * Width-driven overflow is now native: secondaries that don't fit shed under a + * real ellipsis "⋯" `Dropdown` (closes on select), measured automatically. + * Narrow the canvas to watch buttons collapse; shown here at three fixed widths. */ export const OverflowMenu: Story = { name: "Overflow menu (width-driven)", @@ -460,7 +389,7 @@ export const OverflowMenu: Story = { style={{ width: w }} className="rounded-lg border border-solid border-f1-border-secondary p-3" > - + ))} diff --git a/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.stories.tsx b/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.stories.tsx index 8e96d49669..001344b837 100644 --- a/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.stories.tsx +++ b/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.stories.tsx @@ -1,8 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite" -import { F0Button } from "@/components/F0Button" +import { Delete, Pencil, Share } from "@/icons/app" -import { ButtonGroup, ButtonGroupSeparator } from "../ButtonGroup" +import { ButtonGroup } from "../ButtonGroup" + +const noop = () => {} const meta = { title: "ButtonGroup", @@ -20,16 +22,21 @@ const meta = { control: "inline-radio", options: ["none", "sm", "md", "container-md"], }, + size: { + control: "inline-radio", + options: ["sm", "md", "lg"], + }, fullWidthOnStack: { control: "boolean" }, reverseOnStack: { control: "boolean" }, - wrap: { control: "boolean" }, }, args: { align: "end", stack: "none", + size: "md", fullWidthOnStack: false, reverseOnStack: false, - wrap: false, + secondaryActions: [{ id: "cancel", label: "Cancel", onClick: noop }], + primaryAction: { id: "confirm", label: "Confirm", onClick: noop }, }, decorators: [ // `@container` so the `stack="container-md"` option reacts to this box's @@ -41,24 +48,57 @@ const meta = { ), ], - render: (args) => ( - - {}} /> - {}} /> - - ), } satisfies Meta export default meta type Story = StoryObj -/** Default: right-aligned row, primary (last child) rightmost. */ +/** Default: right-aligned row, the primary (solid) pinned at the trailing edge. */ export const Default: Story = {} export const SpaceBetween: Story = { args: { align: "between" }, } +/** + * More secondaries than fit: the ones that don't shed into a "⋯" menu, while the + * primary stays pinned. Narrow the canvas to watch buttons collapse. + */ +export const Overflowing: Story = { + args: { + secondaryActions: [ + { id: "edit", label: "Edit", icon: Pencil, onClick: noop }, + { id: "share", label: "Share", icon: Share, onClick: noop }, + { id: "delete", label: "Delete", icon: Delete, onClick: noop }, + ], + primaryAction: { id: "save", label: "Save changes", onClick: noop }, + }, +} + +/** Extra actions always live in the "⋯" menu via `otherActions`. */ +export const WithOtherActions: Story = { + args: { + secondaryActions: [{ id: "edit", label: "Edit", onClick: noop }], + otherActions: [ + { label: "Duplicate", onClick: noop }, + { type: "separator" }, + { label: "Delete", critical: true, onClick: noop }, + ], + primaryAction: { id: "save", label: "Save", onClick: noop }, + }, +} + +/** An inline `{ type: "separator" }` divides logical groups (hidden when stacked). */ +export const WithSeparator: Story = { + args: { + secondaryActions: [ + { id: "discard", label: "Discard", onClick: noop }, + { type: "separator" }, + ], + primaryAction: { id: "save", label: "Save", onClick: noop }, + }, +} + /** * Stack into a full-width column below the viewport `sm` breakpoint; resize the * canvas across 640px to see it become a row. @@ -68,22 +108,25 @@ export const StackOnMobile: Story = { } /** - * `reverseOnStack` flips the stacked column order so the primary (last child) - * sits on top when stacked, while the row order is unchanged at/above the - * breakpoint. Resize across 640px: below it the primary is on top; above it the - * primary is rightmost. (The whole column reverses, so any secondaries flip too.) + * `reverseOnStack` promotes the primary to the top of the stacked column, while + * the row order is unchanged at/above the breakpoint. Resize across 640px. */ export const StackReversed: Story = { args: { stack: "sm", fullWidthOnStack: true, reverseOnStack: true }, } -/** A `ButtonGroupSeparator` divides logical groups (hidden when stacked). */ -export const WithSeparator: Story = { - render: (args) => ( - - {}} /> - - {}} /> - - ), +/** The primary can be a split button (`type: "split"`), wrapping `F0ButtonDropdown`. */ +export const SplitPrimary: Story = { + args: { + secondaryActions: [{ id: "discard", label: "Discard", onClick: noop }], + primaryAction: { + id: "publish", + type: "split", + items: [ + { value: "now", label: "Publish now" }, + { value: "schedule", label: "Schedule" }, + ], + onClick: noop, + }, + }, } diff --git a/packages/react/src/ui/ButtonGroup/index.ts b/packages/react/src/ui/ButtonGroup/index.ts index 3bf371c1dc..f742420748 100644 --- a/packages/react/src/ui/ButtonGroup/index.ts +++ b/packages/react/src/ui/ButtonGroup/index.ts @@ -1,10 +1,12 @@ export { ButtonGroup, ButtonGroupSeparator, + type ButtonGroupButton, + type ButtonGroupButtonBase, + type ButtonGroupInlineSeparator, type ButtonGroupProps, + type ButtonGroupSecondaryItem, + type ButtonGroupSecondaryLink, + type ButtonGroupSize, + type ButtonGroupSplitAction, } from "./ButtonGroup" -export { - ButtonGroupOverflow, - type ButtonGroupOverflowAction, - type ButtonGroupOverflowProps, -} from "./ButtonGroupOverflow" diff --git a/packages/react/src/ui/ButtonGroup/variants.ts b/packages/react/src/ui/ButtonGroup/variants.ts index 4eefe11057..16f680bc8a 100644 --- a/packages/react/src/ui/ButtonGroup/variants.ts +++ b/packages/react/src/ui/ButtonGroup/variants.ts @@ -1,15 +1,11 @@ import { cva } from "cva" /** - * Layout variants for {@link ButtonGroup}. This primitive controls ONLY the - * arrangement of a group of action buttons (alignment, orientation, gap, - * responsive stacking, full-width-on-stack). It deliberately does NOT own: - * - * - footer/header chrome (borders, padding) — that stays on the surrounding container, - * - button `size` — a React prop that can't be switched by a container query, so it - * stays a consumer concern, - * - collapse decisions (array→dropdown, count thresholds, hide-label-after-first) — - * those are domain logic; compose `F0ButtonDropdown` / `ButtonGroupOverflow` instead. + * Class variants for {@link ButtonGroup}'s **stacked (column) branch** — + * alignment, the stack breakpoint, full-width-on-stack, and stacked-order + * reversal. The row branch builds its flex layout inline (it also runs the + * width-measured overflow), so these classes only apply once the group collapses + * into a column below the breakpoint. * * Every generated class is a static string so Tailwind's JIT can see it. */ From 7c65fb991b4d481d7dedcb3f68051eae8efbc3bd Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Mon, 8 Jun 2026 11:54:46 +0200 Subject: [PATCH 4/5] feat(ui): support inline critical actions in ButtonGroup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose F0Button's critical (destructive/red) variant on action buttons, so authors can choose how guarded a destructive action is instead of the component enforcing one rule: - Guarded (default for permanent resources): critical in otherActions → behind the "⋯" menu, the extra click as a safety gate. - One-click (cheap/recoverable resources): a critical secondaryActions button renders inline in red with no extra click. critical is carried into the "⋯" menu when an inline critical secondary overflows. Documents the guard-by-default guideline in the mdx. Co-Authored-By: Claude Opus 4.8 --- .../react/src/ui/ButtonGroup/ButtonGroup.mdx | 14 ++++++++++ .../react/src/ui/ButtonGroup/ButtonGroup.tsx | 9 +++++- .../__stories__/ButtonGroup.stories.tsx | 28 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/react/src/ui/ButtonGroup/ButtonGroup.mdx b/packages/react/src/ui/ButtonGroup/ButtonGroup.mdx index ea7b96bfb0..7d36d58789 100644 --- a/packages/react/src/ui/ButtonGroup/ButtonGroup.mdx +++ b/packages/react/src/ui/ButtonGroup/ButtonGroup.mdx @@ -77,6 +77,20 @@ and never collapse. The shed actions merge into the same menu as `otherActions`. +## Destructive actions + +`critical` renders a red, destructive action — and `ButtonGroup` lets you choose +how guarded it is, rather than enforcing one rule: + +- **Guarded (default for permanent resources):** put it in `otherActions` with + `critical`, so it lives behind the "⋯". The extra click is the safety gate. +- **One-click (for cheap / recoverable resources):** mark an inline + `secondaryActions` button `critical` to render a red button with no extra click. + +Whether a delete should be guarded is a judgment about blast radius and +recoverability, so it stays an author decision. Default to guarding; reach for the +inline critical button only when a one-click delete is genuinely warranted. + ## Viewport vs container Stacking can react to the **viewport** (`stack="sm" | "md"`) or to the diff --git a/packages/react/src/ui/ButtonGroup/ButtonGroup.tsx b/packages/react/src/ui/ButtonGroup/ButtonGroup.tsx index ec40e53a77..59458cfe95 100644 --- a/packages/react/src/ui/ButtonGroup/ButtonGroup.tsx +++ b/packages/react/src/ui/ButtonGroup/ButtonGroup.tsx @@ -33,6 +33,12 @@ export interface ButtonGroupButtonBase { loading?: boolean hideLabel?: boolean tooltip?: string + /** + * Render as a destructive (red) action. Prefer guarding destructive actions in + * the "⋯" menu via `otherActions`; reach for an inline critical button only + * when the resource is cheap to recreate and a one-click delete is warranted. + */ + critical?: boolean } /** A single clickable (or link) action. `onClick` and `href` are mutually exclusive. */ @@ -199,7 +205,7 @@ export function ButtonGroup({ label={action.label} icon={action.icon} iconPosition={action.iconPosition} - variant={variant} + variant={action.critical ? "critical" : variant} size={resolvedSize} disabled={action.disabled} loading={action.loading} @@ -318,6 +324,7 @@ export function ButtonGroup({ icon: action.icon, onClick: action.onClick, href: action.href, + critical: action.critical, }) ), ...(overflowedPlain.length > 0 && otherActions.length > 0 diff --git a/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.stories.tsx b/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.stories.tsx index 001344b837..21922debed 100644 --- a/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.stories.tsx +++ b/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.stories.tsx @@ -88,6 +88,34 @@ export const WithOtherActions: Story = { }, } +/** + * Destructive actions, two ways. **Guard** it (default for permanent resources): + * put it in `otherActions` with `critical` so it sits behind the "⋯" — the extra + * click is the safety gate. **One-click** (for cheap/recoverable resources): mark + * an inline `secondaryActions` button `critical` to render a red button with no + * extra click. + */ +export const CriticalActions: Story = { + args: { + secondaryActions: [ + { id: "edit", label: "Edit", onClick: noop }, + // One-click destructive button — use only when the resource is cheap to recreate. + { + id: "delete", + label: "Delete", + icon: Delete, + critical: true, + onClick: noop, + }, + ], + // Guarded destructive action — the safer default for permanent resources. + otherActions: [ + { label: "Delete permanently", critical: true, onClick: noop }, + ], + primaryAction: { id: "save", label: "Save", onClick: noop }, + }, +} + /** An inline `{ type: "separator" }` divides logical groups (hidden when stacked). */ export const WithSeparator: Story = { args: { From 6ba83a74f6fe679a7e60ded0bee2440b07e4afec Mon Sep 17 00:00:00 2001 From: Marcos Medina Date: Mon, 8 Jun 2026 12:27:03 +0200 Subject: [PATCH 5/5] fix(ui): fix ButtonGroup render loop + overflow-on-stack; review polish Found by running Storybook and a comparative review against BaseHeader / TimeLine/Actions / CardActions. Bugs: - Infinite render loop ("Maximum update depth exceeded"): the fresh secondaryActions.filter() array changed useOverflowCalculation's callback identity every render. Memoize it. - Secondaries never measured when starting stacked: useOverflowCalculation's ResizeObserver attached before the row DOM existed (usehooks-ts doesn't re-observe when the ref element changes), so everything shed into the menu. Split the row/stacked branches into separate keyed children so the overflow hook mounts with its DOM present; keep the container-width observer on a stable outer wrapper. Review polish: - useMediaQuery: explicit { initializeWithValue: false } (SSR-safe, matches the repo-wide convention). - Separator hairline: bg-f1-border-secondary (the house token) instead of the unused bg-f1-background-secondary. - Remove the dead `wrap` variant from variants.ts. - Document why `id` is required (overflow identity), unlike index-keyed lists. Co-Authored-By: Claude Opus 4.8 --- .../react/src/ui/ButtonGroup/ButtonGroup.tsx | 293 +++++++++++------- packages/react/src/ui/ButtonGroup/variants.ts | 5 - 2 files changed, 186 insertions(+), 112 deletions(-) diff --git a/packages/react/src/ui/ButtonGroup/ButtonGroup.tsx b/packages/react/src/ui/ButtonGroup/ButtonGroup.tsx index 59458cfe95..4da254bb72 100644 --- a/packages/react/src/ui/ButtonGroup/ButtonGroup.tsx +++ b/packages/react/src/ui/ButtonGroup/ButtonGroup.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useEffect, useRef } from "react" +import { type ReactNode, useEffect, useMemo, useRef } from "react" import { useMediaQuery, useResizeObserver } from "usehooks-ts" import { F0Button } from "@/components/F0Button" @@ -23,7 +23,11 @@ import { buttonGroupVariants } from "./variants" /** Fields a primary/secondary action button exposes. Variant is fixed by role * (primary → solid `default`, secondary → `outline`); `size` is a group prop. */ export interface ButtonGroupButtonBase { - /** Stable identifier, used as the React key. */ + /** + * Stable identifier. Required (unlike index-keyed action lists elsewhere) + * because width-overflow moves a button between the measurement copy, the + * visible row, and the "⋯" menu — a stable key keeps it from remounting. + */ id: string /** Visible label; also the a11y name (pair with `hideLabel` for icon-only). */ label: string @@ -117,6 +121,77 @@ const isPlainButton = ( item: ButtonGroupSecondaryItem ): item is ButtonGroupButton => !("type" in item) +const renderActionButton = ( + action: ButtonGroupButton, + size: ButtonSize, + variant: "default" | "outline" +) => ( + +) + +const renderSplitButton = ( + action: ButtonGroupSplitAction, + size: ButtonSize, + variant: "default" | "outline" +) => { + const { id, type: _type, ...rest } = action + return ( + + ) +} + +const renderSecondaryLink = ( + link: ButtonGroupSecondaryLink, + size: ButtonSize +) => ( + +) + +const renderPrimaryNode = ( + action: ButtonGroupButton | ButtonGroupSplitAction, + size: ButtonSize +) => + isSplitAction(action) + ? renderSplitButton(action, size, "default") + : renderActionButton(action, size, "default") + +interface ButtonGroupBranchProps { + primaryAction?: ButtonGroupButton | ButtonGroupSplitAction + secondaryItems: ButtonGroupSecondaryItem[] + secondaryLink?: ButtonGroupSecondaryLink + otherActions: DropdownItem[] + size: ButtonSize + gap: number + align: "end" | "between" +} + /** * A data-driven, responsive action bar. Pass actions as props — `primaryAction` * (solid, pinned at the trailing edge), `secondaryActions` (outline buttons that @@ -130,6 +205,11 @@ const isPlainButton = ( * - An inline `{ type: "separator" }` renders a hairline between two visible * secondaries; as the last secondary it becomes the divider before the primary. * Separators are hidden while stacked. + * + * The row and stacked branches are separate, keyed children: the stable outer + * element keeps measuring the container width, while the overflow machinery + * (which needs its DOM present when it initializes) mounts fresh with whichever + * branch is active. */ export function ButtonGroup({ primaryAction, @@ -150,7 +230,11 @@ export function ButtonGroup({ }) const viewportBreakpoint = stack === "sm" || stack === "md" ? BREAKPOINT_PX[stack] : BREAKPOINT_PX.md - const isViewportRow = useMediaQuery(`(min-width: ${viewportBreakpoint}px)`) + // SSR-safe: render the stacked branch first, reconcile on the client (avoids a + // hydration mismatch). Matches the explicit-option convention used repo-wide. + const isViewportRow = useMediaQuery(`(min-width: ${viewportBreakpoint}px)`, { + initializeWithValue: false, + }) const isRowMode = stack === "none" @@ -173,8 +257,89 @@ export function ButtonGroup({ ? secondaryActions : undefined + const branchProps: ButtonGroupBranchProps = { + primaryAction, + secondaryItems, + secondaryLink, + otherActions, + size: resolvedSize, + gap, + align, + } + + return ( +
+ {isRowMode ? ( + + ) : ( + + )} +
+ ) +} + +/** Stacked (column) branch: everything visible, the menu is a mobile drawer. */ +function ButtonGroupStacked({ + primaryAction, + secondaryItems, + secondaryLink, + otherActions, + size, +}: ButtonGroupBranchProps) { + const stackedSecondaries = secondaryItems + .filter( + (item): item is ButtonGroupButton | ButtonGroupSplitAction => + !isInlineSeparator(item) + ) + .map((item) => + isSplitAction(item) + ? renderSplitButton(item, size, "outline") + : renderActionButton(item, size, "outline") + ) + + return ( + <> + {otherActions.length > 0 && } + {stackedSecondaries} + {secondaryLink && renderSecondaryLink(secondaryLink, size)} + {primaryAction && renderPrimaryNode(primaryAction, size)} + + ) +} + +/** Row branch: width-measured overflow of plain secondaries into a "⋯" Dropdown. */ +function ButtonGroupRow({ + primaryAction, + secondaryItems, + secondaryLink, + otherActions, + size, + gap, + align, +}: ButtonGroupBranchProps) { // Only plain buttons are width-measured; splits + separators are pinned/excluded. - const plainSecondaries = secondaryItems.filter(isPlainButton) + // Memoized so the reference is stable across renders — a fresh array would + // change useOverflowCalculation's callback identity and loop ("Maximum update + // depth exceeded"). + const plainSecondaries = useMemo( + () => secondaryItems.filter(isPlainButton), + [secondaryItems] + ) const { containerRef, @@ -189,104 +354,16 @@ export function ButtonGroup({ // measurement copy imperatively — it removes the copy from focus + a11y. useEffect(() => { measurementContainerRef.current?.setAttribute("inert", "") - }) + }, [measurementContainerRef]) // Before the first measurement, optimistically show everything to avoid a flash. const shownPlain = isInitialized ? visibleItems : plainSecondaries const overflowedPlain = isInitialized ? overflowItems : [] const shownIds = new Set(shownPlain.map((action) => action.id)) - const renderButton = ( - action: ButtonGroupButton, - variant: "default" | "outline" - ) => ( - - ) - - const renderSplit = ( - action: ButtonGroupSplitAction, - variant: "default" | "outline" - ) => { - const { id, type: _type, ...rest } = action - return ( - - ) - } - - const renderLink = (link: ButtonGroupSecondaryLink) => ( - - ) - const primaryNode = primaryAction - ? isSplitAction(primaryAction) - ? renderSplit(primaryAction, "default") - : renderButton(primaryAction, "default") + ? renderPrimaryNode(primaryAction, size) : null - - // ---- Stacked (column) mode: everything visible, menu is a mobile drawer ---- - if (!isRowMode) { - const stackedSecondaries = secondaryItems - .filter( - (item): item is ButtonGroupButton | ButtonGroupSplitAction => - !isInlineSeparator(item) - ) - .map((item) => - isSplitAction(item) - ? renderSplit(item, "outline") - : renderButton(item, "outline") - ) - - return ( -
- {otherActions.length > 0 && } - {stackedSecondaries} - {secondaryLink && renderLink(secondaryLink)} - {primaryNode} -
- ) - } - - // ---- Row mode: width-measured overflow into a "⋯" Dropdown ----------------- const splitSecondaries = secondaryItems.filter(isSplitAction) const lastItem = secondaryItems[secondaryItems.length - 1] const dividerBeforePinned = @@ -306,7 +383,10 @@ export function ButtonGroup({ return } if (shownIds.has(item.id)) { - clusterTokens.push({ kind: "node", node: renderButton(item, "outline") }) + clusterTokens.push({ + kind: "node", + node: renderActionButton(item, size, "outline"), + }) } }) // Drop separators that aren't between two rendered nodes. @@ -334,12 +414,7 @@ export function ButtonGroup({ ] return ( -
+ <>
- {plainSecondaries.map((action) => renderButton(action, "outline"))} + {plainSecondaries.map((action) => + renderActionButton(action, size, "outline") + )}
{menuItems.length > 0 && (
- +
)} @@ -372,13 +449,15 @@ export function ButtonGroup({ ) )} - {secondaryLink && renderLink(secondaryLink)} + {secondaryLink && renderSecondaryLink(secondaryLink, size)}
- {splitSecondaries.map((action) => renderSplit(action, "outline"))} + {splitSecondaries.map((action) => + renderSplitButton(action, size, "outline") + )} {dividerBeforePinned && } {primaryNode} - + ) } @@ -392,7 +471,7 @@ export function ButtonGroupSeparator() {
) } diff --git a/packages/react/src/ui/ButtonGroup/variants.ts b/packages/react/src/ui/ButtonGroup/variants.ts index 16f680bc8a..b118a21fe0 100644 --- a/packages/react/src/ui/ButtonGroup/variants.ts +++ b/packages/react/src/ui/ButtonGroup/variants.ts @@ -54,10 +54,6 @@ export const buttonGroupVariants = cva({ true: "", false: "", }, - wrap: { - true: "flex-wrap", - false: "", - }, }, compoundVariants: [ // full-width only while stacked, released at the row breakpoint @@ -87,6 +83,5 @@ export const buttonGroupVariants = cva({ stack: "none", fullWidthOnStack: false, reverseOnStack: false, - wrap: false, }, })