diff --git a/README.md b/README.md index 13de7032..ae8eeaed 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,12 @@ ReactDOM.render( 24 Padding level multiplier. Right or left padding depends on param "direction". + + inlineMaxDeep + Number + + inline menu, specify at most a certain deep of submenu, deeper submenu will right popover + diff --git a/assets/index.less b/assets/index.less index 1d2f9338..e1593ba5 100644 --- a/assets/index.less +++ b/assets/index.less @@ -227,6 +227,21 @@ } } + &-inline { + .@{menuPrefixCls}-submenu-multi { + .@{menuPrefixCls}-submenu-title { + .@{menuPrefixCls}-submenu-arrow { + transform: rotate(0); + } + } + } + .@{menuPrefixCls}-submenu-multi.@{menuPrefixCls}-submenu-open { + .@{menuPrefixCls}-submenu-title { + background-color: #eaf8fe; + } + } + } + &-vertical&-sub, &-vertical-left&-sub, &-vertical-right&-sub { diff --git a/docs/demo/antd-switch-multi.md b/docs/demo/antd-switch-multi.md new file mode 100644 index 00000000..433d3bc4 --- /dev/null +++ b/docs/demo/antd-switch-multi.md @@ -0,0 +1,5 @@ +## antd-switch-multi + +inline Menu, up to two level menus, more submenus right popover + + \ No newline at end of file diff --git a/docs/examples/antd-switch-multi.tsx b/docs/examples/antd-switch-multi.tsx new file mode 100644 index 00000000..e4697e45 --- /dev/null +++ b/docs/examples/antd-switch-multi.tsx @@ -0,0 +1,44 @@ +/* eslint-disable no-console, react/require-default-props, no-param-reassign */ + +import React from 'react'; +import { CommonMenu, inlineMotion } from './antd'; +import '../../assets/index.less'; + +const Demo = () => { + const [inline, setInline] = React.useState(false); + const [openKeys, setOpenKey] = React.useState(['1']); + + let restProps = {}; + if (inline) { + restProps = { motion: inlineMotion }; + } else { + restProps = { openAnimation: 'zoom' }; + } + + return ( +
+ + { + console.error('Open Keys Changed:', keys); + setOpenKey(keys); + }} + inlineCollapsed={!inline} + {...restProps} + /> +
+ ); +}; + +export default Demo; +/* eslint-enable */ diff --git a/src/Menu.tsx b/src/Menu.tsx index 69e271ed..0be2b1cb 100644 --- a/src/Menu.tsx +++ b/src/Menu.tsx @@ -20,6 +20,7 @@ import { parseChildren } from './utils/nodeUtil'; import MenuContextProvider from './context/MenuContext'; import useMemoCallback from './hooks/useMemoCallback'; import { warnItemProp } from './utils/warnUtil'; +import { genMultiMode } from './utils/multiModeUtil'; import SubMenu from './SubMenu'; import useAccessibility from './hooks/useAccessibility'; import useUUID from './hooks/useUUID'; @@ -61,6 +62,7 @@ export interface MenuProps // Mode mode?: MenuMode; + inlineMaxDeep?: number; inlineCollapsed?: boolean; // Open control @@ -130,6 +132,7 @@ const Menu: React.FC = props => { // Mode mode = 'vertical', + inlineMaxDeep, inlineCollapsed, // Disabled @@ -227,14 +230,17 @@ const Menu: React.FC = props => { postState: keys => keys || EMPTY_LIST, }); + const isMultiMode = genMultiMode(mergedOpenKeys, mergedMode, inlineMaxDeep); + const triggerOpenKeys = (keys: string[]) => { setMergedOpenKeys(keys); onOpenChange?.(keys); }; // >>>>> Cache & Reset open keys when inlineCollapsed changed - const [inlineCacheOpenKeys, setInlineCacheOpenKeys] = - React.useState(mergedOpenKeys); + const [inlineCacheOpenKeys, setInlineCacheOpenKeys] = React.useState( + mergedOpenKeys, + ); const isInlineMode = mergedMode === 'inline'; @@ -279,10 +285,9 @@ const Menu: React.FC = props => { [registerPath, unregisterPath], ); - const pathUserContext = React.useMemo( - () => ({ isSubPathKey }), - [isSubPathKey], - ); + const pathUserContext = React.useMemo(() => ({ isSubPathKey }), [ + isSubPathKey, + ]); React.useEffect(() => { refreshOverflowKeys( @@ -369,6 +374,18 @@ const Menu: React.FC = props => { if (!multiple && mergedOpenKeys.length && mergedMode !== 'inline') { triggerOpenKeys(EMPTY_LIST); } + + if (!multiple && isMultiMode.isMultiPopup) { + const inlineLevelPathKeys = + info.keyPath[info.keyPath.length - inlineMaxDeep + 1]; + if (inlineLevelPathKeys) { + const subPathKeys = getSubPathKeys(inlineLevelPathKeys); + const newOpenKeys = mergedOpenKeys.filter(k => !subPathKeys.has(k)); + triggerOpenKeys(newOpenKeys); + } else { + triggerOpenKeys(EMPTY_LIST); + } + } }; // ========================= Open ========================= @@ -385,7 +402,7 @@ const Menu: React.FC = props => { if (open) { newOpenKeys.push(key); - } else if (mergedMode !== 'inline') { + } else if (mergedMode !== 'inline' || isMultiMode.isMultiPopup) { // We need find all related popup to close const subPathKeys = getSubPathKeys(key); newOpenKeys = newOpenKeys.filter(k => !subPathKeys.has(k)); @@ -506,6 +523,7 @@ const Menu: React.FC = props => { { const { prefixCls, mode, + inlineMaxDeep, openKeys, // Disabled @@ -129,6 +131,7 @@ const InternalSubMenu = (props: SubMenuProps) => { const { isSubPathKey } = React.useContext(PathUserContext); const connectedPath = useFullPath(); + const isMultiMode = genMultiMode(connectedPath, mode, inlineMaxDeep); const subMenuPrefixCls = `${prefixCls}-submenu`; const mergedDisabled = contextDisabled || disabled; @@ -168,25 +171,23 @@ const InternalSubMenu = (props: SubMenuProps) => { } }; - const onInternalMouseEnter: React.MouseEventHandler = - domEvent => { - triggerChildrenActive(true); + const onInternalMouseEnter: React.MouseEventHandler = domEvent => { + triggerChildrenActive(true); - onMouseEnter?.({ - key: eventKey, - domEvent, - }); - }; + onMouseEnter?.({ + key: eventKey, + domEvent, + }); + }; - const onInternalMouseLeave: React.MouseEventHandler = - domEvent => { - triggerChildrenActive(false); + const onInternalMouseLeave: React.MouseEventHandler = domEvent => { + triggerChildrenActive(false); - onMouseLeave?.({ - key: eventKey, - domEvent, - }); - }; + onMouseLeave?.({ + key: eventKey, + domEvent, + }); + }; const mergedActive = React.useMemo(() => { if (active) { @@ -217,7 +218,7 @@ const InternalSubMenu = (props: SubMenuProps) => { }); // Trigger open by click when mode is `inline` - if (mode === 'inline') { + if (mode === 'inline' && !isMultiMode.isMultiPopup) { onOpenChange(eventKey, !originOpen); } }; @@ -233,6 +234,9 @@ const InternalSubMenu = (props: SubMenuProps) => { if (mode !== 'inline') { onOpenChange(eventKey, newVisible); } + if (isMultiMode.isMultiPopup) { + onOpenChange(eventKey, newVisible); + } }; /** @@ -287,6 +291,10 @@ const InternalSubMenu = (props: SubMenuProps) => { triggerModeRef.current = connectedPath.length > 1 ? 'vertical' : mode; } + if (isMultiMode.isMultiPopup) { + triggerModeRef.current = 'vertical'; + } + if (!overflowDisabled) { const triggerMode = triggerModeRef.current; @@ -296,7 +304,11 @@ const InternalSubMenu = (props: SubMenuProps) => { { // Special handle of horizontal mode mode={triggerMode === 'horizontal' ? 'vertical' : triggerMode} > - - {children} - + {!isMultiMode.isMulti || isMultiMode.isPopup ? ( + + {children} + + ) : null} } disabled={mergedDisabled} @@ -339,6 +353,7 @@ const InternalSubMenu = (props: SubMenuProps) => { [`${subMenuPrefixCls}-active`]: mergedActive, [`${subMenuPrefixCls}-selected`]: childrenSelected, [`${subMenuPrefixCls}-disabled`]: mergedDisabled, + [`${subMenuPrefixCls}-multi`]: isMultiMode.isMultiPopup, }, )} onMouseEnter={onInternalMouseEnter} @@ -347,7 +362,7 @@ const InternalSubMenu = (props: SubMenuProps) => { {titleNode} {/* Inline mode */} - {!overflowDisabled && ( + {!overflowDisabled && (!isMultiMode.isMulti || !isMultiMode.isPopup) && ( {children} diff --git a/src/context/MenuContext.tsx b/src/context/MenuContext.tsx index f67090ea..082616a1 100644 --- a/src/context/MenuContext.tsx +++ b/src/context/MenuContext.tsx @@ -17,6 +17,7 @@ export interface MenuContextProps { // Mode mode: MenuMode; + inlineMaxDeep?: number; // Disabled disabled?: boolean; diff --git a/src/utils/multiModeUtil.ts b/src/utils/multiModeUtil.ts new file mode 100644 index 00000000..1db02b9a --- /dev/null +++ b/src/utils/multiModeUtil.ts @@ -0,0 +1,31 @@ +import type { MenuMode } from '../interface'; + +export function genMultiMode( + keys: string[], + mode?: MenuMode, + inlineMaxDeep?: number, +): { + isMulti: boolean; + isPopup: boolean; + isMultiPopup: boolean; +} { + const multi = { + isMulti: false, + isPopup: false, + isMultiPopup: false, + }; + + if (mode === 'inline' && typeof inlineMaxDeep === 'number') { + multi.isMulti = true; + } + + if (keys?.length >= inlineMaxDeep) { + multi.isPopup = true; + } + + if (multi.isMulti && multi.isPopup) { + multi.isMultiPopup = true; + } + + return multi; +} diff --git a/tests/Menu.spec.js b/tests/Menu.spec.js index 4f2b93c9..0ea3909e 100644 --- a/tests/Menu.spec.js +++ b/tests/Menu.spec.js @@ -623,5 +623,113 @@ describe('Menu', () => { jest.useRealTimers(); }); }); + + function createInlineMenu(level, onOpenChange) { + return ( + + + inner inner + + inn + + + + ); + } + + describe('Click or hover inline Menu with inlineMaxDeep', () => { + [ + { + level: null, + openKeys: [], + }, + { + level: -1, + openKeys: [], + }, + { + level: 0, + openKeys: [], + }, + { + level: 1, + openKeys: [], + }, + { + level: 2, + openKeys: ['4'], + }, + { + level: 3, + openKeys: [], + }, + ].forEach(item => { + const { level, openKeys } = item; + it(`Click on the ${level} level menu is open, the other menus is closed`, async () => { + jest.useFakeTimers(); + + const onOpenChange = jest.fn(); + + const wrapper = mount(createInlineMenu(level, onOpenChange)); + + await act(async () => { + jest.runAllTimers(); + wrapper.update(); + }); + + const menuItems = wrapper.find('.rc-menu-item'); + menuItems.at(0).simulate('click'); + + if (typeof level === 'number' && level < 3) { + expect(onOpenChange).toHaveBeenCalledWith(openKeys); + } else { + expect(onOpenChange).not.toHaveBeenCalled(); + } + + jest.useRealTimers(); + }); + + it(`Hover on the ${level} level menu is open, the other menus is closed`, async () => { + jest.useFakeTimers(); + + const onOpenChange = jest.fn(); + + const wrapper = mount(createInlineMenu(level, onOpenChange)); + + await act(async () => { + jest.runAllTimers(); + wrapper.update(); + }); + + // Enter + wrapper.find('.rc-menu-submenu-title').at(1).simulate('mouseEnter'); + await act(async () => { + jest.runAllTimers(); + wrapper.update(); + }); + if (typeof level === 'number' && level < 3) { + expect( + wrapper.find('PopupTrigger').at(1).prop('visible'), + ).toBeTruthy(); + } + + // Leave + wrapper.find('.rc-menu-submenu-title').at(1).simulate('mouseLeave'); + await act(async () => { + jest.runAllTimers(); + wrapper.update(); + }); + + expect(wrapper.find('PopupTrigger').at(1).prop('visible')).toBeFalsy(); + + jest.useRealTimers(); + }); + }); + }); }); /* eslint-enable */