Skip to content

Commit a8ba3e3

Browse files
authored
fix(menu): improve menu interactive behavior (#199)
* fix(menu): improve menu interactive behavior * fix: improve active color in menu * fix: improve event trigger in menu
1 parent 7ad58ab commit a8ba3e3

File tree

8 files changed

+151
-132
lines changed

8 files changed

+151
-132
lines changed

src/common/dom.ts

+3
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ export function triggerEvent(trigger: TriggerEvent) {
9999
case 'hover': {
100100
return 'onMouseOver';
101101
}
102+
case 'contextmenu': {
103+
return 'onContextMenu';
104+
}
102105
default: {
103106
return trigger;
104107
}

src/components/menu/base.ts

+4
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export const disabledClassName = getBEMModifier(
2525
defaultMenuItemClassName,
2626
'disabled'
2727
);
28+
export const activeClassName = getBEMModifier(
29+
defaultMenuItemClassName,
30+
'active'
31+
);
2832
export const labelClassName = getBEMElement(defaultMenuClassName, 'label');
2933
export const menuContentClassName = getBEMElement(
3034
defaultMenuClassName,

src/components/menu/menu.tsx

+134-3
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,60 @@
11
import * as React from 'react';
22
import { classNames } from 'mo/common/className';
33
import { MenuItem } from './menuItem';
4-
import { ISubMenuProps, MenuMode, SubMenu } from './subMenu';
4+
import { isHorizontal, ISubMenuProps, MenuMode, SubMenu } from './subMenu';
5+
import { debounce } from 'lodash';
56
import {
7+
activeClassName,
68
defaultMenuClassName,
9+
defaultSubMenuClassName,
710
horizontalMenuClassName,
811
verticalMenuClassName,
912
} from './base';
1013
import { mergeFunctions } from 'mo/common/utils';
1114
import { cloneReactChildren } from 'mo/react';
15+
import { em2Px } from 'mo/common/css';
16+
import { getRelativePosition, triggerEvent } from 'mo/common/dom';
1217

13-
export interface IMenuProps extends ISubMenuProps {}
18+
export type IMenuProps = ISubMenuProps;
19+
20+
const visibleMenuItem = (item?: HTMLElement) => {
21+
if (!item) return;
22+
if (item?.dataset.submenu) {
23+
const subMenu: HTMLElement = Array.prototype.find.call(
24+
item.children,
25+
(dom: HTMLElement) => dom.nodeName === 'UL'
26+
);
27+
subMenu.style.opacity = '1';
28+
subMenu.style.pointerEvents = 'auto';
29+
item.classList.add(activeClassName);
30+
}
31+
};
32+
33+
const setPositionForSubMenu = (
34+
item?: HTMLElement,
35+
subMenu?: HTMLElement,
36+
isAlignHorizontal: boolean = false
37+
) => {
38+
if (!item || !subMenu) return;
39+
const domRect = item.getBoundingClientRect();
40+
const pos = getRelativePosition(subMenu, domRect);
41+
42+
if (isAlignHorizontal) pos.y = pos.y + domRect.height;
43+
else {
44+
pos.x = pos.x + domRect.width;
45+
// The vertical menu default has padding 0.5em so that need reduce the padding
46+
const fontSize = getComputedStyle(subMenu).getPropertyValue(
47+
'font-size'
48+
);
49+
const paddingTop = em2Px(0.5, parseInt(fontSize.replace('px', ''), 10));
50+
pos.y = pos.y - paddingTop;
51+
}
52+
53+
subMenu.style.cssText = `
54+
left: ${pos.x}px;
55+
top: ${pos.y}px;
56+
`;
57+
};
1458

1559
export function Menu(props: React.PropsWithChildren<IMenuProps>) {
1660
const {
@@ -19,9 +63,14 @@ export function Menu(props: React.PropsWithChildren<IMenuProps>) {
1963
data = [],
2064
children,
2165
onClick,
66+
trigger = 'hover',
2267
...custom
2368
} = props;
69+
const menuRef = React.useRef<HTMLUListElement>(null);
70+
const isMouseInMenu = React.useRef(false);
2471
let content = cloneReactChildren(children, { onClick });
72+
// Only when the trigger is hover need to set the delay
73+
const delay = trigger === 'hover' ? 200 : 0;
2574

2675
const modeClassName =
2776
mode === MenuMode.Horizontal
@@ -55,8 +104,90 @@ export function Menu(props: React.PropsWithChildren<IMenuProps>) {
55104
content = renderMenusByData(data);
56105
}
57106

107+
const initialMenuStyle = () => {
108+
menuRef.current?.querySelectorAll('ul').forEach((ul) => {
109+
ul.style.opacity = '0';
110+
ul.style.pointerEvents = 'none';
111+
});
112+
menuRef.current
113+
?.querySelectorAll(`li.${activeClassName}`)
114+
.forEach((li) => {
115+
li.classList.remove(activeClassName);
116+
});
117+
};
118+
119+
const detectDomElementByEvent = debounce((e) => {
120+
// ensure only when mouse in menu can the submenu toggle visibility
121+
if (isMouseInMenu.current) {
122+
const doms = document.elementsFromPoint(
123+
e.pageX,
124+
e.pageY
125+
) as HTMLElement[];
126+
const ulDom = doms.find((dom) => dom.nodeName === 'UL');
127+
const liDom = doms.find((dom) => dom.nodeName === 'LI');
128+
// clear current ul children style
129+
if (ulDom) {
130+
ulDom.querySelectorAll('ul').forEach((ul) => {
131+
ul.style.opacity = '0';
132+
ul.style.pointerEvents = 'none';
133+
});
134+
ulDom
135+
.querySelectorAll(`li.${activeClassName}`)
136+
.forEach((li) => {
137+
li.classList.remove(activeClassName);
138+
});
139+
}
140+
visibleMenuItem(liDom);
141+
const subMenu = liDom?.querySelector('ul') || undefined;
142+
setPositionForSubMenu(liDom, subMenu, isHorizontal(mode));
143+
}
144+
}, delay);
145+
146+
const handleTriggerEvent = (e) => {
147+
e.preventDefault();
148+
e.persist();
149+
e.stopPropagation();
150+
isMouseInMenu.current = true;
151+
detectDomElementByEvent(e);
152+
};
153+
154+
const handleMouseOut = () => {
155+
isMouseInMenu.current = false;
156+
};
157+
158+
const getEventListener = () => {
159+
// sub menu do not listen any event
160+
if (claNames?.includes(defaultSubMenuClassName)) return {};
161+
return {
162+
[triggerEvent(trigger)]: handleTriggerEvent,
163+
onMouseOut: handleMouseOut,
164+
};
165+
};
166+
167+
const hideAfterLeftWindow = React.useCallback(() => {
168+
if (document.hidden) {
169+
initialMenuStyle();
170+
}
171+
}, []);
172+
173+
React.useEffect(() => {
174+
window.addEventListener('contextmenu', initialMenuStyle);
175+
window.addEventListener('click', initialMenuStyle);
176+
window.addEventListener('visibilitychange', hideAfterLeftWindow);
177+
return () => {
178+
document.removeEventListener('contextmenu', initialMenuStyle);
179+
window.removeEventListener('click', initialMenuStyle);
180+
window.removeEventListener('visibilitychange', hideAfterLeftWindow);
181+
};
182+
}, []);
183+
58184
return (
59-
<ul className={claNames} {...custom}>
185+
<ul
186+
className={claNames}
187+
ref={menuRef}
188+
{...getEventListener()}
189+
{...custom}
190+
>
60191
{content}
61192
</ul>
62193
);

src/components/menu/style.scss

+1
Original file line numberDiff line numberDiff line change
@@ -94,5 +94,6 @@
9494
#{$subMenu} {
9595
display: block;
9696
position: fixed;
97+
transition: opacity 100ms ease-in-out;
9798
z-index: 1;
9899
}

src/components/menu/subMenu.tsx

+4-126
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
import * as React from 'react';
22
import { classNames } from 'mo/common/className';
3-
import { useEffect } from 'react';
4-
import {
5-
findParentByClassName,
6-
getRelativePosition,
7-
TriggerEvent,
8-
} from 'mo/common/dom';
9-
import { em2Px } from 'mo/common/css';
3+
import { TriggerEvent } from 'mo/common/dom';
104
import { Icon } from 'mo/components/icon';
115

126
import { Menu } from './menu';
@@ -44,26 +38,6 @@ export interface ISubMenuProps extends IMenuItemProps {
4438
mode?: MenuMode;
4539
}
4640

47-
function hideSubMenu(target?: HTMLElement) {
48-
const container = target || document.body;
49-
const all = container.querySelectorAll<HTMLMenuElement>(
50-
'.' + defaultSubMenuClassName
51-
);
52-
all?.forEach((ele) => {
53-
ele.style.visibility = 'hidden';
54-
});
55-
}
56-
57-
const hideAll = () => {
58-
hideSubMenu();
59-
};
60-
61-
const hideAfterLeftWindow = () => {
62-
if (document.hidden) {
63-
hideSubMenu();
64-
}
65-
};
66-
6741
export function SubMenu(props: React.PropsWithChildren<ISubMenuProps>) {
6842
const {
6943
className,
@@ -80,129 +54,33 @@ export function SubMenu(props: React.PropsWithChildren<ISubMenuProps>) {
8054
const cNames = classNames(defaultSubMenuClassName, className);
8155
const isAlignHorizontal = isHorizontal(mode);
8256

83-
const events = {
84-
onMouseOver: (event: React.MouseEvent<any, any>) => {
85-
const nextMenuItem = findParentByClassName<HTMLLIElement>(
86-
event.target,
87-
defaultMenuItemClassName
88-
);
89-
const nextSubMenu = nextMenuItem?.querySelector<HTMLMenuElement>(
90-
'.' + defaultSubMenuClassName
91-
);
92-
if (!nextMenuItem || !nextSubMenu) return;
93-
94-
const prevMenuItem = findParentByClassName<HTMLLIElement>(
95-
event.relatedTarget,
96-
defaultMenuItemClassName
97-
);
98-
const prevSubMenu = prevMenuItem?.querySelector<HTMLMenuElement>(
99-
'.' + defaultSubMenuClassName
100-
);
101-
102-
if (
103-
(prevMenuItem &&
104-
prevSubMenu &&
105-
!prevMenuItem.contains(nextMenuItem)) ||
106-
(!prevMenuItem && !prevSubMenu)
107-
) {
108-
hideAll();
109-
}
110-
111-
const domRect = nextMenuItem.getBoundingClientRect();
112-
nextSubMenu.style.visibility = 'visible';
113-
const pos = getRelativePosition(nextSubMenu, domRect);
114-
115-
if (isAlignHorizontal) pos.y = pos.y + domRect.height;
116-
else {
117-
pos.x = pos.x + domRect.width;
118-
// The vertical menu default has padding 0.5em so that need reduce the padding
119-
const fontSize = getComputedStyle(nextSubMenu).getPropertyValue(
120-
'font-size'
121-
);
122-
const paddingTop = em2Px(
123-
0.5,
124-
parseInt(fontSize.replace('px', ''), 10)
125-
);
126-
pos.y = pos.y - paddingTop;
127-
}
128-
129-
nextSubMenu.style.cssText = `
130-
left: ${pos.x}px;
131-
top: ${pos.y}px;
132-
`;
133-
},
134-
onMouseOut: function (event: React.MouseEvent) {
135-
const nextMenuItem = findParentByClassName<HTMLLIElement>(
136-
event.relatedTarget,
137-
defaultMenuItemClassName
138-
);
139-
if (!nextMenuItem) return;
140-
141-
const prevMenuItem = event.currentTarget as HTMLLIElement;
142-
const prevSubMenu = prevMenuItem?.querySelector(
143-
'.' + defaultSubMenuClassName
144-
);
145-
const nextSubMenu = nextMenuItem?.querySelector(
146-
'.' + defaultSubMenuClassName
147-
);
148-
// Hide the prev subMenu when the next menuItem hasn't subMenu and the prev MenuItem
149-
// subMenu not contains it.
150-
if (
151-
!nextSubMenu &&
152-
prevSubMenu &&
153-
!prevMenuItem.contains(nextMenuItem)
154-
) {
155-
hideAll();
156-
}
157-
},
158-
onClick: function (event: React.MouseEvent) {},
159-
};
160-
161-
useEffect(() => {
162-
window.addEventListener('contextmenu', hideAll);
163-
window.addEventListener('click', hideAll);
164-
window.addEventListener('visibilitychange', hideAfterLeftWindow);
165-
return () => {
166-
document.removeEventListener('contextmenu', hideAll);
167-
window.removeEventListener('click', hideAll);
168-
window.removeEventListener('visibilitychange', hideAfterLeftWindow);
169-
};
170-
}, []);
171-
17257
const chevronType = isAlignHorizontal ? 'down' : 'right';
17358
const subMenuContent =
17459
data.length > 0 ? (
17560
<Menu
17661
className={cNames}
177-
style={{ visibility: 'hidden' }}
62+
style={{ opacity: '0', pointerEvents: 'none' }}
17863
data={data}
17964
onClick={onClick}
18065
{...custom}
18166
/>
18267
) : (
18368
<Menu
18469
className={cNames}
185-
style={{ visibility: 'hidden' }}
70+
style={{ opacity: '0', pointerEvents: 'none' }}
18671
onClick={onClick}
18772
>
18873
{children}
18974
</Menu>
19075
);
19176

192-
events.onClick = (event: React.MouseEvent) => {
193-
if (!subMenuContent) {
194-
onClick?.(event, props);
195-
}
196-
event.stopPropagation();
197-
};
198-
19977
return (
20078
<li
20179
className={classNames(
20280
defaultMenuItemClassName,
20381
disabled ? disabledClassName : null
20482
)}
205-
{...events}
83+
data-submenu
20684
{...custom}
20785
>
20886
<a className={menuContentClassName}>

src/extensions/theme-defaults/themes/dark_defaults.json

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"settings.numberInputBackground": "#292929",
3030
"menu.background": "#252526",
3131
"menu.foreground": "#CCCCCC",
32+
"menu.selectionBackground": "#094771",
3233
"statusBarItem.remoteForeground": "#FFF",
3334
"statusBarItem.remoteBackground": "#16825D",
3435
"sidebarSectionHeader.background": "#0000",

src/extensions/theme-defaults/themes/light_defaults.json

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"sidebarSectionHeader.background": "#0000",
3636
"sidebarSectionHeader.border": "#61616130",
3737
"tab.border": "#333",
38+
"menu.selectionBackground": "#0060C0",
3839
"tab.inactiveBackground": "rgb(236, 236, 236)",
3940
"tab.inactiveForeground": "rgba(51, 51, 51, 0.7)",
4041
"tab.activeForeground": "rgb(255, 255, 255)"

0 commit comments

Comments
 (0)