Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .storybook/components/Roadmap/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,4 +374,10 @@ export const rows: Rows = [
stage: '🔵 experimental',
planned: 'Q3 2026',
},
{
component: 'ButtonGroup',
status: '✅ Done',
stage: '🔵 experimental',
planned: 'Q3 2026',
},
];
13 changes: 11 additions & 2 deletions packages/components/src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/components/Button/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
86 changes: 86 additions & 0 deletions packages/components/src/components/ButtonGroup/ButtonGroup.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
Meta,
Story,
Props,
Status,
} from '../../../../../.storybook/components';

import * as Stories from './ButtonGroup.stories';

<Meta of={Stories} />

# ButtonGroup

<Status variant="experimental" />

The ButtonGroup joins buttons that belong together.

## Import

```tsx
import { ButtonGroup, Button } from '@koobiq/react-components';
```

## Usage

<Story of={Stories.Base} />

## Props

<Props of={Stories.Base} />

## Variant

The `variant` prop sets the look of every button in the group and wins over the
`variant` set on a button.

<Story of={Stories.Variant} />

## 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.

<Story of={Stories.Content} />

Buttons with different content can go into one group.

<Story of={Stories.MixedContent} />

## Only icon

<Story of={Stories.OnlyIcon} />

## 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.

<Story of={Stories.Orientation} />

## Disabled

The `isDisabled` prop disables every button in the group. A button can also be
disabled on its own.

<Story of={Stories.Disabled} />

## Loading

Use the `isLoading` prop on a button to show a loader and block clicks.

<Story of={Stories.Loading} />

## Root tag

Use the `as` prop to redefine the HTML-tag of the component to your own.

<Story of={Stories.RootTag} />

## Accessibility

The group is rendered with `role="group"`. Every button keeps its own tab stop,
so <kbd>Tab</kbd> 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.
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading