Skip to content

Commit cb011d8

Browse files
committed
feat(ItemButton): show actions on hover flag
1 parent b21639e commit cb011d8

File tree

6 files changed

+274
-13
lines changed

6 files changed

+274
-13
lines changed

src/components/actions/ItemButton/ItemButton.stories.tsx

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ const meta: Meta<typeof ItemButton> = {
7575
description:
7676
'Which slot to replace with loading icon (auto intelligently selects)',
7777
},
78+
showActionsOnHover: {
79+
control: 'boolean',
80+
description:
81+
'When true, actions are hidden by default and fade in on hover',
82+
},
7883
// Icon controls are typically not included in argTypes since they're complex ReactNode objects
7984
// prefix and suffix are also ReactNode, so omitted from controls
8085
onPress: {
@@ -737,13 +742,99 @@ export const WithActions: Story = {
737742
</ItemButton>
738743
</div>
739744
</div>
745+
746+
<div>
747+
<h4>Actions Visible on Hover Only</h4>
748+
<p style={{ fontSize: 14, color: '#666', marginBottom: 16 }}>
749+
Use <code>showActionsOnHover</code> to hide actions by default and
750+
show them only when hovering over the button:
751+
</p>
752+
<div style={{ display: 'grid', gap: 8, placeItems: 'start' }}>
753+
<ItemButton
754+
{...args}
755+
showActionsOnHover
756+
icon={<IconFile />}
757+
width="200px"
758+
actions={
759+
<>
760+
<ItemButton.Action icon={<IconEdit />} aria-label="Edit" />
761+
<ItemButton.Action icon={<IconTrash />} aria-label="Delete" />
762+
</>
763+
}
764+
>
765+
Hover to see actions
766+
</ItemButton>
767+
<ItemButton
768+
{...args}
769+
showActionsOnHover
770+
type="primary"
771+
width="200px"
772+
icon={<IconFile />}
773+
actions={
774+
<>
775+
<ItemButton.Action icon={<IconEdit />} aria-label="Edit" />
776+
<ItemButton.Action icon={<IconTrash />} aria-label="Delete" />
777+
</>
778+
}
779+
>
780+
Primary with hover actions
781+
</ItemButton>
782+
<ItemButton
783+
{...args}
784+
showActionsOnHover
785+
width="200px"
786+
icon={<IconFile />}
787+
description="Additional information"
788+
descriptionPlacement="block"
789+
actions={
790+
<>
791+
<ItemButton.Action icon={<IconEdit />} aria-label="Edit" />
792+
<ItemButton.Action icon={<IconTrash />} aria-label="Delete" />
793+
</>
794+
}
795+
>
796+
With description and hover actions
797+
</ItemButton>
798+
</div>
799+
</div>
800+
801+
<div>
802+
<h4>Comparison: Always Visible vs Hover Only</h4>
803+
<div style={{ display: 'grid', gap: 8, placeItems: 'start' }}>
804+
<ItemButton
805+
{...args}
806+
icon={<IconFile />}
807+
actions={
808+
<>
809+
<ItemAction icon={<IconEdit />} aria-label="Edit" />
810+
<ItemAction icon={<IconTrash />} aria-label="Delete" />
811+
</>
812+
}
813+
>
814+
Actions always visible (default)
815+
</ItemButton>
816+
<ItemButton
817+
{...args}
818+
showActionsOnHover
819+
icon={<IconFile />}
820+
actions={
821+
<>
822+
<ItemAction icon={<IconEdit />} aria-label="Edit" />
823+
<ItemAction icon={<IconTrash />} aria-label="Delete" />
824+
</>
825+
}
826+
>
827+
Actions on hover only
828+
</ItemButton>
829+
</div>
830+
</div>
740831
</div>
741832
),
742833
parameters: {
743834
docs: {
744835
description: {
745836
story:
746-
'Demonstrates ItemButton with actions displayed on the right side. The actions are absolutely positioned and the button automatically reserves space for them to prevent content overlap. Actions use the ItemAction component for consistent styling.',
837+
'Demonstrates ItemButton with actions displayed on the right side. The actions are absolutely positioned and the button automatically reserves space for them to prevent content overlap. Use `showActionsOnHover={true}` to hide actions by default and show them with a smooth fade transition when hovering over the button. Actions use the ItemAction component for consistent styling.',
747838
},
748839
},
749840
},

src/components/actions/ItemButton/ItemButton.tsx

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import {
66
useRef,
77
useState,
88
} from 'react';
9+
import { useHover } from 'react-aria';
910

1011
import { Styles, tasty } from '../../../tasty';
1112
import { mergeProps } from '../../../utils/react';
1213
import { CubeItemBaseProps, ItemBase } from '../../content/ItemBase';
14+
import { DisplayTransition } from '../../helpers';
1315
import { CubeItemActionProps, ItemAction } from '../ItemAction';
1416
import { ItemActionProvider } from '../ItemActionContext';
1517
import { CubeUseActionProps, useAction } from '../use-action';
@@ -19,6 +21,7 @@ export interface CubeItemButtonProps
1921
Omit<CubeUseActionProps, 'as'> {
2022
actions?: ReactNode;
2123
wrapperStyles?: Styles;
24+
showActionsOnHover?: boolean;
2225
}
2326

2427
const StyledItemBase = tasty(ItemBase, {
@@ -60,6 +63,15 @@ const ActionsContainer = tasty({
6063
pointerEvents: 'auto',
6164
padding: '0 .5x 0 0',
6265
height: '$size',
66+
opacity: {
67+
'': 1,
68+
hidden: 0,
69+
},
70+
translate: {
71+
'': '0 0',
72+
hidden: '.5x 0',
73+
},
74+
transition: 'theme, translate',
6375
},
6476
});
6577

@@ -77,13 +89,15 @@ const ItemButton = forwardRef(function ItemButton(
7789
onPress,
7890
actions,
7991
size = 'medium',
92+
showActionsOnHover = false,
8093
...rest
8194
} = allProps as CubeItemButtonProps & {
8295
as?: 'a' | 'button' | 'div' | 'span';
8396
};
8497

8598
const actionsRef = useRef<HTMLDivElement>(null);
8699
const [actionsWidth, setActionsWidth] = useState(0);
100+
const [areActionsVisible, setAreActionsVisible] = useState(false);
87101

88102
useLayoutEffect(() => {
89103
if (actions && actionsRef.current) {
@@ -92,7 +106,9 @@ const ItemButton = forwardRef(function ItemButton(
92106
setActionsWidth(width);
93107
}
94108
}
95-
}, [actions, actionsWidth]);
109+
}, [actions, areActionsVisible]);
110+
111+
const { hoverProps, isHovered } = useHover({});
96112

97113
const { actionProps } = useAction(
98114
{ ...(allProps as any), htmlType, to, as, mods },
@@ -113,18 +129,46 @@ const ItemButton = forwardRef(function ItemButton(
113129
if (actions) {
114130
return (
115131
<ActionsWrapper
132+
{...hoverProps}
116133
data-size={typeof size === 'number' ? undefined : size}
117134
style={
118135
{
119-
'--actions-width': `${actionsWidth}px`,
136+
'--actions-width':
137+
areActionsVisible || !showActionsOnHover
138+
? `${actionsWidth}px`
139+
: '0px',
120140
'--size': typeof size === 'number' ? `${size}px` : undefined,
121141
} as any
122142
}
123143
>
124144
{button}
125-
<ActionsContainer ref={actionsRef}>
126-
<ItemActionProvider type={type}>{actions}</ItemActionProvider>
127-
</ActionsContainer>
145+
{showActionsOnHover ? (
146+
<DisplayTransition
147+
exposeUnmounted
148+
isShown={isHovered}
149+
onPhaseChange={(phase) => {
150+
setAreActionsVisible(phase !== 'unmounted');
151+
}}
152+
>
153+
{({ isShown, ref: transitionRef }) => {
154+
return (
155+
<ActionsContainer
156+
ref={(node: any) => {
157+
actionsRef.current = node;
158+
transitionRef(node);
159+
}}
160+
mods={{ hidden: !isShown }}
161+
>
162+
<ItemActionProvider type={type}>{actions}</ItemActionProvider>
163+
</ActionsContainer>
164+
);
165+
}}
166+
</DisplayTransition>
167+
) : (
168+
<ActionsContainer ref={actionsRef}>
169+
<ItemActionProvider type={type}>{actions}</ItemActionProvider>
170+
</ActionsContainer>
171+
)}
128172
</ActionsWrapper>
129173
);
130174
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2009,12 +2009,12 @@ export const ItemsWithActions = (props) => {
20092009
icon={<IconReload />}
20102010
actions={
20112011
<>
2012-
<ItemAction
2012+
<Menu.Item.Action
20132013
icon={<EditIcon />}
20142014
aria-label="Edit"
20152015
onPress={() => handleItemAction('file2', 'edit')}
20162016
/>
2017-
<ItemAction
2017+
<Menu.Item.Action
20182018
icon={<ClearIcon />}
20192019
aria-label="Delete"
20202020
onPress={() => handleItemAction('file2', 'delete')}

src/components/content/ItemBase/ItemBase.stories.tsx

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,6 +1308,106 @@ WithActions.parameters = {
13081308
},
13091309
};
13101310

1311+
export const WithActionsOnHover: StoryFn<CubeItemBaseProps> = (args) => (
1312+
<Space gap="2x" flow="column">
1313+
<Title level={4}>Actions on Hover</Title>
1314+
<ItemBase
1315+
{...args}
1316+
showActionsOnHover
1317+
styles={{ ...DEFAULT_STYLES, width: '400px' }}
1318+
icon={<IconUser />}
1319+
actions={
1320+
<>
1321+
<ItemAction icon={<IconEdit />} aria-label="Edit" />
1322+
<ItemAction icon={<IconTrash />} aria-label="Delete" />
1323+
</>
1324+
}
1325+
>
1326+
Hover to see actions
1327+
</ItemBase>
1328+
1329+
<Title level={5}>Different Sizes</Title>
1330+
<Space gap="1x" flow="column">
1331+
<ItemBase
1332+
{...args}
1333+
showActionsOnHover
1334+
styles={DEFAULT_STYLES}
1335+
size="small"
1336+
icon={<IconUser />}
1337+
actions={
1338+
<>
1339+
<ItemAction icon={<IconEdit />} aria-label="Edit" />
1340+
<ItemAction icon={<IconTrash />} aria-label="Delete" />
1341+
</>
1342+
}
1343+
>
1344+
Small with hover actions
1345+
</ItemBase>
1346+
<ItemBase
1347+
{...args}
1348+
showActionsOnHover
1349+
styles={DEFAULT_STYLES}
1350+
size="medium"
1351+
icon={<IconUser />}
1352+
actions={
1353+
<>
1354+
<ItemAction icon={<IconEdit />} aria-label="Edit" />
1355+
<ItemAction icon={<IconTrash />} aria-label="Delete" />
1356+
</>
1357+
}
1358+
>
1359+
Medium with hover actions
1360+
</ItemBase>
1361+
<ItemBase
1362+
{...args}
1363+
showActionsOnHover
1364+
styles={DEFAULT_STYLES}
1365+
size="large"
1366+
icon={<IconUser />}
1367+
actions={
1368+
<>
1369+
<ItemAction icon={<IconEdit />} aria-label="Edit" />
1370+
<ItemAction icon={<IconTrash />} aria-label="Delete" />
1371+
</>
1372+
}
1373+
>
1374+
Large with hover actions
1375+
</ItemBase>
1376+
</Space>
1377+
1378+
<Title level={5}>With Description</Title>
1379+
<ItemBase
1380+
{...args}
1381+
showActionsOnHover
1382+
width="150px"
1383+
styles={DEFAULT_STYLES}
1384+
icon={<IconUser />}
1385+
description="Additional information"
1386+
actions={
1387+
<>
1388+
<ItemAction icon={<IconEdit />} aria-label="Edit" />
1389+
<ItemAction icon={<IconTrash />} aria-label="Delete" />
1390+
</>
1391+
}
1392+
>
1393+
With description
1394+
</ItemBase>
1395+
</Space>
1396+
);
1397+
1398+
WithActionsOnHover.args = {
1399+
width: '450px',
1400+
};
1401+
1402+
WithActionsOnHover.parameters = {
1403+
docs: {
1404+
description: {
1405+
story:
1406+
'Demonstrates the `showActionsOnHover` prop which hides actions until the item is hovered, with a smooth transition. The actions space is reserved in the layout to prevent layout shift on hover.',
1407+
},
1408+
},
1409+
};
1410+
13111411
const timeout = (ms: number) =>
13121412
new Promise((resolve) => setTimeout(resolve, ms));
13131413

src/components/content/ItemBase/ItemBase.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ import {
5858
tasty,
5959
} from '../../../tasty';
6060
import { mergeProps } from '../../../utils/react';
61-
import { CubeItemActionProps, ItemAction } from '../../actions/ItemAction';
61+
import { ItemAction } from '../../actions/ItemAction';
6262
import { ItemActionProvider } from '../../actions/ItemActionContext';
6363
import {
6464
CubeTooltipProviderProps,
@@ -286,17 +286,17 @@ const ItemBaseElement = tasty({
286286
'': '$block-padding $inline-padding',
287287
'(with-icon | with-prefix)':
288288
'$block-padding $inline-padding $block-padding 0',
289-
'(with-right-icon | with-suffix)':
289+
'(with-right-icon | with-suffix | with-actions)':
290290
'$block-padding 0 $block-padding $inline-padding',
291-
'(with-icon | with-prefix) & (with-right-icon | with-suffix)':
291+
'(with-icon | with-prefix) & (with-right-icon | with-suffix | with-actions)':
292292
'$block-padding 0',
293293
'with-description & !with-description-block':
294294
'$block-padding $inline-padding 0 $inline-padding',
295295
'with-description & !with-description-block & (with-icon | with-prefix)':
296296
'$block-padding $inline-padding 0 0',
297-
'with-description & !with-description-block & (with-right-icon | with-suffix)':
297+
'with-description & !with-description-block & (with-right-icon | with-suffix | with-actions)':
298298
'$block-padding 0 0 $inline-padding',
299-
'with-description & !with-description-block & (with-icon | with-prefix) & (with-right-icon | with-suffix)':
299+
'with-description & !with-description-block & (with-icon | with-prefix) & (with-right-icon | with-suffix | with-actions)':
300300
'$block-padding 0 0 0',
301301
},
302302
gridRow: {
@@ -369,6 +369,10 @@ const ItemBaseElement = tasty({
369369
'': 'var(--actions-width, 0px)',
370370
'with-actions-content': 'auto',
371371
},
372+
transition: {
373+
'': false,
374+
'with-action && !with-actions-content': 'width $transition ease-out',
375+
},
372376
},
373377
},
374378
variants: {

0 commit comments

Comments
 (0)