diff --git a/packages/react/src/ui/ButtonGroup/ButtonGroup.mdx b/packages/react/src/ui/ButtonGroup/ButtonGroup.mdx new file mode 100644 index 0000000000..7d36d58789 --- /dev/null +++ b/packages/react/src/ui/ButtonGroup/ButtonGroup.mdx @@ -0,0 +1,100 @@ +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 **data-driven action bar**. You describe the actions as props — +never as JSX children — and it handles arrangement, width-driven overflow, and +responsive stacking for you. + +- **`primaryAction`** — the single solid button, pinned at the trailing edge. A + single object structurally guarantees there is never more than one primary. +- **`secondaryActions`** — outline buttons (or split buttons, or inline + separators). The ones that don't fit shed into a "⋯" menu; the rest stay inline. +- **`otherActions`** — extra actions that always live in the "⋯" menu (a + `DropdownItem[]`, so they support separators and `critical`). + + + + +Across F0 the hand-rolled action rows reduce to **three patterns** — Trailing, +Split, and Reflowing — all of which are now just different prop combinations. + +## At a glance + +| Pattern | Recipe | Responsive | +| ------------- | ------------------------------------------------------- | --------------------------------------------- | +| **Trailing** | `align="end"` (default) | secondaries shed into "⋯" as width shrinks | +| **Split** | `align="between" stack="container-md" fullWidthOnStack` | full-width column when the surface is narrow | +| **Reflowing** | `stack="md" reverseOnStack` + `size={{ base, md }}` | stacks **and** reorders across the breakpoint | + +## Trailing actions + +Actions packed at the trailing (end) edge, primary last — the most common +pattern. _Recognized in: dialog footers, dialog header actions, one-liner cards._ +Compose it by mixing the action props: split buttons (`type: "split"`), icon-only +buttons (`hideLabel`), an inline `{ type: "separator" }`, or an `otherActions` +"⋯" menu. + + + +## Split actions + +Secondary and primary pushed to opposite edges (`align="between"`), collapsing to +a full-width column when the **surface itself** is narrow (`stack="container-md"`, +the container `@md` breakpoint = 28rem / 448px). _Recognized in: card footers._ + + + +## Reflowing actions + +The actions **reorder** between compact and wide: wide is a row +(`… → secondary → divider → primary`); compact promotes the primary to the top of +a full-width column (`reverseOnStack`). _Recognized in: page / resource headers._ + +A single group now expresses the whole behavior. Two things switch automatically +at the breakpoint: the button **`size`** (pass `size={{ base: "lg", md: "md" }}`) +and the overflow **menu component** — a desktop `Dropdown` popover above the +breakpoint, a mobile `MobileDropdown` drawer below it. + + + +### `reverseOnStack` + +Reverses the **stacked (column) order** so 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. No effect when `stack="none"`. + +## Overflow + +Secondary buttons that don't fit shed into a "⋯" menu automatically, measured by +width. Plain secondaries shed first; **split buttons and the primary are pinned** +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 +**container** (`stack="container-md"`). They are **not** interchangeable — a card +footer should stack on its own width (container), not the viewport. The branch (and +the responsive `size`) is chosen in JS from the matching breakpoint, so only one +layout mounts at a time. diff --git a/packages/react/src/ui/ButtonGroup/ButtonGroup.tsx b/packages/react/src/ui/ButtonGroup/ButtonGroup.tsx new file mode 100644 index 0000000000..4da254bb72 --- /dev/null +++ b/packages/react/src/ui/ButtonGroup/ButtonGroup.tsx @@ -0,0 +1,477 @@ +import { type ReactNode, useEffect, useMemo, useRef } from "react" +import { useMediaQuery, useResizeObserver } from "usehooks-ts" + +import { F0Button } from "@/components/F0Button" +import { type ButtonSize } from "@/components/F0Button/types" +import { + F0ButtonDropdown, + type F0ButtonDropdownProps, +} from "@/components/F0ButtonDropdown" +import { type IconType } from "@/components/F0Icon" +import { + Dropdown, + type DropdownItem, + MobileDropdown, +} from "@/experimental/Navigation/Dropdown" +import { Ellipsis } from "@/icons/app" +import { cn } from "@/lib/utils" +import { type NavTarget } from "@/ui/Action" +import { useOverflowCalculation } from "@/ui/OverflowList/useOverflowCalculation" + +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. 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 + icon?: IconType + iconPosition?: "left" | "right" + disabled?: boolean + 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. */ +export type ButtonGroupButton = ButtonGroupButtonBase & + ( + | { onClick: () => void; href?: never; target?: never } + | { href: string; target?: NavTarget; onClick?: never } + ) + +// `Omit` over a union collapses to its common keys, dropping mode-specific props +// like `value`/`trigger`; distribute it so each F0ButtonDropdown mode survives. +type DistributiveOmit = T extends unknown + ? Omit + : never + +/** A split / dropdown button action, wrapping {@link F0ButtonDropdown}. Its + * `variant` and `size` are owned by the group, so they're omitted here. */ +export type ButtonGroupSplitAction = { + id: string + type: "split" +} & DistributiveOmit + +/** A hairline divider between two logical groups of secondaries (row layout only). */ +export type ButtonGroupInlineSeparator = { type: "separator" } + +export type ButtonGroupSecondaryItem = + | ButtonGroupButton + | ButtonGroupSplitAction + | ButtonGroupInlineSeparator + +/** A single link rendered in place of secondary buttons (mirrors F0CardRow). */ +export interface ButtonGroupSecondaryLink { + label: string + href: string + target?: NavTarget + disabled?: boolean +} + +/** A constant size, or a responsive pair: `base` while stacked, `md` in the row. */ +export type ButtonGroupSize = ButtonSize | { base: ButtonSize; md: ButtonSize } + +export interface ButtonGroupProps { + /** The single primary action. A single object structurally guarantees ≤1 primary. */ + primaryAction?: ButtonGroupButton | ButtonGroupSplitAction + /** Secondary actions (buttons / split buttons / inline separators), or a single link. */ + secondaryActions?: ButtonGroupSecondaryItem[] | ButtonGroupSecondaryLink + /** Extra actions, always reachable through the "⋯" menu (supports separators / critical). */ + otherActions?: DropdownItem[] + /** Button + menu-trigger size. Responsive `{ base, md }` flips with `stack`. @default "md" */ + size?: ButtonGroupSize + /** Pixel gap between items. @default 8 */ + gap?: number + /** Row alignment. @default "end" */ + align?: "end" | "between" + /** Stack into a column below the named viewport / container breakpoint. @default "none" */ + stack?: "none" | "sm" | "md" | "container-md" + /** Stretch every item to full width while stacked. */ + fullWidthOnStack?: boolean + /** Reverse the stacked column so the primary lands on top. */ + reverseOnStack?: boolean + className?: string +} + +const BREAKPOINT_PX = { sm: 640, md: 768, "container-md": 448 } as const + +const isInlineSeparator = ( + item: ButtonGroupSecondaryItem +): item is ButtonGroupInlineSeparator => + "type" in item && item.type === "separator" + +const isSplitAction = ( + item: ButtonGroupSecondaryItem | ButtonGroupButton | ButtonGroupSplitAction +): item is ButtonGroupSplitAction => "type" in item && item.type === "split" + +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 + * shed into a "⋯" menu when they don't fit), and `otherActions` (always in that + * menu). Set `stack` to collapse into a full-width column below a breakpoint, + * where the menu becomes a mobile drawer. + * + * Behavior notes: + * - Split-button secondaries and the primary are pinned (never shed); plain + * secondaries shed first under width pressure. + * - 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, + secondaryActions, + otherActions = [], + size = "md", + gap = 8, + align = "end", + stack = "none", + fullWidthOnStack = false, + reverseOnStack = false, + className, +}: ButtonGroupProps) { + const rootRef = useRef(null) + const { width: containerWidth = 0 } = useResizeObserver({ + ref: rootRef, + box: "border-box", + }) + const viewportBreakpoint = + stack === "sm" || stack === "md" ? BREAKPOINT_PX[stack] : BREAKPOINT_PX.md + // 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" + ? true + : stack === "container-md" + ? containerWidth >= BREAKPOINT_PX["container-md"] + : isViewportRow + + const sizeBase = typeof size === "string" ? size : size.base + const sizeMd = typeof size === "string" ? size : size.md + const resolvedSize: ButtonSize = isRowMode ? sizeMd : sizeBase + + const secondaryItems: ButtonGroupSecondaryItem[] = Array.isArray( + secondaryActions + ) + ? secondaryActions + : [] + const secondaryLink = + secondaryActions != null && !Array.isArray(secondaryActions) + ? 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. + // 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, + measurementContainerRef, + customOverflowIndicatorRef, + visibleItems, + overflowItems, + isInitialized, + } = useOverflowCalculation(plainSecondaries, gap) + + // `inert` isn't a typed JSX prop in this React version, so set it on the + // 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 primaryNode = primaryAction + ? renderPrimaryNode(primaryAction, size) + : null + const splitSecondaries = secondaryItems.filter(isSplitAction) + const lastItem = secondaryItems[secondaryItems.length - 1] + const dividerBeforePinned = + lastItem != null && + isInlineSeparator(lastItem) && + (splitSecondaries.length > 0 || primaryNode != null) + + // Cluster = plain secondaries (those that fit) interleaved with inline + // separators; splits are pinned to the right alongside the primary. + const clusterTokens: Array< + { kind: "node"; node: ReactNode } | { kind: "sep"; key: string } + > = [] + secondaryItems.forEach((item, index) => { + if (isSplitAction(item)) return + if (isInlineSeparator(item)) { + clusterTokens.push({ kind: "sep", key: `sep-${index}` }) + return + } + if (shownIds.has(item.id)) { + clusterTokens.push({ + kind: "node", + node: renderActionButton(item, size, "outline"), + }) + } + }) + // Drop separators that aren't between two rendered nodes. + const cleanedTokens = clusterTokens.filter( + (token, index) => + token.kind === "node" || + (clusterTokens[index - 1]?.kind === "node" && + clusterTokens[index + 1]?.kind === "node") + ) + + const menuItems: DropdownItem[] = [ + ...overflowedPlain.map( + (action): DropdownItem => ({ + label: action.label, + icon: action.icon, + onClick: action.onClick, + href: action.href, + critical: action.critical, + }) + ), + ...(overflowedPlain.length > 0 && otherActions.length > 0 + ? [{ type: "separator" } as DropdownItem] + : []), + ...otherActions, + ] + + return ( + <> +
+ {/* Hidden measurement copy, used to compute the visible/overflow split. */} + + + {menuItems.length > 0 && ( +
+ +
+ )} + + {cleanedTokens.map((token) => + token.kind === "sep" ? ( + + ) : ( + token.node + ) + )} + + {secondaryLink && renderSecondaryLink(secondaryLink, size)} +
+ + {splitSecondaries.map((action) => + renderSplitButton(action, size, "outline") + )} + {dividerBeforePinned && } + {primaryNode} + + ) +} + +/** + * Vertical hairline that divides logical groups inside a **row-layout** + * `ButtonGroup`. Rendered for inline `{ type: "separator" }` entries; hidden + * while the group is stacked into a column. + */ +export function ButtonGroupSeparator() { + return ( +
+ ) +} 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..79a9b114a2 --- /dev/null +++ b/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.behaviors.stories.tsx @@ -0,0 +1,398 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { type DropdownItem } from "@/experimental/Navigation/Dropdown" +import { + Archive, + ArrowUp, + Cross, + Delete, + Download, + LayersFront, + Pencil, + Plus, + Share, +} from "@/icons/app" +import { cn } from "@/lib/utils" + +import { ButtonGroup } from "../ButtonGroup" + +const noop = () => {} + +/** + * The action layouts across F0 reconcile to THREE patterns — **Trailing**, + * **Split**, and **Reflowing** — all expressed through `ButtonGroup`'s action + * props (`primaryAction` / `secondaryActions` / `otherActions`), without passing + * any JSX children. 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 — driven by the CONTAINER width (`stack="container-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 +// 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. +// ============================================================================= + +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 }, +] + +const ReflowCluster = ({ + primaryAsDropdown = false, + exportAsDropdown = false, + withOverflow = true, +}: { + primaryAsDropdown?: boolean + exportAsDropdown?: boolean + withOverflow?: boolean +}) => ( + +) + +export const ReflowingActions: Story = { + name: "Reflowing actions", + render: () => ( +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ ), +} + +// ============================================================================= +// 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 }, +] + +/** + * 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)", + 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..21922debed --- /dev/null +++ b/packages/react/src/ui/ButtonGroup/__stories__/ButtonGroup.stories.tsx @@ -0,0 +1,160 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { Delete, Pencil, Share } from "@/icons/app" + +import { ButtonGroup } from "../ButtonGroup" + +const noop = () => {} + +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"], + }, + size: { + control: "inline-radio", + options: ["sm", "md", "lg"], + }, + fullWidthOnStack: { control: "boolean" }, + reverseOnStack: { control: "boolean" }, + }, + args: { + align: "end", + stack: "none", + size: "md", + fullWidthOnStack: false, + reverseOnStack: 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 + // width (resize the canvas); the viewport `sm`/`md` options react to the + // browser width instead. + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +/** 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 }, + }, +} + +/** + * 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: { + 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. + */ +export const StackOnMobile: Story = { + args: { stack: "sm", fullWidthOnStack: true }, +} + +/** + * `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 }, +} + +/** 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 new file mode 100644 index 0000000000..f742420748 --- /dev/null +++ b/packages/react/src/ui/ButtonGroup/index.ts @@ -0,0 +1,12 @@ +export { + ButtonGroup, + ButtonGroupSeparator, + type ButtonGroupButton, + type ButtonGroupButtonBase, + type ButtonGroupInlineSeparator, + type ButtonGroupProps, + type ButtonGroupSecondaryItem, + type ButtonGroupSecondaryLink, + type ButtonGroupSize, + type ButtonGroupSplitAction, +} from "./ButtonGroup" diff --git a/packages/react/src/ui/ButtonGroup/variants.ts b/packages/react/src/ui/ButtonGroup/variants.ts new file mode 100644 index 0000000000..b118a21fe0 --- /dev/null +++ b/packages/react/src/ui/ButtonGroup/variants.ts @@ -0,0 +1,87 @@ +import { cva } from "cva" + +/** + * 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. + */ +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: "", + }, + }, + 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, + }, +})