diff --git a/.eslintrc.js b/.eslintrc.js index a7964d70..feefc81a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,6 +12,12 @@ 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", + '@typescript-eslint/no-empty-interface': 0, + '@typescript-eslint/consistent-indexed-object-style': 0, + '@typescript-eslint/switch-exhaustiveness-check': 0, + '@typescript-eslint/no-parameter-properties': 0, + '@typescript-eslint/no-throw-literal': 0, + '@typescript-eslint/type-annotation-spacing': 0, + '@typescript-eslint/ban-types': 0, }, }; diff --git a/docs/demo/customPopupRender.md b/docs/demo/customPopupRender.md new file mode 100644 index 00000000..6e0b702a --- /dev/null +++ b/docs/demo/customPopupRender.md @@ -0,0 +1,3 @@ +## customPopupRender + + diff --git a/docs/examples/antd.tsx b/docs/examples/antd.tsx index 7dac7ffd..a9281d3f 100644 --- a/docs/examples/antd.tsx +++ b/docs/examples/antd.tsx @@ -56,29 +56,17 @@ const nestSubMenu = ( > inner inner - sub menu 1} - > - sub 4-2-0} - key="4-2-0" - > + sub menu 1}> + sub 4-2-0} key="4-2-0"> inner inner inner inner2 inn - sub menu 4} - key="4-2-2" - > + sub menu 4} key="4-2-2"> inner inner inner inner2 - sub menu 3} - key="4-2-3" - > + sub menu 3} key="4-2-3"> inner inner inner inner2 @@ -91,10 +79,7 @@ function onOpenChange(value) { } const children1 = [ - sub menu} - key="1" - > + sub menu} key="1"> 0-1 0-2 , @@ -108,10 +93,7 @@ const children1 = [ ]; const children2 = [ - sub menu} - key="1" - > + sub menu} key="1"> 0-1 0-2 , @@ -131,10 +113,7 @@ interface CommonMenuState { overflowedIndicator: React.ReactNode; } -export class CommonMenu extends React.Component< - CommonMenuProps, - CommonMenuState -> { +export class CommonMenu extends React.Component { state: CommonMenuState = { children: children1, overflowedIndicator: undefined, @@ -148,8 +127,7 @@ export class CommonMenu extends React.Component< toggleOverflowedIndicator = () => { this.setState(({ overflowedIndicator }) => ({ - overflowedIndicator: - overflowedIndicator === undefined ? customizeIndicator : undefined, + overflowedIndicator: overflowedIndicator === undefined ? customizeIndicator : undefined, })); }; @@ -202,13 +180,9 @@ function Demo() { /> ); - const verticalMenu = ( - - ); + const verticalMenu = ; - const inlineMenu = ( - - ); + const inlineMenu = ; return (
diff --git a/docs/examples/customPopupRender.less b/docs/examples/customPopupRender.less new file mode 100644 index 00000000..0b8518ce --- /dev/null +++ b/docs/examples/customPopupRender.less @@ -0,0 +1,76 @@ +.navigation-popup { + padding: 24px; + min-width: 480px; + background: #fff; + border-radius: 8px; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08); + + .navigation-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + } + + .navigation-menu-item { + padding: 0; + margin: 0; + + a { + display: block; + padding: 16px; + text-decoration: none; + color: inherit; + border-radius: 6px; + transition: all 0.3s; + + h3 { + margin: 0 0 8px; + font-size: 16px; + font-weight: 500; + } + + p { + margin: 0; + color: rgba(0, 0, 0, 0.45); + font-size: 14px; + line-height: 1.5; + } + + &:hover { + background: rgba(0, 0, 0, 0.02); + } + } + } +} + +.panel-popup { + padding: 16px; + min-width: 240px; + background: #fff; + border-radius: 8px; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08); + + .panel-header { + padding: 0 8px 12px; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + margin-bottom: 8px; + + h4 { + margin: 0; + font-size: 14px; + color: rgba(0, 0, 0, 0.45); + } + } + + .panel-content { + .rc-menu-item { + padding: 8px 12px; + margin: 0; + border-radius: 4px; + + &:hover { + background: rgba(0, 0, 0, 0.02); + } + } + } +} diff --git a/docs/examples/customPopupRender.tsx b/docs/examples/customPopupRender.tsx new file mode 100644 index 00000000..cf2d7138 --- /dev/null +++ b/docs/examples/customPopupRender.tsx @@ -0,0 +1,164 @@ +import React from 'react'; +import Menu, { SubMenu, Item as MenuItem } from '../../src'; +import type { ReactElement } from 'react'; +import './customPopupRender.less'; + +const NavigationDemo = () => { + const menuItems = [ + { + key: 'home', + label: 'Home', + }, + { + key: 'features', + label: 'Features', + children: [ + { + key: 'getting-started', + label: ( + +

Getting Started

+

Quick start guide and learn the basics.

+
+ ), + }, + { + key: 'components', + label: ( + +

Components

+

Explore our component library.

+
+ ), + }, + { + key: 'templates', + label: ( + +

Templates

+

Ready-to-use template designs.

+
+ ), + }, + ], + }, + { + key: 'resources', + label: 'Resources', + children: [ + { + key: 'blog', + label: ( + +

Blog

+

Latest updates and articles.

+
+ ), + }, + { + key: 'community', + label: ( + +

Community

+

Join our developer community.

+
+ ), + }, + ], + }, + ]; + const popupRender = (node: ReactElement) => ( +
+
+ {React.Children.map(node.props.children.props.children, child => ( +
+ {React.cloneElement(child, { + className: `${child.props.className || ''} navigation-menu-item`, + })} +
+ ))} +
+
+ ); + + return ; +}; + +const MixedPanelDemo = () => { + const totalPopupRender = (node: ReactElement, info: { item: any; keys: string[] }) => { + const isSecondLevel = info.keys.length == 2; + if (isSecondLevel) { + return ( +
+
+ {React.Children.map(node.props.children.props.children, child => ( +
+ {React.cloneElement(child, { + className: `${child.props.className || ''} navigation-menu-item`, + })} +
+ ))} +
+
+ ); + } + return node; + }; + const singlePopupRender = (node: ReactElement, info: { item: any; keys: string[] }) => { + const isSecondLevel = info.keys.length == 2; + if (isSecondLevel) { + return ( +
+
+

{info.item.title}

+
+
{node}
+
+ ); + } + return node; + }; + return ( + + Home + + Product A + Product B + + + +

Product C

+

Description for Product C.

+
+
+ + +

Product D

+

Description for Product D.

+
+
+
+
+ + Enterprise + Personal + + Healthcare + Education + + +
+ ); +}; + +const Demo = () => { + return ( +
+

NavigationDemo

+ +

MixedPanelDemo

+ +
+ ); +}; +export default Demo; diff --git a/docs/examples/inlineCollapsed.tsx b/docs/examples/inlineCollapsed.tsx index 691f8f03..15fdc87b 100644 --- a/docs/examples/inlineCollapsed.tsx +++ b/docs/examples/inlineCollapsed.tsx @@ -7,11 +7,7 @@ const App = () => { return ( <> (

menu item group

- console.log('click')} - > + console.log('click')}> 2 3 diff --git a/docs/examples/rtl-antd.tsx b/docs/examples/rtl-antd.tsx index 466936f4..a8fa6ecd 100644 --- a/docs/examples/rtl-antd.tsx +++ b/docs/examples/rtl-antd.tsx @@ -2,12 +2,7 @@ import React from 'react'; import type { CSSMotionProps } from '@rc-component/motion'; -import Menu, { - SubMenu, - Item as MenuItem, - Divider, - MenuProps, -} from '@rc-component/menu'; +import Menu, { SubMenu, Item as MenuItem, Divider, MenuProps } from '@rc-component/menu'; import '../../assets/index.less'; function handleClick(info) { @@ -61,29 +56,17 @@ const nestSubMenu = ( > inner inner - sub menu 1} - > - sub 4-2-0} - key="4-2-0" - > + sub menu 1}> + sub 4-2-0} key="4-2-0"> inner inner inner inner2 inn - sub menu 4} - key="4-2-2" - > + sub menu 4} key="4-2-2"> inner inner inner inner2 - sub menu 3} - key="4-2-3" - > + sub menu 3} key="4-2-3"> inner inner inner inner2 @@ -96,10 +79,7 @@ function onOpenChange(value) { } const children1 = [ - sub menu} - key="1" - > + sub menu} key="1"> 0-1 0-2 , @@ -113,10 +93,7 @@ const children1 = [ ]; const children2 = [ - sub menu} - key="1" - > + sub menu} key="1"> 0-1 0-2 , @@ -145,8 +122,7 @@ class CommonMenu extends React.Component { toggleOverflowedIndicator = () => { this.setState(({ overflowedIndicator }) => ({ - overflowedIndicator: - overflowedIndicator === undefined ? customizeIndicator : undefined, + overflowedIndicator: overflowedIndicator === undefined ? customizeIndicator : undefined, })); }; @@ -199,13 +175,9 @@ function Demo() { /> ); - const verticalMenu = ( - - ); + const verticalMenu = ; - const inlineMenu = ( - - ); + const inlineMenu = ; return (
diff --git a/src/Menu.tsx b/src/Menu.tsx index b6952c98..0207191e 100644 --- a/src/Menu.tsx +++ b/src/Menu.tsx @@ -11,11 +11,7 @@ import { IdContext } from './context/IdContext'; import MenuContextProvider from './context/MenuContext'; import { PathRegisterContext, PathUserContext } from './context/PathContext'; import PrivateContext from './context/PrivateContext'; -import { - getFocusableElements, - refreshElements, - useAccessibility, -} from './hooks/useAccessibility'; +import { getFocusableElements, refreshElements, useAccessibility } from './hooks/useAccessibility'; import useKeyRecords, { OVERFLOW_KEY } from './hooks/useKeyRecords'; import useMemoCallback from './hooks/useMemoCallback'; import useUUID from './hooks/useUUID'; @@ -31,6 +27,7 @@ import type { SelectEventHandler, SelectInfo, TriggerSubMenuAction, + PopupRender, } from './interface'; import MenuItem from './MenuItem'; import SubMenu from './SubMenu'; @@ -54,10 +51,7 @@ import { warnItemProp } from './utils/warnUtil'; const EMPTY_LIST: string[] = []; export interface MenuProps - extends Omit< - React.HTMLAttributes, - 'onClick' | 'onSelect' | 'dir' - > { + extends Omit, 'onClick' | 'onSelect' | 'dir'> { prefixCls?: string; rootClassName?: string; items?: ItemType[]; @@ -101,6 +95,7 @@ export interface MenuProps /** Menu motion define. Use `defaultMotions` if you need config motion of each mode */ motion?: CSSMotionProps; /** Default menu motion of each mode */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars defaultMotions?: Partial<{ [key in MenuMode | 'other']: CSSMotionProps }>; // Popup @@ -158,6 +153,8 @@ export interface MenuProps * By zombieJ */ _internalComponents?: Components; + + popupRender?: PopupRender; } interface LegacyMenuProps extends MenuProps { @@ -240,6 +237,7 @@ const Menu = React.forwardRef((props, ref) => { _internalComponents, + popupRender, ...restProps } = props as LegacyMenuProps; @@ -292,15 +290,12 @@ const Menu = React.forwardRef((props, ref) => { }; // >>>>> Cache & Reset open keys when inlineCollapsed changed - const [inlineCacheOpenKeys, setInlineCacheOpenKeys] = - React.useState(mergedOpenKeys); + const [inlineCacheOpenKeys, setInlineCacheOpenKeys] = React.useState(mergedOpenKeys); const mountRef = React.useRef(false); // ========================= Mode ========================= - const [mergedMode, mergedInlineCollapsed] = React.useMemo< - [MenuMode, boolean] - >(() => { + const [mergedMode, mergedInlineCollapsed] = React.useMemo<[MenuMode, boolean]>(() => { if ((mode === 'inline' || mode === 'vertical') && inlineCollapsed) { return ['vertical', inlineCollapsed]; } @@ -310,9 +305,8 @@ const Menu = React.forwardRef((props, ref) => { const isInlineMode = mergedMode === 'inline'; const [internalMode, setInternalMode] = React.useState(mergedMode); - const [internalInlineCollapsed, setInternalInlineCollapsed] = React.useState( - mergedInlineCollapsed, - ); + const [internalInlineCollapsed, setInternalInlineCollapsed] = + React.useState(mergedInlineCollapsed); React.useEffect(() => { setInternalMode(mergedMode); @@ -333,9 +327,7 @@ const Menu = React.forwardRef((props, ref) => { // ====================== Responsive ====================== const [lastVisibleIndex, setLastVisibleIndex] = React.useState(0); const allVisible = - lastVisibleIndex >= childList.length - 1 || - internalMode !== 'horizontal' || - disabledOverflow; + lastVisibleIndex >= childList.length - 1 || internalMode !== 'horizontal' || disabledOverflow; // Cache React.useEffect(() => { @@ -369,18 +361,13 @@ const Menu = React.forwardRef((props, ref) => { [registerPath, unregisterPath], ); - const pathUserContext = React.useMemo( - () => ({ isSubPathKey }), - [isSubPathKey], - ); + const pathUserContext = React.useMemo(() => ({ isSubPathKey }), [isSubPathKey]); React.useEffect(() => { refreshOverflowKeys( allVisible ? EMPTY_LIST - : childList - .slice(lastVisibleIndex + 1) - .map(child => child.key as string), + : childList.slice(lastVisibleIndex + 1).map(child => child.key as string), ); }, [lastVisibleIndex, allVisible]); @@ -405,14 +392,8 @@ const Menu = React.forwardRef((props, ref) => { list: containerRef.current, focus: options => { const keys = getKeys(); - const { elements, key2element, element2key } = refreshElements( - keys, - uuid, - ); - const focusableElements = getFocusableElements( - containerRef.current, - elements, - ); + const { elements, key2element, element2key } = refreshElements(keys, uuid); + const focusableElements = getFocusableElements(containerRef.current, elements); const shouldFocusKey = mergedActiveKey ?? @@ -436,25 +417,22 @@ const Menu = React.forwardRef((props, ref) => { // ======================== Select ======================== // >>>>> Select keys - const [mergedSelectKeys, setMergedSelectKeys] = useMergedState( - defaultSelectedKeys || [], - { - value: selectedKeys, + const [mergedSelectKeys, setMergedSelectKeys] = useMergedState(defaultSelectedKeys || [], { + value: selectedKeys, - // Legacy convert key to array - postState: keys => { - if (Array.isArray(keys)) { - return keys; - } + // Legacy convert key to array + postState: keys => { + if (Array.isArray(keys)) { + return keys; + } - if (keys === null || keys === undefined) { - return EMPTY_LIST; - } + if (keys === null || keys === undefined) { + return EMPTY_LIST; + } - return [keys]; - }, + return [keys]; }, - ); + }); // >>>>> Trigger select const triggerSelection = (info: MenuInfo) => { @@ -566,10 +544,7 @@ const Menu = React.forwardRef((props, ref) => { : // Need wrap for overflow dropdown that do not response for open childList.map((child, index) => ( // Always wrap provider to avoid sub node re-mount - lastVisibleIndex} - > + lastVisibleIndex}> {child} )); @@ -668,10 +643,9 @@ const Menu = React.forwardRef((props, ref) => { // Events onItemClick={onInternalClick} onOpenChange={onInternalOpenChange} + popupRender={popupRender} > - - {container} - + {container} {/* Measure menu keys. Add `display: none` to avoid some developer miss use the Menu */}
diff --git a/src/MenuItem.tsx b/src/MenuItem.tsx index a806bdea..6b179e73 100644 --- a/src/MenuItem.tsx +++ b/src/MenuItem.tsx @@ -50,10 +50,7 @@ class LegacyMenuItem extends React.Component { 'popupOffset', 'onTitleClick', ]); - warning( - !attribute, - '`attribute` of Menu.Item is deprecated. Please pass attribute directly.', - ); + warning(!attribute, '`attribute` of Menu.Item is deprecated. Please pass attribute directly.'); return ( { /** * Real Menu Item component */ -const InternalMenuItem = React.forwardRef( - (props: MenuItemProps, ref: React.Ref) => { - const { - style, - className, +const InternalMenuItem = React.forwardRef((props: MenuItemProps, ref: React.Ref) => { + const { + style, + className, - eventKey, - warnKey, - disabled, - itemIcon, - children, + eventKey, + warnKey, + disabled, + itemIcon, + children, - // Aria - role, + // Aria + role, - // Active - onMouseEnter, - onMouseLeave, + // Active + onMouseEnter, + onMouseLeave, - onClick, - onKeyDown, + onClick, + onKeyDown, - onFocus, + onFocus, - ...restProps - } = props; + ...restProps + } = props; - const domDataId = useMenuId(eventKey); + const domDataId = useMenuId(eventKey); - const { - prefixCls, - onItemClick, + const { + prefixCls, + onItemClick, - disabled: contextDisabled, - overflowDisabled, + disabled: contextDisabled, + overflowDisabled, - // Icon - itemIcon: contextItemIcon, + // Icon + itemIcon: contextItemIcon, - // Select - selectedKeys, + // Select + selectedKeys, - // Active - onActive, - } = React.useContext(MenuContext); + // Active + onActive, + } = React.useContext(MenuContext); - const { _internalRenderMenuItem } = React.useContext(PrivateContext); + const { _internalRenderMenuItem } = React.useContext(PrivateContext); - const itemCls = `${prefixCls}-item`; + const itemCls = `${prefixCls}-item`; - const legacyMenuItemRef = React.useRef(); - const elementRef = React.useRef(); - const mergedDisabled = contextDisabled || disabled; + const legacyMenuItemRef = React.useRef(); + const elementRef = React.useRef(); + const mergedDisabled = contextDisabled || disabled; - const mergedEleRef = useComposeRef(ref, elementRef); + const mergedEleRef = useComposeRef(ref, elementRef); - const connectedKeys = useFullPath(eventKey); + const connectedKeys = useFullPath(eventKey); - // ================================ Warn ================================ - if (process.env.NODE_ENV !== 'production' && warnKey) { - warning(false, 'MenuItem should not leave undefined `key`.'); - } + // ================================ Warn ================================ + if (process.env.NODE_ENV !== 'production' && warnKey) { + warning(false, 'MenuItem should not leave undefined `key`.'); + } - // ============================= Info ============================= - const getEventInfo = ( - e: React.MouseEvent | React.KeyboardEvent, - ): MenuInfo => { - return { - key: eventKey, - // Note: For legacy code is reversed which not like other antd component - keyPath: [...connectedKeys].reverse(), - item: legacyMenuItemRef.current, - domEvent: e, - }; + // ============================= Info ============================= + const getEventInfo = ( + e: React.MouseEvent | React.KeyboardEvent, + ): MenuInfo => { + return { + key: eventKey, + // Note: For legacy code is reversed which not like other antd component + keyPath: [...connectedKeys].reverse(), + item: legacyMenuItemRef.current, + domEvent: e, }; + }; - // ============================= Icon ============================= - const mergedItemIcon = itemIcon || contextItemIcon; - - // ============================ Active ============================ - const { active, ...activeProps } = useActive( - eventKey, - mergedDisabled, - onMouseEnter, - onMouseLeave, - ); - - // ============================ Select ============================ - const selected = selectedKeys.includes(eventKey); - - // ======================== DirectionStyle ======================== - const directionStyle = useDirectionStyle(connectedKeys.length); + // ============================= Icon ============================= + const mergedItemIcon = itemIcon || contextItemIcon; - // ============================ Events ============================ - const onInternalClick: React.MouseEventHandler = e => { - if (mergedDisabled) { - return; - } + // ============================ Active ============================ + const { active, ...activeProps } = useActive( + eventKey, + mergedDisabled, + onMouseEnter, + onMouseLeave, + ); - const info = getEventInfo(e); + // ============================ Select ============================ + const selected = selectedKeys.includes(eventKey); - onClick?.(warnItemProp(info)); - onItemClick(info); - }; + // ======================== DirectionStyle ======================== + const directionStyle = useDirectionStyle(connectedKeys.length); - const onInternalKeyDown: React.KeyboardEventHandler = e => { - onKeyDown?.(e); + // ============================ Events ============================ + const onInternalClick: React.MouseEventHandler = e => { + if (mergedDisabled) { + return; + } - if (e.which === KeyCode.ENTER) { - const info = getEventInfo(e); + const info = getEventInfo(e); - // Legacy. Key will also trigger click event - onClick?.(warnItemProp(info)); - onItemClick(info); - } - }; + onClick?.(warnItemProp(info)); + onItemClick(info); + }; - /** - * Used for accessibility. Helper will focus element without key board. - * We should manually trigger an active - */ - const onInternalFocus: React.FocusEventHandler = e => { - onActive(eventKey); - onFocus?.(e); - }; + const onInternalKeyDown: React.KeyboardEventHandler = e => { + onKeyDown?.(e); - // ============================ Render ============================ - const optionRoleProps: React.HTMLAttributes = {}; + if (e.which === KeyCode.ENTER) { + const info = getEventInfo(e); - if (props.role === 'option') { - optionRoleProps['aria-selected'] = selected; + // Legacy. Key will also trigger click event + onClick?.(warnItemProp(info)); + onItemClick(info); } + }; + + /** + * Used for accessibility. Helper will focus element without key board. + * We should manually trigger an active + */ + const onInternalFocus: React.FocusEventHandler = e => { + onActive(eventKey); + onFocus?.(e); + }; + + // ============================ Render ============================ + const optionRoleProps: React.HTMLAttributes = {}; + + if (props.role === 'option') { + optionRoleProps['aria-selected'] = selected; + } - let renderNode = ( - + {children} + - {children} - - - ); + icon={mergedItemIcon} + /> + + ); - if (_internalRenderMenuItem) { - renderNode = _internalRenderMenuItem(renderNode, props, { selected }); - } + if (_internalRenderMenuItem) { + renderNode = _internalRenderMenuItem(renderNode, props, { selected }); + } - return renderNode; - }, -); + return renderNode; +}); -function MenuItem( - props: MenuItemProps, - ref: React.Ref, -): React.ReactElement { +function MenuItem(props: MenuItemProps, ref: React.Ref): React.ReactElement { const { eventKey } = props; // ==================== Record KeyPath ==================== diff --git a/src/MenuItemGroup.tsx b/src/MenuItemGroup.tsx index dfe8b30f..75da0c83 100644 --- a/src/MenuItemGroup.tsx +++ b/src/MenuItemGroup.tsx @@ -6,8 +6,7 @@ import { useFullPath, useMeasure } from './context/PathContext'; import type { MenuItemGroupType } from './interface'; import { parseChildren } from './utils/commonUtil'; -export interface MenuItemGroupProps - extends Omit { +export interface MenuItemGroupProps extends Omit { title?: React.ReactNode; children?: React.ReactNode; @@ -19,10 +18,7 @@ export interface MenuItemGroupProps warnKey?: boolean; } -const InternalMenuItemGroup = React.forwardRef< - HTMLLIElement, - MenuItemGroupProps ->((props, ref) => { +const InternalMenuItemGroup = React.forwardRef((props, ref) => { const { className, title, eventKey, children, ...restProps } = props; const { prefixCls } = React.useContext(MenuContext); @@ -50,27 +46,22 @@ const InternalMenuItemGroup = React.forwardRef< ); }); -const MenuItemGroup = React.forwardRef( - (props, ref) => { - const { eventKey, children } = props; - const connectedKeyPath = useFullPath(eventKey); - const childList: React.ReactElement[] = parseChildren( - children, - connectedKeyPath, - ); +const MenuItemGroup = React.forwardRef((props, ref) => { + const { eventKey, children } = props; + const connectedKeyPath = useFullPath(eventKey); + const childList: React.ReactElement[] = parseChildren(children, connectedKeyPath); - const measure = useMeasure(); - if (measure) { - return childList as any as React.ReactElement; - } + const measure = useMeasure(); + if (measure) { + return childList as any as React.ReactElement; + } - return ( - - {childList} - - ); - }, -); + return ( + + {childList} + + ); +}); if (process.env.NODE_ENV !== 'production') { MenuItemGroup.displayName = 'MenuItemGroup'; diff --git a/src/SubMenu/InlineSubMenuList.tsx b/src/SubMenu/InlineSubMenuList.tsx index 26de7633..77f14116 100644 --- a/src/SubMenu/InlineSubMenuList.tsx +++ b/src/SubMenu/InlineSubMenuList.tsx @@ -12,12 +12,7 @@ export interface InlineSubMenuListProps { children: React.ReactNode; } -export default function InlineSubMenuList({ - id, - open, - keyPath, - children, -}: InlineSubMenuListProps) { +export default function InlineSubMenuList({ id, open, keyPath, children }: InlineSubMenuListProps) { const fixedMode: MenuMode = 'inline'; const { prefixCls, forceSubMenuRender, motion, defaultMotions, mode } = @@ -74,11 +69,7 @@ export default function InlineSubMenuList({ > {({ className: motionClassName, style: motionStyle }) => { return ( - + {children} ); diff --git a/src/SubMenu/index.tsx b/src/SubMenu/index.tsx index 59ace838..758191ab 100644 --- a/src/SubMenu/index.tsx +++ b/src/SubMenu/index.tsx @@ -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 } from '../interface'; +import type { MenuInfo, SubMenuType, PopupRender } from '../interface'; import MenuContextProvider, { MenuContext } from '../context/MenuContext'; import useMemoCallback from '../hooks/useMemoCallback'; import PopupTrigger from './PopupTrigger'; @@ -22,8 +22,7 @@ import { import { useMenuId } from '../context/IdContext'; import PrivateContext from '../context/PrivateContext'; -export interface SubMenuProps - extends Omit { +export interface SubMenuProps extends Omit { title?: React.ReactNode; children?: React.ReactNode; @@ -36,342 +35,349 @@ export interface SubMenuProps /** @private Do not use. Private warning empty usage */ warnKey?: boolean; - + popupRender?: PopupRender; // >>>>>>>>>>>>>>>>>>>>> Next Round <<<<<<<<<<<<<<<<<<<<<<< // onDestroy?: DestroyEventHandler; } -const InternalSubMenu = React.forwardRef( - (props, ref) => { - const { - style, - className, +const InternalSubMenu = React.forwardRef((props, ref) => { + const { + style, + className, - title, - eventKey, - warnKey, + title, + eventKey, + warnKey, - disabled, - internalPopupClose, + disabled, + internalPopupClose, - children, + children, - // Icons - itemIcon, - expandIcon, + // Icons + itemIcon, + expandIcon, - // Popup - popupClassName, - popupOffset, - popupStyle, + // Popup + popupClassName, + popupOffset, + popupStyle, - // Events - onClick, - onMouseEnter, - onMouseLeave, - onTitleClick, - onTitleMouseEnter, - onTitleMouseLeave, + // Events + onClick, + onMouseEnter, + onMouseLeave, + onTitleClick, + onTitleMouseEnter, + onTitleMouseLeave, + popupRender: propsPopupRender, + ...restProps + } = props; - ...restProps - } = props; + const domDataId = useMenuId(eventKey); - const domDataId = useMenuId(eventKey); + const { + prefixCls, + mode, + openKeys, - const { - prefixCls, - mode, - openKeys, + // Disabled + disabled: contextDisabled, + overflowDisabled, - // Disabled - disabled: contextDisabled, - overflowDisabled, + // ActiveKey + activeKey, - // ActiveKey - activeKey, + // SelectKey + selectedKeys, - // SelectKey - selectedKeys, + // Icon + itemIcon: contextItemIcon, + expandIcon: contextExpandIcon, - // Icon - itemIcon: contextItemIcon, - expandIcon: contextExpandIcon, + // Events + onItemClick, + onOpenChange, - // Events - onItemClick, - onOpenChange, + onActive, + popupRender: contextPopupRender, + } = React.useContext(MenuContext); - onActive, - } = React.useContext(MenuContext); + const { _internalRenderSubMenuItem } = React.useContext(PrivateContext); - const { _internalRenderSubMenuItem } = React.useContext(PrivateContext); + const { isSubPathKey } = React.useContext(PathUserContext); + const connectedPath = useFullPath(); - const { isSubPathKey } = React.useContext(PathUserContext); - const connectedPath = useFullPath(); + const subMenuPrefixCls = `${prefixCls}-submenu`; + const mergedDisabled = contextDisabled || disabled; + const elementRef = React.useRef(); + const popupRef = React.useRef(); - const subMenuPrefixCls = `${prefixCls}-submenu`; - const mergedDisabled = contextDisabled || disabled; - const elementRef = React.useRef(); - const popupRef = React.useRef(); + // ================================ Warn ================================ + if (process.env.NODE_ENV !== 'production' && warnKey) { + warning(false, 'SubMenu should not leave undefined `key`.'); + } - // ================================ Warn ================================ - if (process.env.NODE_ENV !== 'production' && warnKey) { - warning(false, 'SubMenu should not leave undefined `key`.'); - } + // ================================ Icon ================================ + const mergedItemIcon = itemIcon ?? contextItemIcon; + const mergedExpandIcon = expandIcon ?? contextExpandIcon; - // ================================ Icon ================================ - const mergedItemIcon = itemIcon ?? contextItemIcon; - const mergedExpandIcon = expandIcon ?? contextExpandIcon; + // ================================ Open ================================ + const originOpen = openKeys.includes(eventKey); + const open = !overflowDisabled && originOpen; - // ================================ Open ================================ - const originOpen = openKeys.includes(eventKey); - const open = !overflowDisabled && originOpen; + // =============================== Select =============================== + const childrenSelected = isSubPathKey(selectedKeys, eventKey); - // =============================== Select =============================== - const childrenSelected = isSubPathKey(selectedKeys, eventKey); + // =============================== Active =============================== + const { active, ...activeProps } = useActive( + eventKey, + mergedDisabled, + onTitleMouseEnter, + onTitleMouseLeave, + ); - // =============================== Active =============================== - const { active, ...activeProps } = useActive( - eventKey, - mergedDisabled, - onTitleMouseEnter, - onTitleMouseLeave, - ); + // Fallback of active check to avoid hover on menu title or disabled item + const [childrenActive, setChildrenActive] = React.useState(false); - // Fallback of active check to avoid hover on menu title or disabled item - const [childrenActive, setChildrenActive] = React.useState(false); + const triggerChildrenActive = (newActive: boolean) => { + if (!mergedDisabled) { + setChildrenActive(newActive); + } + }; - const triggerChildrenActive = (newActive: boolean) => { - if (!mergedDisabled) { - setChildrenActive(newActive); - } - }; + const onInternalMouseEnter: React.MouseEventHandler = domEvent => { + triggerChildrenActive(true); - const onInternalMouseEnter: React.MouseEventHandler< - HTMLLIElement - > = domEvent => { - triggerChildrenActive(true); + onMouseEnter?.({ + key: eventKey, + domEvent, + }); + }; - onMouseEnter?.({ - key: eventKey, - domEvent, - }); - }; + const onInternalMouseLeave: React.MouseEventHandler = domEvent => { + triggerChildrenActive(false); - const onInternalMouseLeave: React.MouseEventHandler< - HTMLLIElement - > = domEvent => { - triggerChildrenActive(false); + onMouseLeave?.({ + key: eventKey, + domEvent, + }); + }; - onMouseLeave?.({ - key: eventKey, - domEvent, - }); - }; - - const mergedActive = React.useMemo(() => { - if (active) { - return active; - } - - if (mode !== 'inline') { - return childrenActive || isSubPathKey([activeKey], eventKey); - } - - return false; - }, [mode, active, activeKey, childrenActive, eventKey, isSubPathKey]); - - // ========================== DirectionStyle ========================== - const directionStyle = useDirectionStyle(connectedPath.length); - - // =============================== Events =============================== - // >>>> Title click - const onInternalTitleClick: React.MouseEventHandler = e => { - // Skip if disabled - if (mergedDisabled) { - return; - } - - onTitleClick?.({ - key: eventKey, - domEvent: e, - }); + const mergedActive = React.useMemo(() => { + if (active) { + return active; + } - // Trigger open by click when mode is `inline` - if (mode === 'inline') { - onOpenChange(eventKey, !originOpen); - } - }; + if (mode !== 'inline') { + return childrenActive || isSubPathKey([activeKey], eventKey); + } - // >>>> Context for children click - const onMergedItemClick = useMemoCallback((info: MenuInfo) => { - onClick?.(warnItemProp(info)); - onItemClick(info); - }); + return false; + }, [mode, active, activeKey, childrenActive, eventKey, isSubPathKey]); - // >>>>> Visible change - const onPopupVisibleChange = (newVisible: boolean) => { - if (mode !== 'inline') { - onOpenChange(eventKey, newVisible); - } - }; - - /** - * Used for accessibility. Helper will focus element without key board. - * We should manually trigger an active - */ - const onInternalFocus: React.FocusEventHandler = () => { - onActive(eventKey); - }; - - // =============================== Render =============================== - const popupId = domDataId && `${domDataId}-popup`; - - const expandIconNode = React.useMemo( - () => ( - - - - ), - [mode, mergedExpandIcon, props, open, subMenuPrefixCls], - ); + // ========================== DirectionStyle ========================== + const directionStyle = useDirectionStyle(connectedPath.length); - // >>>>> Title - let titleNode: React.ReactElement = ( -
- {title} + // =============================== Events =============================== + // >>>> Title click + const onInternalTitleClick: React.MouseEventHandler = e => { + // Skip if disabled + if (mergedDisabled) { + return; + } - {/* Only non-horizontal mode shows the icon */} - {expandIconNode} -
- ); + onTitleClick?.({ + key: eventKey, + domEvent: e, + }); - // Cache mode if it change to `inline` which do not have popup motion - const triggerModeRef = React.useRef(mode); - if (mode !== 'inline' && connectedPath.length > 1) { - triggerModeRef.current = 'vertical'; - } else { - triggerModeRef.current = mode; + // Trigger open by click when mode is `inline` + if (mode === 'inline') { + onOpenChange(eventKey, !originOpen); } - - if (!overflowDisabled) { - const triggerMode = triggerModeRef.current; - - // Still wrap with Trigger here since we need avoid react re-mount dom node - // Which makes motion failed - titleNode = ( - - - {children} - - - } - disabled={mergedDisabled} - onVisibleChange={onPopupVisibleChange} - > - {titleNode} - - ); + }; + + // >>>> Context for children click + const onMergedItemClick = useMemoCallback((info: MenuInfo) => { + onClick?.(warnItemProp(info)); + onItemClick(info); + }); + + // >>>>> Visible change + const onPopupVisibleChange = (newVisible: boolean) => { + if (mode !== 'inline') { + onOpenChange(eventKey, newVisible); } - - // >>>>> List node - let listNode = ( - = () => { + onActive(eventKey); + }; + + // =============================== Render =============================== + const popupId = domDataId && `${domDataId}-popup`; + + const expandIconNode = React.useMemo( + () => ( + - {titleNode} - - {/* Inline mode */} - {!overflowDisabled && ( - - {children} - - )} - - ); + + + ), + [mode, mergedExpandIcon, props, open, subMenuPrefixCls], + ); - if (_internalRenderSubMenuItem) { - listNode = _internalRenderSubMenuItem(listNode, props, { - selected: childrenSelected, - active: mergedActive, - open, - disabled: mergedDisabled, - }); - } + // >>>>> Title + let titleNode: React.ReactElement = ( +
+ {title} + + {/* Only non-horizontal mode shows the icon */} + {expandIconNode} +
+ ); - // >>>>> Render - return ( + // Cache mode if it change to `inline` which do not have popup motion + const triggerModeRef = React.useRef(mode); + if (mode !== 'inline' && connectedPath.length > 1) { + triggerModeRef.current = 'vertical'; + } else { + triggerModeRef.current = mode; + } + const popupContentTriggerMode = triggerModeRef.current; + const renderPopupContent = React.useMemo(() => { + const originNode = ( - {listNode} + + {children} + ); - }, -); + const mergedPopupRender = propsPopupRender || contextPopupRender; + if (mergedPopupRender) { + const node = mergedPopupRender(originNode, { + item: props, + keys: connectedPath, + }); + return node; + } + return originNode; + }, [ + propsPopupRender, + contextPopupRender, + connectedPath, + popupId, + children, + props, + popupContentTriggerMode, + ]); + + if (!overflowDisabled) { + const triggerMode = triggerModeRef.current; + + // Still wrap with Trigger here since we need avoid react re-mount dom node + // Which makes motion failed + titleNode = ( + + {titleNode} + + ); + } + + // >>>>> List node + let listNode = ( + + {titleNode} + + {/* Inline mode */} + {!overflowDisabled && ( + + {children} + + )} + + ); + + if (_internalRenderSubMenuItem) { + listNode = _internalRenderSubMenuItem(listNode, props, { + selected: childrenSelected, + active: mergedActive, + open, + disabled: mergedDisabled, + }); + } + + // >>>>> Render + return ( + + {listNode} + + ); +}); const SubMenu = React.forwardRef((props, ref) => { const { eventKey, children } = props; const connectedKeyPath = useFullPath(eventKey); - const childList: React.ReactElement[] = parseChildren( - children, - connectedKeyPath, - ); + const childList: React.ReactElement[] = parseChildren(children, connectedKeyPath); // ==================== Record KeyPath ==================== const measure = useMeasure(); @@ -401,9 +407,7 @@ const SubMenu = React.forwardRef((props, ref) => { } return ( - - {renderNode} - + {renderNode} ); }); diff --git a/src/context/MenuContext.tsx b/src/context/MenuContext.tsx index 5327910d..5611c1ed 100644 --- a/src/context/MenuContext.tsx +++ b/src/context/MenuContext.tsx @@ -8,6 +8,7 @@ import type { MenuMode, RenderIconType, TriggerSubMenuAction, + PopupRender, } from '../interface'; export interface MenuContextProps { @@ -37,6 +38,7 @@ export interface MenuContextProps { // Motion motion?: CSSMotionProps; + // eslint-disable-next-line @typescript-eslint/no-unused-vars defaultMotions?: Partial<{ [key in MenuMode | 'other']: CSSMotionProps }>; // Popup @@ -46,6 +48,8 @@ export interface MenuContextProps { builtinPlacements?: BuiltinPlacements; triggerSubMenuAction?: TriggerSubMenuAction; + popupRender?: PopupRender; + // Icon itemIcon?: RenderIconType; expandIcon?: RenderIconType; @@ -58,10 +62,7 @@ export interface MenuContextProps { export const MenuContext = React.createContext(null); -function mergeProps( - origin: MenuContextProps, - target: Partial, -): MenuContextProps { +function mergeProps(origin: MenuContextProps, target: Partial): MenuContextProps { const clone = { ...origin }; Object.keys(target).forEach(key => { @@ -89,13 +90,8 @@ export default function InheritableContextProvider({ const inheritableContext = useMemo( () => mergeProps(context, restProps), [context, restProps], - (prev, next) => - !locked && (prev[0] !== next[0] || !isEqual(prev[1], next[1], true)), + (prev, next) => !locked && (prev[0] !== next[0] || !isEqual(prev[1], next[1], true)), ); - return ( - - {children} - - ); + return {children}; } diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts index d4c18658..695f2860 100644 --- a/src/hooks/useAccessibility.ts +++ b/src/hooks/useAccessibility.ts @@ -43,10 +43,7 @@ function getOffset( [RIGHT]: isRtl ? parent : children, }; - const offsets: Record< - string, - Record - > = { + const offsets: Record> = { inline, horizontal, vertical, @@ -93,10 +90,7 @@ function findContainerUL(element: HTMLElement): HTMLUListElement { /** * Find focused element within element set provided */ -function getFocusElement( - activeElement: HTMLElement, - elements: Set, -): HTMLElement { +function getFocusElement(activeElement: HTMLElement, elements: Set): HTMLElement { let current = activeElement || document.activeElement; while (current) { @@ -113,10 +107,7 @@ function getFocusElement( /** * Get focusable elements from the element set under provided container */ -export function getFocusableElements( - container: HTMLElement, - elements: Set, -) { +export function getFocusableElements(container: HTMLElement, elements: Set) { const list = getFocusNodeList(container, true); return list.filter(ele => elements.has(ele)); } @@ -133,16 +124,11 @@ function getNextFocusElement( } // List current level menu item elements - const sameLevelFocusableMenuElementList = getFocusableElements( - parentQueryContainer, - elements, - ); + const sameLevelFocusableMenuElementList = getFocusableElements(parentQueryContainer, elements); // Find next focus index const count = sameLevelFocusableMenuElementList.length; - let focusIndex = sameLevelFocusableMenuElementList.findIndex( - ele => focusMenuElement === ele, - ); + let focusIndex = sameLevelFocusableMenuElementList.findIndex(ele => focusMenuElement === ele); if (offset < 0) { if (focusIndex === -1) { @@ -166,9 +152,7 @@ export const refreshElements = (keys: string[], id: string) => { const element2key = new Map(); keys.forEach(key => { - const element = document.querySelector( - `[data-menu-id='${getMenuId(id, key)}']`, - ) as HTMLElement; + const element = document.querySelector(`[data-menu-id='${getMenuId(id, key)}']`) as HTMLElement; if (element) { elements.add(element); @@ -225,12 +209,7 @@ export function useAccessibility( const focusMenuElement = getFocusElement(activeElement, elements); const focusMenuKey = element2key.get(focusMenuElement); - const offsetObj = getOffset( - mode, - getKeyPath(focusMenuKey, true).length === 1, - isRtl, - which, - ); + const offsetObj = getOffset(mode, getKeyPath(focusMenuKey, true).length === 1, isRtl, which); // Some mode do not have fully arrow operation like inline if (!offsetObj && which !== HOME && which !== END) { @@ -269,11 +248,7 @@ export function useAccessibility( } }; - if ( - [HOME, END].includes(which) || - offsetObj.sibling || - !focusMenuElement - ) { + if ([HOME, END].includes(which) || offsetObj.sibling || !focusMenuElement) { // ========================== Sibling ========================== // Find walkable focus menu element container let parentQueryContainer: HTMLElement; @@ -285,10 +260,7 @@ export function useAccessibility( // Get next focus element let targetElement; - const focusableElements = getFocusableElements( - parentQueryContainer, - elements, - ); + const focusableElements = getFocusableElements(parentQueryContainer, elements); if (which === HOME) { targetElement = focusableElements[0]; } else if (which === END) { @@ -321,10 +293,7 @@ export function useAccessibility( const subQueryContainer = document.getElementById(controlId); // Get sub focusable menu item - const targetElement = getNextFocusElement( - subQueryContainer, - refreshedElements.elements, - ); + const targetElement = getNextFocusElement(subQueryContainer, refreshedElements.elements); // Focus menu item tryFocus(targetElement); diff --git a/src/hooks/useUUID.ts b/src/hooks/useUUID.ts index 45c8a0cd..a939de82 100644 --- a/src/hooks/useUUID.ts +++ b/src/hooks/useUUID.ts @@ -10,10 +10,7 @@ export default function useUUID(id?: string) { React.useEffect(() => { internalId += 1; - const newId = - process.env.NODE_ENV === 'test' - ? 'test' - : `${uniquePrefix}-${internalId}`; + const newId = process.env.NODE_ENV === 'test' ? 'test' : `${uniquePrefix}-${internalId}`; setUUID(`rc-menu-uuid-${newId}`); }, []); diff --git a/src/interface.ts b/src/interface.ts index f44b9eb6..e845058b 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,4 +1,5 @@ import type * as React from 'react'; +import type { SubMenuProps } from './SubMenu'; // ========================= Options ========================= interface ItemSharedProps { @@ -73,12 +74,7 @@ export interface MenuDividerType extends Omit { type: 'divider'; } -export type ItemType = - | SubMenuType - | MenuItemType - | MenuItemGroupType - | MenuDividerType - | null; +export type ItemType = SubMenuType | MenuItemType | MenuItemGroupType | MenuDividerType | null; // ========================== Basic ========================== export type MenuMode = 'horizontal' | 'vertical' | 'inline'; @@ -94,9 +90,7 @@ export interface RenderIconInfo { disabled?: boolean; } -export type RenderIconType = - | React.ReactNode - | ((props: RenderIconInfo) => React.ReactNode); +export type RenderIconType = React.ReactNode | ((props: RenderIconInfo) => React.ReactNode); export interface MenuInfo { key: string; @@ -140,6 +134,9 @@ export type MenuRef = { // ======================== Component ======================== export type ComponentType = 'submenu' | 'item' | 'group' | 'divider'; -export type Components = Partial< - Record> ->; +export type Components = Partial>>; + +export type PopupRender = ( + node: React.ReactElement, + info: { item: SubMenuProps; keys: string[] }, +) => React.ReactNode; diff --git a/src/utils/commonUtil.ts b/src/utils/commonUtil.ts index 99da70fa..fc09b9f4 100644 --- a/src/utils/commonUtil.ts +++ b/src/utils/commonUtil.ts @@ -1,10 +1,7 @@ import toArray from '@rc-component/util/lib/Children/toArray'; import * as React from 'react'; -export function parseChildren( - children: React.ReactNode | undefined, - keyPath: string[], -) { +export function parseChildren(children: React.ReactNode | undefined, keyPath: string[]) { return toArray(children).map((child, index) => { if (React.isValidElement(child)) { const { key } = child; diff --git a/src/utils/warnUtil.ts b/src/utils/warnUtil.ts index 5cf99b36..60cbdfe5 100644 --- a/src/utils/warnUtil.ts +++ b/src/utils/warnUtil.ts @@ -4,10 +4,7 @@ import warning from '@rc-component/util/lib/warning'; * `onClick` event return `info.item` which point to react node directly. * We should warning this since it will not work on FC. */ -export function warnItemProp({ - item, - ...restInfo -}: T): T { +export function warnItemProp({ item, ...restInfo }: T): T { Object.defineProperty(restInfo, 'item', { get: () => { warning( diff --git a/tests/Focus.spec.tsx b/tests/Focus.spec.tsx index 0b299e75..f4ac1bec 100644 --- a/tests/Focus.spec.tsx +++ b/tests/Focus.spec.tsx @@ -39,9 +39,7 @@ describe('Focus', () => { // Item focus fireEvent.focus(container.querySelector('.rc-menu-item')); - expect(container.querySelector('.rc-menu-item')).toHaveClass( - 'rc-menu-item-active', - ); + expect(container.querySelector('.rc-menu-item')).toHaveClass('rc-menu-item-active'); // Submenu focus fireEvent.focus(container.querySelector('.rc-menu-submenu-title')); @@ -150,10 +148,7 @@ describe('Focus', () => { nested-group-child-1 - + nested-group-child-2 diff --git a/tests/Keyboard.spec.tsx b/tests/Keyboard.spec.tsx index 68a4a372..c7ac7c00 100644 --- a/tests/Keyboard.spec.tsx +++ b/tests/Keyboard.spec.tsx @@ -143,11 +143,7 @@ describe('Menu.Keyboard', () => { }); describe('go to children & back of parent', () => { - function testDirection( - direction: 'ltr' | 'rtl', - subKey: number, - parentKey: number, - ) { + function testDirection(direction: 'ltr' | 'rtl', subKey: number, parentKey: number) { it(`direction ${direction}`, () => { const { container, unmount } = render( @@ -166,35 +162,26 @@ describe('Menu.Keyboard', () => { keyDown(container, subKey); expect(container.querySelector('.rc-menu-submenu-open')).toBeTruthy(); expect( - last( - container.querySelectorAll( - '.rc-menu-submenu-active > .rc-menu-submenu-title', - ), - ).textContent, + last(container.querySelectorAll('.rc-menu-submenu-active > .rc-menu-submenu-title')) + .textContent, ).toEqual('Light'); // Open and active sub keyDown(container, subKey); - expect( - container.querySelectorAll('.rc-menu-submenu-open'), - ).toHaveLength(2); - expect( - last(container.querySelectorAll('.rc-menu-item-active')).textContent, - ).toEqual('Little'); + expect(container.querySelectorAll('.rc-menu-submenu-open')).toHaveLength(2); + expect(last(container.querySelectorAll('.rc-menu-item-active')).textContent).toEqual( + 'Little', + ); // Back to parent keyDown(container, parentKey); - expect( - container.querySelectorAll('.rc-menu-submenu-open'), - ).toHaveLength(1); + expect(container.querySelectorAll('.rc-menu-submenu-open')).toHaveLength(1); expect(container.querySelector('.rc-menu-item-active')).toBeFalsy(); // Back to parent keyDown(container, parentKey); expect(container.querySelector('.rc-menu-submenu-open')).toBeFalsy(); - expect( - container.querySelectorAll('.rc-menu-submenu-active'), - ).toHaveLength(1); + expect(container.querySelectorAll('.rc-menu-submenu-active')).toHaveLength(1); unmount(); }); diff --git a/tests/Menu.spec.tsx b/tests/Menu.spec.tsx index 61368acd..0b8248a4 100644 --- a/tests/Menu.spec.tsx +++ b/tests/Menu.spec.tsx @@ -44,12 +44,7 @@ describe('Menu', () => { describe('should render', () => { function createMenu(props?, subKey?) { return ( - + 1 @@ -71,19 +66,14 @@ describe('Menu', () => { it('popup with rtl has correct className', () => { const { container, unmount } = render( - createMenu( - { mode: 'vertical', direction: 'rtl', openKeys: ['sub'] }, - 'sub', - ), + createMenu({ mode: 'vertical', direction: 'rtl', openKeys: ['sub'] }, 'sub'), ); act(() => { jest.runAllTimers(); }); - expect( - container.querySelector('.rc-menu-submenu-popup .rc-menu-rtl'), - ).toBeTruthy(); + expect(container.querySelector('.rc-menu-submenu-popup .rc-menu-rtl')).toBeTruthy(); unmount(); }); @@ -186,9 +176,7 @@ describe('Menu', () => { 2 , ); - expect(container.querySelector('.rc-menu-item')).toHaveClass( - 'rc-menu-item-active', - ); + expect(container.querySelector('.rc-menu-item')).toHaveClass('rc-menu-item-active'); }); it('should render none menu item children', () => { @@ -220,9 +208,7 @@ describe('Menu', () => { fireEvent.click(container.querySelector('.rc-menu-item')); fireEvent.click(last(container.querySelectorAll('.rc-menu-item'))); - expect(container.querySelectorAll('.rc-menu-item-selected')).toHaveLength( - 2, - ); + expect(container.querySelectorAll('.rc-menu-item-selected')).toHaveLength(2); }); it('items support ref', () => { @@ -283,9 +269,7 @@ describe('Menu', () => { const { container, rerender } = render(genMenu()); expect(container.querySelector('li').className).toContain('-selected'); rerender(genMenu({ selectedKeys: ['2'] })); - expect(last(container.querySelectorAll('li')).className).toContain( - '-selected', - ); + expect(last(container.querySelectorAll('li')).className).toContain('-selected'); }); it('empty selectedKeys not to throw', () => { @@ -309,9 +293,7 @@ describe('Menu', () => { fireEvent.click(container.querySelector('.rc-menu-item')); - expect(onSelect).toHaveBeenCalledWith( - expect.objectContaining({ selectedKeys: ['bamboo'] }), - ); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ selectedKeys: ['bamboo'] })); onSelect.mockReset(); rerender(genMenu({ selectable: false })); @@ -339,9 +321,7 @@ describe('Menu', () => { , ); expect(container.querySelector('li').className).not.toContain('-selected'); - expect(container.querySelectorAll('li')[1].className).toContain( - '-selected', - ); + expect(container.querySelectorAll('li')[1].className).toContain('-selected'); }); describe('openKeys', () => { @@ -358,20 +338,20 @@ describe('Menu', () => { ); const { container, rerender } = render(genMenu()); - expect( - container.querySelectorAll('.rc-menu-submenu-vertical')[0], - ).toHaveClass('rc-menu-submenu-open'); - expect( - container.querySelectorAll('.rc-menu-submenu-vertical')[1], - ).not.toHaveClass('rc-menu-submenu-open'); + expect(container.querySelectorAll('.rc-menu-submenu-vertical')[0]).toHaveClass( + 'rc-menu-submenu-open', + ); + expect(container.querySelectorAll('.rc-menu-submenu-vertical')[1]).not.toHaveClass( + 'rc-menu-submenu-open', + ); rerender(genMenu({ openKeys: ['g2'] })); - expect( - container.querySelectorAll('.rc-menu-submenu-vertical')[0], - ).not.toHaveClass('rc-menu-submenu-open'); - expect( - container.querySelectorAll('.rc-menu-submenu-vertical')[1], - ).toHaveClass('rc-menu-submenu-open'); + expect(container.querySelectorAll('.rc-menu-submenu-vertical')[0]).not.toHaveClass( + 'rc-menu-submenu-open', + ); + expect(container.querySelectorAll('.rc-menu-submenu-vertical')[1]).toHaveClass( + 'rc-menu-submenu-open', + ); }); it('openKeys should allow to be empty', () => { @@ -423,12 +403,12 @@ describe('Menu', () => { jest.runAllTimers(); }); - expect( - container.querySelectorAll('.rc-menu-submenu-vertical')[0], - ).toHaveClass('rc-menu-submenu-open'); - expect( - container.querySelectorAll('.rc-menu-submenu-vertical')[1], - ).not.toHaveClass('rc-menu-submenu-open'); + expect(container.querySelectorAll('.rc-menu-submenu-vertical')[0]).toHaveClass( + 'rc-menu-submenu-open', + ); + expect(container.querySelectorAll('.rc-menu-submenu-vertical')[1]).not.toHaveClass( + 'rc-menu-submenu-open', + ); }); it('fires select event', () => { @@ -558,9 +538,7 @@ describe('Menu', () => { , ); - expect(global.triggerProps.builtinPlacements.leftTop).toEqual( - builtinPlacements.leftTop, - ); + expect(global.triggerProps.builtinPlacements.leftTop).toEqual(builtinPlacements.leftTop); }); describe('motion', () => { @@ -587,15 +565,11 @@ describe('Menu', () => { // Horizontal rerender(genMenu({ mode: 'horizontal' })); - expect(global.triggerProps.popupMotion.motionName).toEqual( - 'horizontalMotion', - ); + expect(global.triggerProps.popupMotion.motionName).toEqual('horizontalMotion'); // Default rerender(genMenu({ mode: 'vertical' })); - expect(global.triggerProps.popupMotion.motionName).toEqual( - 'defaultMotion', - ); + expect(global.triggerProps.popupMotion.motionName).toEqual('defaultMotion'); }); it('motion is first level', () => { @@ -637,9 +611,7 @@ describe('Menu', () => { const { rerender } = render(genMenu({ mode: 'vertical' })); rerender(genMenu({ mode: 'inline' })); - expect(global.triggerProps.popupMotion.motionName).toEqual( - 'defaultMotion', - ); + expect(global.triggerProps.popupMotion.motionName).toEqual('defaultMotion'); }); }); @@ -692,12 +664,7 @@ describe('Menu', () => { const onOpenChange = jest.fn(); const { container } = render( - + Light @@ -790,9 +757,7 @@ describe('Menu', () => { ); - const { container, rerender } = render( - , - ); + const { container, rerender } = render(); expect(container.querySelectorAll('.rc-menu-submenu-arrow').length).toBe(0); rerender(); diff --git a/tests/Responsive.spec.tsx b/tests/Responsive.spec.tsx index a59cfa4c..064dfaf9 100644 --- a/tests/Responsive.spec.tsx +++ b/tests/Responsive.spec.tsx @@ -98,12 +98,7 @@ describe('Menu.Responsive', () => { const onOpenChange = jest.fn(); const genMenu = (props?: any) => ( - + Light Bamboo @@ -158,9 +153,9 @@ describe('Menu.Responsive', () => { // }); // Should set active on rest - expect( - last(container.querySelectorAll('.rc-menu-overflow-item-rest')), - ).toHaveClass('rc-menu-submenu-active'); + expect(last(container.querySelectorAll('.rc-menu-overflow-item-rest'))).toHaveClass( + 'rc-menu-submenu-active', + ); // Key down can open expect(onOpenChange).not.toHaveBeenCalled(); diff --git a/tests/SubMenu.spec.tsx b/tests/SubMenu.spec.tsx index 6fa748b4..6220117e 100644 --- a/tests/SubMenu.spec.tsx +++ b/tests/SubMenu.spec.tsx @@ -94,11 +94,7 @@ describe('SubMenu', () => { it('should render custom arrow icon correctly.', () => { const { container } = render( - SubMenuIconNode} - > + SubMenuIconNode}> 1 2 @@ -107,11 +103,7 @@ describe('SubMenu', () => { ); const wrapperWithExpandIconFunction = render( - SubMenuIconNode} - > + SubMenuIconNode}> 1 2 @@ -119,13 +111,9 @@ describe('SubMenu', () => { , ); - const subMenuText = container.querySelector( - '.rc-menu-submenu-title', - ).textContent; + const subMenuText = container.querySelector('.rc-menu-submenu-title').textContent; const subMenuTextWithExpandIconFunction = - wrapperWithExpandIconFunction.container.querySelector( - '.rc-menu-submenu-title', - ).textContent; + wrapperWithExpandIconFunction.container.querySelector('.rc-menu-submenu-title').textContent; expect(subMenuText).toEqual('submenuSubMenuIconNode'); expect(subMenuTextWithExpandIconFunction).toEqual('submenuSubMenuIconNode'); }); @@ -144,9 +132,7 @@ describe('SubMenu', () => { , ); - const childText = container.querySelector( - '.rc-menu-submenu-title', - ).textContent; + const childText = container.querySelector('.rc-menu-submenu-title').textContent; expect(childText).toEqual('submenu'); }); @@ -239,23 +225,16 @@ describe('SubMenu', () => { expect(handleOpenChange).toHaveBeenCalledWith(['tmp_key-1']); // Second - fireEvent.mouseEnter( - container.querySelectorAll('.rc-menu-submenu-title')[1], - ); + fireEvent.mouseEnter(container.querySelectorAll('.rc-menu-submenu-title')[1]); runAllTimer(); - expect(handleOpenChange).toHaveBeenCalledWith([ - 'tmp_key-1', - 'tmp_key-tmp_key-1-1', - ]); + expect(handleOpenChange).toHaveBeenCalledWith(['tmp_key-1', 'tmp_key-tmp_key-1-1']); }); describe('undefined key', () => { it('warning item', () => { resetWarned(); - const errorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); render( @@ -263,9 +242,7 @@ describe('SubMenu', () => { , ); - expect(errorSpy).toHaveBeenCalledWith( - 'Warning: MenuItem should not leave undefined `key`.', - ); + expect(errorSpy).toHaveBeenCalledWith('Warning: MenuItem should not leave undefined `key`.'); errorSpy.mockRestore(); }); @@ -273,9 +250,7 @@ describe('SubMenu', () => { it('warning sub menu', () => { resetWarned(); - const errorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); render( @@ -283,9 +258,7 @@ describe('SubMenu', () => { , ); - expect(errorSpy).toHaveBeenCalledWith( - 'Warning: SubMenu should not leave undefined `key`.', - ); + expect(errorSpy).toHaveBeenCalledWith('Warning: SubMenu should not leave undefined `key`.'); errorSpy.mockRestore(); }); @@ -293,9 +266,7 @@ describe('SubMenu', () => { it('should not warning', () => { resetWarned(); - const errorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); render( @@ -321,9 +292,7 @@ describe('SubMenu', () => { }); it('click to open a submenu should not activate first item', () => { - const { container } = render( - createMenu({ triggerSubMenuAction: 'click' }), - ); + const { container } = render(createMenu({ triggerSubMenuAction: 'click' })); fireEvent.click(container.querySelector('.rc-menu-submenu-title')); runAllTimer(); @@ -373,9 +342,7 @@ describe('SubMenu', () => { runAllTimer(); fireEvent.click(container.querySelector('.rc-menu-item')); - expect(container.querySelector('.rc-menu-submenu')).toHaveClass( - 'rc-menu-submenu-selected', - ); + expect(container.querySelector('.rc-menu-submenu')).toHaveClass('rc-menu-submenu-selected'); }); it('fires deselect event for multiple menu', () => { @@ -458,9 +425,7 @@ describe('SubMenu', () => { runAllTimer(); - expect(container.querySelector('.rc-menu-submenu')).toHaveClass( - 'rc-menu-submenu-horizontal', - ); + expect(container.querySelector('.rc-menu-submenu')).toHaveClass('rc-menu-submenu-horizontal'); expect(last(container.querySelectorAll('.rc-menu-submenu'))).toHaveClass( 'rc-menu-submenu-vertical', @@ -485,12 +450,8 @@ describe('SubMenu', () => { ); expect(container.children).toMatchSnapshot(); - expect(container.querySelector('.rc-menu-root')).toHaveClass( - 'custom-className', - ); - expect(container.querySelectorAll('.rc-menu-submenu-popup')).toHaveLength( - 0, - ); + expect(container.querySelector('.rc-menu-root')).toHaveClass('custom-className'); + expect(container.querySelectorAll('.rc-menu-submenu-popup')).toHaveLength(0); runAllTimer(); @@ -499,9 +460,7 @@ describe('SubMenu', () => { runAllTimer(); - expect(container.querySelector('.rc-menu-submenu-popup')).toHaveClass( - 'custom-className', - ); + expect(container.querySelector('.rc-menu-submenu-popup')).toHaveClass('custom-className'); expect(container.children).toMatchSnapshot(); }); @@ -509,11 +468,7 @@ describe('SubMenu', () => { it('submenu should support popupStyle', () => { const { container } = render( - + 1 , @@ -521,14 +476,12 @@ describe('SubMenu', () => { fireEvent.mouseEnter(container.querySelector('.rc-menu-submenu-title')); runAllTimer(); - expect( - (container.querySelector('.rc-menu-submenu-popup') as HTMLElement).style - .zIndex, - ).toEqual('100'); - expect( - (container.querySelector('.rc-menu-submenu-popup') as HTMLElement).style - .width, - ).toEqual('150px'); + expect((container.querySelector('.rc-menu-submenu-popup') as HTMLElement).style.zIndex).toEqual( + '100', + ); + expect((container.querySelector('.rc-menu-submenu-popup') as HTMLElement).style.width).toEqual( + '150px', + ); }); }); /* eslint-enable */ diff --git a/tests/popupRender.test.tsx b/tests/popupRender.test.tsx new file mode 100644 index 00000000..69ccb383 --- /dev/null +++ b/tests/popupRender.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import Menu, { SubMenu, Item as MenuItem } from '../src'; +import type { ReactElement } from 'react'; + +describe('Menu PopupRender Tests', () => { + const basicPopupRender = (node: ReactElement) => ( +
+ {React.cloneElement(node, { + className: `${node.props.className || ''} custom-popup-content`, + })} +
+ ); + + it('should render popup with custom wrapper', async () => { + const { container } = render( + + + Child + + , + ); + expect( + container.querySelectorAll('.rc-menu-submenu-horizontal')[0], + ).toHaveClass('rc-menu-submenu-open'); + }); + + it('should work with items prop', async () => { + const items = [ + { + key: 'submenu1', + label: 'SubMenu 1', + children: [ + { key: 'child1', label: 'Child 1' }, + { key: 'child2', label: 'Child 2' }, + ], + }, + ]; + + const { container } = render( + , + ); + + expect( + container.querySelectorAll('.rc-menu-submenu-horizontal')[0], + ).toHaveClass('rc-menu-submenu-open'); + }); +});