diff --git a/.eslintrc.js b/.eslintrc.js index f88dce83..74887e5b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,5 +15,6 @@ module.exports = { '@typescript-eslint/no-explicit-any': 0, 'jsx-a11y/label-has-associated-control': 0, 'jsx-a11y/label-has-for': 0, + '@typescript-eslint/no-empty-interface': "off", }, }; diff --git a/docs/demo/options.md b/docs/demo/options.md new file mode 100644 index 00000000..9666c5af --- /dev/null +++ b/docs/demo/options.md @@ -0,0 +1,3 @@ +## options + + \ No newline at end of file diff --git a/docs/examples/options.tsx b/docs/examples/options.tsx new file mode 100644 index 00000000..4fa612be --- /dev/null +++ b/docs/examples/options.tsx @@ -0,0 +1,68 @@ +/* eslint no-console:0 */ + +import React from 'react'; +import Menu from '../../src'; +import '../../assets/index.less'; + +export default () => ( + +); diff --git a/src/Divider.tsx b/src/Divider.tsx index f65130be..f6107b76 100644 --- a/src/Divider.tsx +++ b/src/Divider.tsx @@ -2,11 +2,9 @@ import * as React from 'react'; import classNames from 'classnames'; import { MenuContext } from './context/MenuContext'; import { useMeasure } from './context/PathContext'; +import type { MenuDividerType } from './interface'; -export interface DividerProps { - className?: string; - style?: React.CSSProperties; -} +export type DividerProps = Omit; export default function Divider({ className, style }: DividerProps) { const { prefixCls } = React.useContext(MenuContext); diff --git a/src/Menu.tsx b/src/Menu.tsx index a28b4bf2..d671124c 100644 --- a/src/Menu.tsx +++ b/src/Menu.tsx @@ -14,9 +14,10 @@ import type { TriggerSubMenuAction, SelectInfo, RenderIconType, + ItemType, } from './interface'; import MenuItem from './MenuItem'; -import { parseChildren } from './utils/nodeUtil'; +import { parseItems } from './utils/nodeUtil'; import MenuContextProvider from './context/MenuContext'; import useMemoCallback from './hooks/useMemoCallback'; import { warnItemProp } from './utils/warnUtil'; @@ -52,6 +53,8 @@ export interface MenuProps > { prefixCls?: string; + items?: ItemType[]; + /** @deprecated Please use `items` instead */ children?: React.ReactNode; disabled?: boolean; @@ -152,6 +155,7 @@ const Menu = React.forwardRef((props, ref) => { style, className, tabIndex = 0, + items, children, direction, @@ -220,7 +224,11 @@ const Menu = React.forwardRef((props, ref) => { ...restProps } = props as LegacyMenuProps; - const childList: React.ReactElement[] = parseChildren(children, EMPTY_LIST); + const childList: React.ReactElement[] = parseItems( + children, + items, + EMPTY_LIST, + ); const [mounted, setMounted] = React.useState(false); const containerRef = React.useRef(); diff --git a/src/MenuItem.tsx b/src/MenuItem.tsx index 5e7a724b..fee2faa6 100644 --- a/src/MenuItem.tsx +++ b/src/MenuItem.tsx @@ -4,12 +4,7 @@ import Overflow from 'rc-overflow'; import warning from 'rc-util/lib/warning'; import KeyCode from 'rc-util/lib/KeyCode'; import omit from 'rc-util/lib/omit'; -import type { - MenuClickEventHandler, - MenuInfo, - MenuHoverEventHandler, - RenderIconType, -} from './interface'; +import type { MenuInfo, MenuItemType } from './interface'; import { MenuContext } from './context/MenuContext'; import useActive from './hooks/useActive'; import { warnItemProp } from './utils/warnUtil'; @@ -20,10 +15,11 @@ import { useMenuId } from './context/IdContext'; import PrivateContext from './context/PrivateContext'; export interface MenuItemProps - extends Omit< - React.HTMLAttributes, - 'onClick' | 'onMouseEnter' | 'onMouseLeave' | 'onSelect' - > { + extends Omit, + Omit< + React.HTMLAttributes, + 'onClick' | 'onMouseEnter' | 'onMouseLeave' | 'onSelect' + > { children?: React.ReactNode; /** @private Internal filled key. Do not set it directly */ @@ -32,18 +28,8 @@ export interface MenuItemProps /** @private Do not use. Private warning empty usage */ warnKey?: boolean; - disabled?: boolean; - - itemIcon?: RenderIconType; - /** @deprecated No place to use this. Should remove */ attribute?: Record; - // >>>>> Active - onMouseEnter?: MenuHoverEventHandler; - onMouseLeave?: MenuHoverEventHandler; - - // >>>>> Events - onClick?: MenuClickEventHandler; } // Since Menu event provide the `info.item` which point to the MenuItem node instance. diff --git a/src/MenuItemGroup.tsx b/src/MenuItemGroup.tsx index 08053f4c..5897cda0 100644 --- a/src/MenuItemGroup.tsx +++ b/src/MenuItemGroup.tsx @@ -4,10 +4,10 @@ import omit from 'rc-util/lib/omit'; import { parseChildren } from './utils/nodeUtil'; import { MenuContext } from './context/MenuContext'; import { useFullPath, useMeasure } from './context/PathContext'; +import type { MenuItemGroupType } from './interface'; -export interface MenuItemGroupProps { - className?: string; - title?: React.ReactNode; +export interface MenuItemGroupProps + extends Omit { children?: React.ReactNode; /** @private Internal filled key. Do not set it directly */ diff --git a/src/SubMenu/index.tsx b/src/SubMenu/index.tsx index 06360b34..aadd9b02 100644 --- a/src/SubMenu/index.tsx +++ b/src/SubMenu/index.tsx @@ -4,13 +4,7 @@ import Overflow from 'rc-overflow'; import warning from 'rc-util/lib/warning'; import SubMenuList from './SubMenuList'; import { parseChildren } from '../utils/nodeUtil'; -import type { - MenuClickEventHandler, - MenuHoverEventHandler, - MenuInfo, - MenuTitleInfo, - RenderIconType, -} from '../interface'; +import type { MenuInfo, SubMenuType } from '../interface'; import MenuContextProvider, { MenuContext } from '../context/MenuContext'; import useMemoCallback from '../hooks/useMemoCallback'; import PopupTrigger from './PopupTrigger'; @@ -28,14 +22,9 @@ import { import { useMenuId } from '../context/IdContext'; import PrivateContext from '../context/PrivateContext'; -export interface SubMenuProps { - style?: React.CSSProperties; - className?: string; - - title?: React.ReactNode; +export interface SubMenuProps extends Omit { children?: React.ReactNode; - disabled?: boolean; /** @private Used for rest popup. Do not use in your prod */ internalPopupClose?: boolean; @@ -45,24 +34,6 @@ export interface SubMenuProps { /** @private Do not use. Private warning empty usage */ warnKey?: boolean; - // >>>>> Icon - itemIcon?: RenderIconType; - expandIcon?: RenderIconType; - - // >>>>> Active - onMouseEnter?: MenuHoverEventHandler; - onMouseLeave?: MenuHoverEventHandler; - - // >>>>> Popup - popupClassName?: string; - popupOffset?: number[]; - - // >>>>> Events - onClick?: MenuClickEventHandler; - onTitleClick?: (info: MenuTitleInfo) => void; - onTitleMouseEnter?: MenuHoverEventHandler; - onTitleMouseLeave?: MenuHoverEventHandler; - // >>>>>>>>>>>>>>>>>>>>> Next Round <<<<<<<<<<<<<<<<<<<<<<< // onDestroy?: DestroyEventHandler; } diff --git a/src/interface.ts b/src/interface.ts index ace7673d..51864dfc 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,5 +1,74 @@ import type * as React from 'react'; +// ========================= Options ========================= +interface ItemSharedProps { + style?: React.CSSProperties; + className?: string; +} + +export interface SubMenuType extends ItemSharedProps { + title?: React.ReactNode; + + children: ItemType[]; + + disabled?: boolean; + + key: string; + + // >>>>> Icon + itemIcon?: RenderIconType; + expandIcon?: RenderIconType; + + // >>>>> Active + onMouseEnter?: MenuHoverEventHandler; + onMouseLeave?: MenuHoverEventHandler; + + // >>>>> Popup + popupClassName?: string; + popupOffset?: number[]; + + // >>>>> Events + onClick?: MenuClickEventHandler; + onTitleClick?: (info: MenuTitleInfo) => void; + onTitleMouseEnter?: MenuHoverEventHandler; + onTitleMouseLeave?: MenuHoverEventHandler; +} + +export interface MenuItemType extends ItemSharedProps { + title?: React.ReactNode; + + disabled?: boolean; + + itemIcon?: RenderIconType; + + key: string; + + // >>>>> Active + onMouseEnter?: MenuHoverEventHandler; + onMouseLeave?: MenuHoverEventHandler; + + // >>>>> Events + onClick?: MenuClickEventHandler; +} + +export interface MenuItemGroupType extends ItemSharedProps { + type: 'group'; + + title?: React.ReactNode; + + children?: ItemType[]; +} + +export interface MenuDividerType extends ItemSharedProps { + type: 'divider'; +} + +export type ItemType = + | SubMenuType + | MenuItemType + | MenuItemGroupType + | MenuDividerType; + // ========================== Basic ========================== export type MenuMode = 'horizontal' | 'vertical' | 'inline'; diff --git a/src/utils/nodeUtil.ts b/src/utils/nodeUtil.ts deleted file mode 100644 index 5e77f849..00000000 --- a/src/utils/nodeUtil.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as React from 'react'; -import toArray from 'rc-util/lib/Children/toArray'; - -export function parseChildren(children: React.ReactNode, keyPath: string[]) { - return toArray(children).map((child, index) => { - if (React.isValidElement(child)) { - const { key } = child; - let eventKey = (child.props as any)?.eventKey ?? key; - - const emptyKey = eventKey === null || eventKey === undefined; - - if (emptyKey) { - eventKey = `tmp_key-${[...keyPath, index].join('-')}`; - } - - const cloneProps = { - key: eventKey, - eventKey, - } as any; - - if (process.env.NODE_ENV !== 'production' && emptyKey) { - cloneProps.warnKey = true; - } - - return React.cloneElement(child, cloneProps); - } - - return child; - }); -} diff --git a/src/utils/nodeUtil.tsx b/src/utils/nodeUtil.tsx new file mode 100644 index 00000000..16028a3b --- /dev/null +++ b/src/utils/nodeUtil.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import toArray from 'rc-util/lib/Children/toArray'; +import type { MenuItemType, ItemType } from '../interface'; +import { Divider, MenuItem, MenuItemGroup, SubMenu } from '..'; + +export function parseChildren( + children: React.ReactNode | undefined, + keyPath: string[], +) { + return toArray(children).map((child, index) => { + if (React.isValidElement(child)) { + const { key } = child; + let eventKey = (child.props as any)?.eventKey ?? key; + + const emptyKey = eventKey === null || eventKey === undefined; + + if (emptyKey) { + eventKey = `tmp_key-${[...keyPath, index].join('-')}`; + } + + const cloneProps = { + key: eventKey, + eventKey, + } as any; + + if (process.env.NODE_ENV !== 'production' && emptyKey) { + cloneProps.warnKey = true; + } + + return React.cloneElement(child, cloneProps); + } + + return child; + }); +} + +function convertItemsToNodes(list: ItemType[]) { + return (list || []) + .map((opt, index) => { + if (opt && typeof opt === 'object') { + const { children, key, type, ...restProps } = opt as any; + const mergedKey = key ?? `tmp-${index}`; + + // MenuItemGroup & SubMenuItem + if (children || type === 'group') { + if (type === 'group') { + // Group + return ( + + {convertItemsToNodes(children)} + + ); + } + + // Sub Menu + return ( + + {convertItemsToNodes(children)} + + ); + } + + // MenuItem & Divider + if (type === 'divider') { + return ; + } + + const { title, ...restMenuItemProps } = restProps as MenuItemType; + return ( + + {title} + + ); + } + + return null; + }) + .filter(opt => opt); +} + +export function parseItems( + children: React.ReactNode | undefined, + items: ItemType[] | undefined, + keyPath: string[], +) { + let childNodes = children; + + if (items) { + childNodes = convertItemsToNodes(items); + } + + return parseChildren(childNodes, keyPath); +} diff --git a/tests/Options.spec.tsx b/tests/Options.spec.tsx new file mode 100644 index 00000000..54e1623b --- /dev/null +++ b/tests/Options.spec.tsx @@ -0,0 +1,45 @@ +/* eslint-disable no-undef */ +import React from 'react'; +import { mount } from 'enzyme'; +import Menu from '../src'; + +describe('Options', () => { + it('should work', () => { + const wrapper = mount( + , + ); + + expect(wrapper.render()).toMatchSnapshot(); + }); +}); +/* eslint-enable */ diff --git a/tests/__snapshots__/Options.spec.tsx.snap b/tests/__snapshots__/Options.spec.tsx.snap new file mode 100644 index 00000000..e9db788e --- /dev/null +++ b/tests/__snapshots__/Options.spec.tsx.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Options should work 1`] = ` +Array [ + , +