From 86c784a5327e51eddd0f94b34075b81d1f268068 Mon Sep 17 00:00:00 2001 From: Brandon Strittmatter Date: Fri, 17 Jul 2026 16:59:13 -0400 Subject: [PATCH 1/5] feat(button-group): add ButtonGroup component Joins related buttons into a single segmented control. Layout-only: children keep their own variant, size, and shape, while ButtonGroup flattens inner corners and overlaps borders so adjacent buttons share one seam. - Horizontal (default) and vertical orientation - Works for action rows, icon toolbars, and split buttons (primary action + dropdown trigger) - role="group" by default, overridable (e.g. role="toolbar") - Uses only kumo semantic tokens (no raw colors, no dark: variant) Includes unit tests, docs page + demos, HomeGrid showcase entry, sidebar nav entry, registry category mapping, and a changeset. --- .changeset/button-group.md | 5 + .../src/components/SidebarNav.tsx | 1 + .../src/components/demos/ButtonGroupDemo.tsx | 145 +++ .../src/components/demos/HomeGrid.tsx | 15 + .../src/pages/components/button-group.mdx | 146 +++ packages/kumo/package.json | 1032 +++++++++-------- .../scripts/component-registry/discovery.ts | 1 + .../button-group/button-group.test.tsx | 81 ++ .../components/button-group/button-group.tsx | 125 ++ .../kumo/src/components/button-group/index.ts | 8 + packages/kumo/src/index.ts | 8 + packages/kumo/vite.config.ts | 3 +- 12 files changed, 1055 insertions(+), 515 deletions(-) create mode 100644 .changeset/button-group.md create mode 100644 packages/kumo-docs-astro/src/components/demos/ButtonGroupDemo.tsx create mode 100644 packages/kumo-docs-astro/src/pages/components/button-group.mdx create mode 100644 packages/kumo/src/components/button-group/button-group.test.tsx create mode 100644 packages/kumo/src/components/button-group/button-group.tsx create mode 100644 packages/kumo/src/components/button-group/index.ts diff --git a/.changeset/button-group.md b/.changeset/button-group.md new file mode 100644 index 0000000000..4541ac9ccc --- /dev/null +++ b/.changeset/button-group.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": minor +--- + +Add `ButtonGroup` component for joining related buttons into a single segmented control. Handles layout only — children keep their own variant, size, and shape — by flattening inner corners and overlapping borders so adjacent buttons share one seam. Supports horizontal and vertical orientation, works for action rows, icon toolbars, and split buttons (a primary action next to a dropdown trigger). diff --git a/packages/kumo-docs-astro/src/components/SidebarNav.tsx b/packages/kumo-docs-astro/src/components/SidebarNav.tsx index 6e05655061..ac533217b7 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 0000000000..40fa9255c6 --- /dev/null +++ b/packages/kumo-docs-astro/src/components/demos/ButtonGroupDemo.tsx @@ -0,0 +1,145 @@ +import { Button, ButtonGroup, DropdownMenu } from "@cloudflare/kumo"; +import { + ArrowClockwiseIcon, + CaretDownIcon, + CopyIcon, + PencilSimpleIcon, + TextAlignCenterIcon, + TextAlignLeftIcon, + TextAlignRightIcon, + TrashIcon, +} from "@phosphor-icons/react"; + +/** + * A basic segmented row of related actions joined into a single control. + */ +export function ButtonGroupBasicDemo() { + return ( + + + + + + ); +} + +/** + * Split button: a primary action joined with a dropdown trigger for 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… + + + + ); +} + +/** + * Icon-only groups work well as toolbars. Set `role="toolbar"` and an + * `aria-label` to describe the set of actions. + */ +export function ButtonGroupIconDemo() { + return ( + + + + + + ); +} + +/** + * Buttons with leading icons and labels, grouped together. + */ +export function ButtonGroupWithIconsDemo() { + return ( + + + + + + ); +} + +/** + * Stack buttons into a column with `orientation="vertical"`. + */ +export function ButtonGroupVerticalDemo() { + return ( + + + + + + ); +} + +/** + * ButtonGroup respects each child's `size` — set the same size on every button + * to keep the group aligned. + */ +export function ButtonGroupSizesDemo() { + return ( +
+ + + + + + + + + + +
+ ); +} diff --git a/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx b/packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx index 74f0f50a6b..59fb90ed25 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, @@ -42,6 +43,7 @@ import { import { ShikiProvider, CodeHighlighted } from "@cloudflare/kumo/code"; import { InputGroupDemo } from "~/components/demos/InputGroupDemo"; import { + CaretDownIcon, MagnifyingGlassIcon, PlusIcon, TextBolderIcon, @@ -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", @@ -139,6 +142,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 0000000000..2edf8dfd1d --- /dev/null +++ b/packages/kumo-docs-astro/src/pages/components/button-group.mdx @@ -0,0 +1,146 @@ +--- +layout: ~/layouts/MdxDocLayout.astro +title: "Button Group" +description: "Visually joins a set of related buttons into a single segmented control." +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 { + ButtonGroupBasicDemo, + ButtonGroupSplitDemo, + ButtonGroupIconDemo, + ButtonGroupWithIconsDemo, + ButtonGroupVerticalDemo, + ButtonGroupSizesDemo, +} from "~/components/demos/ButtonGroupDemo"; + +{/* Demo */} + + + + + + + +{/* 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 adjacent buttons share a single seam. Use the same `size` and + `variant` on every child for a consistent segmented control. +

+ + + + + + + ); +}`} + lang="tsx" + /> +
+ +{/* Examples */} + + + +## Examples + +### Basic + +

A segmented row of related actions joined into a single control.

+ + + + +### Split button + +

+ Pair a primary action with a `DropdownMenu` trigger for secondary actions. The + caret button uses `shape="square"` and an `aria-label`. +

+ + + + +### Icon toolbar + +

+ Icon-only groups make compact toolbars. Set `role="toolbar"` and an + `aria-label` describing the set of actions. +

+ + + + +### With icons and labels + +

Buttons with leading icons, grouped together.

+ + + + +### Vertical + +

Stack buttons into a column with `orientation="vertical"`.

+ + + + +### Sizes + +

Set the same `size` on every button to keep the group aligned.

+ + + + +
+ +{/* API Reference */} + + + +## API Reference + + + diff --git a/packages/kumo/package.json b/packages/kumo/package.json index 189038814e..4d8d1e883e 100644 --- a/packages/kumo/package.json +++ b/packages/kumo/package.json @@ -1,516 +1,520 @@ { - "name": "@cloudflare/kumo", - "version": "2.8.0", - "private": false, - "type": "module", - "description": "Kumo - Cloudflare's component library for building modern web applications", - "repository": { - "type": "git", - "url": "https://github.com/cloudflare/kumo.git", - "directory": "packages/kumo" - }, - "license": "MIT", - "keywords": [ - "react", - "components", - "ui", - "cloudflare", - "design-system" - ], - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./dist/src/index.d.ts", - "bin": { - "kumo": "./bin/kumo.js" - }, - "exports": { - ".": { - "types": "./dist/src/index.d.ts", - "import": "./dist/index.js" - }, - "./components/badge": { - "types": "./dist/src/components/badge/index.d.ts", - "import": "./dist/components/badge.js" - }, - "./components/banner": { - "types": "./dist/src/components/banner/index.d.ts", - "import": "./dist/components/banner.js" - }, - "./components/button": { - "types": "./dist/src/components/button/index.d.ts", - "import": "./dist/components/button.js" - }, - "./components/date-range-picker": { - "types": "./dist/src/components/date-range-picker/index.d.ts", - "import": "./dist/components/date-range-picker.js" - }, - "./components/date-picker": { - "types": "./dist/src/components/date-picker/index.d.ts", - "import": "./dist/components/date-picker.js" - }, - "./components/checkbox": { - "types": "./dist/src/components/checkbox/index.d.ts", - "import": "./dist/components/checkbox.js" - }, - "./components/chart": { - "types": "./dist/src/components/chart/index.d.ts", - "import": "./dist/components/chart.js" - }, - "./components/clipboard-text": { - "types": "./dist/src/components/clipboard-text/index.d.ts", - "import": "./dist/components/clipboard-text.js" - }, - "./components/code": { - "types": "./dist/src/components/code/index.d.ts", - "import": "./dist/components/code.js" - }, - "./components/collapsible": { - "types": "./dist/src/components/collapsible/index.d.ts", - "import": "./dist/components/collapsible.js" - }, - "./components/combobox": { - "types": "./dist/src/components/combobox/index.d.ts", - "import": "./dist/components/combobox.js" - }, - "./components/toolbar": { - "types": "./dist/src/components/toolbar/index.d.ts", - "import": "./dist/components/toolbar.js" - }, - "./components/dialog": { - "types": "./dist/src/components/dialog/index.d.ts", - "import": "./dist/components/dialog.js" - }, - "./components/dropdown": { - "types": "./dist/src/components/dropdown/index.d.ts", - "import": "./dist/components/dropdown.js" - }, - "./components/input": { - "types": "./dist/src/components/input/index.d.ts", - "import": "./dist/components/input.js" - }, - "./components/input-group": { - "types": "./dist/src/components/input-group/index.d.ts", - "import": "./dist/components/input-group.js" - }, - "./components/layer-card": { - "types": "./dist/src/components/layer-card/index.d.ts", - "import": "./dist/components/layer-card.js" - }, - "./components/label": { - "types": "./dist/src/components/label/index.d.ts", - "import": "./dist/components/label.js" - }, - "./components/loader": { - "types": "./dist/src/components/loader/index.d.ts", - "import": "./dist/components/loader.js" - }, - "./components/menubar": { - "types": "./dist/src/components/menubar/index.d.ts", - "import": "./dist/components/menubar.js" - }, - "./components/meter": { - "types": "./dist/src/components/meter/index.d.ts", - "import": "./dist/components/meter.js" - }, - "./components/pagination": { - "types": "./dist/src/components/pagination/index.d.ts", - "import": "./dist/components/pagination.js" - }, - "./components/select": { - "types": "./dist/src/components/select/index.d.ts", - "import": "./dist/components/select.js" - }, - "./components/sensitive-input": { - "types": "./dist/src/components/sensitive-input/index.d.ts", - "import": "./dist/components/sensitive-input.js" - }, - "./components/surface": { - "types": "./dist/src/components/surface/index.d.ts", - "import": "./dist/components/surface.js" - }, - "./components/switch": { - "types": "./dist/src/components/switch/index.d.ts", - "import": "./dist/components/switch.js" - }, - "./components/table": { - "types": "./dist/src/components/table/index.d.ts", - "import": "./dist/components/table.js" - }, - "./components/tabs": { - "types": "./dist/src/components/tabs/index.d.ts", - "import": "./dist/components/tabs.js" - }, - "./components/text": { - "types": "./dist/src/components/text/index.d.ts", - "import": "./dist/components/text.js" - }, - "./components/toast": { - "types": "./dist/src/components/toast/index.d.ts", - "import": "./dist/components/toast.js" - }, - "./components/tooltip": { - "types": "./dist/src/components/tooltip/index.d.ts", - "import": "./dist/components/tooltip.js" - }, - "./components/popover": { - "types": "./dist/src/components/popover/index.d.ts", - "import": "./dist/components/popover.js" - }, - "./ai/component-registry.json": { - "import": "./ai/component-registry.json" - }, - "./components/radio": { - "types": "./dist/src/components/radio/index.d.ts", - "import": "./dist/components/radio.js" - }, - "./components/command-palette": { - "types": "./dist/src/components/command-palette/index.d.ts", - "import": "./dist/components/command-palette.js" - }, - "./components/link": { - "types": "./dist/src/components/link/index.d.ts", - "import": "./dist/components/link.js" - }, - "./components/breadcrumbs": { - "types": "./dist/src/components/breadcrumbs/index.d.ts", - "import": "./dist/components/breadcrumbs.js" - }, - "./components/empty": { - "types": "./dist/src/components/empty/index.d.ts", - "import": "./dist/components/empty.js" - }, - "./components/field": { - "types": "./dist/src/components/field/index.d.ts", - "import": "./dist/components/field.js" - }, - "./components/grid": { - "types": "./dist/src/components/grid/index.d.ts", - "import": "./dist/components/grid.js" - }, - "./components/cloudflare-logo": { - "types": "./dist/src/components/cloudflare-logo/index.d.ts", - "import": "./dist/components/cloudflare-logo.js" - }, - "./components/flow": { - "types": "./dist/src/components/flow/index.d.ts", - "import": "./dist/components/flow.js" - }, - "./components/autocomplete": { - "types": "./dist/src/components/autocomplete/index.d.ts", - "import": "./dist/components/autocomplete.js" - }, - "./components/sidebar": { - "types": "./dist/src/components/sidebar/index.d.ts", - "import": "./dist/components/sidebar.js" - }, - "./components/table-of-contents": { - "types": "./dist/src/components/table-of-contents/index.d.ts", - "import": "./dist/components/table-of-contents.js" - }, - "./utils": { - "types": "./dist/src/utils/index.d.ts", - "import": "./dist/utils.js" - }, - "./primitives": { - "types": "./dist/src/primitives/index.d.ts", - "import": "./dist/primitives.js" - }, - "./primitives/accordion": { - "types": "./dist/src/primitives/accordion.d.ts", - "import": "./dist/primitives/accordion.js" - }, - "./primitives/alert-dialog": { - "types": "./dist/src/primitives/alert-dialog.d.ts", - "import": "./dist/primitives/alert-dialog.js" - }, - "./primitives/autocomplete": { - "types": "./dist/src/primitives/autocomplete.d.ts", - "import": "./dist/primitives/autocomplete.js" - }, - "./primitives/avatar": { - "types": "./dist/src/primitives/avatar.d.ts", - "import": "./dist/primitives/avatar.js" - }, - "./primitives/button": { - "types": "./dist/src/primitives/button.d.ts", - "import": "./dist/primitives/button.js" - }, - "./primitives/checkbox": { - "types": "./dist/src/primitives/checkbox.d.ts", - "import": "./dist/primitives/checkbox.js" - }, - "./primitives/checkbox-group": { - "types": "./dist/src/primitives/checkbox-group.d.ts", - "import": "./dist/primitives/checkbox-group.js" - }, - "./primitives/collapsible": { - "types": "./dist/src/primitives/collapsible.d.ts", - "import": "./dist/primitives/collapsible.js" - }, - "./primitives/combobox": { - "types": "./dist/src/primitives/combobox.d.ts", - "import": "./dist/primitives/combobox.js" - }, - "./primitives/context-menu": { - "types": "./dist/src/primitives/context-menu.d.ts", - "import": "./dist/primitives/context-menu.js" - }, - "./primitives/csp-provider": { - "types": "./dist/src/primitives/csp-provider.d.ts", - "import": "./dist/primitives/csp-provider.js" - }, - "./primitives/dialog": { - "types": "./dist/src/primitives/dialog.d.ts", - "import": "./dist/primitives/dialog.js" - }, - "./primitives/direction-provider": { - "types": "./dist/src/primitives/direction-provider.d.ts", - "import": "./dist/primitives/direction-provider.js" - }, - "./primitives/drawer": { - "types": "./dist/src/primitives/drawer.d.ts", - "import": "./dist/primitives/drawer.js" - }, - "./primitives/field": { - "types": "./dist/src/primitives/field.d.ts", - "import": "./dist/primitives/field.js" - }, - "./primitives/fieldset": { - "types": "./dist/src/primitives/fieldset.d.ts", - "import": "./dist/primitives/fieldset.js" - }, - "./primitives/form": { - "types": "./dist/src/primitives/form.d.ts", - "import": "./dist/primitives/form.js" - }, - "./primitives/input": { - "types": "./dist/src/primitives/input.d.ts", - "import": "./dist/primitives/input.js" - }, - "./primitives/menu": { - "types": "./dist/src/primitives/menu.d.ts", - "import": "./dist/primitives/menu.js" - }, - "./primitives/menubar": { - "types": "./dist/src/primitives/menubar.d.ts", - "import": "./dist/primitives/menubar.js" - }, - "./primitives/meter": { - "types": "./dist/src/primitives/meter.d.ts", - "import": "./dist/primitives/meter.js" - }, - "./primitives/navigation-menu": { - "types": "./dist/src/primitives/navigation-menu.d.ts", - "import": "./dist/primitives/navigation-menu.js" - }, - "./primitives/number-field": { - "types": "./dist/src/primitives/number-field.d.ts", - "import": "./dist/primitives/number-field.js" - }, - "./primitives/otp-field": { - "types": "./dist/src/primitives/otp-field.d.ts", - "import": "./dist/primitives/otp-field.js" - }, - "./primitives/popover": { - "types": "./dist/src/primitives/popover.d.ts", - "import": "./dist/primitives/popover.js" - }, - "./primitives/preview-card": { - "types": "./dist/src/primitives/preview-card.d.ts", - "import": "./dist/primitives/preview-card.js" - }, - "./primitives/progress": { - "types": "./dist/src/primitives/progress.d.ts", - "import": "./dist/primitives/progress.js" - }, - "./primitives/radio": { - "types": "./dist/src/primitives/radio.d.ts", - "import": "./dist/primitives/radio.js" - }, - "./primitives/radio-group": { - "types": "./dist/src/primitives/radio-group.d.ts", - "import": "./dist/primitives/radio-group.js" - }, - "./primitives/scroll-area": { - "types": "./dist/src/primitives/scroll-area.d.ts", - "import": "./dist/primitives/scroll-area.js" - }, - "./primitives/select": { - "types": "./dist/src/primitives/select.d.ts", - "import": "./dist/primitives/select.js" - }, - "./primitives/separator": { - "types": "./dist/src/primitives/separator.d.ts", - "import": "./dist/primitives/separator.js" - }, - "./primitives/slider": { - "types": "./dist/src/primitives/slider.d.ts", - "import": "./dist/primitives/slider.js" - }, - "./primitives/switch": { - "types": "./dist/src/primitives/switch.d.ts", - "import": "./dist/primitives/switch.js" - }, - "./primitives/tabs": { - "types": "./dist/src/primitives/tabs.d.ts", - "import": "./dist/primitives/tabs.js" - }, - "./primitives/toast": { - "types": "./dist/src/primitives/toast.d.ts", - "import": "./dist/primitives/toast.js" - }, - "./primitives/toggle": { - "types": "./dist/src/primitives/toggle.d.ts", - "import": "./dist/primitives/toggle.js" - }, - "./primitives/toggle-group": { - "types": "./dist/src/primitives/toggle-group.d.ts", - "import": "./dist/primitives/toggle-group.js" - }, - "./primitives/toolbar": { - "types": "./dist/src/primitives/toolbar.d.ts", - "import": "./dist/primitives/toolbar.js" - }, - "./primitives/tooltip": { - "types": "./dist/src/primitives/tooltip.d.ts", - "import": "./dist/primitives/tooltip.js" - }, - "./registry": { - "types": "./dist/src/registry/index.d.ts", - "import": "./dist/registry.js" - }, - "./catalog": { - "types": "./dist/src/catalog/index.d.ts", - "import": "./dist/catalog.js" - }, - "./code": { - "types": "./dist/src/code/index.d.ts", - "import": "./dist/code.js" - }, - "./code/server": { - "types": "./dist/src/code/server.d.ts", - "import": "./dist/code/server.js" - }, - "./ai/schemas": { - "types": "./dist/ai/schemas.d.ts", - "import": "./dist/ai/schemas.js" - }, - "./registry/component-registry.json": "./ai/component-registry.json", - "./registry/component-registry.md": "./ai/component-registry.md", - "./styles/tailwind": "./dist/styles/kumo.css", - "./styles/standalone": "./dist/styles/kumo-standalone.css", - "./styles": "./dist/styles/kumo.css", - "./styles/*": "./dist/styles/*.css", - "./scripts/theme-generator/config": { - "types": "./dist/scripts/theme-generator/config.d.ts", - "import": "./dist/scripts/theme-generator/config.js" - }, - "./scripts/theme-generator/types": { - "types": "./dist/scripts/theme-generator/types.d.ts", - "import": "./dist/scripts/theme-generator/types.js" - } - }, - "files": [ - "dist", - "bin", - "ai", - "templates", - "scripts", - "README.md", - "LICENSE", - "CHANGELOG.md" - ], - "sideEffects": [ - "*.css", - "dist/styles/*.css" - ], - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org" - }, - "scripts": { - "prepublishOnly": "pnpm run build && pnpm run test:unit", - "build": "pnpm run codegen:registry && vite build --mode production && tsx scripts/css-build.ts && tsx src/command-line/build-cli.ts", - "dev": "vite build --watch --mode development", - "new:component": "plop component", - "clean": "rm -rf dist", - "codegen": "pnpm run codegen:primitives && pnpm run codegen:themes && pnpm run codegen:registry", - "codegen:themes": "tsx scripts/theme-generator/index.ts", - "migrate:tokens": "tsx scripts/theme-generator/migrate.ts", - "codegen:primitives": "tsx scripts/generate-primitives.ts", - "codegen:registry": "pnpm --filter @cloudflare/kumo-docs-astro codegen:demos && tsx scripts/component-registry/index.ts", - "lint": "pnpm run lint:oxlint", - "lint:oxlint": "oxlint --config .oxlintrc.json src/** --type-aware", - "test": "CI=true vitest run --project=unit", - "test:ui": "vitest --ui", - "test:run": "vitest run", - "test:unit": "vitest run --project=unit", - "test:coverage": "vitest run --coverage", - "test:exports": "vitest run --project=unit tests/imports/export-path-validation.test.ts", - "test:watch": "vitest run --project=unit --watch", - "typecheck": "tsc --noEmit", - "validate:build": "vitest run --project=unit tests/imports/export-path-validation.test.ts", - "validate:changeset": "tsx ../../ci/scripts/validate-kumo-changeset.ts", - "test:browser": "vitest --config=vitest.browser.config.ts" - }, - "peerDependencies": { - "@phosphor-icons/react": "^2.1.10", - "echarts": "^6.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0", - "zod": "^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - }, - "echarts": { - "optional": true - } - }, - "dependencies": { - "@base-ui/react": "^1.6.0", - "@shikijs/langs": "^4.0.0", - "@shikijs/themes": "^4.0.0", - "cnfast": "^0.0.8", - "d3-geo": "^3.1.1", - "motion": "^12.34.1", - "react-day-picker": "^9.13.2", - "shiki": "^4.0.0" - }, - "devDependencies": { - "@tailwindcss/cli": "catalog:", - "@tailwindcss/vite": "catalog:", - "@testing-library/react": "16.3.1", - "@testing-library/user-event": "14.6.1", - "@types/d3-geo": "^3.1.0", - "@types/glob": "9.0.0", - "@types/node": "catalog:", - "@types/react": "catalog:", - "@types/react-dom": "catalog:", - "@types/svg-path-parser": "^1.1.6", - "@vitejs/plugin-react": "^5.1.4", - "@vitest/browser-playwright": "^4.0.18", - "@vitest/ui": "catalog:", - "esbuild": "0.27.2", - "eslint-plugin-jsx-a11y": "catalog:", - "glob": "13.0.0", - "happy-dom": "catalog:", - "oxlint": "catalog:", - "oxlint-tsgolint": "catalog:", - "playwright": "catalog:", - "plop": "4.0.4", - "rollup-plugin-preserve-directives": "0.4.0", - "svg-path-parser": "^1.1.0", - "tailwindcss": "catalog:", - "ts-json-schema-generator": "2.4.0", - "tsx": "catalog:", - "typescript": "catalog:", - "vite": "catalog:", - "vite-plugin-dts": "catalog:", - "vitest": "catalog:", - "vitest-browser-react": "^2.0.5", - "wrangler": "catalog:", - "zod": "^4.0.0" - } + "name": "@cloudflare/kumo", + "version": "2.8.0", + "private": false, + "type": "module", + "description": "Kumo - Cloudflare's component library for building modern web applications", + "repository": { + "type": "git", + "url": "https://github.com/cloudflare/kumo.git", + "directory": "packages/kumo" + }, + "license": "MIT", + "keywords": [ + "react", + "components", + "ui", + "cloudflare", + "design-system" + ], + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/src/index.d.ts", + "bin": { + "kumo": "./bin/kumo.js" + }, + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/index.js" + }, + "./components/badge": { + "types": "./dist/src/components/badge/index.d.ts", + "import": "./dist/components/badge.js" + }, + "./components/banner": { + "types": "./dist/src/components/banner/index.d.ts", + "import": "./dist/components/banner.js" + }, + "./components/button": { + "types": "./dist/src/components/button/index.d.ts", + "import": "./dist/components/button.js" + }, + "./components/date-range-picker": { + "types": "./dist/src/components/date-range-picker/index.d.ts", + "import": "./dist/components/date-range-picker.js" + }, + "./components/date-picker": { + "types": "./dist/src/components/date-picker/index.d.ts", + "import": "./dist/components/date-picker.js" + }, + "./components/checkbox": { + "types": "./dist/src/components/checkbox/index.d.ts", + "import": "./dist/components/checkbox.js" + }, + "./components/chart": { + "types": "./dist/src/components/chart/index.d.ts", + "import": "./dist/components/chart.js" + }, + "./components/clipboard-text": { + "types": "./dist/src/components/clipboard-text/index.d.ts", + "import": "./dist/components/clipboard-text.js" + }, + "./components/code": { + "types": "./dist/src/components/code/index.d.ts", + "import": "./dist/components/code.js" + }, + "./components/collapsible": { + "types": "./dist/src/components/collapsible/index.d.ts", + "import": "./dist/components/collapsible.js" + }, + "./components/combobox": { + "types": "./dist/src/components/combobox/index.d.ts", + "import": "./dist/components/combobox.js" + }, + "./components/toolbar": { + "types": "./dist/src/components/toolbar/index.d.ts", + "import": "./dist/components/toolbar.js" + }, + "./components/dialog": { + "types": "./dist/src/components/dialog/index.d.ts", + "import": "./dist/components/dialog.js" + }, + "./components/dropdown": { + "types": "./dist/src/components/dropdown/index.d.ts", + "import": "./dist/components/dropdown.js" + }, + "./components/input": { + "types": "./dist/src/components/input/index.d.ts", + "import": "./dist/components/input.js" + }, + "./components/input-group": { + "types": "./dist/src/components/input-group/index.d.ts", + "import": "./dist/components/input-group.js" + }, + "./components/layer-card": { + "types": "./dist/src/components/layer-card/index.d.ts", + "import": "./dist/components/layer-card.js" + }, + "./components/label": { + "types": "./dist/src/components/label/index.d.ts", + "import": "./dist/components/label.js" + }, + "./components/loader": { + "types": "./dist/src/components/loader/index.d.ts", + "import": "./dist/components/loader.js" + }, + "./components/menubar": { + "types": "./dist/src/components/menubar/index.d.ts", + "import": "./dist/components/menubar.js" + }, + "./components/meter": { + "types": "./dist/src/components/meter/index.d.ts", + "import": "./dist/components/meter.js" + }, + "./components/pagination": { + "types": "./dist/src/components/pagination/index.d.ts", + "import": "./dist/components/pagination.js" + }, + "./components/select": { + "types": "./dist/src/components/select/index.d.ts", + "import": "./dist/components/select.js" + }, + "./components/sensitive-input": { + "types": "./dist/src/components/sensitive-input/index.d.ts", + "import": "./dist/components/sensitive-input.js" + }, + "./components/surface": { + "types": "./dist/src/components/surface/index.d.ts", + "import": "./dist/components/surface.js" + }, + "./components/switch": { + "types": "./dist/src/components/switch/index.d.ts", + "import": "./dist/components/switch.js" + }, + "./components/table": { + "types": "./dist/src/components/table/index.d.ts", + "import": "./dist/components/table.js" + }, + "./components/tabs": { + "types": "./dist/src/components/tabs/index.d.ts", + "import": "./dist/components/tabs.js" + }, + "./components/text": { + "types": "./dist/src/components/text/index.d.ts", + "import": "./dist/components/text.js" + }, + "./components/toast": { + "types": "./dist/src/components/toast/index.d.ts", + "import": "./dist/components/toast.js" + }, + "./components/tooltip": { + "types": "./dist/src/components/tooltip/index.d.ts", + "import": "./dist/components/tooltip.js" + }, + "./components/popover": { + "types": "./dist/src/components/popover/index.d.ts", + "import": "./dist/components/popover.js" + }, + "./ai/component-registry.json": { + "import": "./ai/component-registry.json" + }, + "./components/radio": { + "types": "./dist/src/components/radio/index.d.ts", + "import": "./dist/components/radio.js" + }, + "./components/command-palette": { + "types": "./dist/src/components/command-palette/index.d.ts", + "import": "./dist/components/command-palette.js" + }, + "./components/link": { + "types": "./dist/src/components/link/index.d.ts", + "import": "./dist/components/link.js" + }, + "./components/breadcrumbs": { + "types": "./dist/src/components/breadcrumbs/index.d.ts", + "import": "./dist/components/breadcrumbs.js" + }, + "./components/empty": { + "types": "./dist/src/components/empty/index.d.ts", + "import": "./dist/components/empty.js" + }, + "./components/field": { + "types": "./dist/src/components/field/index.d.ts", + "import": "./dist/components/field.js" + }, + "./components/grid": { + "types": "./dist/src/components/grid/index.d.ts", + "import": "./dist/components/grid.js" + }, + "./components/cloudflare-logo": { + "types": "./dist/src/components/cloudflare-logo/index.d.ts", + "import": "./dist/components/cloudflare-logo.js" + }, + "./components/flow": { + "types": "./dist/src/components/flow/index.d.ts", + "import": "./dist/components/flow.js" + }, + "./components/autocomplete": { + "types": "./dist/src/components/autocomplete/index.d.ts", + "import": "./dist/components/autocomplete.js" + }, + "./components/sidebar": { + "types": "./dist/src/components/sidebar/index.d.ts", + "import": "./dist/components/sidebar.js" + }, + "./components/table-of-contents": { + "types": "./dist/src/components/table-of-contents/index.d.ts", + "import": "./dist/components/table-of-contents.js" + }, + "./components/button-group": { + "types": "./dist/src/components/button-group/index.d.ts", + "import": "./dist/components/button-group.js" + }, + "./utils": { + "types": "./dist/src/utils/index.d.ts", + "import": "./dist/utils.js" + }, + "./primitives": { + "types": "./dist/src/primitives/index.d.ts", + "import": "./dist/primitives.js" + }, + "./primitives/accordion": { + "types": "./dist/src/primitives/accordion.d.ts", + "import": "./dist/primitives/accordion.js" + }, + "./primitives/alert-dialog": { + "types": "./dist/src/primitives/alert-dialog.d.ts", + "import": "./dist/primitives/alert-dialog.js" + }, + "./primitives/autocomplete": { + "types": "./dist/src/primitives/autocomplete.d.ts", + "import": "./dist/primitives/autocomplete.js" + }, + "./primitives/avatar": { + "types": "./dist/src/primitives/avatar.d.ts", + "import": "./dist/primitives/avatar.js" + }, + "./primitives/button": { + "types": "./dist/src/primitives/button.d.ts", + "import": "./dist/primitives/button.js" + }, + "./primitives/checkbox": { + "types": "./dist/src/primitives/checkbox.d.ts", + "import": "./dist/primitives/checkbox.js" + }, + "./primitives/checkbox-group": { + "types": "./dist/src/primitives/checkbox-group.d.ts", + "import": "./dist/primitives/checkbox-group.js" + }, + "./primitives/collapsible": { + "types": "./dist/src/primitives/collapsible.d.ts", + "import": "./dist/primitives/collapsible.js" + }, + "./primitives/combobox": { + "types": "./dist/src/primitives/combobox.d.ts", + "import": "./dist/primitives/combobox.js" + }, + "./primitives/context-menu": { + "types": "./dist/src/primitives/context-menu.d.ts", + "import": "./dist/primitives/context-menu.js" + }, + "./primitives/csp-provider": { + "types": "./dist/src/primitives/csp-provider.d.ts", + "import": "./dist/primitives/csp-provider.js" + }, + "./primitives/dialog": { + "types": "./dist/src/primitives/dialog.d.ts", + "import": "./dist/primitives/dialog.js" + }, + "./primitives/direction-provider": { + "types": "./dist/src/primitives/direction-provider.d.ts", + "import": "./dist/primitives/direction-provider.js" + }, + "./primitives/drawer": { + "types": "./dist/src/primitives/drawer.d.ts", + "import": "./dist/primitives/drawer.js" + }, + "./primitives/field": { + "types": "./dist/src/primitives/field.d.ts", + "import": "./dist/primitives/field.js" + }, + "./primitives/fieldset": { + "types": "./dist/src/primitives/fieldset.d.ts", + "import": "./dist/primitives/fieldset.js" + }, + "./primitives/form": { + "types": "./dist/src/primitives/form.d.ts", + "import": "./dist/primitives/form.js" + }, + "./primitives/input": { + "types": "./dist/src/primitives/input.d.ts", + "import": "./dist/primitives/input.js" + }, + "./primitives/menu": { + "types": "./dist/src/primitives/menu.d.ts", + "import": "./dist/primitives/menu.js" + }, + "./primitives/menubar": { + "types": "./dist/src/primitives/menubar.d.ts", + "import": "./dist/primitives/menubar.js" + }, + "./primitives/meter": { + "types": "./dist/src/primitives/meter.d.ts", + "import": "./dist/primitives/meter.js" + }, + "./primitives/navigation-menu": { + "types": "./dist/src/primitives/navigation-menu.d.ts", + "import": "./dist/primitives/navigation-menu.js" + }, + "./primitives/number-field": { + "types": "./dist/src/primitives/number-field.d.ts", + "import": "./dist/primitives/number-field.js" + }, + "./primitives/otp-field": { + "types": "./dist/src/primitives/otp-field.d.ts", + "import": "./dist/primitives/otp-field.js" + }, + "./primitives/popover": { + "types": "./dist/src/primitives/popover.d.ts", + "import": "./dist/primitives/popover.js" + }, + "./primitives/preview-card": { + "types": "./dist/src/primitives/preview-card.d.ts", + "import": "./dist/primitives/preview-card.js" + }, + "./primitives/progress": { + "types": "./dist/src/primitives/progress.d.ts", + "import": "./dist/primitives/progress.js" + }, + "./primitives/radio": { + "types": "./dist/src/primitives/radio.d.ts", + "import": "./dist/primitives/radio.js" + }, + "./primitives/radio-group": { + "types": "./dist/src/primitives/radio-group.d.ts", + "import": "./dist/primitives/radio-group.js" + }, + "./primitives/scroll-area": { + "types": "./dist/src/primitives/scroll-area.d.ts", + "import": "./dist/primitives/scroll-area.js" + }, + "./primitives/select": { + "types": "./dist/src/primitives/select.d.ts", + "import": "./dist/primitives/select.js" + }, + "./primitives/separator": { + "types": "./dist/src/primitives/separator.d.ts", + "import": "./dist/primitives/separator.js" + }, + "./primitives/slider": { + "types": "./dist/src/primitives/slider.d.ts", + "import": "./dist/primitives/slider.js" + }, + "./primitives/switch": { + "types": "./dist/src/primitives/switch.d.ts", + "import": "./dist/primitives/switch.js" + }, + "./primitives/tabs": { + "types": "./dist/src/primitives/tabs.d.ts", + "import": "./dist/primitives/tabs.js" + }, + "./primitives/toast": { + "types": "./dist/src/primitives/toast.d.ts", + "import": "./dist/primitives/toast.js" + }, + "./primitives/toggle": { + "types": "./dist/src/primitives/toggle.d.ts", + "import": "./dist/primitives/toggle.js" + }, + "./primitives/toggle-group": { + "types": "./dist/src/primitives/toggle-group.d.ts", + "import": "./dist/primitives/toggle-group.js" + }, + "./primitives/toolbar": { + "types": "./dist/src/primitives/toolbar.d.ts", + "import": "./dist/primitives/toolbar.js" + }, + "./primitives/tooltip": { + "types": "./dist/src/primitives/tooltip.d.ts", + "import": "./dist/primitives/tooltip.js" + }, + "./registry": { + "types": "./dist/src/registry/index.d.ts", + "import": "./dist/registry.js" + }, + "./catalog": { + "types": "./dist/src/catalog/index.d.ts", + "import": "./dist/catalog.js" + }, + "./code": { + "types": "./dist/src/code/index.d.ts", + "import": "./dist/code.js" + }, + "./code/server": { + "types": "./dist/src/code/server.d.ts", + "import": "./dist/code/server.js" + }, + "./ai/schemas": { + "types": "./dist/ai/schemas.d.ts", + "import": "./dist/ai/schemas.js" + }, + "./registry/component-registry.json": "./ai/component-registry.json", + "./registry/component-registry.md": "./ai/component-registry.md", + "./styles/tailwind": "./dist/styles/kumo.css", + "./styles/standalone": "./dist/styles/kumo-standalone.css", + "./styles": "./dist/styles/kumo.css", + "./styles/*": "./dist/styles/*.css", + "./scripts/theme-generator/config": { + "types": "./dist/scripts/theme-generator/config.d.ts", + "import": "./dist/scripts/theme-generator/config.js" + }, + "./scripts/theme-generator/types": { + "types": "./dist/scripts/theme-generator/types.d.ts", + "import": "./dist/scripts/theme-generator/types.js" + } + }, + "files": [ + "dist", + "bin", + "ai", + "templates", + "scripts", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "sideEffects": [ + "*.css", + "dist/styles/*.css" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "prepublishOnly": "pnpm run build && pnpm run test:unit", + "build": "pnpm run codegen:registry && vite build --mode production && tsx scripts/css-build.ts && tsx src/command-line/build-cli.ts", + "dev": "vite build --watch --mode development", + "new:component": "plop component", + "clean": "rm -rf dist", + "codegen": "pnpm run codegen:primitives && pnpm run codegen:themes && pnpm run codegen:registry", + "codegen:themes": "tsx scripts/theme-generator/index.ts", + "migrate:tokens": "tsx scripts/theme-generator/migrate.ts", + "codegen:primitives": "tsx scripts/generate-primitives.ts", + "codegen:registry": "pnpm --filter @cloudflare/kumo-docs-astro codegen:demos && tsx scripts/component-registry/index.ts", + "lint": "pnpm run lint:oxlint", + "lint:oxlint": "oxlint --config .oxlintrc.json src/** --type-aware", + "test": "CI=true vitest run --project=unit", + "test:ui": "vitest --ui", + "test:run": "vitest run", + "test:unit": "vitest run --project=unit", + "test:coverage": "vitest run --coverage", + "test:exports": "vitest run --project=unit tests/imports/export-path-validation.test.ts", + "test:watch": "vitest run --project=unit --watch", + "typecheck": "tsc --noEmit", + "validate:build": "vitest run --project=unit tests/imports/export-path-validation.test.ts", + "validate:changeset": "tsx ../../ci/scripts/validate-kumo-changeset.ts", + "test:browser": "vitest --config=vitest.browser.config.ts" + }, + "peerDependencies": { + "@phosphor-icons/react": "^2.1.10", + "echarts": "^6.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + }, + "echarts": { + "optional": true + } + }, + "dependencies": { + "@base-ui/react": "^1.6.0", + "@shikijs/langs": "^4.0.0", + "@shikijs/themes": "^4.0.0", + "cnfast": "^0.0.8", + "d3-geo": "^3.1.1", + "motion": "^12.34.1", + "react-day-picker": "^9.13.2", + "shiki": "^4.0.0" + }, + "devDependencies": { + "@tailwindcss/cli": "catalog:", + "@tailwindcss/vite": "catalog:", + "@testing-library/react": "16.3.1", + "@testing-library/user-event": "14.6.1", + "@types/d3-geo": "^3.1.0", + "@types/glob": "9.0.0", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@types/svg-path-parser": "^1.1.6", + "@vitejs/plugin-react": "^5.1.4", + "@vitest/browser-playwright": "^4.0.18", + "@vitest/ui": "catalog:", + "esbuild": "0.27.2", + "eslint-plugin-jsx-a11y": "catalog:", + "glob": "13.0.0", + "happy-dom": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", + "playwright": "catalog:", + "plop": "4.0.4", + "rollup-plugin-preserve-directives": "0.4.0", + "svg-path-parser": "^1.1.0", + "tailwindcss": "catalog:", + "ts-json-schema-generator": "2.4.0", + "tsx": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-dts": "catalog:", + "vitest": "catalog:", + "vitest-browser-react": "^2.0.5", + "wrangler": "catalog:", + "zod": "^4.0.0" + } } diff --git a/packages/kumo/scripts/component-registry/discovery.ts b/packages/kumo/scripts/component-registry/discovery.ts index 9940a9901d..2c9b29cc17 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 0000000000..dedfe4f59f --- /dev/null +++ b/packages/kumo/src/components/button-group/button-group.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +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("allows overriding the role", () => { + render( + + + , + ); + expect(screen.getByRole("toolbar", { name: "Actions" })).toBeTruthy(); + }); + + it("applies horizontal orientation classes by default", () => { + render( + + + , + ); + expect(screen.getByRole("group").className).toContain("flex-row"); + }); + + it("applies vertical orientation classes when requested", () => { + render( + + + , + ); + expect(screen.getByRole("group").className).toContain("flex-col"); + }); + + 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 0000000000..81f3a6a582 --- /dev/null +++ b/packages/kumo/src/components/button-group/button-group.tsx @@ -0,0 +1,125 @@ +import { forwardRef, type HTMLAttributes, type ReactNode } from "react"; +import { cn } from "../../utils/cn"; +import { resolveVariant } from "../../utils/resolve-variant"; + +/** + * ButtonGroup variant definitions. Controls how child buttons are laid out and + * joined together. + */ +export const KUMO_BUTTON_GROUP_VARIANTS = { + orientation: { + horizontal: { + classes: cn( + "flex-row", + // Flatten the inner edges so buttons read as one joined control while + // the outer corners keep each button's own size-appropriate radius. + "[&>*:not(:first-child)]:rounded-l-none", + "[&>*:not(:last-child)]:rounded-r-none", + // Overlap borders/rings by 1px so adjacent buttons share a single seam + // instead of doubling up to a 2px line. + "[&>*:not(:first-child)]:-ml-px", + ), + description: "Lay buttons out in a horizontal row.", + }, + vertical: { + classes: cn( + "flex-col", + "[&>*:not(:first-child)]:rounded-t-none", + "[&>*:not(:last-child)]:rounded-b-none", + "[&>*:not(:first-child)]:-mt-px", + ), + description: "Stack buttons vertically into a column.", + }, + }, +} as const; + +export const KUMO_BUTTON_GROUP_DEFAULT_VARIANTS = { + orientation: "horizontal", +} as const; + +/** Base classes shared by every ButtonGroup, regardless of orientation. */ +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", + // Give every child a stacking context so the lifts below take effect. + "[&>*]:relative", + // Lift the interacted button above its neighbours so its focus/hover ring + // isn't clipped by the -1px overlap. + "[&>*:hover]:z-10 [&>*:focus]:z-10 [&>*:focus-visible]:z-10", + ), +} as const; + +export type KumoButtonGroupOrientation = + keyof typeof KUMO_BUTTON_GROUP_VARIANTS.orientation; + +export interface ButtonGroupProps extends HTMLAttributes { + /** + * Layout direction of the grouped buttons. + * - `"horizontal"` — buttons sit side by side (default) + * - `"vertical"` — buttons stack in a column + * @default "horizontal" + */ + orientation?: KumoButtonGroupOrientation; + /** Additional CSS classes merged via `cn()`. Use kumo semantic tokens only. */ + className?: string; + /** Buttons to join together. Typically `Button` / `LinkButton` elements. */ + children?: ReactNode; +} + +/** + * Visually joins a set of related buttons into a single segmented control. + * + * Children keep their own variant, size, and shape — ButtonGroup only handles + * the layout: it flattens the inner corners and overlaps borders so adjacent + * buttons share one seam. Works for both action rows and split buttons (a + * primary action next to a dropdown trigger). + * + * @example + * ```tsx + * + * + * + * + * + * ``` + * + * @example Split button + * ```tsx + * + * + * + * + * ``` + */ +export const ButtonGroup = forwardRef( + ( + { orientation = "horizontal", className, children, role, ...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 0000000000..dd5e726df7 --- /dev/null +++ b/packages/kumo/src/components/button-group/index.ts @@ -0,0 +1,8 @@ +export { + ButtonGroup, + KUMO_BUTTON_GROUP_VARIANTS, + KUMO_BUTTON_GROUP_DEFAULT_VARIANTS, + KUMO_BUTTON_GROUP_STYLING, + type ButtonGroupProps, + type KumoButtonGroupOrientation, +} from "./button-group"; diff --git a/packages/kumo/src/index.ts b/packages/kumo/src/index.ts index aaba46e2ed..eb089042a6 100644 --- a/packages/kumo/src/index.ts +++ b/packages/kumo/src/index.ts @@ -325,6 +325,14 @@ export { KUMO_TABLE_OF_CONTENTS_DEFAULT_VARIANTS, type KumoTableOfContentsState, } from "./components/table-of-contents"; +export { + ButtonGroup, + KUMO_BUTTON_GROUP_VARIANTS, + KUMO_BUTTON_GROUP_DEFAULT_VARIANTS, + KUMO_BUTTON_GROUP_STYLING, + type ButtonGroupProps, + type KumoButtonGroupOrientation, +} from "./components/button-group"; // PLOP_INJECT_EXPORT // Utils diff --git a/packages/kumo/vite.config.ts b/packages/kumo/vite.config.ts index df0c5d8429..35e5f48909 100644 --- a/packages/kumo/vite.config.ts +++ b/packages/kumo/vite.config.ts @@ -212,7 +212,8 @@ export default defineConfig(({ mode }) => { __dirname, "src/components/table-of-contents/index.ts", ), - // PLOP_INJECT_COMPONENT_ENTRY + 'components/button-group': resolve(__dirname, 'src/components/button-group/index.ts'), + // PLOP_INJECT_COMPONENT_ENTRY // Utils entry point utils: resolve(__dirname, "src/utils/index.ts"), // Primitives entry point (base-ui re-exports) From 81a426b05e530f64c5afeda0e528235978e3b028 Mon Sep 17 00:00:00 2001 From: Brandon Strittmatter Date: Fri, 17 Jul 2026 17:07:03 -0400 Subject: [PATCH 2/5] fix(button-group): remove vertical orientation, stabilize seam, robust corners - Remove vertical orientation; ButtonGroup is horizontal-only - Fix seam jumping on hover: only lift z-index on keyboard focus, never on hover, so the shared 1px seam stays put (later sibling paints on top) - Make corner rounding robust to runtime-injected elements (dropdown popup / backdrop / focus guards) by scoping the join selectors to button/a via :*-of-type, so outer corners stay rounded when a menu opens --- .changeset/button-group.md | 2 +- .../src/components/demos/ButtonGroupDemo.tsx | 20 ---- .../src/pages/components/button-group.mdx | 8 -- .../button-group/button-group.test.tsx | 11 +-- .../components/button-group/button-group.tsx | 96 +++++++------------ .../kumo/src/components/button-group/index.ts | 1 - packages/kumo/src/index.ts | 1 - 7 files changed, 34 insertions(+), 105 deletions(-) diff --git a/.changeset/button-group.md b/.changeset/button-group.md index 4541ac9ccc..a5d26d77e0 100644 --- a/.changeset/button-group.md +++ b/.changeset/button-group.md @@ -2,4 +2,4 @@ "@cloudflare/kumo": minor --- -Add `ButtonGroup` component for joining related buttons into a single segmented control. Handles layout only — children keep their own variant, size, and shape — by flattening inner corners and overlapping borders so adjacent buttons share one seam. Supports horizontal and vertical orientation, works for action rows, icon toolbars, and split buttons (a primary action next to a dropdown trigger). +Add `ButtonGroup` component for joining related buttons into a single horizontal segmented control. Handles layout only — children keep their own variant, size, and shape — by flattening inner corners and overlapping borders so adjacent buttons share one seam. Works for action rows, icon toolbars, and split buttons (a primary action next to a dropdown trigger). diff --git a/packages/kumo-docs-astro/src/components/demos/ButtonGroupDemo.tsx b/packages/kumo-docs-astro/src/components/demos/ButtonGroupDemo.tsx index 40fa9255c6..cccd21f2a0 100644 --- a/packages/kumo-docs-astro/src/components/demos/ButtonGroupDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/ButtonGroupDemo.tsx @@ -1,6 +1,5 @@ import { Button, ButtonGroup, DropdownMenu } from "@cloudflare/kumo"; import { - ArrowClockwiseIcon, CaretDownIcon, CopyIcon, PencilSimpleIcon, @@ -92,25 +91,6 @@ export function ButtonGroupWithIconsDemo() { ); } -/** - * Stack buttons into a column with `orientation="vertical"`. - */ -export function ButtonGroupVerticalDemo() { - return ( - - - - - - ); -} - /** * ButtonGroup respects each child's `size` — set the same size on every button * to keep the group aligned. diff --git a/packages/kumo-docs-astro/src/pages/components/button-group.mdx b/packages/kumo-docs-astro/src/pages/components/button-group.mdx index 2edf8dfd1d..e9e0b816cd 100644 --- a/packages/kumo-docs-astro/src/pages/components/button-group.mdx +++ b/packages/kumo-docs-astro/src/pages/components/button-group.mdx @@ -14,7 +14,6 @@ import { ButtonGroupSplitDemo, ButtonGroupIconDemo, ButtonGroupWithIconsDemo, - ButtonGroupVerticalDemo, ButtonGroupSizesDemo, } from "~/components/demos/ButtonGroupDemo"; @@ -120,13 +119,6 @@ export default function Example() { -### Vertical - -

Stack buttons into a column with `orientation="vertical"`.

- - - - ### Sizes

Set the same `size` on every button to keep the group aligned.

diff --git a/packages/kumo/src/components/button-group/button-group.test.tsx b/packages/kumo/src/components/button-group/button-group.test.tsx index dedfe4f59f..73b8322531 100644 --- a/packages/kumo/src/components/button-group/button-group.test.tsx +++ b/packages/kumo/src/components/button-group/button-group.test.tsx @@ -42,7 +42,7 @@ describe("ButtonGroup", () => { expect(screen.getByRole("toolbar", { name: "Actions" })).toBeTruthy(); }); - it("applies horizontal orientation classes by default", () => { + it("lays buttons out horizontally", () => { render( @@ -51,15 +51,6 @@ describe("ButtonGroup", () => { expect(screen.getByRole("group").className).toContain("flex-row"); }); - it("applies vertical orientation classes when requested", () => { - render( - - - , - ); - expect(screen.getByRole("group").className).toContain("flex-col"); - }); - it("merges a custom className", () => { render( diff --git a/packages/kumo/src/components/button-group/button-group.tsx b/packages/kumo/src/components/button-group/button-group.tsx index 81f3a6a582..2a011ca415 100644 --- a/packages/kumo/src/components/button-group/button-group.tsx +++ b/packages/kumo/src/components/button-group/button-group.tsx @@ -1,67 +1,45 @@ import { forwardRef, type HTMLAttributes, type ReactNode } from "react"; import { cn } from "../../utils/cn"; -import { resolveVariant } from "../../utils/resolve-variant"; /** - * ButtonGroup variant definitions. Controls how child buttons are laid out and - * joined together. + * 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 = { - orientation: { - horizontal: { - classes: cn( - "flex-row", - // Flatten the inner edges so buttons read as one joined control while - // the outer corners keep each button's own size-appropriate radius. - "[&>*:not(:first-child)]:rounded-l-none", - "[&>*:not(:last-child)]:rounded-r-none", - // Overlap borders/rings by 1px so adjacent buttons share a single seam - // instead of doubling up to a 2px line. - "[&>*:not(:first-child)]:-ml-px", - ), - description: "Lay buttons out in a horizontal row.", - }, - vertical: { - classes: cn( - "flex-col", - "[&>*:not(:first-child)]:rounded-t-none", - "[&>*:not(:last-child)]:rounded-b-none", - "[&>*:not(:first-child)]:-mt-px", - ), - description: "Stack buttons vertically into a column.", - }, - }, -} as const; +export const KUMO_BUTTON_GROUP_VARIANTS = {} as const; -export const KUMO_BUTTON_GROUP_DEFAULT_VARIANTS = { - orientation: "horizontal", -} as const; +export const KUMO_BUTTON_GROUP_DEFAULT_VARIANTS = {} as const; -/** Base classes shared by every ButtonGroup, regardless of orientation. */ +/** 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", - // Give every child a stacking context so the lifts below take effect. + "relative isolate inline-flex w-max flex-row", + // Give every child a stacking context so the lift below takes effect. "[&>*]:relative", - // Lift the interacted button above its neighbours so its focus/hover ring - // isn't clipped by the -1px overlap. - "[&>*:hover]:z-10 [&>*:focus]:z-10 [&>*:focus-visible]:z-10", + // Only lift on keyboard focus, so the 2px focus ring isn't clipped by the + // -1px overlap. We deliberately do NOT lift on hover: restacking on hover + // would make the shared 1px seam visibly jump by a pixel depending on which + // button is hovered. Leaving the stacking order fixed (later sibling paints + // on top) keeps the seam stable. + "[&>*:focus-visible]:z-10", + // Join the buttons. Scope to real controls (`button` / `a`) via `of-type` + // so that any element a child injects at runtime — e.g. a dropdown's popup, + // backdrop, or focus guards when it opens — can't change which control is + // treated as first/last and accidentally un-round the outer corners. + // Flatten the inner edges; keep each end's own size-appropriate radius. + "[&>button:not(:first-of-type)]:rounded-l-none", + "[&>button:not(:last-of-type)]:rounded-r-none", + "[&>a:not(:first-of-type)]:rounded-l-none", + "[&>a:not(:last-of-type)]:rounded-r-none", + // Overlap borders/rings by 1px so adjacent buttons share a single seam + // instead of doubling up to a 2px line. + "[&>button:not(:first-of-type)]:-ml-px", + "[&>a:not(:first-of-type)]:-ml-px", ), } as const; -export type KumoButtonGroupOrientation = - keyof typeof KUMO_BUTTON_GROUP_VARIANTS.orientation; - export interface ButtonGroupProps extends HTMLAttributes { - /** - * Layout direction of the grouped buttons. - * - `"horizontal"` — buttons sit side by side (default) - * - `"vertical"` — buttons stack in a column - * @default "horizontal" - */ - orientation?: KumoButtonGroupOrientation; /** Additional CSS classes merged via `cn()`. Use kumo semantic tokens only. */ className?: string; /** Buttons to join together. Typically `Button` / `LinkButton` elements. */ @@ -69,12 +47,13 @@ export interface ButtonGroupProps extends HTMLAttributes { } /** - * Visually joins a set of related buttons into a single segmented control. + * Visually joins a set of related buttons into a single horizontal segmented + * control. * * Children keep their own variant, size, and shape — ButtonGroup only handles * the layout: it flattens the inner corners and overlaps borders so adjacent - * buttons share one seam. Works for both action rows and split buttons (a - * primary action next to a dropdown trigger). + * buttons share one seam. Works for action rows, icon toolbars, and split + * buttons (a primary action next to a dropdown trigger). * * @example * ```tsx @@ -96,24 +75,13 @@ export interface ButtonGroupProps extends HTMLAttributes { * ``` */ export const ButtonGroup = forwardRef( - ( - { orientation = "horizontal", className, children, role, ...props }, - ref, - ) => { + ({ className, children, role, ...props }, ref) => { return (
{children} diff --git a/packages/kumo/src/components/button-group/index.ts b/packages/kumo/src/components/button-group/index.ts index dd5e726df7..46472fcbc2 100644 --- a/packages/kumo/src/components/button-group/index.ts +++ b/packages/kumo/src/components/button-group/index.ts @@ -4,5 +4,4 @@ export { KUMO_BUTTON_GROUP_DEFAULT_VARIANTS, KUMO_BUTTON_GROUP_STYLING, type ButtonGroupProps, - type KumoButtonGroupOrientation, } from "./button-group"; diff --git a/packages/kumo/src/index.ts b/packages/kumo/src/index.ts index eb089042a6..7fe091e28b 100644 --- a/packages/kumo/src/index.ts +++ b/packages/kumo/src/index.ts @@ -331,7 +331,6 @@ export { KUMO_BUTTON_GROUP_DEFAULT_VARIANTS, KUMO_BUTTON_GROUP_STYLING, type ButtonGroupProps, - type KumoButtonGroupOrientation, } from "./components/button-group"; // PLOP_INJECT_EXPORT From b46e1cac8ffeb9f5747d4e3c4bbaad18d607db87 Mon Sep 17 00:00:00 2001 From: Brandon Strittmatter Date: Fri, 17 Jul 2026 17:12:57 -0400 Subject: [PATCH 3/5] fix(button-group): flatten per-button shadows so the group reads as one control Each kumo Button ships its own shadow-xs. Inside a group those drop shadows overlap at the seams, making the middle button look elevated/boxed and leaving a clipped-corner artifact on its trailing edge. Drop child shadows within the group; the shared rings provide all the definition needed. --- packages/kumo/src/components/button-group/button-group.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/kumo/src/components/button-group/button-group.tsx b/packages/kumo/src/components/button-group/button-group.tsx index 2a011ca415..be735826e3 100644 --- a/packages/kumo/src/components/button-group/button-group.tsx +++ b/packages/kumo/src/components/button-group/button-group.tsx @@ -17,6 +17,11 @@ export const KUMO_BUTTON_GROUP_STYLING = { "relative isolate inline-flex w-max flex-row", // Give every child a stacking context so the lift below takes effect. "[&>*]:relative", + // 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). + "[&>*]:shadow-none", // Only lift on keyboard focus, so the 2px focus ring isn't clipped by the // -1px overlap. We deliberately do NOT lift on hover: restacking on hover // would make the shared 1px seam visibly jump by a pixel depending on which From bf9d392a505bd2fa053e3846e996c4ce272f2516 Mon Sep 17 00:00:00 2001 From: Brandon Strittmatter Date: Fri, 17 Jul 2026 17:22:08 -0400 Subject: [PATCH 4/5] fix(button-group): suppress mouse-focus ring so segments don't show a dark box Root cause of the boxed/dark middle segment: kumo's Button draws a near-black ring on *any* focus (focus:ring-kumo-focus/50), including mouse clicks. Inside a segmented group that renders as a jarring dark box on the clicked segment. - Reset the ring to the resting line color for mouse focus (:focus:not(:focus-visible)); the fill/selected state is the feedback - Keep the keyboard focus-visible ring (lifted) for a11y Verified in a real browser across resting / hover / mouse-click / keyboard-focus for the segmented control, the primary split button (incl. open dropdown), and the icon toolbar. --- packages/kumo/src/components/button-group/button-group.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/kumo/src/components/button-group/button-group.tsx b/packages/kumo/src/components/button-group/button-group.tsx index be735826e3..ca94d3d651 100644 --- a/packages/kumo/src/components/button-group/button-group.tsx +++ b/packages/kumo/src/components/button-group/button-group.tsx @@ -22,6 +22,12 @@ export const KUMO_BUTTON_GROUP_STYLING = { // boxed. Drop the per-button shadows so the group reads as one flat, // uniform control (the shared rings provide all the definition needed). "[&>*]:shadow-none", + // Kumo's Button draws a dark ring on *any* focus, including mouse clicks + // (`focus:ring-kumo-focus/50`). Inside a segmented group that reads as an + // ugly dark box on the clicked segment. Suppress the mouse-only focus ring + // (the fill/selected state is the feedback) and keep the resting line + // color; the keyboard `focus-visible` ring below still provides a11y focus. + "[&>*:focus:not(:focus-visible)]:!ring-kumo-line", // Only lift on keyboard focus, so the 2px focus ring isn't clipped by the // -1px overlap. We deliberately do NOT lift on hover: restacking on hover // would make the shared 1px seam visibly jump by a pixel depending on which From 2b44260b453bf8641e06c358f1614fc421169115 Mon Sep 17 00:00:00 2001 From: Brandon Strittmatter Date: Mon, 20 Jul 2026 12:40:19 -0400 Subject: [PATCH 5/5] refactor(button-group): focus on split button; defer grouping to Toolbar Addresses review feedback about overlap with a toolbar. - Remove toolbar-flavored usage: ButtonGroup no longer sets/encourages role="toolbar"; it renders a plain role="group" (correct for a split button) - Drop the icon-toolbar, segmented, and action-row demos; ButtonGroup is now documented around the split-button pattern (primary action + dropdown) - Docs: add a 'When to use' section directing multi-button/input grouping to Toolbar (which is already built on Base UI's Toolbar primitive) - Update JSDoc, tests, and changeset accordingly Rationale: Base UI's Toolbar.Group requires a Toolbar.Root ancestor (roaming tabindex / arrow-key nav), which is wrong for a standalone split button. Real toolbars should use kumo Toolbar; ButtonGroup stays a minimal role=group join. --- .changeset/button-group.md | 2 +- .../src/components/demos/ButtonGroupDemo.tsx | 138 +++++++----------- .../src/pages/components/button-group.mdx | 94 +++++++----- .../button-group/button-group.test.tsx | 6 +- .../components/button-group/button-group.tsx | 48 +++--- 5 files changed, 142 insertions(+), 146 deletions(-) diff --git a/.changeset/button-group.md b/.changeset/button-group.md index a5d26d77e0..5efe035f05 100644 --- a/.changeset/button-group.md +++ b/.changeset/button-group.md @@ -2,4 +2,4 @@ "@cloudflare/kumo": minor --- -Add `ButtonGroup` component for joining related buttons into a single horizontal segmented control. Handles layout only — children keep their own variant, size, and shape — by flattening inner corners and overlapping borders so adjacent buttons share one seam. Works for action rows, icon toolbars, and split buttons (a primary action next to a dropdown trigger). +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/demos/ButtonGroupDemo.tsx b/packages/kumo-docs-astro/src/components/demos/ButtonGroupDemo.tsx index cccd21f2a0..1e585ffad8 100644 --- a/packages/kumo-docs-astro/src/components/demos/ButtonGroupDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/ButtonGroupDemo.tsx @@ -1,34 +1,14 @@ import { Button, ButtonGroup, DropdownMenu } from "@cloudflare/kumo"; -import { - CaretDownIcon, - CopyIcon, - PencilSimpleIcon, - TextAlignCenterIcon, - TextAlignLeftIcon, - TextAlignRightIcon, - TrashIcon, -} from "@phosphor-icons/react"; +import { CaretDownIcon } from "@phosphor-icons/react"; /** - * A basic segmented row of related actions joined into a single control. - */ -export function ButtonGroupBasicDemo() { - return ( - - - - - - ); -} - -/** - * Split button: a primary action joined with a dropdown trigger for secondary - * actions. The caret button uses `shape="square"` and an `aria-label`. + * 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 ( - + - - - - - ); -} - -/** - * Buttons with leading icons and labels, grouped together. + * Split buttons work with any button variant — here the secondary style for a + * lower-emphasis action. */ -export function ButtonGroupWithIconsDemo() { +export function ButtonGroupSecondaryDemo() { return ( - - - - + + + + + + + } + /> + + Save as draft + Save and publish + Save a copy… + + ); } /** - * ButtonGroup respects each child's `size` — set the same size on every button - * to keep the group aligned. + * 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/pages/components/button-group.mdx b/packages/kumo-docs-astro/src/pages/components/button-group.mdx index e9e0b816cd..633450e17d 100644 --- a/packages/kumo-docs-astro/src/pages/components/button-group.mdx +++ b/packages/kumo-docs-astro/src/pages/components/button-group.mdx @@ -1,7 +1,7 @@ --- layout: ~/layouts/MdxDocLayout.astro title: "Button Group" -description: "Visually joins a set of related buttons into a single segmented control." +description: "Joins a primary action and a dropdown trigger into a single split button." sourceFile: "components/button-group" --- @@ -10,10 +10,8 @@ import ComponentSection from "~/components/docs/ComponentSection.astro"; import CodeBlock from "~/components/docs/CodeBlock.astro"; import PropsTable from "~/components/docs/PropsTable.astro"; import { - ButtonGroupBasicDemo, ButtonGroupSplitDemo, - ButtonGroupIconDemo, - ButtonGroupWithIconsDemo, + ButtonGroupSecondaryDemo, ButtonGroupSizesDemo, } from "~/components/demos/ButtonGroupDemo"; @@ -21,14 +19,35 @@ import { - + +{/* 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 */} @@ -57,21 +76,36 @@ import { ## Usage

- ButtonGroup is a layout wrapper. Its children keep their own `variant`, + `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 adjacent buttons share a single seam. Use the same `size` and - `variant` on every child for a consistent segmented control. + 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 + + ); }`} @@ -85,43 +119,23 @@ export default function Example() { ## Examples -### Basic - -

A segmented row of related actions joined into a single control.

- - - - ### Split button -

- Pair a primary action with a `DropdownMenu` trigger for secondary actions. The - caret button uses `shape="square"` and an `aria-label`. -

+

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

-### Icon toolbar - -

- Icon-only groups make compact toolbars. Set `role="toolbar"` and an - `aria-label` describing the set of actions. -

- - - - -### With icons and labels +### Secondary -

Buttons with leading icons, grouped together.

- - +

Split buttons work with any button variant.

+ + ### Sizes -

Set the same `size` on every button to keep the group aligned.

+

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

diff --git a/packages/kumo/src/components/button-group/button-group.test.tsx b/packages/kumo/src/components/button-group/button-group.test.tsx index 73b8322531..8787d19977 100644 --- a/packages/kumo/src/components/button-group/button-group.test.tsx +++ b/packages/kumo/src/components/button-group/button-group.test.tsx @@ -33,13 +33,13 @@ describe("ButtonGroup", () => { expect(screen.getByRole("group")).toBeTruthy(); }); - it("allows overriding the role", () => { + it("forwards aria-label to the group", () => { render( - + , ); - expect(screen.getByRole("toolbar", { name: "Actions" })).toBeTruthy(); + expect(screen.getByRole("group", { name: "Deploy" })).toBeTruthy(); }); it("lays buttons out horizontally", () => { diff --git a/packages/kumo/src/components/button-group/button-group.tsx b/packages/kumo/src/components/button-group/button-group.tsx index ca94d3d651..15a21dbc25 100644 --- a/packages/kumo/src/components/button-group/button-group.tsx +++ b/packages/kumo/src/components/button-group/button-group.tsx @@ -53,44 +53,52 @@ export const KUMO_BUTTON_GROUP_STYLING = { export interface ButtonGroupProps extends HTMLAttributes { /** Additional CSS classes merged via `cn()`. Use kumo semantic tokens only. */ className?: string; - /** Buttons to join together. Typically `Button` / `LinkButton` elements. */ + /** + * The tightly-coupled controls to join. Typically two `Button`s: a primary + * action and a dropdown trigger (a "split button"). + */ children?: ReactNode; } /** - * Visually joins a set of related buttons into a single horizontal segmented - * control. + * 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 adjacent - * buttons share one seam. Works for action rows, icon toolbars, and split - * buttons (a primary action next to a dropdown trigger). + * 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`). * - * @example - * ```tsx - * - * - * - * - * - * ``` + * 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, role, ...props }, ref) => { + ({ className, children, ...props }, ref) => { return (