Skip to content

Commit 5d8e416

Browse files
authored
feat(components): add ButtonGroup component (DS-5244) (#436)
1 parent fdfc45f commit 5d8e416

16 files changed

Lines changed: 837 additions & 4 deletions

File tree

.storybook/components/Roadmap/data.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,4 +374,10 @@ export const rows: Rows = [
374374
stage: '🔵 experimental',
375375
planned: 'Q3 2026',
376376
},
377+
{
378+
component: 'ButtonGroup',
379+
status: '✅ Done',
380+
stage: '🔵 experimental',
381+
planned: 'Q3 2026',
382+
},
377383
];

packages/components/src/components/Button/Button.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { clsx, polymorphicForwardRef } from '@koobiq/react-core';
77
import type { ButtonBaseProps as ButtonPrimitiveProps } from '@koobiq/react-primitives';
88
import { Button as ButtonPrimitive } from '@koobiq/react-primitives';
99

10+
import { useButtonGroupContext } from '../ButtonGroup';
11+
1012
import s from './Button.module.css';
1113
import type { ButtonBaseProps } from './types.js';
1214

@@ -15,7 +17,7 @@ export const Button = polymorphicForwardRef<'button', ButtonBaseProps>(
1517
(props, ref) => {
1618
const {
1719
as: Tag = 'button',
18-
variant = 'contrast-filled',
20+
variant: variantProp,
1921
onlyIcon,
2022
fullWidth,
2123
isLoading: isLoadingProp,
@@ -29,8 +31,15 @@ export const Button = polymorphicForwardRef<'button', ButtonBaseProps>(
2931
...other
3032
} = props;
3133

34+
const group = useButtonGroupContext();
35+
3236
const isLoading = isLoadingProp ?? progress;
33-
const isDisabled = isDisabledProp ?? disabled;
37+
38+
// Inside a group the group's variant wins, so the group looks like one control.
39+
const variant = group.variant ?? variantProp ?? 'contrast-filled';
40+
41+
// The group wins when it disables, but a single button can still disable itself.
42+
const isDisabled = (isDisabledProp ?? disabled) || group.isDisabled;
3443

3544
if (process.env.NODE_ENV !== 'production' && 'progress' in props) {
3645
deprecate(

packages/components/src/components/Button/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import type { ButtonBaseProps as ButtonBasePrimitiveProps } from '@koobiq/react-
55

66
export const buttonPropVariant = [
77
'contrast-filled',
8-
'contrast-transparent',
98
'fade-contrast-filled',
109
'fade-contrast-outline',
1110
'fade-theme-outline',
11+
'contrast-transparent',
1212
'theme-transparent',
1313
] as const;
1414

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import {
2+
Meta,
3+
Story,
4+
Props,
5+
Status,
6+
} from '../../../../../.storybook/components';
7+
8+
import * as Stories from './ButtonGroup.stories';
9+
10+
<Meta of={Stories} />
11+
12+
# ButtonGroup
13+
14+
<Status variant="experimental" />
15+
16+
The ButtonGroup joins buttons that belong together.
17+
18+
## Import
19+
20+
```tsx
21+
import { ButtonGroup, Button } from '@koobiq/react-components';
22+
```
23+
24+
## Usage
25+
26+
<Story of={Stories.Base} />
27+
28+
## Props
29+
30+
<Props of={Stories.Base} />
31+
32+
## Variant
33+
34+
The `variant` prop sets the look of every button in the group and wins over the
35+
`variant` set on a button.
36+
37+
<Story of={Stories.Variant} />
38+
39+
## Content
40+
41+
A button in the group can hold a label, an icon, or both. Add a caret with
42+
`endIcon` to make it a menu button.
43+
44+
<Story of={Stories.Content} />
45+
46+
Buttons with different content can go into one group.
47+
48+
<Story of={Stories.MixedContent} />
49+
50+
## Only icon
51+
52+
<Story of={Stories.OnlyIcon} />
53+
54+
## Orientation
55+
56+
Use the `orientation` prop to stack the buttons in a column. Only icon buttons
57+
can go into a vertical group. Groups with no fill and no border have no divider
58+
between the buttons.
59+
60+
<Story of={Stories.Orientation} />
61+
62+
## Disabled
63+
64+
The `isDisabled` prop disables every button in the group. A button can also be
65+
disabled on its own.
66+
67+
<Story of={Stories.Disabled} />
68+
69+
## Loading
70+
71+
Use the `isLoading` prop on a button to show a loader and block clicks.
72+
73+
<Story of={Stories.Loading} />
74+
75+
## Root tag
76+
77+
Use the `as` prop to redefine the HTML-tag of the component to your own.
78+
79+
<Story of={Stories.RootTag} />
80+
81+
## Accessibility
82+
83+
The group is rendered with `role="group"`. Every button keeps its own tab stop,
84+
so <kbd>Tab</kbd> steps through the group button by button. Give icon-only
85+
buttons an `aria-label`, and label the group itself with `aria-label` when the
86+
surrounding content doesn't already name it.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
.base {
2+
display: inline-flex;
3+
align-items: stretch;
4+
5+
& > [data-slot='button']:not(:last-child) {
6+
box-shadow: var(--button-group-divider);
7+
}
8+
}
9+
10+
.horizontal {
11+
--button-group-divider: var(--kbq-size-border-width) 0 0
12+
var(--button-group-divider-color);
13+
14+
flex-direction: row;
15+
16+
& > [data-slot='button'] {
17+
&:not(:first-child) {
18+
border-start-start-radius: 0;
19+
border-end-start-radius: 0;
20+
21+
&::before {
22+
border-inline-start-width: 0;
23+
}
24+
}
25+
26+
&:not(:last-child) {
27+
border-start-end-radius: 0;
28+
border-end-end-radius: 0;
29+
margin-inline-end: var(--kbq-size-border-width);
30+
31+
&::before {
32+
border-inline-end-width: 0;
33+
}
34+
}
35+
}
36+
}
37+
38+
.vertical {
39+
--button-group-divider: 0 var(--kbq-size-border-width) 0
40+
var(--button-group-divider-color);
41+
42+
flex-direction: column;
43+
44+
& > [data-slot='button'] {
45+
&:not(:first-child) {
46+
border-start-start-radius: 0;
47+
border-start-end-radius: 0;
48+
49+
&::before {
50+
border-block-start-width: 0;
51+
}
52+
}
53+
54+
&:not(:last-child) {
55+
border-end-start-radius: 0;
56+
border-end-end-radius: 0;
57+
margin-block-end: var(--kbq-size-border-width);
58+
59+
&::before {
60+
border-block-end-width: 0;
61+
}
62+
}
63+
}
64+
}
65+
66+
.contrast-filled {
67+
--button-group-divider-color: var(--kbq-line-on-contrast-fade);
68+
}
69+
70+
.fade-contrast-filled {
71+
--button-group-divider-color: var(--kbq-line-contrast-fade);
72+
}
73+
74+
.fade-contrast-outline {
75+
--button-group-divider-color: var(--kbq-line-contrast-fade);
76+
}
77+
78+
.fade-theme-outline {
79+
--button-group-divider-color: var(--kbq-line-theme-fade);
80+
}
81+
82+
.contrast-transparent {
83+
--button-group-divider-color: var(--kbq-background-transparent);
84+
}
85+
86+
.theme-transparent {
87+
--button-group-divider-color: var(--kbq-background-transparent);
88+
}
89+
90+
.disabled:is(.fade-contrast-outline, .fade-theme-outline) {
91+
--button-group-divider-color: var(--kbq-states-line-disabled);
92+
}

0 commit comments

Comments
 (0)