Skip to content

Commit 5f625b5

Browse files
committed
feat(ItemAction): brand new component
1 parent e5eed62 commit 5f625b5

File tree

17 files changed

+921
-335
lines changed

17 files changed

+921
-335
lines changed
Lines changed: 272 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,293 @@
11
import { FocusableRef } from '@react-types/shared';
2-
import { forwardRef } from 'react';
2+
import {
3+
ComponentProps,
4+
forwardRef,
5+
HTMLAttributes,
6+
ReactNode,
7+
RefObject,
8+
useMemo,
9+
} from 'react';
310

11+
import {
12+
DANGER_CLEAR_STYLES,
13+
DANGER_NEUTRAL_STYLES,
14+
DANGER_PRIMARY_STYLES,
15+
DANGER_SECONDARY_STYLES,
16+
DEFAULT_CLEAR_STYLES,
17+
DEFAULT_NEUTRAL_STYLES,
18+
DEFAULT_PRIMARY_STYLES,
19+
DEFAULT_SECONDARY_STYLES,
20+
SPECIAL_CLEAR_STYLES,
21+
SPECIAL_NEUTRAL_STYLES,
22+
SPECIAL_PRIMARY_STYLES,
23+
SPECIAL_SECONDARY_STYLES,
24+
SUCCESS_CLEAR_STYLES,
25+
SUCCESS_NEUTRAL_STYLES,
26+
SUCCESS_PRIMARY_STYLES,
27+
SUCCESS_SECONDARY_STYLES,
28+
} from '../../../data/item-themes';
29+
import { CheckIcon } from '../../../icons/CheckIcon';
30+
import { LoadingIcon } from '../../../icons/LoadingIcon';
431
import { tasty } from '../../../tasty';
5-
import { Button, CubeButtonProps } from '../Button';
32+
import { mergeProps } from '../../../utils/react';
33+
import { TooltipProvider } from '../../overlays/Tooltip/TooltipProvider';
634
import { useItemActionContext } from '../ItemActionContext';
35+
import { CubeUseActionProps, useAction } from '../use-action';
736

8-
export interface CubeItemActionProps extends CubeButtonProps {
9-
// All props from Button are inherited
37+
export interface CubeItemActionProps
38+
extends Omit<CubeUseActionProps, 'as' | 'htmlType'> {
39+
icon?: ReactNode | 'checkbox';
40+
children?: ReactNode;
41+
isLoading?: boolean;
42+
isSelected?: boolean;
43+
type?: 'primary' | 'secondary' | 'neutral' | 'clear' | (string & {});
44+
theme?: 'default' | 'danger' | 'success' | 'special' | (string & {});
45+
tooltip?:
46+
| string
47+
| (Omit<ComponentProps<typeof TooltipProvider>, 'children'> & {
48+
title?: string;
49+
});
1050
}
1151

12-
const StyledButton = tasty(Button, {
52+
type ItemActionVariant =
53+
| 'default.primary'
54+
| 'default.secondary'
55+
| 'default.neutral'
56+
| 'default.clear'
57+
| 'danger.primary'
58+
| 'danger.secondary'
59+
| 'danger.neutral'
60+
| 'danger.clear'
61+
| 'success.primary'
62+
| 'success.secondary'
63+
| 'success.neutral'
64+
| 'success.clear'
65+
| 'special.primary'
66+
| 'special.secondary'
67+
| 'special.neutral'
68+
| 'special.clear';
69+
70+
const ItemActionElement = tasty({
71+
qa: 'ItemAction',
1372
styles: {
14-
border: 0,
15-
height: '($size - 1x)',
16-
width: '($size - 1x)',
73+
display: 'inline-grid',
74+
flow: 'column',
75+
placeItems: 'center',
76+
placeContent: 'center',
77+
gap: '.75x',
78+
position: 'relative',
1779
margin: {
1880
'': '0 1bw 0 1bw',
19-
':last-child & !:first-child': '0 (.5x - 1bw) 0 0',
20-
'!:last-child & :first-child': '0 0 0 (.5x - 1bw)',
21-
':last-child & :first-child': '0 (.5x - 1bw)',
81+
':last-child & !:first-child': '0 $side-padding 0 0',
82+
'!:last-child & :first-child': '0 0 0 $side-padding',
83+
':last-child & :first-child': '0 $side-padding',
2284
context: '0',
2385
},
86+
padding: 0,
87+
reset: 'button',
88+
outline: 0,
89+
outlineOffset: 1,
90+
cursor: { '': 'pointer', disabled: 'default' },
91+
radius: true,
92+
transition: 'theme',
93+
flexShrink: 0,
94+
textDecoration: 'none',
95+
boxSizing: 'border-box',
96+
whiteSpace: 'nowrap',
97+
border: 0,
98+
height: '$action-size',
99+
width: {
100+
'': '$action-size',
101+
'with-label': 'auto',
102+
},
103+
placeSelf: 'center',
104+
105+
// Size using custom property
106+
'$action-size': 'min(max((2x + 2bw), ($size - 1x - 2bw)), (3x - 2bw))',
107+
// Side padding for the button
108+
'$side-padding': 'max(min(.5x, (($size - 3x + 2bw) / 2)), 1bw)',
109+
110+
// Icon styles
111+
Icon: {
112+
display: 'grid',
113+
placeItems: 'center',
114+
aspectRatio: '1 / 1',
115+
width: '$action-size',
116+
opacity: {
117+
'': 1,
118+
'checkbox & selected': 1,
119+
'checkbox & !selected': 0,
120+
'checkbox & !selected & hovered': 0.4,
121+
},
122+
},
123+
},
124+
variants: {
125+
// Default theme
126+
'default.primary': DEFAULT_PRIMARY_STYLES,
127+
'default.secondary': DEFAULT_SECONDARY_STYLES,
128+
'default.neutral': DEFAULT_NEUTRAL_STYLES,
129+
'default.clear': DEFAULT_CLEAR_STYLES,
130+
131+
// Danger theme
132+
'danger.primary': DANGER_PRIMARY_STYLES,
133+
'danger.secondary': DANGER_SECONDARY_STYLES,
134+
'danger.neutral': DANGER_NEUTRAL_STYLES,
135+
'danger.clear': DANGER_CLEAR_STYLES,
136+
137+
// Success theme
138+
'success.primary': SUCCESS_PRIMARY_STYLES,
139+
'success.secondary': SUCCESS_SECONDARY_STYLES,
140+
'success.neutral': SUCCESS_NEUTRAL_STYLES,
141+
'success.clear': SUCCESS_CLEAR_STYLES,
142+
143+
// Special theme
144+
'special.primary': SPECIAL_PRIMARY_STYLES,
145+
'special.secondary': SPECIAL_SECONDARY_STYLES,
146+
'special.neutral': SPECIAL_NEUTRAL_STYLES,
147+
'special.clear': SPECIAL_CLEAR_STYLES,
24148
},
25149
});
26150

27151
export const ItemAction = forwardRef(function ItemAction(
28-
props: CubeItemActionProps,
152+
allProps: CubeItemActionProps,
29153
ref: FocusableRef<HTMLElement>,
30154
) {
31155
const { type: contextType } = useItemActionContext();
32-
const { type = contextType ?? 'neutral', ...rest } = props;
33-
34-
return (
35-
<StyledButton
36-
tabIndex={contextType ? -1 : undefined}
37-
{...rest}
38-
ref={ref}
39-
mods={{ context: !!contextType }}
40-
type={type}
41-
/>
156+
157+
const {
158+
type = contextType ?? 'neutral',
159+
theme = 'default',
160+
icon,
161+
children,
162+
isLoading = false,
163+
isSelected = false,
164+
tooltip,
165+
mods,
166+
...rest
167+
} = allProps;
168+
169+
// Determine if we should show checkbox
170+
const hasCheckbox = icon === 'checkbox';
171+
172+
// Determine final icon (loading takes precedence)
173+
const finalIcon = isLoading ? (
174+
<LoadingIcon />
175+
) : hasCheckbox ? (
176+
<CheckIcon />
177+
) : (
178+
icon
42179
);
180+
181+
// Build modifiers
182+
const finalMods = useMemo(
183+
() => ({
184+
checkbox: hasCheckbox,
185+
selected: isSelected,
186+
loading: isLoading,
187+
'with-label': !!children,
188+
context: !!contextType,
189+
...mods,
190+
}),
191+
[hasCheckbox, isSelected, isLoading, children, contextType, mods],
192+
);
193+
194+
// Extract aria-label from tooltip if needed
195+
const ariaLabel = useMemo(() => {
196+
if (typeof tooltip === 'string') {
197+
return tooltip;
198+
}
199+
if (typeof tooltip === 'object' && tooltip.title) {
200+
return tooltip.title;
201+
}
202+
return rest['aria-label'];
203+
}, [tooltip, rest]);
204+
205+
// Call useAction hook
206+
const { actionProps } = useAction(
207+
{
208+
...rest,
209+
'aria-label': ariaLabel,
210+
mods: finalMods,
211+
htmlType: 'button',
212+
},
213+
ref,
214+
);
215+
216+
// Set tabIndex when in context
217+
const finalTabIndex = contextType ? -1 : undefined;
218+
219+
// Determine if we should show tooltip (icon-only buttons)
220+
const showTooltip = !children && tooltip;
221+
222+
// Extract tooltip content and props
223+
const tooltipContent = useMemo(() => {
224+
if (typeof tooltip === 'string') {
225+
return tooltip;
226+
}
227+
if (typeof tooltip === 'object' && tooltip.title) {
228+
return tooltip.title;
229+
}
230+
return undefined;
231+
}, [tooltip]);
232+
233+
const tooltipProps = useMemo(() => {
234+
if (typeof tooltip === 'object') {
235+
const { title, ...rest } = tooltip;
236+
return rest;
237+
}
238+
return {};
239+
}, [tooltip]);
240+
241+
// Render function that accepts tooltip trigger props and ref
242+
const renderButton = (
243+
tooltipTriggerProps?: HTMLAttributes<HTMLElement>,
244+
tooltipRef?: RefObject<HTMLElement>,
245+
) => {
246+
// Merge tooltip ref with actionProps if provided
247+
const mergedProps = tooltipRef
248+
? mergeProps(actionProps, tooltipTriggerProps || {}, {
249+
ref: (element: HTMLElement | null) => {
250+
// Set the tooltip ref
251+
if (tooltipRef) {
252+
(tooltipRef as any).current = element;
253+
}
254+
// Set the action ref if it exists in actionProps
255+
const actionRef = (actionProps as any).ref;
256+
if (actionRef) {
257+
if (typeof actionRef === 'function') {
258+
actionRef(element);
259+
} else {
260+
actionRef.current = element;
261+
}
262+
}
263+
},
264+
})
265+
: mergeProps(actionProps, tooltipTriggerProps || {});
266+
267+
return (
268+
<ItemActionElement
269+
{...mergedProps}
270+
variant={`${theme}.${type}` as ItemActionVariant}
271+
data-theme={theme}
272+
data-type={type}
273+
tabIndex={finalTabIndex}
274+
>
275+
{finalIcon && <div data-element="Icon">{finalIcon}</div>}
276+
{children}
277+
</ItemActionElement>
278+
);
279+
};
280+
281+
// Wrap with tooltip if needed
282+
if (showTooltip && tooltipContent) {
283+
return (
284+
<TooltipProvider title={tooltipContent} {...tooltipProps}>
285+
{(triggerProps, tooltipRef) => renderButton(triggerProps, tooltipRef)}
286+
</TooltipProvider>
287+
);
288+
}
289+
290+
return renderButton();
43291
});
292+
293+
export type { CubeItemActionProps as ItemActionProps };

0 commit comments

Comments
 (0)