Skip to content

feat: add api inlineMaxLevel #421

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 4 commits into
base: master
Choose a base branch
from
Open
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
@@ -219,6 +219,12 @@ ReactDOM.render(
<th>24</th>
<td>Padding level multiplier. Right or left padding depends on param "direction".</td>
</tr>
<tr>
<td>inlineMaxDeep</td>
<td>Number</td>
<th></th>
<td>inline menu, specify at most a certain deep of submenu, deeper submenu will right popover</td>
</tr>
</tbody>
</table>

15 changes: 15 additions & 0 deletions assets/index.less
Original file line number Diff line number Diff line change
@@ -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 {
5 changes: 5 additions & 0 deletions docs/demo/antd-switch-multi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## antd-switch-multi

inline Menu, up to two level menus, more submenus right popover

<code src="../examples/antd-switch-multi.tsx">
44 changes: 44 additions & 0 deletions docs/examples/antd-switch-multi.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ margin: 20, width: 200 }}>
<label>
<input
type="checkbox"
checked={inline}
onChange={() => setInline(!inline)}
/>{' '}
Inline
</label>
<CommonMenu
mode="inline"
inlineMaxDeep={2}
openKeys={openKeys}
onOpenChange={keys => {
console.error('Open Keys Changed:', keys);
setOpenKey(keys);
}}
inlineCollapsed={!inline}
{...restProps}
/>
</div>
);
};

export default Demo;
/* eslint-enable */
32 changes: 25 additions & 7 deletions src/Menu.tsx
Original file line number Diff line number Diff line change
@@ -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<MenuProps> = props => {

// Mode
mode = 'vertical',
inlineMaxDeep,
inlineCollapsed,

// Disabled
@@ -227,14 +230,17 @@ const Menu: React.FC<MenuProps> = 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<MenuProps> = 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<MenuProps> = 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<MenuProps> = 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<MenuProps> = props => {
<MenuContextProvider
prefixCls={prefixCls}
mode={mergedMode}
inlineMaxDeep={inlineMaxDeep}
openKeys={mergedOpenKeys}
rtl={isRtl}
// Disabled
59 changes: 37 additions & 22 deletions src/SubMenu/index.tsx
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@ import PopupTrigger from './PopupTrigger';
import Icon from '../Icon';
import useActive from '../hooks/useActive';
import { warnItemProp } from '../utils/warnUtil';
import { genMultiMode } from '../utils/multiModeUtil';
import useDirectionStyle from '../hooks/useDirectionStyle';
import InlineSubMenuList from './InlineSubMenuList';
import {
@@ -104,6 +105,7 @@ const InternalSubMenu = (props: SubMenuProps) => {
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<HTMLLIElement> =
domEvent => {
triggerChildrenActive(true);
const onInternalMouseEnter: React.MouseEventHandler<HTMLLIElement> = domEvent => {
triggerChildrenActive(true);

onMouseEnter?.({
key: eventKey,
domEvent,
});
};
onMouseEnter?.({
key: eventKey,
domEvent,
});
};

const onInternalMouseLeave: React.MouseEventHandler<HTMLLIElement> =
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) {
@@ -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,17 +304,23 @@ const InternalSubMenu = (props: SubMenuProps) => {
<PopupTrigger
mode={triggerMode}
prefixCls={subMenuPrefixCls}
visible={!internalPopupClose && open && mode !== 'inline'}
visible={
!internalPopupClose &&
open &&
(mode !== 'inline' || isMultiMode.isMultiPopup)
}
popupClassName={popupClassName}
popupOffset={popupOffset}
popup={
<MenuContextProvider
// Special handle of horizontal mode
mode={triggerMode === 'horizontal' ? 'vertical' : triggerMode}
>
<SubMenuList id={popupId} ref={popupRef}>
{children}
</SubMenuList>
{!isMultiMode.isMulti || isMultiMode.isPopup ? (
<SubMenuList id={popupId} ref={popupRef}>
{children}
</SubMenuList>
) : null}
</MenuContextProvider>
}
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) && (
<InlineSubMenuList id={popupId} open={open} keyPath={connectedPath}>
{children}
</InlineSubMenuList>
1 change: 1 addition & 0 deletions src/context/MenuContext.tsx
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@ export interface MenuContextProps {

// Mode
mode: MenuMode;
inlineMaxDeep?: number;

// Disabled
disabled?: boolean;
31 changes: 31 additions & 0 deletions src/utils/multiModeUtil.ts
Original file line number Diff line number Diff line change
@@ -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;
}
108 changes: 108 additions & 0 deletions tests/Menu.spec.js
Original file line number Diff line number Diff line change
@@ -623,5 +623,113 @@ describe('Menu', () => {
jest.useRealTimers();
});
});

function createInlineMenu(level, onOpenChange) {
return (
<Menu
defaultOpenKeys={['4', '4-2']}
mode="inline"
inlineMaxDeep={level}
onOpenChange={onOpenChange}
>
<SubMenu title="offset sub menu 2" key="4">
<MenuItem key="4-1">inner inner</MenuItem>
<SubMenu key="4-2" title="sub menu 1">
<MenuItem key="4-2-1">inn</MenuItem>
</SubMenu>
</SubMenu>
</Menu>
);
}

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 */