Skip to content

Commit 23b1329

Browse files
committed
✨ feat(context-menu): add support for item descriptions and icon alignment
- Introduced a `desc` property for menu items to display secondary descriptions below the label. - Added `iconAlign` option to control the vertical alignment of icons when descriptions are present, allowing for 'center' and 'start' alignment. - Updated rendering logic in `ContextMenu` and `DropdownMenu` to accommodate new features. - Enhanced documentation to reflect these changes and provide usage examples. This enhancement improves the contextual information available in menus, enhancing user experience. Signed-off-by: Innei <tukon479@gmail.com>
1 parent 5c0fab4 commit 23b1329

File tree

21 files changed

+404
-26
lines changed

21 files changed

+404
-26
lines changed

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@
106106
},
107107
"lint-staged": {
108108
"*.md": [
109-
"remark --quiet --output --",
110109
"prettier --write --no-error-on-unmatched-pattern"
111110
],
112111
"*.json": [

src/ContextMenu/ContextMenuHost.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,12 @@ export const ContextMenuHost = memo(() => {
4949
}, []);
5050

5151
const menuItems = useMemo(
52-
() => renderContextMenuItems(state.items, [], { iconSpaceMode: state.iconSpaceMode }),
53-
[state.items, state.iconSpaceMode],
52+
() =>
53+
renderContextMenuItems(state.items, [], {
54+
iconAlign: state.iconAlign,
55+
iconSpaceMode: state.iconSpaceMode,
56+
}),
57+
[state.items, state.iconAlign, state.iconSpaceMode],
5458
);
5559
const portalContainer = usePortalContainer(CONTEXT_MENU_CONTAINER_ATTR);
5660

src/ContextMenu/demos/desc.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import {
2+
Block,
3+
ContextMenuTrigger,
4+
type GenericItemType,
5+
Icon,
6+
showContextMenu,
7+
Text,
8+
} from '@lobehub/ui';
9+
import { GithubIcon, GlobeIcon, PencilIcon, UploadIcon } from 'lucide-react';
10+
import { type MouseEvent } from 'react';
11+
import { useCallback, useMemo } from 'react';
12+
13+
export default () => {
14+
const items = useMemo<GenericItemType[]>(
15+
() => [
16+
{
17+
desc: 'Import from a direct SKILL.md link',
18+
icon: <Icon icon={GlobeIcon} />,
19+
key: 'import-url',
20+
label: 'Import from URL',
21+
},
22+
{
23+
desc: 'Import from a public GitHub repository',
24+
icon: <Icon icon={GithubIcon} />,
25+
key: 'import-github',
26+
label: 'Import from GitHub',
27+
},
28+
{
29+
desc: 'Upload a local .zip or .skill file',
30+
icon: <Icon icon={UploadIcon} />,
31+
key: 'upload-zip',
32+
label: 'Upload Zip',
33+
},
34+
{
35+
desc: 'Manually configure a custom MCP server',
36+
icon: <Icon icon={PencilIcon} />,
37+
key: 'add-custom',
38+
label: 'Add Custom MCP Skill',
39+
},
40+
],
41+
[],
42+
);
43+
44+
const handleContextMenu = useCallback(
45+
(event: MouseEvent<HTMLElement>) => {
46+
event.preventDefault();
47+
showContextMenu(items, { iconAlign: 'start' });
48+
},
49+
[items],
50+
);
51+
52+
return (
53+
<ContextMenuTrigger onContextMenu={handleContextMenu}>
54+
<Block direction="vertical" gap={8} padding={16}>
55+
<Text strong as={'p'}>
56+
Right click this panel
57+
</Text>
58+
<Text as={'p'} type="secondary">
59+
Each item has a description below the label
60+
</Text>
61+
</Block>
62+
</ContextMenuTrigger>
63+
);
64+
};

src/ContextMenu/index.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ description: ContextMenu provides an imperative API to open a menu at the last p
2121

2222
<code src="./demos/switch.tsx" center></code>
2323

24+
## Description
25+
26+
Items can display a secondary description below the label using the `desc` property.
27+
28+
<code src="./demos/desc.tsx" center></code>
29+
2430
## Empty Submenu
2531

2632
<code src="./demos/emptySubmenu.tsx" center></code>
@@ -49,6 +55,7 @@ Control how icon spacing is reserved across menu items. In `global` mode (defaul
4955

5056
| Property | Description | Type | Default |
5157
| ------------- | ---------------------------------------------------------------------------------------------- | --------------------- | ---------- |
58+
| iconAlign | Icon vertical alignment when items have `desc`. Only effective with descriptions. | `'center' \| 'start'` | `'center'` |
5259
| iconSpaceMode | Icon space reservation: `global` reserves for all items, `group` reserves per group with icons | `'global' \| 'group'` | `'global'` |
5360

5461
### ContextMenuHost

src/ContextMenu/renderItems.tsx

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535
type ContextMenuSwitchItem,
3636
} from './type';
3737

38-
export type { IconSpaceMode } from '@/Menu';
38+
export type { IconAlign, IconSpaceMode } from '@/Menu';
3939

4040
const EmptyMenuItem = memo(() => {
4141
const { t } = useTranslation(common);
@@ -117,22 +117,36 @@ const renderItemContent = (
117117
iconNode?: ReactNode,
118118
) => {
119119
const label = getItemLabel(item);
120+
const desc = 'desc' in item ? item.desc : undefined;
120121
const extra = 'extra' in item ? item.extra : undefined;
121122
const indicatorOnRight = options?.indicatorOnRight;
123+
const alignStart = Boolean(desc) && options?.iconAlign === 'start';
122124
const hasCustomIcon = iconNode !== undefined && !indicatorOnRight;
123125
const hasIcon = hasCustomIcon ? Boolean(iconNode) : Boolean(item.icon);
124126
const shouldRenderIcon = hasCustomIcon
125127
? Boolean(options?.reserveIconSpace || iconNode)
126128
: Boolean(hasIcon || options?.reserveIconSpace);
127129

130+
const labelNode = desc ? (
131+
<div className={styles.labelGroup}>
132+
<span className={styles.label}>{label}</span>
133+
<span className={styles.desc}>{desc}</span>
134+
</div>
135+
) : (
136+
<span className={styles.label}>{label}</span>
137+
);
138+
128139
return (
129-
<div className={styles.itemContent}>
140+
<div className={cx(styles.itemContent, alignStart && styles.itemContentAlignStart)}>
130141
{shouldRenderIcon ? (
131-
<span aria-hidden={!hasIcon} className={styles.icon}>
142+
<span
143+
aria-hidden={!hasIcon}
144+
className={cx(styles.icon, alignStart && styles.iconAlignStart)}
145+
>
132146
{hasCustomIcon ? iconNode : hasIcon ? renderIcon(item.icon, 'small') : null}
133147
</span>
134148
) : null}
135-
<span className={styles.label}>{label}</span>
149+
{labelNode}
136150
{extra ? <span className={styles.extra}>{extra}</span> : null}
137151
{indicatorOnRight && iconNode ? iconNode : null}
138152
{options?.submenu ? (
@@ -165,6 +179,7 @@ export const renderContextMenuItems = (
165179
keyPath: string[] = [],
166180
options?: RenderOptions,
167181
): ReactNode[] => {
182+
const iconAlign = options?.iconAlign;
168183
const iconSpaceMode = options?.iconSpaceMode ?? 'global';
169184
const reserveIconSpace =
170185
options?.reserveIconSpace ?? hasAnyIcon(items, iconSpaceMode === 'global');
@@ -199,7 +214,11 @@ export const renderContextMenuItems = (
199214
label={labelText}
200215
onCheckedChange={(checked) => checkboxItem.onCheckedChange?.(checked)}
201216
>
202-
{renderItemContent(checkboxItem, { indicatorOnRight, reserveIconSpace }, indicator)}
217+
{renderItemContent(
218+
checkboxItem,
219+
{ iconAlign, indicatorOnRight, reserveIconSpace },
220+
indicator,
221+
)}
203222
</ContextMenu.CheckboxItem>
204223
);
205224
}
@@ -221,7 +240,7 @@ export const renderContextMenuItems = (
221240
label={labelText}
222241
onCheckedChange={switchItem.onCheckedChange}
223242
>
224-
{renderItemContent(switchItem, { reserveIconSpace })}
243+
{renderItemContent(switchItem, { iconAlign, reserveIconSpace })}
225244
</ContextMenuSwitchItemInternal>
226245
);
227246
}
@@ -248,6 +267,7 @@ export const renderContextMenuItems = (
248267
) : null}
249268
{group.children
250269
? renderContextMenuItems(group.children, nextKeyPath, {
270+
iconAlign,
251271
iconSpaceMode,
252272
indicatorOnRight: groupIndicatorOnRight,
253273
reserveIconSpace: groupReserveIconSpace,
@@ -274,6 +294,7 @@ export const renderContextMenuItems = (
274294
label={labelText}
275295
>
276296
{renderItemContent(submenu, {
297+
iconAlign,
277298
reserveIconSpace,
278299
submenu: true,
279300
})}
@@ -288,7 +309,10 @@ export const renderContextMenuItems = (
288309
>
289310
<ContextMenu.Popup className={styles.popup}>
290311
{submenu.children && submenu.children.length > 0 ? (
291-
renderContextMenuItems(submenu.children, nextKeyPath, { iconSpaceMode })
312+
renderContextMenuItems(submenu.children, nextKeyPath, {
313+
iconAlign,
314+
iconSpaceMode,
315+
})
292316
) : (
293317
<EmptyMenuItem />
294318
)}
@@ -313,7 +337,7 @@ export const renderContextMenuItems = (
313337
label={labelText}
314338
onClick={(event) => invokeItemClick(menuItem, nextKeyPath, event)}
315339
>
316-
{renderItemContent(menuItem, { reserveIconSpace })}
340+
{renderItemContent(menuItem, { iconAlign, reserveIconSpace })}
317341
</ContextMenu.Item>
318342
);
319343
});

src/ContextMenu/store.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import type { VirtualElement } from '@floating-ui/react';
22

3+
import type { IconAlign } from '@/Menu';
4+
35
import type { IconSpaceMode } from './renderItems';
46
import type { ContextMenuItem } from './type';
57

68
export type ContextMenuState = {
79
anchor: VirtualElement | null;
10+
iconAlign?: IconAlign;
811
iconSpaceMode: IconSpaceMode;
912
items: ContextMenuItem[];
1013
open: boolean;
@@ -69,6 +72,7 @@ export const setContextMenuState = (next: Partial<ContextMenuState>) => {
6972
};
7073

7174
export interface ShowContextMenuOptions {
75+
iconAlign?: IconAlign;
7276
iconSpaceMode?: IconSpaceMode;
7377
}
7478

@@ -80,6 +84,7 @@ export const showContextMenu = (items: ContextMenuItem[], options?: ShowContextM
8084

8185
setContextMenuState({
8286
anchor: createVirtualElement(point),
87+
iconAlign: options?.iconAlign,
8388
iconSpaceMode: options?.iconSpaceMode ?? 'global',
8489
items,
8590
open: true,

src/DropdownMenu/DropdownMenu.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const DropdownMenu = memo<DropdownMenuProps>(
1919
({
2020
children,
2121
defaultOpen,
22+
iconAlign,
2223
iconSpaceMode,
2324
items,
2425
nativeButton,
@@ -57,12 +58,15 @@ const DropdownMenu = memo<DropdownMenuProps>(
5758
const menuItems = useMemo(() => {
5859
if (isOpen) {
5960
const resolvedItems = typeof items === 'function' ? items() : items;
60-
const renderedItems = renderDropdownMenuItems(resolvedItems, [], { iconSpaceMode });
61+
const renderedItems = renderDropdownMenuItems(resolvedItems, [], {
62+
iconAlign,
63+
iconSpaceMode,
64+
});
6165
menuItemsRef.current = renderedItems;
6266
return renderedItems;
6367
}
6468
return menuItemsRef.current;
65-
}, [isOpen, items, iconSpaceMode]);
69+
}, [isOpen, items, iconAlign, iconSpaceMode]);
6670
const handleOpenChangeComplete = useCallback(
6771
(nextOpen: boolean) => {
6872
onOpenChangeComplete?.(nextOpen);

src/DropdownMenu/atoms.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,17 @@ export const DropdownMenuItemIcon = ({ className, ...rest }: DropdownMenuItemIco
264264

265265
DropdownMenuItemIcon.displayName = 'DropdownMenuItemIcon';
266266

267+
export type DropdownMenuItemLabelGroupProps = React.HTMLAttributes<HTMLDivElement>;
268+
269+
export const DropdownMenuItemLabelGroup = ({
270+
className,
271+
...rest
272+
}: DropdownMenuItemLabelGroupProps) => {
273+
return <div {...rest} className={cx(styles.labelGroup, className)} />;
274+
};
275+
276+
DropdownMenuItemLabelGroup.displayName = 'DropdownMenuItemLabelGroup';
277+
267278
export type DropdownMenuItemLabelProps = React.HTMLAttributes<HTMLSpanElement>;
268279

269280
export const DropdownMenuItemLabel = ({ className, ...rest }: DropdownMenuItemLabelProps) => {
@@ -272,6 +283,14 @@ export const DropdownMenuItemLabel = ({ className, ...rest }: DropdownMenuItemLa
272283

273284
DropdownMenuItemLabel.displayName = 'DropdownMenuItemLabel';
274285

286+
export type DropdownMenuItemDescProps = React.HTMLAttributes<HTMLSpanElement>;
287+
288+
export const DropdownMenuItemDesc = ({ className, ...rest }: DropdownMenuItemDescProps) => {
289+
return <span {...rest} className={cx(styles.desc, className)} />;
290+
};
291+
292+
DropdownMenuItemDesc.displayName = 'DropdownMenuItemDesc';
293+
275294
export type DropdownMenuItemExtraProps = React.HTMLAttributes<HTMLSpanElement>;
276295

277296
export const DropdownMenuItemExtra = ({ className, ...rest }: DropdownMenuItemExtraProps) => {

src/DropdownMenu/demos/desc.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { DropdownMenu, type DropdownMenuProps, Icon } from '@lobehub/ui';
2+
import { createStaticStyles } from 'antd-style';
3+
import { GithubIcon, GlobeIcon, MoreHorizontal, PencilIcon, UploadIcon } from 'lucide-react';
4+
5+
const styles = createStaticStyles(({ css, cssVar }) => ({
6+
trigger: css`
7+
cursor: pointer;
8+
9+
display: inline-flex;
10+
align-items: center;
11+
justify-content: center;
12+
13+
width: 44px;
14+
height: 44px;
15+
padding: 0;
16+
border: 1px solid ${cssVar.colorBorderSecondary};
17+
border-radius: 12px;
18+
19+
color: ${cssVar.colorTextSecondary};
20+
21+
background: ${cssVar.colorBgElevated};
22+
23+
transition: all 150ms ${cssVar.motionEaseOut};
24+
25+
&:hover {
26+
color: ${cssVar.colorText};
27+
background: ${cssVar.colorFillSecondary};
28+
}
29+
30+
&:active {
31+
transform: translateY(1px);
32+
}
33+
34+
&[data-state='open'],
35+
&[aria-expanded='true'] {
36+
color: ${cssVar.colorText};
37+
background: ${cssVar.colorFillTertiary};
38+
}
39+
`,
40+
}));
41+
42+
const items: Exclude<DropdownMenuProps['items'], () => unknown> = [
43+
{
44+
desc: 'Import from a direct SKILL.md link',
45+
icon: <Icon icon={GlobeIcon} />,
46+
key: 'import-url',
47+
label: 'Import from URL',
48+
},
49+
{
50+
desc: 'Import from a public GitHub repository',
51+
icon: <Icon icon={GithubIcon} />,
52+
key: 'import-github',
53+
label: 'Import from GitHub',
54+
},
55+
{
56+
desc: 'Upload a local .zip or .skill file',
57+
icon: <Icon icon={UploadIcon} />,
58+
key: 'upload-zip',
59+
label: 'Upload Zip',
60+
},
61+
{
62+
desc: 'Manually configure a custom MCP server',
63+
icon: <Icon icon={PencilIcon} />,
64+
key: 'add-custom',
65+
label: 'Add Custom MCP Skill',
66+
},
67+
];
68+
69+
export default () => {
70+
return (
71+
<DropdownMenu nativeButton iconAlign="start" items={items}>
72+
<button aria-label="Open menu" className={styles.trigger} type="button">
73+
<MoreHorizontal />
74+
</button>
75+
</DropdownMenu>
76+
);
77+
};

0 commit comments

Comments
 (0)