Skip to content

feat: Support custom rendering #792

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,12 @@ ReactDOM.render(
<th>() => document.body</th>
<td>Where to render the DOM node of popup menu when the mode is horizontal or vertical</td>
</tr>
<tr>
<td>itemRender</td>
<td>Function(originNode:React.ReactNode, item:ItemType) => React.ReactNode</td>
<th>() => originNode</th>
<td>Customize the rendering of menu item</td>
</tr>
<tr>
<td>builtinPlacements</td>
<td>Object of alignConfigs for <a href="https://github.com/yiminghe/dom-align">dom-align</a></td>
Expand Down
19 changes: 19 additions & 0 deletions docs/examples/items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,32 @@ import '../../assets/index.less';

export default () => (
<Menu
itemRender={(originNode, { item }) => {
if (item.type === 'item') {
return (
<a href="https://ant.design" target="_blank" rel="noopener noreferrer">
{originNode}
</a>
);
}
return originNode;
}}
items={[
{
// MenuItem
label: 'Top Menu Item',
key: 'top',
extra: '⌘B',
},
{
key: 'ToOriginNode',
type: 'item',
label: 'Navigation Two',
},
{
key: 'ToOriginNode1',
label: 'SubMenu',
},
{
// MenuGroup
type: 'group',
Expand Down
20 changes: 17 additions & 3 deletions src/Divider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,36 @@ import classNames from 'classnames';
import { MenuContext } from './context/MenuContext';
import { useMeasure } from './context/PathContext';
import type { MenuDividerType } from './interface';
import { useFullPath } from './context/PathContext';

export type DividerProps = Omit<MenuDividerType, 'type'>;

export default function Divider({ className, style }: DividerProps) {
const { prefixCls } = React.useContext(MenuContext);
export default function Divider(props: DividerProps) {
const { className, style, itemRender: propItemRender } = props;
const { prefixCls, itemRender: contextItemRender } = React.useContext(MenuContext);
const measure = useMeasure();
const connectedKeyPath = useFullPath();

if (measure) {
return null;
}

return (
const renderNode = (
<li
role="separator"
className={classNames(`${prefixCls}-item-divider`, className)}
style={style}
/>
);

const mergedItemRender = propItemRender || contextItemRender;

if (typeof mergedItemRender === 'function') {
return mergedItemRender(renderNode, {
item: { type: 'divider', ...props },
keys: connectedKeyPath,
});
}

return renderNode;
Comment on lines +30 to +37
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

防止用户返回非

  • 根节点导致 UL/LI 结构失效,建议安全注入策略

    当前直接返回 mergedItemRender 的结果,若用户返回 <div> 或 Fragment,将导致 <ul> 子元素不是 <li> 的非法结构,破坏语义与可访问性。

    建议:若返回值是 <li> 则直接使用;否则作为 children 注入到现有 <li> 中,并在开发环境给出告警。

    -  if (typeof mergedItemRender === 'function') {
    -    return mergedItemRender(renderNode, {
    -      item: { type: 'divider', ...props },
    -      keys: connectedKeyPath,
    -    });
    -  }
    +  if (typeof mergedItemRender === 'function') {
    +    const custom = mergedItemRender(renderNode, {
    +      item: { type: 'divider', ...props },
    +      keys: connectedKeyPath,
    +    });
    +    if (React.isValidElement(custom) && (custom as any).type === 'li') {
    +      return custom as React.ReactElement;
    +    }
    +    if (process.env.NODE_ENV !== 'production') {
    +      // 返回的根节点不是 <li>,可能破坏 UL/LI 结构
    +      // eslint-disable-next-line no-console
    +      console.warn('[rc-menu] itemRender for Divider should return <li> root. Fallback to inject as children.');
    +    }
    +    return React.cloneElement(renderNode as React.ReactElement, undefined, custom);
    +  }
    📝 Committable suggestion

    ‼️ IMPORTANT
    Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    Suggested change
    if (typeof mergedItemRender === 'function') {
    return mergedItemRender(renderNode, {
    item: { type: 'divider', ...props },
    keys: connectedKeyPath,
    });
    }
    return renderNode;
    if (typeof mergedItemRender === 'function') {
    const custom = mergedItemRender(renderNode, {
    item: { type: 'divider', ...props },
    keys: connectedKeyPath,
    });
    if (React.isValidElement(custom) && (custom as any).type === 'li') {
    return custom as React.ReactElement;
    }
    if (process.env.NODE_ENV !== 'production') {
    // 返回的根节点不是 <li>,可能破坏 UL/LI 结构
    // eslint-disable-next-line no-console
    console.warn(
    '[rc-menu] itemRender for Divider should return <li> root. ' +
    'Fallback to inject as children.'
    );
    }
    return React.cloneElement(
    renderNode as React.ReactElement,
    undefined,
    custom
    );
    }
    return renderNode;
    🤖 Prompt for AI Agents
    In src/Divider.tsx around lines 30 to 37, the code currently returns
    mergedItemRender output directly which can break UL/LI semantics if the user
    returns a non-<li> element; change the flow so you inspect the returned React
    element: if it's an <li> (or a ReactElement with type 'li') return it as-is;
    otherwise render a safe <li> wrapper and inject the returned value as its
    children (preserving props, keys and connectedKeyPath) and in development mode
    emit a console.warn explaining that non-<li> output was wrapped to preserve list
    semantics and accessibility.
    
  • }
    11 changes: 9 additions & 2 deletions src/Menu.tsx
    Original file line number Diff line number Diff line change
    Expand Up @@ -28,9 +28,11 @@ import type {
    SelectInfo,
    TriggerSubMenuAction,
    PopupRender,
    ItemRenderType,
    } from './interface';
    import MenuItem from './MenuItem';
    import SubMenu, { SemanticName } from './SubMenu';
    import type { SemanticName } from './SubMenu';
    import SubMenu from './SubMenu';
    import { parseItems } from './utils/nodeUtil';
    import { warnItemProp } from './utils/warnUtil';

    Expand Down Expand Up @@ -157,6 +159,8 @@ export interface MenuProps
    _internalComponents?: Components;

    popupRender?: PopupRender;

    itemRender?: ItemRenderType;
    }

    interface LegacyMenuProps extends MenuProps {
    Expand Down Expand Up @@ -242,6 +246,8 @@ const Menu = React.forwardRef<MenuRef, MenuProps>((props, ref) => {
    _internalComponents,

    popupRender,

    itemRender,
    ...restProps
    } = props as LegacyMenuProps;

    Expand All @@ -253,7 +259,7 @@ const Menu = React.forwardRef<MenuRef, MenuProps>((props, ref) => {
    parseItems(children, items, EMPTY_LIST, _internalComponents, prefixCls),
    parseItems(children, items, EMPTY_LIST, {}, prefixCls),
    ],
    [children, items, _internalComponents],
    [children, items, _internalComponents, prefixCls],
    );

    const [mounted, setMounted] = React.useState(false);
    Expand Down Expand Up @@ -655,6 +661,7 @@ const Menu = React.forwardRef<MenuRef, MenuProps>((props, ref) => {
    onItemClick={onInternalClick}
    onOpenChange={onInternalOpenChange}
    popupRender={popupRender}
    itemRender={itemRender}
    >
    <PathUserContext.Provider value={pathUserContext}>{container}</PathUserContext.Provider>

    Expand Down
    20 changes: 18 additions & 2 deletions src/MenuItem.tsx
    Original file line number Diff line number Diff line change
    Expand Up @@ -12,7 +12,7 @@ import PrivateContext from './context/PrivateContext';
    import useActive from './hooks/useActive';
    import useDirectionStyle from './hooks/useDirectionStyle';
    import Icon from './Icon';
    import type { MenuInfo, MenuItemType } from './interface';
    import type { MenuInfo, MenuItemType, ItemType } from './interface';
    import { warnItemProp } from './utils/warnUtil';

    export interface MenuItemProps
    Expand Down Expand Up @@ -89,6 +89,8 @@ const InternalMenuItem = React.forwardRef((props: MenuItemProps, ref: React.Ref<

    onFocus,

    itemRender: propItemRender,

    ...restProps
    } = props;

    Expand All @@ -109,8 +111,12 @@ const InternalMenuItem = React.forwardRef((props: MenuItemProps, ref: React.Ref<

    // Active
    onActive,

    itemRender: contextItemRender,
    } = React.useContext(MenuContext);

    const mergedItemRender = propItemRender || contextItemRender;

    const { _internalRenderMenuItem } = React.useContext(PrivateContext);

    const itemCls = `${prefixCls}-item`;
    Expand Down Expand Up @@ -198,7 +204,7 @@ const InternalMenuItem = React.forwardRef((props: MenuItemProps, ref: React.Ref<
    optionRoleProps['aria-selected'] = selected;
    }

    let renderNode = (
    let renderNode: React.ReactElement = (
    <LegacyMenuItem
    ref={legacyMenuItemRef}
    elementRef={mergedEleRef}
    Expand Down Expand Up @@ -238,6 +244,16 @@ const InternalMenuItem = React.forwardRef((props: MenuItemProps, ref: React.Ref<
    </LegacyMenuItem>
    );

    if (typeof mergedItemRender === 'function') {
    renderNode = mergedItemRender(renderNode, {
    item: {
    type: 'item',
    ...props,
    } as ItemType,
    keys: connectedKeys,
    }) as React.ReactElement;
    }

    if (_internalRenderMenuItem) {
    renderNode = _internalRenderMenuItem(renderNode, props, { selected });
    }
    Expand Down
    16 changes: 13 additions & 3 deletions src/MenuItemGroup.tsx
    Original file line number Diff line number Diff line change
    Expand Up @@ -3,7 +3,7 @@ import omit from '@rc-component/util/lib/omit';
    import * as React from 'react';
    import { MenuContext } from './context/MenuContext';
    import { useFullPath, useMeasure } from './context/PathContext';
    import type { MenuItemGroupType } from './interface';
    import type { MenuItemGroupType, ItemType } from './interface';
    import { parseChildren } from './utils/commonUtil';

    export interface MenuItemGroupProps extends Omit<MenuItemGroupType, 'type' | 'children' | 'label'> {
    Expand Down Expand Up @@ -52,18 +52,28 @@ const InternalMenuItemGroup = React.forwardRef<HTMLLIElement, MenuItemGroupProps
    });

    const MenuItemGroup = React.forwardRef<HTMLLIElement, MenuItemGroupProps>((props, ref) => {
    const { eventKey, children } = props;
    const { eventKey, children, itemRender: propItemRender } = props;
    const connectedKeyPath = useFullPath(eventKey);
    const childList: React.ReactElement[] = parseChildren(children, connectedKeyPath);
    const { itemRender: contextItemRender } = React.useContext(MenuContext);

    const measure = useMeasure();
    if (measure) {
    return childList as any as React.ReactElement;
    }

    const mergedItemRender = propItemRender || contextItemRender;
    return (
    <InternalMenuItemGroup ref={ref} {...omit(props, ['warnKey'])}>
    {childList}
    {typeof mergedItemRender === 'function'
    ? mergedItemRender(childList, {
    item: {
    type: 'group',
    ...props,
    } as ItemType,
    keys: connectedKeyPath,
    })
    : childList}
    </InternalMenuItemGroup>
    );
    });
    Expand Down
    20 changes: 16 additions & 4 deletions src/SubMenu/index.tsx
    Original file line number Diff line number Diff line change
    Expand Up @@ -4,7 +4,7 @@ import Overflow from 'rc-overflow';
    import warning from '@rc-component/util/lib/warning';
    import SubMenuList from './SubMenuList';
    import { parseChildren } from '../utils/commonUtil';
    import type { MenuInfo, SubMenuType, PopupRender } from '../interface';
    import type { MenuInfo, SubMenuType, PopupRender, ItemType } from '../interface';
    import MenuContextProvider, { MenuContext } from '../context/MenuContext';
    import useMemoCallback from '../hooks/useMemoCallback';
    import PopupTrigger from './PopupTrigger';
    Expand Down Expand Up @@ -384,7 +384,7 @@ const InternalSubMenu = React.forwardRef<HTMLLIElement, SubMenuProps>((props, re
    });

    const SubMenu = React.forwardRef<HTMLLIElement, SubMenuProps>((props, ref) => {
    const { eventKey, children } = props;
    const { eventKey, children, itemRender } = props;

    const connectedKeyPath = useFullPath(eventKey);
    const childList: React.ReactElement[] = parseChildren(children, connectedKeyPath);
    Expand All @@ -406,12 +406,24 @@ const SubMenu = React.forwardRef<HTMLLIElement, SubMenuProps>((props, ref) => {
    let renderNode: React.ReactNode;

    // ======================== Render ========================

    const childListNode =
    typeof itemRender === 'function'
    ? itemRender(childList, {
    item: {
    type: 'submenu',
    ...props,
    } as ItemType,
    keys: connectedKeyPath,
    })
    : childList;

    if (measure) {
    renderNode = childList;
    renderNode = childListNode;
    } else {
    renderNode = (
    <InternalSubMenu ref={ref} {...props}>
    {childList}
    {childListNode}
    </InternalSubMenu>
    );
    }
    Expand Down
    3 changes: 3 additions & 0 deletions src/context/MenuContext.tsx
    Original file line number Diff line number Diff line change
    Expand Up @@ -9,6 +9,7 @@ import type {
    RenderIconType,
    TriggerSubMenuAction,
    PopupRender,
    ItemRenderType,
    } from '../interface';
    import { SubMenuProps } from '..';

    Expand Down Expand Up @@ -53,6 +54,8 @@ export interface MenuContextProps {

    popupRender?: PopupRender;

    itemRender?: ItemRenderType;

    // Icon
    itemIcon?: RenderIconType;
    expandIcon?: RenderIconType;
    Expand Down
    9 changes: 9 additions & 0 deletions src/interface.ts
    Original file line number Diff line number Diff line change
    @@ -1,11 +1,15 @@
    import type * as React from 'react';
    import type { SubMenuProps } from './SubMenu';
    import type { MenuItemProps } from './MenuItem';
    import type { MenuItemGroupProps } from './MenuItemGroup';
    import type { DividerProps } from './Divider';

    // ========================= Options =========================
    interface ItemSharedProps {
    ref?: React.Ref<HTMLLIElement | null>;
    style?: React.CSSProperties;
    className?: string;
    itemRender?: ItemRenderType;
    }

    export interface SubMenuType extends ItemSharedProps {
    Expand Down Expand Up @@ -140,3 +144,8 @@ export type PopupRender = (
    node: React.ReactElement,
    info: { item: SubMenuProps; keys: string[] },
    ) => React.ReactNode;

    export type ItemRenderType = (
    node: React.ReactElement | React.ReactElement<any, string | React.JSXElementConstructor<any>>[],
    info: { item: ItemType; keys: string[] },
    ) => React.ReactNode | React.ReactElement;
    5 changes: 4 additions & 1 deletion src/utils/commonUtil.ts
    Original file line number Diff line number Diff line change
    Expand Up @@ -13,7 +13,10 @@ export function parseChildren(children: React.ReactNode | undefined, keyPath: st
    eventKey = `tmp_key-${[...keyPath, index].join('-')}`;
    }

    const cloneProps = { key: eventKey, eventKey } as any;
    const cloneProps = {
    key: eventKey,
    eventKey,
    } as any;

    if (process.env.NODE_ENV !== 'production' && emptyKey) {
    cloneProps.warnKey = true;
    Expand Down
    4 changes: 1 addition & 3 deletions src/utils/nodeUtil.tsx
    Original file line number Diff line number Diff line change
    Expand Up @@ -51,9 +51,7 @@ function convertItemsToNodes(
    return (
    <MergedMenuItem key={mergedKey} {...restProps} extra={extra}>
    {label}
    {(!!extra || extra === 0) && (
    <span className={`${prefixCls}-item-extra`}>{extra}</span>
    )}
    {(!!extra || extra === 0) && <span className={`${prefixCls}-item-extra`}>{extra}</span>}
    </MergedMenuItem>
    );
    }
    Expand Down
    35 changes: 35 additions & 0 deletions tests/MenuItem.spec.tsx
    Original file line number Diff line number Diff line change
    Expand Up @@ -228,5 +228,40 @@ describe('MenuItem', () => {

    expect(container.querySelector('li')).toMatchSnapshot();
    });

    it('should wrap originNode with custom render', () => {
    const { container } = render(
    <Menu
    itemRender={(originNode, { item }) => {
    if (item.type === 'item') {
    return (
    <a href="https://ant.design" target="_blank" rel="noopener noreferrer">
    {originNode}
    </a>
    );
    }
    return originNode;
    }}
    items={[
    {
    key: 'mail',
    type: 'item',
    label: 'Navigation One',
    },
    {
    key: 'app',
    label: 'Navigation Two',
    },
    Copy link
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    render 放 item 里意义不大,和直接写 label 没区别。用户期望的是可以在顶层统一配置 itemRender

    Copy link
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    <Menu itemRender={...} />

    Copy link
    Author

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    done

    {
    key: 'upload',
    label: 'Upload File',
    },
    ]}
    />,
    );

    const link = container.querySelector('a');
    expect(link).toHaveAttribute('href', 'https://ant.design');
    });
    });
    });
    Loading