Skip to content

Commit d6f08cd

Browse files
committed
feat(ButtonSplit): add component
1 parent 1fd2c4e commit d6f08cd

14 files changed

Lines changed: 686 additions & 29 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cube-dev/ui-kit": patch
3+
---
4+
5+
**Button:** The dynamic `icon` callback now receives `pressed` in its mods argument. Use it to change the icon when the button is pressed (e.g., arrow up when menu is open, arrow down when closed).

.changeset/button-split.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cube-dev/ui-kit": minor
3+
---
4+
5+
**Button.Split:** New compound component for split-button patterns. Supports two modes: **custom** (arbitrary `<Button>` children with joined radius) and **strict** (declarative `actions` array with built-in dropdown menu, controlled/uncontrolled selection). `type`, `theme`, `size`, and `isDisabled` are inherited by child buttons via context.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@
111111
"@storybook/addon-docs": "^10.2.3",
112112
"@storybook/addon-links": "^10.2.3",
113113
"@storybook/react-vite": "^10.2.3",
114-
"@tenphi/eslint-plugin-tasty": "^0.3.0",
114+
"@tenphi/eslint-plugin-tasty": "^0.3.1",
115115
"@testing-library/dom": "^10.4.1",
116116
"@testing-library/jest-dom": "^6.7.0",
117117
"@testing-library/react": "^16.3.0",

pnpm-lock.yaml

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/actions/Button/Button.tsx

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import { DisplayTransition } from '../../helpers/DisplayTransition';
6666
import { IconSwitch } from '../../helpers/IconSwitch/IconSwitch';
6767
import { CubeTooltipProviderProps } from '../../overlays/Tooltip/TooltipProvider';
6868
import { CubeActionProps } from '../Action/Action';
69+
import { useButtonSplitContext } from '../ButtonSplit/context';
6970
import { useAction } from '../use-action';
7071

7172
const BUTTON_SIZE_VALUES = [
@@ -79,6 +80,7 @@ const BUTTON_SIZE_VALUES = [
7980

8081
/** Known modifiers for Button component */
8182
export type ButtonMods = Mods<{
83+
pressed?: boolean;
8284
loading?: boolean;
8385
selected?: boolean;
8486
'has-icons'?: boolean;
@@ -222,6 +224,18 @@ export const DEFAULT_BUTTON_STYLES: Styles = {
222224
radius: {
223225
'': true,
224226
'type=link & !focused': 0,
227+
'@parent(button-split, >) & !:last-child': '1r left',
228+
'@parent(button-split, >) & !:first-child': '1r right',
229+
'@parent(button-split, >) & !:first-child & !:last-child': 0,
230+
},
231+
margin: {
232+
'': 0,
233+
'@parent(button-split, >) & !:first-child & (type=secondary | type=outline | type=primary)':
234+
'-1bw left',
235+
},
236+
zIndex: {
237+
'@parent(button-split, >) & :hover': 1,
238+
'@parent(button-split, >) & :focus-visible': 2,
225239
},
226240
transition: 'theme, grid-template, padding',
227241
verticalAlign: 'bottom',
@@ -364,12 +378,14 @@ export const Button = forwardRef(function Button(
364378
allProps: CubeButtonProps,
365379
ref: FocusableRef<HTMLElement>,
366380
) {
381+
const splitContext = useButtonSplitContext();
382+
367383
let {
368384
type,
369385
size: sizeProp,
370386
label,
371387
children,
372-
theme = 'default',
388+
theme = splitContext?.theme ?? 'default',
373389
icon: iconProp,
374390
rightIcon: rightIconProp,
375391
mods,
@@ -379,20 +395,34 @@ export const Button = forwardRef(function Button(
379395
...props
380396
} = allProps;
381397

382-
const size = sizeProp ?? (type === 'link' ? 'inline' : 'medium');
398+
type = type ?? splitContext?.type;
399+
const size =
400+
sizeProp ?? splitContext?.size ?? (type === 'link' ? 'inline' : 'medium');
383401

384-
const isDisabled = props.isDisabled ?? props.isLoading;
402+
const isDisabled =
403+
props.isDisabled ?? props.isLoading ?? splitContext?.isDisabled;
385404
const isLoading = props.isLoading;
386405
const isSelected = props.isSelected;
387406

407+
const { actionProps, isPressed } = useAction(
408+
{ ...allProps, isDisabled, ...(label ? { label } : {}) },
409+
ref,
410+
);
411+
412+
const styles = extractStyles(props, STYLE_PROPS);
413+
const isDisabledElement = actionProps.isDisabled;
414+
415+
delete actionProps.isDisabled;
416+
388417
// Base mods for icon resolution (without icon-dependent mods)
389418
const baseMods = useMemo<ButtonMods>(
390419
() => ({
420+
pressed: isPressed && !isDisabled,
391421
loading: isLoading,
392422
selected: isSelected,
393423
...mods,
394424
}),
395-
[isLoading, isSelected, mods],
425+
[isPressed, isDisabled, isLoading, isSelected, mods],
396426
);
397427

398428
// Resolve dynamic icon props
@@ -499,16 +529,6 @@ export const Button = forwardRef(function Button(
499529
],
500530
);
501531

502-
const { actionProps } = useAction(
503-
{ ...allProps, isDisabled, mods: modifiers, ...(label ? { label } : {}) },
504-
ref,
505-
);
506-
507-
const styles = extractStyles(props, STYLE_PROPS);
508-
const isDisabledElement = actionProps.isDisabled;
509-
510-
delete actionProps.isDisabled;
511-
512532
const {
513533
labelProps: finalLabelProps,
514534
labelRef,
@@ -552,6 +572,7 @@ export const Button = forwardRef(function Button(
552572
download={download}
553573
{...mergeProps(actionProps, tooltipTriggerProps || {})}
554574
ref={handleRef}
575+
mods={{ ...actionProps.mods, ...modifiers }}
555576
disabled={isDisabledElement}
556577
variant={`${theme}.${type ?? 'outline'}` as ButtonVariant}
557578
data-theme={theme}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { Meta, Story, Controls } from '@storybook/addon-docs/blocks';
2+
import * as ButtonSplitStories from './ButtonSplit.stories';
3+
4+
<Meta of={ButtonSplitStories} />
5+
6+
# Button.Split
7+
8+
A split button that groups a primary action with a dropdown trigger for secondary actions. Supports two modes: **custom** (arbitrary button children) and **strict** (declarative action list with built-in menu).
9+
10+
## When to Use
11+
12+
- Present a default action alongside alternative actions in a compact control
13+
- Allow users to switch between related operations (e.g., deploy targets, save modes)
14+
- Group two or more buttons visually as a single connected unit
15+
16+
## Component
17+
18+
<Story of={ButtonSplitStories.Default} />
19+
20+
---
21+
22+
## Properties
23+
24+
<Controls />
25+
26+
### Base Properties
27+
28+
Supports [Base properties](https://github.com/tenphi/tasty/blob/main/docs/tasty.md)
29+
30+
### Styling Properties
31+
32+
#### styles
33+
34+
Customises the root wrapper element of the split button.
35+
36+
### Style Properties
37+
38+
These properties allow direct styling without using the `styles` prop: `width`, `height`.
39+
40+
## Modes
41+
42+
### Strict Mode
43+
44+
Pass an `actions` array to automatically render an action button and a trigger button with a dropdown menu. The trigger displays a `DirectionIcon` pointing down.
45+
46+
```jsx
47+
import { Button } from '@cube-dev/ui-kit';
48+
49+
const actions = [
50+
{ key: 'deploy', label: 'Deploy', icon: <IconSend /> },
51+
{ key: 'deploy-staging', label: 'Deploy to Staging', icon: <IconSend /> },
52+
{ key: 'deploy-preview', label: 'Deploy Preview', icon: <IconPlayerPlay /> },
53+
];
54+
55+
<Button.Split
56+
actions={actions}
57+
defaultActionKey="deploy"
58+
type="primary"
59+
onAction={(key) => console.log('Executed:', key)}
60+
onActionChange={(key) => console.log('Switched to:', key)}
61+
/>
62+
```
63+
64+
<Story of={ButtonSplitStories.StrictUncontrolled} />
65+
66+
#### Controlled
67+
68+
Use `actionKey` and `onActionChange` for controlled state.
69+
70+
```jsx
71+
const [currentKey, setCurrentKey] = useState('deploy');
72+
73+
<Button.Split
74+
actions={actions}
75+
actionKey={currentKey}
76+
type="primary"
77+
onAction={(key) => console.log('Executed:', key)}
78+
onActionChange={setCurrentKey}
79+
/>
80+
```
81+
82+
<Story of={ButtonSplitStories.StrictControlled} />
83+
84+
### Custom Mode
85+
86+
Pass `children` to render arbitrary buttons with split-group styling (joined radius, collapsed borders). Use `Menu.Trigger` and `Menu` for dropdown behavior.
87+
88+
```jsx
89+
import { Button, Menu } from '@cube-dev/ui-kit';
90+
91+
<Button.Split>
92+
<Button type="primary" onPress={() => console.log('Save')}>
93+
Save
94+
</Button>
95+
<Menu.Trigger placement="bottom end">
96+
<Button type="primary" aria-label="More save options" icon={true} />
97+
<Menu onAction={(key) => console.log(key)}>
98+
<Menu.Item key="save-draft">Save as Draft</Menu.Item>
99+
<Menu.Item key="save-publish">Save & Publish</Menu.Item>
100+
</Menu>
101+
</Menu.Trigger>
102+
</Button.Split>
103+
```
104+
105+
<Story of={ButtonSplitStories.Custom} />
106+
107+
### Multiple Buttons
108+
109+
More than two buttons are supported. Middle buttons have no border radius.
110+
111+
```jsx
112+
<Button.Split>
113+
<Button type="outline" icon={<IconPlus />}>Add</Button>
114+
<Button type="outline" icon={<IconCopy />}>Copy</Button>
115+
<Button type="outline" icon={<IconDownload />}>Export</Button>
116+
</Button.Split>
117+
```
118+
119+
<Story of={ButtonSplitStories.ThreeButtons} />
120+
121+
## Examples
122+
123+
### Variants
124+
125+
Different types and themes can be applied in strict mode.
126+
127+
<Story of={ButtonSplitStories.Variants} />
128+
129+
```jsx
130+
<Button.Split actions={actions} defaultActionKey="copy" type="primary" />
131+
<Button.Split actions={actions} defaultActionKey="copy" type="secondary" />
132+
<Button.Split actions={actions} defaultActionKey="copy" type="outline" />
133+
<Button.Split actions={actions} defaultActionKey="copy" type="primary" theme="danger" />
134+
<Button.Split actions={actions} defaultActionKey="copy" type="primary" isDisabled />
135+
```
136+
137+
### Customizing Sub-Components
138+
139+
Use `actionProps`, `triggerProps`, and `menuProps` to customise individual parts.
140+
141+
```jsx
142+
<Button.Split
143+
actions={actions}
144+
defaultActionKey="deploy"
145+
type="primary"
146+
actionProps={{ size: 'large' }}
147+
triggerProps={{ 'aria-label': 'Select deploy target' }}
148+
menuProps={{ placement: 'bottom start' }}
149+
/>
150+
```
151+
152+
## Accessibility
153+
154+
### Keyboard Navigation
155+
156+
- `Tab` -- Moves focus between the action button and the trigger button.
157+
- `Space` / `Enter` -- Activates the focused button. On the trigger, opens the dropdown menu.
158+
- `Arrow Down` -- When the menu is open, navigates menu items.
159+
- `Escape` -- Closes the dropdown menu and returns focus to the trigger.
160+
161+
### Screen Reader Support
162+
163+
- The action button announces its label.
164+
- The trigger button has `aria-label="More options"` by default (override via `triggerProps`).
165+
- The dropdown menu announces selection changes.
166+
167+
### ARIA Properties
168+
169+
- `aria-label` -- Provide on the trigger for context (e.g., "Select deploy target").
170+
- Menu items support `aria-disabled` via the `isDisabled` property on actions.
171+
172+
## Best Practices
173+
174+
1. **Do**: Use strict mode for standard action+trigger patterns.
175+
```jsx
176+
<Button.Split actions={actions} defaultActionKey="deploy" type="primary" />
177+
```
178+
179+
2. **Do**: Use custom mode when you need full control over button content and behavior.
180+
```jsx
181+
<Button.Split>
182+
<Button>Primary</Button>
183+
<Button icon={<DirectionIcon to="down" />} aria-label="Options" />
184+
</Button.Split>
185+
```
186+
187+
3. **Don't**: Mix `actions` prop with `children`. Use one mode or the other.
188+
189+
4. **Accessibility**: Always provide `aria-label` on icon-only trigger buttons.
190+
191+
## Suggested Improvements
192+
193+
- Add `size` variants demo for strict mode across all sizes.
194+
- Support `isLoading` on the action button in strict mode.
195+
- Add keyboard shortcut display in menu items via `hotkeys` prop.
196+
197+
## Related Components
198+
199+
- [Button](/docs/actions-button--docs) -- Standalone action button.
200+
- [Menu](/docs/actions-menu--docs) -- Dropdown menu for action lists.
201+
- [Button.Group](/docs/actions-buttongroup--docs) -- Layout wrapper for spacing multiple buttons.

0 commit comments

Comments
 (0)