diff --git a/.changeset/button-group.md b/.changeset/button-group.md new file mode 100644 index 000000000..5efe035f0 --- /dev/null +++ b/.changeset/button-group.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": minor +--- + +Add `ButtonGroup` component for joining a small set of tightly-coupled buttons into a single control — most commonly a split button (a primary action next to a dropdown trigger). Handles layout only: children keep their own variant, size, and shape while the group flattens inner corners and overlaps borders so the buttons share one seam. Renders `role="group"`. For grouping multiple independent buttons or inputs, use `Toolbar` instead. diff --git a/packages/kumo-docs-astro/src/components/SidebarNav.tsx b/packages/kumo-docs-astro/src/components/SidebarNav.tsx index 5d79adcb7..852d63b0e 100644 --- a/packages/kumo-docs-astro/src/components/SidebarNav.tsx +++ b/packages/kumo-docs-astro/src/components/SidebarNav.tsx @@ -44,6 +44,7 @@ const componentItems: NavItem[] = [ { label: "Banner", href: "/components/banner" }, { label: "Breadcrumbs", href: "/components/breadcrumbs" }, { label: "Button", href: "/components/button" }, + { label: "Button Group", href: "/components/button-group" }, { label: "Checkbox", href: "/components/checkbox" }, { label: "Clipboard Text", href: "/components/clipboard-text" }, { label: "Cloudflare Logo", href: "/components/cloudflare-logo" }, diff --git a/packages/kumo-docs-astro/src/components/demos/ButtonGroupDemo.tsx b/packages/kumo-docs-astro/src/components/demos/ButtonGroupDemo.tsx new file mode 100644 index 000000000..1e585ffad --- /dev/null +++ b/packages/kumo-docs-astro/src/components/demos/ButtonGroupDemo.tsx @@ -0,0 +1,99 @@ +import { Button, ButtonGroup, DropdownMenu } from "@cloudflare/kumo"; +import { CaretDownIcon } from "@phosphor-icons/react"; + +/** + * Split button: a primary action joined with a dropdown trigger for related + * secondary actions. The caret button uses `shape="square"` and an + * `aria-label`. + */ +export function ButtonGroupSplitDemo() { + return ( + + + + + + + } + /> + + Deploy to staging + Deploy and tail logs + Schedule deploy… + + + + ); +} + +/** + * Split buttons work with any button variant — here the secondary style for a + * lower-emphasis action. + */ +export function ButtonGroupSecondaryDemo() { + return ( + + + + + + + } + /> + + Save as draft + Save and publish + Save a copy… + + + + ); +} + +/** + * Match the `size` on both buttons to keep the split button aligned. + */ +export function ButtonGroupSizesDemo() { + const sizes = ["sm", "base", "lg"] as const; + return ( +
+ {sizes.map((size) => ( + + + + + + + } + /> + + Deploy to staging + Schedule deploy… + + + + ))} +
+ ); +} diff --git a/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx b/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx index 10d82219d..50b3025b8 100644 --- a/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx +++ b/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx @@ -4,6 +4,7 @@ import { Badge, Banner, Button, + ButtonGroup, Checkbox, ClipboardText, Collapsible, @@ -43,6 +44,7 @@ import { ShikiProvider, CodeHighlighted } from "@cloudflare/kumo/code"; import { CommandPaletteBasicDemo } from "~/components/demos/CommandPaletteDemo"; import { InputGroupDemo } from "~/components/demos/InputGroupDemo"; import { + CaretDownIcon, MagnifyingGlassIcon, PlusIcon, TranslateIcon, @@ -56,6 +58,7 @@ const componentRoutes: Record = { banner: "/components/banner", breadcrumbs: "/components/breadcrumbs", button: "/components/button", + "button-group": "/components/button-group", checkbox: "/components/checkbox", "clipboard-text": "/components/clipboard-text", "code-highlighted": "/components/code-highlighted", @@ -138,6 +141,18 @@ export function HomeGrid() { ), }, + { + name: "Button Group", + id: "button-group", + Component: ( + + + + + ), + }, { name: "Input", id: "input", diff --git a/packages/kumo-docs-astro/src/pages/components/button-group.mdx b/packages/kumo-docs-astro/src/pages/components/button-group.mdx new file mode 100644 index 000000000..633450e17 --- /dev/null +++ b/packages/kumo-docs-astro/src/pages/components/button-group.mdx @@ -0,0 +1,152 @@ +--- +layout: ~/layouts/MdxDocLayout.astro +title: "Button Group" +description: "Joins a primary action and a dropdown trigger into a single split button." +sourceFile: "components/button-group" +--- + +import ComponentExample from "~/components/docs/ComponentExample.astro"; +import ComponentSection from "~/components/docs/ComponentSection.astro"; +import CodeBlock from "~/components/docs/CodeBlock.astro"; +import PropsTable from "~/components/docs/PropsTable.astro"; +import { + ButtonGroupSplitDemo, + ButtonGroupSecondaryDemo, + ButtonGroupSizesDemo, +} from "~/components/demos/ButtonGroupDemo"; + +{/* Demo */} + + + + + + + +{/* When to use */} + + + +## When to use + +

+ `ButtonGroup` joins a small set of tightly-coupled buttons into a single + control — most commonly a **split button**: a primary action next to a + dropdown trigger for related, secondary actions. +

+ +

+ Grouping multiple independent buttons or inputs? Use{" "} + Toolbar instead. A toolbar (e.g. a + formatting bar or a page-level set of actions) needs roaming-focus keyboard + semantics that `ButtonGroup` intentionally does not provide. +

+ +
+ +{/* Installation */} + + + +## Installation + +### Barrel + + + +### Granular + + + + +{/* Usage */} + + + +## Usage + +

+ `ButtonGroup` is a layout wrapper. Its children keep their own `variant`, + `size`, and `shape` — the group only flattens the inner corners and overlaps + borders so the buttons share a single seam. Give the group an `aria-label` + describing the action, and give the dropdown trigger its own `aria-label`. +

+ + + + + + + + } + /> + + Deploy to staging + + + + ); +}`} + lang="tsx" + /> +
+ +{/* Examples */} + + + +## Examples + +### Split button + +

A primary action joined with a dropdown trigger for secondary actions.

+ + + + +### Secondary + +

Split buttons work with any button variant.

+ + + + +### Sizes + +

Match the `size` on both buttons to keep the split button aligned.

+ + + + +
+ +{/* API Reference */} + + + +## API Reference + + + diff --git a/packages/kumo/package.json b/packages/kumo/package.json index a29ebc4be..f335ea12d 100644 --- a/packages/kumo/package.json +++ b/packages/kumo/package.json @@ -76,6 +76,10 @@ "types": "./dist/components/button.d.ts", "import": "./dist/components/button.js" }, + "./components/button-group": { + "types": "./dist/components/button-group.d.ts", + "import": "./dist/components/button-group.js" + }, "./components/chart": { "types": "./dist/components/chart.d.ts", "import": "./dist/components/chart.js" diff --git a/packages/kumo/scripts/component-registry/discovery.ts b/packages/kumo/scripts/component-registry/discovery.ts index b9f2ca0fe..0b4ec0990 100644 --- a/packages/kumo/scripts/component-registry/discovery.ts +++ b/packages/kumo/scripts/component-registry/discovery.ts @@ -30,6 +30,7 @@ import { export const CATEGORY_MAP: Record = { // Action button: "Action", + "button-group": "Action", "clipboard-text": "Action", // Display badge: "Display", diff --git a/packages/kumo/src/components/button-group/button-group.test.tsx b/packages/kumo/src/components/button-group/button-group.test.tsx new file mode 100644 index 000000000..3ed744910 --- /dev/null +++ b/packages/kumo/src/components/button-group/button-group.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vite-plus/test"; +import { createRef } from "react"; +import { render, screen } from "@testing-library/react"; +import { ButtonGroup } from "./button-group"; +import { Button } from "../button/button"; + +describe("ButtonGroup", () => { + it("should be importable", () => { + expect(ButtonGroup).toBeDefined(); + }); + + it("should have correct display name", () => { + expect(ButtonGroup.displayName).toBe("ButtonGroup"); + }); + + it("renders its children", () => { + render( + + + + , + ); + expect(screen.getByText("One")).toBeTruthy(); + expect(screen.getByText("Two")).toBeTruthy(); + }); + + it('defaults to role="group"', () => { + render( + + + , + ); + expect(screen.getByRole("group")).toBeTruthy(); + }); + + it("forwards aria-label to the group", () => { + render( + + + , + ); + expect(screen.getByRole("group", { name: "Deploy" })).toBeTruthy(); + }); + + it('does not allow role="group" to be overridden', () => { + const props = { role: "toolbar" }; + render( + + + , + ); + expect(screen.getByRole("group")).toBeTruthy(); + expect(screen.queryByRole("toolbar")).toBeNull(); + }); + + it("lays buttons out horizontally", () => { + render( + + + , + ); + expect(screen.getByRole("group").className).toContain("flex-row"); + }); + + it("uses logical child-position selectors to join controls", () => { + render( + + + Two + , + ); + const className = screen.getByRole("group").className; + expect(className).toContain("rounded-s-none"); + expect(className).toContain("rounded-e-none"); + expect(className).toContain("-ms-px"); + expect(className).not.toContain("!ring-kumo-line"); + }); + + it("merges a custom className", () => { + render( + + + , + ); + expect(screen.getByRole("group").className).toContain("custom-class"); + }); + + it("forwards a ref to the container", () => { + const ref = createRef(); + render( + + + , + ); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); +}); diff --git a/packages/kumo/src/components/button-group/button-group.tsx b/packages/kumo/src/components/button-group/button-group.tsx new file mode 100644 index 000000000..c9ae60ea3 --- /dev/null +++ b/packages/kumo/src/components/button-group/button-group.tsx @@ -0,0 +1,101 @@ +import { forwardRef, type HTMLAttributes, type ReactNode } from "react"; +import { cn } from "../../utils/cn"; + +/** + * ButtonGroup has no visual variants — it's a horizontal layout wrapper. The + * required exports are kept for the Kumo variant standard. + */ +export const KUMO_BUTTON_GROUP_VARIANTS = {} as const; + +export const KUMO_BUTTON_GROUP_DEFAULT_VARIANTS = {} as const; + +/** Base classes shared by every ButtonGroup. */ +export const KUMO_BUTTON_GROUP_STYLING = { + baseClasses: cn( + // `isolate` keeps child z-index changes contained; `w-max` shrinks the + // group to its content so it doesn't stretch across its container. + "relative isolate inline-flex w-max flex-row", + // Give every child a stacking context so keyboard-focused controls can + // lift above the adjacent seam, including tooltip-wrapped buttons. + "[&>*]:relative [&>*:focus-visible]:z-10 [&>*:has(:focus-visible)]:z-10", + // Each kumo Button carries its own `shadow-xs`. Inside a group those + // shadows overlap at the seams and make the middle button look elevated / + // boxed. Drop the per-button shadows so the group reads as one flat, + // uniform control (the shared rings provide all the definition needed). + "[&>*>:is(button,a)]:shadow-none [&>:is(button,a)]:shadow-none", + // Join controls by child position rather than element type so mixed Button + // and LinkButton groups retain the correct outer corners. The one-level + // descendant selectors support Button's tooltip wrapper. + "[&>*:not(:first-child):is(button,a)]:rounded-s-none", + "[&>*:not(:last-child):is(button,a)]:rounded-e-none", + "[&>*:not(:first-child)>:is(button,a)]:rounded-s-none", + "[&>*:not(:last-child)>:is(button,a)]:rounded-e-none", + // Overlap borders/rings by 1px so adjacent buttons share a single seam + // instead of doubling up to a 2px line. Logical utilities support RTL. + "[&>*:not(:first-child)]:-ms-px", + ), +} as const; + +export interface ButtonGroupProps extends Omit< + HTMLAttributes, + "role" +> { + /** Additional CSS classes merged via `cn()`. Use kumo semantic tokens only. */ + className?: string; + /** + * The tightly-coupled controls to join. Typically two `Button`s: a primary + * action and a dropdown trigger (a "split button"). + */ + children?: ReactNode; +} + +/** + * Joins a small set of tightly-coupled buttons into a single control — most + * commonly a **split button**: a primary action next to a dropdown trigger for + * related, secondary actions. + * + * Children keep their own variant, size, and shape — ButtonGroup only handles + * the layout: it flattens the inner corners and overlaps borders so the buttons + * share one seam. Renders `role="group"` so assistive technology treats the + * buttons as a related set (pair it with an `aria-label`). + * + * For grouping multiple *independent* buttons or inputs (e.g. a formatting bar + * or a page-level set of actions), use `Toolbar` instead — it provides the + * correct roaming-focus keyboard semantics for a toolbar. + * + * @example Split button + * ```tsx + * + * + * + * + * + * + * } + * /> + * + * Deploy to staging + * + * + * + * ``` + */ +export const ButtonGroup = forwardRef( + ({ className, children, ...props }, ref) => { + return ( +
+ {children} +
+ ); + }, +); + +ButtonGroup.displayName = "ButtonGroup"; diff --git a/packages/kumo/src/components/button-group/index.ts b/packages/kumo/src/components/button-group/index.ts new file mode 100644 index 000000000..46472fcbc --- /dev/null +++ b/packages/kumo/src/components/button-group/index.ts @@ -0,0 +1,7 @@ +export { + ButtonGroup, + KUMO_BUTTON_GROUP_VARIANTS, + KUMO_BUTTON_GROUP_DEFAULT_VARIANTS, + KUMO_BUTTON_GROUP_STYLING, + type ButtonGroupProps, +} from "./button-group"; diff --git a/packages/kumo/src/index.ts b/packages/kumo/src/index.ts index 2ade2daa4..2d57ce434 100644 --- a/packages/kumo/src/index.ts +++ b/packages/kumo/src/index.ts @@ -336,6 +336,13 @@ export { type UseTableOfContentsActiveIdOptions, type UseTableOfContentsActiveIdResult, } from "./components/table-of-contents"; +export { + ButtonGroup, + KUMO_BUTTON_GROUP_VARIANTS, + KUMO_BUTTON_GROUP_DEFAULT_VARIANTS, + KUMO_BUTTON_GROUP_STYLING, + type ButtonGroupProps, +} from "./components/button-group"; export { TagInput, type TagInputLabels, diff --git a/packages/kumo/vite.config.ts b/packages/kumo/vite.config.ts index a7240a91d..b9eeb2c33 100644 --- a/packages/kumo/vite.config.ts +++ b/packages/kumo/vite.config.ts @@ -35,6 +35,10 @@ const packEntries = { "components/badge": resolve(__dirname, "src/components/badge/index.ts"), "components/banner": resolve(__dirname, "src/components/banner/index.ts"), "components/button": resolve(__dirname, "src/components/button/index.ts"), + "components/button-group": resolve( + __dirname, + "src/components/button-group/index.ts", + ), "components/date-range-picker": resolve( __dirname, "src/components/date-range-picker/index.ts",