diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index 1585e632..58f6a3ea 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 838c7ffa..837bbd16 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 5a701913..cb4f668e 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 00000000..e14d9611 --- /dev/null +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.mdx @@ -0,0 +1,86 @@ +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. 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 00000000..be822696 --- /dev/null +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.module.css @@ -0,0 +1,92 @@ +.base { + display: inline-flex; + align-items: stretch; + + & > [data-slot='button']:not(:last-child) { + box-shadow: var(--button-group-divider); + } +} + +.horizontal { + --button-group-divider: var(--kbq-size-border-width) 0 0 + var(--button-group-divider-color); + + 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; + margin-inline-end: var(--kbq-size-border-width); + + &::before { + border-inline-end-width: 0; + } + } + } +} + +.vertical { + --button-group-divider: 0 var(--kbq-size-border-width) 0 + var(--button-group-divider-color); + + 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; + margin-block-end: var(--kbq-size-border-width); + + &::before { + border-block-end-width: 0; + } + } + } +} + +.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); +} + +.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 new file mode 100644 index 00000000..88d7e0e3 --- /dev/null +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.stories.tsx @@ -0,0 +1,242 @@ +import { + IconBoxArchive16, + IconChevronDownS16, + IconClock16, + IconEllipsisVertical16, + IconMinus16, + IconPlus16, + IconTriangleExclamation16, +} from '@koobiq/react-icons'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Button } from '../Button'; +import { FlexBox } from '../FlexBox'; +import { Menu } from '../Menu'; +import { Typography } from '../Typography'; + +import { + ButtonGroup, + type ButtonGroupProps, + type ButtonGroupPropVariant, + buttonGroupPropVariant, +} from './index'; + +import './__stories__/styles.css'; + +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: () => ( + + {buttonGroupPropVariant.map((variant) => ( + + + + + + ))} + + ), +}; + +export const Content: Story = { + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), +}; + +export const Loading: Story = { + render: () => ( + + + + + + ), +}; + +export const RootTag: Story = { + render: () => ( + + + + + + ), +}; + +export const SplitButton: Story = { + render: () => { + const splitButtonVariants: { + label: string; + variant: ButtonGroupPropVariant; + }[] = [ + { 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} + + + ( + + + + ))} +
+ ); + }, +}; 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 00000000..bde31716 --- /dev/null +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.test.tsx @@ -0,0 +1,181 @@ +import { createRef } from 'react'; + +import { screen, render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; + +import { Button } from '../Button'; + +import { + ButtonGroup, + buttonGroupPropOrientation, + buttonGroupPropVariant, +} 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(buttonGroupPropVariant)( + '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 00000000..b4911279 --- /dev/null +++ b/packages/components/src/components/ButtonGroup/ButtonGroup.tsx @@ -0,0 +1,57 @@ +'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 00000000..a01a461c --- /dev/null +++ b/packages/components/src/components/ButtonGroup/ButtonGroupContext.ts @@ -0,0 +1,18 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +import type { ButtonGroupPropVariant } from './types'; + +export type ButtonGroupContextProps = { + /** The variant applied to every nested button, overriding their own `variant`. */ + variant?: ButtonGroupPropVariant; + /** 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/__stories__/styles.css b/packages/components/src/components/ButtonGroup/__stories__/styles.css new file mode 100644 index 00000000..c1055939 --- /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; +} diff --git a/packages/components/src/components/ButtonGroup/index.ts b/packages/components/src/components/ButtonGroup/index.ts new file mode 100644 index 00000000..8d1f8a4d --- /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 00000000..1ac586c2 --- /dev/null +++ b/packages/components/src/components/ButtonGroup/types.ts @@ -0,0 +1,35 @@ +import type { CSSProperties, ReactNode } from 'react'; + +import { buttonPropVariant } from '../Button/types.js'; + +export const buttonGroupPropOrientation = ['horizontal', 'vertical'] as const; + +export type ButtonGroupPropOrientation = + (typeof buttonGroupPropOrientation)[number]; + +export const buttonGroupPropVariant = buttonPropVariant; + +export type ButtonGroupPropVariant = (typeof buttonGroupPropVariant)[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?: ButtonGroupPropVariant; + /** 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 5a00d1d6..2736181b 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 babae623..b1b17b23 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 461191c9..7550bda9 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 00000000..bab0c947 --- /dev/null +++ b/tools/public_api_guard/components/ButtonGroup.api.md @@ -0,0 +1,57 @@ +## 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?: ButtonGroupPropVariant; + isDisabled?: boolean; + className?: string; + style?: CSSProperties; + 'data-testid'?: string | number; +}; + +// @public (undocumented) +export const ButtonGroupContext: Context; + +// @public (undocumented) +export type ButtonGroupContextProps = { + variant?: ButtonGroupPropVariant; + 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 type ButtonGroupPropVariant = (typeof buttonGroupPropVariant)[number]; + +// @public (undocumented) +export const buttonGroupPropVariant: readonly ["contrast-filled", "fade-contrast-filled", "fade-contrast-outline", "fade-theme-outline", "contrast-transparent", "theme-transparent"]; + +// @public (undocumented) +export function useButtonGroupContext(): ButtonGroupContextProps; + +// (No @packageDocumentation comment for this package) + +```