Skip to content

Commit c4e5092

Browse files
committed
feat(Item): dynamic icon
1 parent d4e4201 commit c4e5092

File tree

11 files changed

+315
-88
lines changed

11 files changed

+315
-88
lines changed

.changeset/violet-bees-promise.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@cube-dev/ui-kit": minor
3+
---
4+
5+
Add dynamic icon support to Button and Item components. The `icon` and `rightIcon` props now support:
6+
- `true` - renders an empty slot (reserves space but shows nothing)
7+
- Function `({ loading, selected, ...mods }) => ReactNode | true` - dynamically renders icon based on component modifiers
8+
9+
Also made `Mods` type generic for better type definitions: `Mods<{ loading?: boolean }>` instead of extending interface.

src/components/actions/Button/Button.docs.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,27 @@ The `mods` prop accepts the following modifiers you can override:
9696
```jsx
9797
import { IconPlus } from '@tabler/icons-react';
9898

99+
{/* Standard icon */}
99100
<Button icon={<IconPlus />} aria-label="Add item" />
101+
102+
{/* Empty slot (reserves space but shows nothing) */}
103+
<Button icon={true} aria-label="Empty slot" />
104+
105+
{/* Dynamic icon based on mods */}
106+
<Button
107+
icon={({ loading }) => loading ? <SpinnerIcon /> : <IconPlus />}
108+
aria-label="Dynamic icon"
109+
>
110+
Save
111+
</Button>
112+
113+
{/* Return true from function for empty slot */}
114+
<Button
115+
icon={({ selected }) => selected ? <CheckIcon /> : true}
116+
aria-label="Conditional icon"
117+
>
118+
Option
119+
</Button>
100120
```
101121

102122
### Link Button

src/components/actions/Button/Button.stories.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@ export default {
4040
/* Content */
4141
icon: {
4242
control: { type: null },
43-
description: 'Icon element rendered before the content',
43+
description:
44+
'Icon rendered before the content. Can be: ReactNode, `true` (empty slot), or function `({ loading, selected, ...mods }) => ReactNode | true`',
4445
},
4546
rightIcon: {
4647
control: { type: null },
47-
description: 'Icon element rendered after the content',
48+
description:
49+
'Icon rendered after the content. Can be: ReactNode, `true` (empty slot), or function `({ loading, selected, ...mods }) => ReactNode | true`',
4850
},
4951
children: {
5052
control: { type: 'text' },

src/components/actions/Button/Button.tsx

Lines changed: 84 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { FocusableRef } from '@react-types/shared';
2-
import { cloneElement, forwardRef, ReactElement, useMemo } from 'react';
2+
import {
3+
cloneElement,
4+
forwardRef,
5+
isValidElement,
6+
ReactElement,
7+
ReactNode,
8+
useMemo,
9+
} from 'react';
310

411
import { useWarn } from '../../../_internal/hooks/use-warn';
512
import {
@@ -32,16 +39,29 @@ import { LoadingIcon } from '../../../icons';
3239
import {
3340
CONTAINER_STYLES,
3441
extractStyles,
42+
Mods,
3543
tasty,
3644
TEXT_STYLES,
3745
} from '../../../tasty';
46+
import { DynamicIcon, resolveIcon } from '../../../utils/react';
3847
import { Text } from '../../content/Text';
3948
import { CubeActionProps } from '../Action/Action';
4049
import { useAction } from '../use-action';
4150

51+
/** Known modifiers for Button component */
52+
export type ButtonMods = Mods<{
53+
loading?: boolean;
54+
selected?: boolean;
55+
'has-icons'?: boolean;
56+
'left-icon'?: boolean;
57+
'right-icon'?: boolean;
58+
'single-icon'?: boolean;
59+
'text-only'?: boolean;
60+
}>;
61+
4262
export interface CubeButtonProps extends CubeActionProps {
43-
icon?: ReactElement;
44-
rightIcon?: ReactElement;
63+
icon?: DynamicIcon<ButtonMods>;
64+
rightIcon?: DynamicIcon<ButtonMods>;
4565
isLoading?: boolean;
4666
isSelected?: boolean;
4767
type?:
@@ -215,8 +235,8 @@ export const Button = forwardRef(function Button(
215235
label,
216236
children,
217237
theme = 'default',
218-
icon,
219-
rightIcon,
238+
icon: iconProp,
239+
rightIcon: rightIconProp,
220240
mods,
221241
download,
222242
...props
@@ -226,22 +246,67 @@ export const Button = forwardRef(function Button(
226246
const isLoading = props.isLoading;
227247
const isSelected = props.isSelected;
228248

229-
children = children || icon || rightIcon ? children : label;
249+
// Base mods for icon resolution (without icon-dependent mods)
250+
const baseMods = useMemo<ButtonMods>(
251+
() => ({
252+
loading: isLoading,
253+
selected: isSelected,
254+
...mods,
255+
}),
256+
[isLoading, isSelected, mods],
257+
);
258+
259+
// Resolve dynamic icon props
260+
const resolvedIcon = useMemo(
261+
() => resolveIcon(iconProp, baseMods),
262+
[iconProp, baseMods],
263+
);
264+
const resolvedRightIcon = useMemo(
265+
() => resolveIcon(rightIconProp, baseMods),
266+
[rightIconProp, baseMods],
267+
);
268+
269+
const hasLeftSlot = resolvedIcon.hasSlot;
270+
const hasRightSlot = resolvedRightIcon.hasSlot;
271+
272+
// Clone elements to add data-element attribute if they're valid ReactElements
273+
let icon: ReactNode = resolvedIcon.content;
274+
let rightIcon: ReactNode = resolvedRightIcon.content;
275+
276+
if (isValidElement(icon)) {
277+
icon = cloneElement(
278+
icon as ReactElement,
279+
{
280+
'data-element': 'ButtonIcon',
281+
} as any,
282+
);
283+
}
284+
285+
if (isValidElement(rightIcon)) {
286+
rightIcon = cloneElement(
287+
rightIcon as ReactElement,
288+
{
289+
'data-element': 'ButtonIcon',
290+
} as any,
291+
);
292+
}
293+
294+
children = children || hasLeftSlot || hasRightSlot ? children : label;
230295

231296
const specifiedLabel =
232297
label ?? props['aria-label'] ?? props['aria-labelledby'];
233298

234299
// Warn about accessibility issues when button has no accessible label
235-
useWarn(!children && icon && !specifiedLabel, {
236-
key: ['button-icon-no-label', !!icon],
300+
useWarn(!children && hasLeftSlot && !specifiedLabel, {
301+
key: ['button-icon-no-label', hasLeftSlot],
237302
args: [
238303
'accessibility issue:',
239304
'If you provide `icon` property for a Button and do not provide any children then you should specify the `aria-label` property to make sure the Button element stays accessible.',
240305
],
241306
});
242307

243-
useWarn(!children && !icon && !specifiedLabel, {
244-
key: ['button-no-content-no-label', !!icon],
308+
useWarn(!children && !hasLeftSlot && !specifiedLabel, {
309+
key: ['button-no-content-no-label', hasLeftSlot],
245310
args: [
246311
'accessibility issue:',
247312
'If you provide no children for a Button then you should specify the `aria-label` property to make sure the Button element stays accessible.',
@@ -252,46 +317,23 @@ export const Button = forwardRef(function Button(
252317
label = 'Unnamed'; // fix to avoid warning in production
253318
}
254319

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-
267320
const singleIcon = !!(
268-
((icon && !rightIcon) || (rightIcon && !icon)) &&
321+
((hasLeftSlot && !hasRightSlot) || (hasRightSlot && !hasLeftSlot)) &&
269322
!children
270323
);
271324

272-
const hasIcons = !!icon || !!rightIcon;
325+
const hasIcons = hasLeftSlot || hasRightSlot;
273326

274-
const modifiers = useMemo(
327+
const modifiers = useMemo<ButtonMods>(
275328
() => ({
276-
loading: isLoading,
277-
selected: isSelected,
329+
...baseMods,
278330
'has-icons': hasIcons,
279-
'left-icon': !!icon,
280-
'right-icon': !!rightIcon,
331+
'left-icon': hasLeftSlot,
332+
'right-icon': hasRightSlot,
281333
'single-icon': singleIcon,
282334
'text-only': !!(children && typeof children === 'string' && !hasIcons),
283-
...mods,
284335
}),
285-
[
286-
mods,
287-
children,
288-
icon,
289-
rightIcon,
290-
isLoading,
291-
isSelected,
292-
singleIcon,
293-
hasIcons,
294-
],
336+
[baseMods, children, hasLeftSlot, hasRightSlot, singleIcon, hasIcons],
295337
);
296338

297339
const { actionProps } = useAction(
@@ -315,7 +357,7 @@ export const Button = forwardRef(function Button(
315357
data-size={size ?? 'medium'}
316358
styles={styles}
317359
>
318-
{icon || isLoading ? (
360+
{hasLeftSlot || isLoading ? (
319361
!isLoading ? (
320362
icon
321363
) : (
@@ -329,7 +371,7 @@ export const Button = forwardRef(function Button(
329371
) : (
330372
children
331373
)}
332-
{rightIcon}
374+
{hasRightSlot ? rightIcon : null}
333375
</ButtonElement>
334376
);
335377
});

src/components/content/Item/Item.docs.mdx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,30 @@ The `mods` property accepts the following modifiers:
120120
### With Icons
121121

122122
```jsx
123+
{/* Standard icons */}
123124
<Item icon={<IconUser />} rightIcon={<IconSettings />}>
124125
Item with icons
125126
</Item>
127+
128+
{/* Empty slot (reserves space but shows nothing) */}
129+
<Item icon={true} rightIcon={true}>
130+
Item with empty slots
131+
</Item>
132+
133+
{/* Dynamic icon based on mods */}
134+
<Item
135+
icon={({ loading }) => loading ? <LoadingIcon /> : <IconUser />}
136+
rightIcon={({ selected }) => selected ? <CheckIcon /> : <IconSettings />}
137+
>
138+
Dynamic icons
139+
</Item>
140+
141+
{/* Return true from function for empty slot */}
142+
<Item
143+
icon={({ selected }) => selected ? <CheckIcon /> : true}
144+
>
145+
Conditional icon
146+
</Item>
126147
```
127148

128149
### With Description

src/components/content/Item/Item.stories.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,13 @@ export default {
3636
},
3737
icon: {
3838
control: { type: null },
39-
description: 'Icon element rendered before the content',
39+
description:
40+
'Icon rendered before the content. Can be: ReactNode, `"checkbox"`, `true` (empty slot), or function `({ selected, loading, ...mods }) => ReactNode | true`',
4041
},
4142
rightIcon: {
4243
control: { type: null },
43-
description: 'Icon element rendered after the content',
44+
description:
45+
'Icon rendered after the content. Can be: ReactNode, `true` (empty slot), or function `({ selected, loading, ...mods }) => ReactNode | true`',
4446
},
4547
prefix: {
4648
control: { type: null },

0 commit comments

Comments
 (0)