Skip to content

Commit cb6340e

Browse files
authored
feat(Button): rework (#923)
1 parent 0a5a32f commit cb6340e

File tree

8 files changed

+605
-500
lines changed

8 files changed

+605
-500
lines changed

.changeset/fresh-pumas-knock.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+
Rework of Button component to align its implementation and layout with Item and ItemButton components.

.size-limit.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ module.exports = [
2727
path: './dist/es/index.js',
2828
webpack: true,
2929
import: '{ Button }',
30-
limit: '35 kB',
30+
limit: '45 kB',
3131
},
3232
{
3333
name: 'Tree shaking (just an Icon)',

src/components/actions/Button/Button.tsx

Lines changed: 174 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { FocusableRef } from '@react-types/shared';
2-
import { cloneElement, forwardRef, ReactElement, useMemo } from 'react';
2+
import {
3+
forwardRef,
4+
HTMLAttributes,
5+
ReactElement,
6+
ReactNode,
7+
RefObject,
8+
useMemo,
9+
} from 'react';
10+
import { OverlayProps } from 'react-aria';
311

412
import { useWarn } from '../../../_internal/hooks/use-warn';
513
import {
@@ -32,10 +40,13 @@ import { LoadingIcon } from '../../../icons';
3240
import {
3341
CONTAINER_STYLES,
3442
extractStyles,
43+
Styles,
3544
tasty,
3645
TEXT_STYLES,
3746
} from '../../../tasty';
38-
import { Text } from '../../content/Text';
47+
import { mergeProps } from '../../../utils/react';
48+
import { useAutoTooltip } from '../../content/use-auto-tooltip';
49+
import { CubeTooltipProviderProps } from '../../overlays/Tooltip/TooltipProvider';
3950
import { CubeActionProps } from '../Action/Action';
4051
import { useAction } from '../use-action';
4152

@@ -54,6 +65,22 @@ export interface CubeButtonProps extends CubeActionProps {
5465
| 'neutral'
5566
| (string & {});
5667
size?: 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | (string & {});
68+
/**
69+
* Tooltip content and configuration:
70+
* - string: simple tooltip text
71+
* - true: auto tooltip on overflow (shows children as tooltip when truncated)
72+
* - object: advanced configuration with optional auto property
73+
*/
74+
tooltip?:
75+
| string
76+
| boolean
77+
| (Omit<CubeTooltipProviderProps, 'children'> & { auto?: boolean });
78+
/**
79+
* @private
80+
* Default tooltip placement for the button.
81+
* @default "top"
82+
*/
83+
defaultTooltipPlacement?: OverlayProps['placement'];
5784
}
5885

5986
export type ButtonVariant =
@@ -84,18 +111,28 @@ export type ButtonVariant =
84111

85112
const STYLE_PROPS = [...CONTAINER_STYLES, ...TEXT_STYLES];
86113

114+
const DEFAULT_ICON_STYLES: Styles = {
115+
$: '>',
116+
display: 'grid',
117+
placeItems: 'center',
118+
placeContent: 'stretch',
119+
aspectRatio: '1 / 1',
120+
width: '($size - 2bw)',
121+
};
122+
87123
export const DEFAULT_BUTTON_STYLES = {
88124
display: 'inline-grid',
89-
flow: 'column',
90-
placeItems: 'center start',
91-
placeContent: {
92-
'': 'center',
93-
'right-icon | suffix': 'center stretch',
125+
flow: 'column dense',
126+
gap: 0,
127+
gridTemplate: {
128+
'': '"icon label rightIcon" auto / max-content 1sf max-content',
129+
'raw-children': 'initial',
94130
},
95-
gridColumns: {
96-
'': 'initial',
97-
'left-icon | loading | prefix': 'max-content',
131+
placeItems: {
132+
'': 'stretch',
133+
'raw-children': 'center stretch',
98134
},
135+
placeContent: 'center stretch',
99136
position: 'relative',
100137
margin: 0,
101138
boxSizing: 'border-box',
@@ -105,10 +142,6 @@ export const DEFAULT_BUTTON_STYLES = {
105142
':is(button)': '$pointer',
106143
disabled: 'not-allowed',
107144
},
108-
gap: {
109-
'': '.75x',
110-
'size=small': '.5x',
111-
},
112145
preset: {
113146
'': 't3m',
114147
'size=xsmall': 't4',
@@ -120,16 +153,14 @@ export const DEFAULT_BUTTON_STYLES = {
120153
outline: 0,
121154
outlineOffset: 1,
122155
padding: {
123-
'': '.5x (1.5x - 1bw)',
124-
'size=small | size=xsmall': '.5x (1.25x - 1bw)',
125-
'size=medium': '.5x (1.5x - 1bw)',
126-
'size=large': '.5x (1.75x - 1bw)',
127-
'size=xlarge': '.5x (2x - 1bw)',
128-
'single-icon | type=link': 0,
156+
'': 0,
157+
'raw-children':
158+
'$block-padding $label-padding-right $block-padding $label-padding-left',
159+
'raw-children & type=link': 0,
129160
},
130161
width: {
131162
'': 'min $size',
132-
'left-icon & right-icon': 'min ($size * 2 - 2bw)',
163+
'has-icon & has-right-icon': 'min ($size * 2 - 2bw)',
133164
'single-icon': 'fixed $size',
134165
'type=link': 'min 1ch',
135166
},
@@ -151,19 +182,57 @@ export const DEFAULT_BUTTON_STYLES = {
151182
'size=large': '$size-lg',
152183
'size=xlarge': '$size-xl',
153184
},
185+
'$inline-padding': {
186+
'': 'max($min-inline-padding, (($size - 1lh - 2bw) / 2 + $inline-compensation))',
187+
},
188+
'$block-padding': {
189+
'': '.5x',
190+
'size=xsmall | size=small': '.25x',
191+
},
192+
'$inline-compensation': '.5x',
193+
'$min-inline-padding': '(1x - 1bw)',
194+
'$label-padding-left': {
195+
'': '$inline-padding',
196+
'has-icon': 0,
197+
},
198+
'$label-padding-right': {
199+
'': '$inline-padding',
200+
'has-right-icon': 0,
201+
},
154202

155-
ButtonIcon: {
156-
width: 'min 1fs',
203+
// Icon sub-element (recommended format)
204+
Icon: {
205+
...DEFAULT_ICON_STYLES,
206+
gridArea: 'icon',
157207
},
158208

159-
'& [data-element="ButtonIcon"]:first-child:not(:last-child)': {
160-
marginLeft: '-.5x',
161-
placeSelf: 'center start',
209+
// RightIcon sub-element (recommended format)
210+
RightIcon: {
211+
...DEFAULT_ICON_STYLES,
212+
gridArea: 'rightIcon',
162213
},
163214

164-
'& [data-element="ButtonIcon"]:last-child:not(:first-child)': {
165-
marginRight: '-.5x',
166-
placeSelf: 'center end',
215+
// Label sub-element (recommended format)
216+
Label: {
217+
$: '>',
218+
gridArea: 'label',
219+
display: 'block',
220+
placeSelf: 'center stretch',
221+
boxSizing: 'border-box',
222+
whiteSpace: 'nowrap',
223+
overflow: 'hidden',
224+
textOverflow: 'ellipsis',
225+
maxWidth: '100%',
226+
textAlign: 'center',
227+
padding: {
228+
'': '$block-padding $label-padding-right $block-padding $label-padding-left',
229+
'type=link': 0,
230+
},
231+
},
232+
233+
// ButtonIcon sub-element (backward compatibility)
234+
ButtonIcon: {
235+
width: 'min 1fs',
167236
},
168237
} as const;
169238

@@ -219,6 +288,8 @@ export const Button = forwardRef(function Button(
219288
rightIcon,
220289
mods,
221290
download,
291+
tooltip = true,
292+
defaultTooltipPlacement = 'top',
222293
...props
223294
} = allProps;
224295

@@ -252,34 +323,30 @@ export const Button = forwardRef(function Button(
252323
label = 'Unnamed'; // fix to avoid warning in production
253324
}
254325

255-
if (icon) {
256-
icon = cloneElement(icon, {
257-
'data-element': 'ButtonIcon',
258-
} as any);
259-
}
260-
261-
if (rightIcon) {
262-
rightIcon = cloneElement(rightIcon, {
263-
'data-element': 'ButtonIcon',
264-
} as any);
265-
}
266-
326+
const hasLeftIcon = !!icon || isLoading;
327+
const hasChildren = children != null;
267328
const singleIcon = !!(
268-
((icon && !rightIcon) || (rightIcon && !icon)) &&
269-
!children
329+
((hasLeftIcon && !rightIcon) || (rightIcon && !hasLeftIcon)) &&
330+
!hasChildren
270331
);
271332

272-
const hasIcons = !!icon || !!rightIcon;
333+
const hasIcons = hasLeftIcon || !!rightIcon;
334+
const rawChildren = !!(
335+
hasChildren &&
336+
typeof children !== 'string' &&
337+
!hasIcons
338+
);
273339

274340
const modifiers = useMemo(
275341
() => ({
276342
loading: isLoading,
277343
selected: isSelected,
278344
'has-icons': hasIcons,
279-
'left-icon': !!icon,
280-
'right-icon': !!rightIcon,
345+
'has-icon': hasLeftIcon,
346+
'has-right-icon': !!rightIcon,
281347
'single-icon': singleIcon,
282-
'text-only': !!(children && typeof children === 'string' && !hasIcons),
348+
'text-only': !!(hasChildren && typeof children === 'string' && !hasIcons),
349+
'raw-children': rawChildren,
283350
...mods,
284351
}),
285352
[
@@ -291,6 +358,7 @@ export const Button = forwardRef(function Button(
291358
isSelected,
292359
singleIcon,
293360
hasIcons,
361+
rawChildren,
294362
],
295363
);
296364

@@ -304,32 +372,63 @@ export const Button = forwardRef(function Button(
304372

305373
delete actionProps.isDisabled;
306374

307-
return (
308-
<ButtonElement
309-
download={download}
310-
{...actionProps}
311-
disabled={isDisabledElement}
312-
variant={`${theme}.${type ?? 'outline'}` as ButtonVariant}
313-
data-theme={theme}
314-
data-type={type ?? 'outline'}
315-
data-size={size ?? 'medium'}
316-
styles={styles}
317-
>
318-
{icon || isLoading ? (
319-
!isLoading ? (
320-
icon
321-
) : (
322-
<LoadingIcon data-element="ButtonIcon" />
323-
)
324-
) : null}
325-
{typeof children === 'string' ? (
326-
<Text ellipsis nowrap>
327-
{children}
328-
</Text>
329-
) : (
330-
children
331-
)}
332-
{rightIcon}
333-
</ButtonElement>
334-
);
375+
const {
376+
labelProps: finalLabelProps,
377+
labelRef,
378+
renderWithTooltip,
379+
} = useAutoTooltip({
380+
tooltip,
381+
children,
382+
labelProps: undefined,
383+
});
384+
385+
// Render function that creates the button element
386+
const renderButtonElement = (
387+
tooltipTriggerProps?: HTMLAttributes<HTMLElement>,
388+
tooltipRef?: RefObject<HTMLElement>,
389+
): ReactNode => {
390+
// Use callback ref to merge multiple refs without calling hooks
391+
const handleRef = (element: HTMLElement | null) => {
392+
// Set the component's forwarded ref from useAction
393+
const domRef = actionProps.ref as any;
394+
if (typeof domRef === 'function') {
395+
domRef(element);
396+
} else if (domRef) {
397+
domRef.current = element;
398+
}
399+
// Set the tooltip ref if provided
400+
if (tooltipRef) {
401+
(tooltipRef as any).current = element;
402+
}
403+
};
404+
405+
return (
406+
<ButtonElement
407+
download={download}
408+
{...mergeProps(actionProps, tooltipTriggerProps || {})}
409+
ref={handleRef}
410+
disabled={isDisabledElement}
411+
variant={`${theme}.${type ?? 'outline'}` as ButtonVariant}
412+
data-theme={theme}
413+
data-type={type ?? 'outline'}
414+
data-size={size ?? 'medium'}
415+
styles={styles}
416+
>
417+
{(icon || isLoading) && (
418+
<div data-element="Icon">{isLoading ? <LoadingIcon /> : icon}</div>
419+
)}
420+
{hasChildren &&
421+
(rawChildren ? (
422+
children
423+
) : (
424+
<div data-element="Label" {...finalLabelProps} ref={labelRef}>
425+
{children}
426+
</div>
427+
))}
428+
{rightIcon && <div data-element="RightIcon">{rightIcon}</div>}
429+
</ButtonElement>
430+
);
431+
};
432+
433+
return renderWithTooltip(renderButtonElement, defaultTooltipPlacement);
335434
});

src/components/actions/Menu/Menu.stories.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1308,7 +1308,6 @@ export const TabWithMultipleTriggers = () => {
13081308
size="small"
13091309
icon={<IconDotsVertical />}
13101310
aria-label="Tab actions"
1311-
padding="1x"
13121311
radius="right"
13131312
margin="-1bw left"
13141313
onPress={openActionsMenu}

src/components/fields/NumberInput/StepButton.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,13 @@ const StepButtonElement = tasty(Button, {
1414
},
1515
fontSize: '12px',
1616
lineHeight: '12px',
17-
fill: {
18-
'': '#dark.0',
19-
hovered: '#dark.04',
20-
pressed: '#purple.10',
21-
disabled: '#dark.0',
22-
},
2317

2418
'$icon-size': '1fs',
19+
20+
Icon: {
21+
width: 'auto',
22+
height: 'auto',
23+
},
2524
},
2625
});
2726

0 commit comments

Comments
 (0)