From b5b91ac432548823e159c9fd6c539d456ff073f8 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 29 Jul 2026 17:16:23 +0300 Subject: [PATCH 1/6] feat(components): add `ButtonGroup` component (DS-5244) --- .storybook/components/Roadmap/data.ts | 6 + .../src/components/Button/Button.tsx | 13 +- .../components/src/components/Button/types.ts | 2 +- .../components/ButtonGroup/ButtonGroup.mdx | 94 +++++++++ .../ButtonGroup/ButtonGroup.module.css | 93 +++++++++ .../ButtonGroup/ButtonGroup.stories.tsx | 181 ++++++++++++++++++ .../ButtonGroup/ButtonGroup.test.tsx | 177 +++++++++++++++++ .../components/ButtonGroup/ButtonGroup.tsx | 51 +++++ .../ButtonGroup/ButtonGroupContext.ts | 18 ++ .../src/components/ButtonGroup/index.ts | 3 + .../src/components/ButtonGroup/types.ts | 31 +++ packages/components/src/components/index.ts | 1 + tools/api-extractor/config.json | 1 + .../public_api_guard/components/Button.api.md | 2 +- .../components/ButtonGroup.api.md | 55 ++++++ 15 files changed, 724 insertions(+), 4 deletions(-) create mode 100644 packages/components/src/components/ButtonGroup/ButtonGroup.mdx create mode 100644 packages/components/src/components/ButtonGroup/ButtonGroup.module.css create mode 100644 packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx create mode 100644 packages/components/src/components/ButtonGroup/ButtonGroup.test.tsx create mode 100644 packages/components/src/components/ButtonGroup/ButtonGroup.tsx create mode 100644 packages/components/src/components/ButtonGroup/ButtonGroupContext.ts create mode 100644 packages/components/src/components/ButtonGroup/index.ts create mode 100644 packages/components/src/components/ButtonGroup/types.ts create mode 100644 tools/public_api_guard/components/ButtonGroup.api.md diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index 1585e632d..58f6a3eac 100644 --- a/.storybook/components/Roadmap/data.ts +++ b/.storybook/components/Roadmap/data.ts @@ -374,4 +374,10 @@ export const rows: Rows = [ stage: '🔵 experimental', planned: 'Q3 2026', }, + { + component: 'ButtonGroup', + status: '✅ Done', + stage: '🔵 experimental', + planned: 'Q3 2026', + }, ]; diff --git a/packages/components/src/components/Button/Button.tsx b/packages/components/src/components/Button/Button.tsx index 838c7ffaf..837bbd16b 100644 --- a/packages/components/src/components/Button/Button.tsx +++ b/packages/components/src/components/Button/Button.tsx @@ -7,6 +7,8 @@ import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; import type { ButtonBaseProps as ButtonPrimitiveProps } from '@koobiq/react-primitives'; import { Button as ButtonPrimitive } from '@koobiq/react-primitives'; +import { useButtonGroupContext } from '../ButtonGroup'; + import s from './Button.module.css'; import type { ButtonBaseProps } from './types.js'; @@ -15,7 +17,7 @@ export const Button = polymorphicForwardRef<'button', ButtonBaseProps>( (props, ref) => { const { as: Tag = 'button', - variant = 'contrast-filled', + variant: variantProp, onlyIcon, fullWidth, isLoading: isLoadingProp, @@ -29,8 +31,15 @@ export const Button = polymorphicForwardRef<'button', ButtonBaseProps>( ...other } = props; + const group = useButtonGroupContext(); + const isLoading = isLoadingProp ?? progress; - const isDisabled = isDisabledProp ?? disabled; + + // Inside a group the group's variant wins, so the group looks like one control. + const variant = group.variant ?? variantProp ?? 'contrast-filled'; + + // The group wins when it disables, but a single button can still disable itself. + const isDisabled = (isDisabledProp ?? disabled) || group.isDisabled; if (process.env.NODE_ENV !== 'production' && 'progress' in props) { deprecate( diff --git a/packages/components/src/components/Button/types.ts b/packages/components/src/components/Button/types.ts index 5a7019133..cb4f668e9 100644 --- a/packages/components/src/components/Button/types.ts +++ b/packages/components/src/components/Button/types.ts @@ -5,10 +5,10 @@ import type { ButtonBaseProps as ButtonBasePrimitiveProps } from '@koobiq/react- export const buttonPropVariant = [ 'contrast-filled', - 'contrast-transparent', 'fade-contrast-filled', 'fade-contrast-outline', 'fade-theme-outline', + 'contrast-transparent', 'theme-transparent', ] as const; diff --git a/packages/components/src/components/ButtonGroup/ButtonGroup.mdx b/packages/components/src/components/ButtonGroup/ButtonGroup.mdx new file mode 100644 index 000000000..ad9247d2c --- /dev/null +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.mdx @@ -0,0 +1,94 @@ +import { + Meta, + Story, + Props, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './ButtonGroup.stories'; + + + +# ButtonGroup + + + +The ButtonGroup joins buttons that belong together. + +## Import + +```tsx +import { ButtonGroup, Button } from '@koobiq/react-components'; +``` + +## Usage + + + +## Props + + + +## Variant + +The `variant` prop sets the look of every button in the group and wins over the +`variant` set on a button. + + + +## Content + +A button in the group can hold a label, an icon, or both. Add a caret with +`endIcon` to make it a menu button. + + + +Buttons with different content can go into one group. + + + +## Only icon + + + +## Orientation + +Use the `orientation` prop to stack the buttons in a column. Only icon buttons +can go into a vertical group. Groups with no fill and no border have no divider +between the buttons. + + + +## Disabled + +The `isDisabled` prop disables every button in the group. A button can also be +disabled on its own. + + + +## Loading + +Use the `isLoading` prop on a button to show a loader and block clicks. + + + +## Root tag + +Use the `as` prop to redefine the HTML-tag of the component to your own. + + + +## Accessibility + +The group is rendered with `role="group"`. Every button keeps its own tab stop, +so Tab steps through the group button by button. Give icon-only +buttons an `aria-label`, and label the group itself with `aria-label` when the +surrounding content doesn't already name it. + +## CSS Variables + +Use CSS variables to customize the button group. + +| Variable | +| ---------------------------------- | +| `--kbq-button-group-divider-color` | diff --git a/packages/components/src/components/ButtonGroup/ButtonGroup.module.css b/packages/components/src/components/ButtonGroup/ButtonGroup.module.css new file mode 100644 index 000000000..8a4e27fbb --- /dev/null +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.module.css @@ -0,0 +1,93 @@ +.base { + --button-group-divider-color: var(--kbq-background-transparent); + + display: inline-flex; + align-items: stretch; +} + +.horizontal { + flex-direction: row; + + & > [data-slot='button'] { + &:not(:first-child) { + border-start-start-radius: 0; + border-end-start-radius: 0; + + &::before { + border-inline-start-width: 0; + } + } + + &:not(:last-child) { + border-start-end-radius: 0; + border-end-end-radius: 0; + background-clip: padding-box; + border-inline-end: var(--kbq-size-border-width) solid + var(--kbq-button-group-divider-color, var(--button-group-divider-color)); + + &::before { + border-inline-end-width: 0; + inset-inline-end: calc(-1 * var(--kbq-size-border-width)); + } + } + + &:not(:last-child)[data-onlyicon] { + inline-size: calc( + var(--button-block-size) + var(--kbq-size-border-width) + ); + } + } +} + +.vertical { + flex-direction: column; + + & > [data-slot='button'] { + &:not(:first-child) { + border-start-start-radius: 0; + border-start-end-radius: 0; + + &::before { + border-block-start-width: 0; + } + } + + &:not(:last-child) { + border-end-start-radius: 0; + border-end-end-radius: 0; + background-clip: padding-box; + border-block-end: var(--kbq-size-border-width) solid + var(--kbq-button-group-divider-color, var(--button-group-divider-color)); + block-size: calc(var(--button-block-size) + var(--kbq-size-border-width)); + + &::before { + border-block-end-width: 0; + inset-block-end: calc(-1 * var(--kbq-size-border-width)); + } + } + } +} + +.contrast-filled { + --button-group-divider-color: var(--kbq-line-on-contrast-fade); +} + +.fade-contrast-filled { + --button-group-divider-color: var(--kbq-line-contrast-fade); +} + +.fade-contrast-outline { + --button-group-divider-color: var(--kbq-line-contrast-fade); +} + +.fade-theme-outline { + --button-group-divider-color: var(--kbq-line-theme-fade); +} + +.contrast-transparent { + --button-group-divider-color: var(--kbq-background-transparent); +} + +.theme-transparent { + --button-group-divider-color: var(--kbq-background-transparent); +} diff --git a/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx b/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx new file mode 100644 index 000000000..6243d155c --- /dev/null +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx @@ -0,0 +1,181 @@ +import { + IconBoxArchive16, + IconChevronDown16, + IconClock16, + IconEllipsisVertical16, + IconMinus16, + IconPlus16, + IconTriangleExclamation16, +} from '@koobiq/react-icons'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { Button, buttonPropVariant } from '../Button'; +import { FlexBox } from '../FlexBox'; + +import { ButtonGroup, type ButtonGroupProps } from './index'; + +const meta = { + title: 'Components/ButtonGroup', + component: ButtonGroup, + subcomponents: { Button }, + parameters: { + layout: 'centered', + }, + tags: ['status:new', 'date:2026-07-29'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Base: Story = { + render: (args) => ( + + + + + + ), +}; + +export const Variant: Story = { + render: () => ( + + {buttonPropVariant.map((variant) => ( + + + + + + ))} + + ), +}; + +export const Content: Story = { + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), +}; + +export const Loading: Story = { + render: () => ( + + + + + + ), +}; + +export const RootTag: Story = { + render: () => ( + + + + + + ), +}; diff --git a/packages/components/src/components/ButtonGroup/ButtonGroup.test.tsx b/packages/components/src/components/ButtonGroup/ButtonGroup.test.tsx new file mode 100644 index 000000000..432060ccc --- /dev/null +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.test.tsx @@ -0,0 +1,177 @@ +import { createRef } from 'react'; + +import { screen, render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; + +import { Button, buttonPropVariant } from '../Button'; + +import { ButtonGroup, buttonGroupPropOrientation } from './index.js'; + +describe('ButtonGroup', () => { + const baseProps = { 'data-testid': 'button-group' }; + + const getRoot = () => screen.getByTestId('button-group'); + + it('should receive ref', () => { + const ref = createRef(); + const { container } = render(); + const group = container.querySelector('div'); + + expect(ref.current).toBe(group); + }); + + it('should render the component with the correct tag', () => { + render(); + + expect(getRoot().tagName).toBe('SECTION'); + }); + + it('should apply a custom class name', () => { + render(); + + expect(getRoot()).toHaveClass('custom'); + }); + + it('should apply custom styles', () => { + render(); + + expect(getRoot()).toHaveStyle({ opacity: '0.5' }); + }); + + it('should render children', () => { + render( + + + + + ); + + expect(screen.getAllByRole('button')).toHaveLength(2); + }); + + it('should have the group role', () => { + render(); + + expect(getRoot()).toHaveAttribute('role', 'group'); + }); + + describe('check the orientation prop', () => { + it('should default to the horizontal orientation', () => { + render(); + + expect(getRoot()).toHaveAttribute('data-orientation', 'horizontal'); + }); + + it.each(buttonGroupPropOrientation)( + 'should apply the orientation as a "%s"', + (orientation) => { + render(); + + expect(getRoot()).toHaveAttribute('data-orientation', orientation); + } + ); + }); + + describe('check the variant prop', () => { + it('should default to the fade-contrast-outline variant', () => { + render( + + + + ); + + expect(getRoot()).toHaveAttribute( + 'data-variant', + 'fade-contrast-outline' + ); + + expect(screen.getByRole('button')).toHaveAttribute( + 'data-variant', + 'fade-contrast-outline' + ); + }); + + it.each(buttonPropVariant)( + 'should pass the "%s" variant to the nested buttons', + (variant) => { + render( + + + + ); + + expect(getRoot()).toHaveAttribute('data-variant', variant); + + expect(screen.getByRole('button')).toHaveAttribute( + 'data-variant', + variant + ); + } + ); + + it("should override the button's own variant", () => { + render( + + + + + ); + + screen.getAllByRole('button').forEach((button) => { + expect(button).toHaveAttribute('data-variant', 'fade-contrast-outline'); + }); + }); + + it('should not affect a button rendered outside of the group', () => { + render(); + + expect(screen.getByRole('button')).toHaveAttribute( + 'data-variant', + 'contrast-filled' + ); + }); + }); + + describe('check the isDisabled prop', () => { + it('should be enabled by default', () => { + render( + + + + ); + + expect(getRoot()).not.toHaveAttribute('data-disabled'); + expect(screen.getByRole('button')).not.toBeDisabled(); + }); + + it('should disable every nested button', () => { + render( + + + + + ); + + expect(getRoot()).toHaveAttribute('data-disabled', 'true'); + + screen.getAllByRole('button').forEach((button) => { + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('data-disabled', 'true'); + }); + }); + + it('should disable a single button while the group stays enabled', () => { + render( + + + + + ); + + const [archive, remove] = screen.getAllByRole('button'); + + expect(archive).not.toBeDisabled(); + expect(remove).toBeDisabled(); + }); + }); +}); diff --git a/packages/components/src/components/ButtonGroup/ButtonGroup.tsx b/packages/components/src/components/ButtonGroup/ButtonGroup.tsx new file mode 100644 index 000000000..689c4bce5 --- /dev/null +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useMemo } from 'react'; +import type { ComponentPropsWithRef, ElementType } from 'react'; + +import { clsx, polymorphicForwardRef } from '@koobiq/react-core'; + +import s from './ButtonGroup.module.css'; +import { ButtonGroupContext } from './ButtonGroupContext'; +import type { ButtonGroupBaseProps } from './types'; + +/** The ButtonGroup joins buttons that belong together. */ +export const ButtonGroup = polymorphicForwardRef<'div', ButtonGroupBaseProps>( + (props, ref) => { + const { + as: Tag = 'div', + orientation = 'horizontal', + variant = 'fade-contrast-outline', + isDisabled, + className, + children, + ...other + } = props; + + const contextValue = useMemo( + () => ({ variant, isDisabled }), + [variant, isDisabled] + ); + + return ( + + + {children} + + + ); + } +); + +ButtonGroup.displayName = 'ButtonGroup'; + +export type ButtonGroupProps = + ComponentPropsWithRef>; diff --git a/packages/components/src/components/ButtonGroup/ButtonGroupContext.ts b/packages/components/src/components/ButtonGroup/ButtonGroupContext.ts new file mode 100644 index 000000000..3ce76999b --- /dev/null +++ b/packages/components/src/components/ButtonGroup/ButtonGroupContext.ts @@ -0,0 +1,18 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +import type { ButtonPropVariant } from '../Button'; + +export type ButtonGroupContextProps = { + /** The variant applied to every nested button, overriding their own `variant`. */ + variant?: ButtonPropVariant; + /** If `true`, every nested button is disabled. */ + isDisabled?: boolean; +}; + +export const ButtonGroupContext = createContext({}); + +export function useButtonGroupContext() { + return useContext(ButtonGroupContext); +} diff --git a/packages/components/src/components/ButtonGroup/index.ts b/packages/components/src/components/ButtonGroup/index.ts new file mode 100644 index 000000000..8d1f8a4d2 --- /dev/null +++ b/packages/components/src/components/ButtonGroup/index.ts @@ -0,0 +1,3 @@ +export * from './ButtonGroup'; +export * from './ButtonGroupContext'; +export * from './types'; diff --git a/packages/components/src/components/ButtonGroup/types.ts b/packages/components/src/components/ButtonGroup/types.ts new file mode 100644 index 000000000..c80582611 --- /dev/null +++ b/packages/components/src/components/ButtonGroup/types.ts @@ -0,0 +1,31 @@ +import type { CSSProperties, ReactNode } from 'react'; + +import type { ButtonPropVariant } from '../Button'; + +export const buttonGroupPropOrientation = ['horizontal', 'vertical'] as const; + +export type ButtonGroupPropOrientation = + (typeof buttonGroupPropOrientation)[number]; + +export type ButtonGroupBaseProps = { + /** The content of the component. */ + children?: ReactNode; + /** + * The orientation of the group. + * @default 'horizontal' + */ + orientation?: ButtonGroupPropOrientation; + /** + * The variant applied to every nested button, overriding their own `variant`. + * @default 'fade-contrast-outline' + */ + variant?: ButtonPropVariant; + /** If `true`, every nested button is disabled. */ + isDisabled?: boolean; + /** Additional CSS-classes. */ + className?: string; + /** Inline styles. */ + style?: CSSProperties; + /** Unique identifier for testing purposes. */ + 'data-testid'?: string | number; +}; diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index 5a00d1d64..2736181be 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -5,6 +5,7 @@ export * from './FormField'; export * from './IconItem'; export * from './Alert'; export * from './Button'; +export * from './ButtonGroup'; export * from './IconButton'; export * from './Typography'; export * from './Checkbox'; diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index babae623b..b1b17b23f 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -8,6 +8,7 @@ "Badge", "Breadcrumbs", "Button", + "ButtonGroup", "ButtonToggleGroup", "Calendar", "Checkbox", diff --git a/tools/public_api_guard/components/Button.api.md b/tools/public_api_guard/components/Button.api.md index 461191c90..7550bda97 100644 --- a/tools/public_api_guard/components/Button.api.md +++ b/tools/public_api_guard/components/Button.api.md @@ -37,7 +37,7 @@ export type ButtonProps = ComponentPropsWithR export type ButtonPropVariant = (typeof buttonPropVariant)[number]; // @public (undocumented) -export const buttonPropVariant: readonly ["contrast-filled", "contrast-transparent", "fade-contrast-filled", "fade-contrast-outline", "fade-theme-outline", "theme-transparent"]; +export const buttonPropVariant: readonly ["contrast-filled", "fade-contrast-filled", "fade-contrast-outline", "fade-theme-outline", "contrast-transparent", "theme-transparent"]; // (No @packageDocumentation comment for this package) diff --git a/tools/public_api_guard/components/ButtonGroup.api.md b/tools/public_api_guard/components/ButtonGroup.api.md new file mode 100644 index 000000000..2398a47c8 --- /dev/null +++ b/tools/public_api_guard/components/ButtonGroup.api.md @@ -0,0 +1,55 @@ +## API Report File for "koobiq-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ComponentPropsWithRef } from 'react'; +import { Context } from 'react'; +import type { CSSProperties } from 'react'; +import type { ElementType } from 'react'; +import { PolyForwardComponent } from '@koobiq/react-core'; +import type { ReactNode } from 'react'; + +// @public +export const ButtonGroup: PolyForwardComponent<"div", ButtonGroupBaseProps, ElementType>; + +// @public (undocumented) +export type ButtonGroupBaseProps = { + children?: ReactNode; + orientation?: ButtonGroupPropOrientation; + variant?: ButtonPropVariant; + isDisabled?: boolean; + className?: string; + style?: CSSProperties; + 'data-testid'?: string | number; +}; + +// @public (undocumented) +export const ButtonGroupContext: Context; + +// @public (undocumented) +export type ButtonGroupContextProps = { + variant?: ButtonPropVariant; + isDisabled?: boolean; +}; + +// @public (undocumented) +export type ButtonGroupPropOrientation = (typeof buttonGroupPropOrientation)[number]; + +// @public (undocumented) +export const buttonGroupPropOrientation: readonly ["horizontal", "vertical"]; + +// @public (undocumented) +export type ButtonGroupProps = ComponentPropsWithRef>; + +// @public (undocumented) +export function useButtonGroupContext(): ButtonGroupContextProps; + +// Warnings were encountered during analysis: +// +// packages/components/dist/components/ButtonGroup/types.d.ts:17:5 - (ae-forgotten-export) The symbol "ButtonPropVariant" needs to be exported by the entry point index.d.ts + +// (No @packageDocumentation comment for this package) + +``` From 7fdee981737b6aa602cc1cf6a890aa4871685314 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 29 Jul 2026 19:28:18 +0300 Subject: [PATCH 2/6] chore(ButtonGroup): add split button demo story --- .../ButtonGroup/ButtonGroup.stories.tsx | 131 +++++++++++++++++- 1 file changed, 129 insertions(+), 2 deletions(-) diff --git a/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx b/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx index 6243d155c..1b0e87700 100644 --- a/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx @@ -7,10 +7,12 @@ import { IconPlus16, IconTriangleExclamation16, } from '@koobiq/react-icons'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Button, buttonPropVariant } from '../Button'; +import { Button, buttonPropVariant, type ButtonPropVariant } from '../Button'; import { FlexBox } from '../FlexBox'; +import { Menu } from '../Menu'; +import { Typography } from '../Typography'; import { ButtonGroup, type ButtonGroupProps } from './index'; @@ -179,3 +181,128 @@ export const RootTag: Story = { ), }; + +export const SplitButton: Story = { + render: () => { + const splitButtonVariants: { + label: string; + variant: ButtonPropVariant; + }[] = [ + { label: 'Filled Contrast', variant: 'contrast-filled' }, + { label: 'Filled Fade Contrast', variant: 'fade-contrast-filled' }, + { label: 'Outline Fade Theme', variant: 'fade-theme-outline' }, + { label: 'Outline Fade Contrast', variant: 'fade-contrast-outline' }, + { label: 'Transparent Theme', variant: 'theme-transparent' }, + { label: 'Transparent Contrast', variant: 'contrast-transparent' }, + ]; + + return ( + <> + +
+ {splitButtonVariants.map(({ label, variant }) => ( + + {label} + + + ( + + + + ))} +
+ + ); + }, +}; From 96adf14f44b4759ecd7da1a461f24024158b99b1 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 29 Jul 2026 21:39:58 +0300 Subject: [PATCH 3/6] refactor(ButtonGroup): improve the group layout --- .../ButtonGroup/ButtonGroup.module.css | 27 ++++++------ .../ButtonGroup/ButtonGroup.stories.tsx | 43 ++++++------------- 2 files changed, 25 insertions(+), 45 deletions(-) diff --git a/packages/components/src/components/ButtonGroup/ButtonGroup.module.css b/packages/components/src/components/ButtonGroup/ButtonGroup.module.css index 8a4e27fbb..b8b08e0be 100644 --- a/packages/components/src/components/ButtonGroup/ButtonGroup.module.css +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.module.css @@ -3,9 +3,16 @@ display: inline-flex; align-items: stretch; + + & > [data-slot='button']:not(:last-child) { + box-shadow: var(--button-group-divider-shadow); + } } .horizontal { + --button-group-divider-shadow: var(--kbq-size-border-width) 0 0 + var(--kbq-button-group-divider-color, var(--button-group-divider-color)); + flex-direction: row; & > [data-slot='button'] { @@ -21,25 +28,19 @@ &:not(:last-child) { border-start-end-radius: 0; border-end-end-radius: 0; - background-clip: padding-box; - border-inline-end: var(--kbq-size-border-width) solid - var(--kbq-button-group-divider-color, var(--button-group-divider-color)); + margin-inline-end: var(--kbq-size-border-width); &::before { border-inline-end-width: 0; - inset-inline-end: calc(-1 * var(--kbq-size-border-width)); } } - - &:not(:last-child)[data-onlyicon] { - inline-size: calc( - var(--button-block-size) + var(--kbq-size-border-width) - ); - } } } .vertical { + --button-group-divider-shadow: 0 var(--kbq-size-border-width) 0 + var(--kbq-button-group-divider-color, var(--button-group-divider-color)); + flex-direction: column; & > [data-slot='button'] { @@ -55,14 +56,10 @@ &:not(:last-child) { border-end-start-radius: 0; border-end-end-radius: 0; - background-clip: padding-box; - border-block-end: var(--kbq-size-border-width) solid - var(--kbq-button-group-divider-color, var(--button-group-divider-color)); - block-size: calc(var(--button-block-size) + var(--kbq-size-border-width)); + margin-block-end: var(--kbq-size-border-width); &::before { border-block-end-width: 0; - inset-block-end: calc(-1 * var(--kbq-size-border-width)); } } } diff --git a/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx b/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx index 1b0e87700..880e6826f 100644 --- a/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx @@ -205,19 +205,10 @@ export const SplitButton: Story = { gap: var(--kbq-size-3xl) var(--kbq-size-6xl); } - .split-button-demo__group { - --kbq-button-group-divider-color: var(--kbq-background-transparent); - } - .split-button-demo__group > [data-slot='button']:not(:last-child) { - border-inline-end: 0; - background-clip: border-box; - } - - .split-button-demo__group - > [data-slot='button']:not(:last-child)::before { - inset-inline-end: 0; + margin-inline-end: 0; + box-shadow: none; } .split-button-demo__group[data-variant='contrast-filled'] { @@ -235,38 +226,30 @@ export const SplitButton: Story = { --split-button-divider-color: var(--kbq-line-theme-fade); } - .split-button-demo__group - > [data-slot='button']:last-child - > span::before { - content: ''; - position: absolute; - pointer-events: none; - inset-inline-start: 0; - inset-block-start: calc( - var(--kbq-size-s) - var(--kbq-size-border-width) + .split-button-demo__group > [data-slot='button']:last-child { + background-image: linear-gradient( + var(--split-button-divider-color), + var(--split-button-divider-color) ); - inline-size: var(--kbq-size-border-width); - block-size: var(--kbq-size-l); - background-color: var(--split-button-divider-color); + background-repeat: no-repeat; + background-position: 0 50%; + background-size: var(--kbq-size-border-width) var(--kbq-size-l); } .split-button-demo__group - > [data-slot='button']:last-child[aria-expanded='true'] - > span::before, + > [data-slot='button']:last-child[aria-expanded='true'], .split-button-demo__group > [data-slot='button']:last-child:is( :hover, :focus-visible - ) - > span::before, + ), .split-button-demo__group > [data-slot='button']:not(:last-child):is( :hover, :focus-visible ) - + [data-slot='button']:last-child - > span::before { - display: none; + + [data-slot='button']:last-child { + background-image: none; } `}
From 84fdda24357365d138a4f0c42f2982085a65b6ec Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 30 Jul 2026 13:49:48 +0300 Subject: [PATCH 4/6] fix(ButtonGroup): correct disabled state styles --- .../components/ButtonGroup/ButtonGroup.mdx | 8 -- .../ButtonGroup/ButtonGroup.module.css | 16 ++- .../ButtonGroup/ButtonGroup.stories.tsx | 120 +++++------------- .../components/ButtonGroup/ButtonGroup.tsx | 8 +- .../ButtonGroup/__stories__/styles.css | 45 +++++++ 5 files changed, 94 insertions(+), 103 deletions(-) create mode 100644 packages/components/src/components/ButtonGroup/__stories__/styles.css diff --git a/packages/components/src/components/ButtonGroup/ButtonGroup.mdx b/packages/components/src/components/ButtonGroup/ButtonGroup.mdx index ad9247d2c..e14d9611f 100644 --- a/packages/components/src/components/ButtonGroup/ButtonGroup.mdx +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.mdx @@ -84,11 +84,3 @@ The group is rendered with `role="group"`. Every button keeps its own tab stop, so Tab steps through the group button by button. Give icon-only buttons an `aria-label`, and label the group itself with `aria-label` when the surrounding content doesn't already name it. - -## CSS Variables - -Use CSS variables to customize the button group. - -| Variable | -| ---------------------------------- | -| `--kbq-button-group-divider-color` | diff --git a/packages/components/src/components/ButtonGroup/ButtonGroup.module.css b/packages/components/src/components/ButtonGroup/ButtonGroup.module.css index b8b08e0be..be8226967 100644 --- a/packages/components/src/components/ButtonGroup/ButtonGroup.module.css +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.module.css @@ -1,17 +1,15 @@ .base { - --button-group-divider-color: var(--kbq-background-transparent); - display: inline-flex; align-items: stretch; & > [data-slot='button']:not(:last-child) { - box-shadow: var(--button-group-divider-shadow); + box-shadow: var(--button-group-divider); } } .horizontal { - --button-group-divider-shadow: var(--kbq-size-border-width) 0 0 - var(--kbq-button-group-divider-color, var(--button-group-divider-color)); + --button-group-divider: var(--kbq-size-border-width) 0 0 + var(--button-group-divider-color); flex-direction: row; @@ -38,8 +36,8 @@ } .vertical { - --button-group-divider-shadow: 0 var(--kbq-size-border-width) 0 - var(--kbq-button-group-divider-color, var(--button-group-divider-color)); + --button-group-divider: 0 var(--kbq-size-border-width) 0 + var(--button-group-divider-color); flex-direction: column; @@ -88,3 +86,7 @@ .theme-transparent { --button-group-divider-color: var(--kbq-background-transparent); } + +.disabled:is(.fade-contrast-outline, .fade-theme-outline) { + --button-group-divider-color: var(--kbq-states-line-disabled); +} diff --git a/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx b/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx index 880e6826f..1b6025596 100644 --- a/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx @@ -16,6 +16,8 @@ import { Typography } from '../Typography'; import { ButtonGroup, type ButtonGroupProps } from './index'; +import './__stories__/styles.css'; + const meta = { title: 'Components/ButtonGroup', component: ButtonGroup, @@ -197,95 +199,39 @@ export const SplitButton: Story = { ]; return ( - <> - -
- {splitButtonVariants.map(({ label, variant }) => ( - + {splitButtonVariants.map(({ label, variant }) => ( + + {label} + - {label} - }>Split Button + ( + - ( - - - - ))} -
- + Create from template + Import + + + + ))} +
); }, }; diff --git a/packages/components/src/components/ButtonGroup/ButtonGroup.tsx b/packages/components/src/components/ButtonGroup/ButtonGroup.tsx index 689c4bce5..b4911279b 100644 --- a/packages/components/src/components/ButtonGroup/ButtonGroup.tsx +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.tsx @@ -34,7 +34,13 @@ export const ButtonGroup = polymorphicForwardRef<'div', ButtonGroupBaseProps>( data-variant={variant} data-orientation={orientation} data-disabled={isDisabled || undefined} - className={clsx(s.base, s[orientation], s[variant], className)} + className={clsx( + s.base, + s[variant], + s[orientation], + isDisabled && s.disabled, + className + )} {...other} ref={ref} > diff --git a/packages/components/src/components/ButtonGroup/__stories__/styles.css b/packages/components/src/components/ButtonGroup/__stories__/styles.css new file mode 100644 index 000000000..c10559390 --- /dev/null +++ b/packages/components/src/components/ButtonGroup/__stories__/styles.css @@ -0,0 +1,45 @@ +.split-button-demo { + display: grid; + grid-template-columns: repeat(2, max-content); + gap: var(--kbq-size-3xl) var(--kbq-size-6xl); +} + +.split-button-demo__group > [data-slot='button']:not(:last-child) { + margin-inline-end: 0; + box-shadow: none; +} + +.split-button-demo__group[data-variant='contrast-filled'] { + --split-button-divider-color: var(--kbq-line-on-contrast-fade); +} + +.split-button-demo__group[data-variant='fade-contrast-filled'], +.split-button-demo__group[data-variant='fade-contrast-outline'], +.split-button-demo__group[data-variant='contrast-transparent'] { + --split-button-divider-color: var(--kbq-line-contrast-fade); +} + +.split-button-demo__group[data-variant='fade-theme-outline'], +.split-button-demo__group[data-variant='theme-transparent'] { + --split-button-divider-color: var(--kbq-line-theme-fade); +} + +.split-button-demo__group > [data-slot='button']:last-child { + background-image: linear-gradient( + var(--split-button-divider-color), + var(--split-button-divider-color) + ); + background-repeat: no-repeat; + background-position: 0 50%; + background-size: var(--kbq-size-border-width) var(--kbq-size-l); +} + +.split-button-demo__group + > [data-slot='button']:last-child[aria-expanded='true'], +.split-button-demo__group + > [data-slot='button']:last-child:is(:hover, :focus-visible), +.split-button-demo__group + > [data-slot='button']:not(:last-child):is(:hover, :focus-visible) + + [data-slot='button']:last-child { + background-image: none; +} From 125a362919b20ef7d1d641629ae27128b7f4a662 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 30 Jul 2026 14:16:19 +0300 Subject: [PATCH 5/6] chore(ButtonGroup): improve stories --- .../components/ButtonGroup/ButtonGroup.stories.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx b/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx index 1b6025596..416936f7a 100644 --- a/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx @@ -1,6 +1,6 @@ import { IconBoxArchive16, - IconChevronDown16, + IconChevronDownS16, IconClock16, IconEllipsisVertical16, IconMinus16, @@ -61,17 +61,17 @@ export const Content: Story = { - @@ -105,10 +105,10 @@ export const Content: Story = { export const MixedContent: Story = { render: () => ( - - + @@ -122,7 +127,7 @@ export const MixedContent: Story = { export const OnlyIcon: Story = { render: () => ( - {buttonPropVariant.map((variant) => ( + {buttonGroupPropVariant.map((variant) => (