Skip to content

Commit 1fe6831

Browse files
committed
feat(menu): add menu component
1 parent 4bd9b7e commit 1fe6831

File tree

7 files changed

+500
-41
lines changed

7 files changed

+500
-41
lines changed

src/components/menu/index.tsx

+3-18
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,3 @@
1-
import './style.scss';
2-
import * as React from 'react';
3-
import { classNames, prefixClaName } from 'mo/common/className';
4-
import ActionBar, { IActionBar, IActionBarItem } from 'mo/components/actionbar';
5-
6-
export interface IMenuItem extends IActionBarItem {}
7-
export interface IMenu extends IActionBar {}
8-
9-
export function Menu(props: IMenu) {
10-
const { className, ...others } = props;
11-
const claNames = classNames(prefixClaName('menu'), className);
12-
13-
return (
14-
<menu className={claNames}>
15-
<ActionBar {...others} />
16-
</menu>
17-
);
18-
}
1+
export * from './menu';
2+
export * from './menuItem';
3+
export * from './subMenu';

src/components/menu/menu.tsx

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import './style.scss';
2+
import * as React from 'react';
3+
import { classNames, prefixClaName } from 'mo/common/className';
4+
import { MenuItem } from './menuItem';
5+
import { ISubMenu, MenuMode, SubMenu } from './subMenu';
6+
7+
export interface IMenu extends ISubMenu {}
8+
9+
export const defaultMenuClassName = 'menu';
10+
11+
export function Menu(props: React.PropsWithChildren<IMenu>) {
12+
const { className, mode = MenuMode.Vertical, data = [], children, ...others } = props;
13+
let content = children;
14+
const claNames = classNames(prefixClaName(defaultMenuClassName), mode, className);
15+
16+
if (data.length > 0) {
17+
const renderMenusByData = (menus: IMenu[]) => {
18+
return menus.map((item: IMenu) => {
19+
if (item.data && item.data.length > 0) {
20+
return <SubMenu mode={mode} {...item}>{ renderMenusByData(item.data) }</SubMenu>
21+
}
22+
return <MenuItem key={item.id} {...item}>{item.name}</MenuItem>
23+
})
24+
}
25+
content = renderMenusByData(data);
26+
}
27+
28+
return (
29+
<ul className={claNames} {...others}>
30+
{ content }
31+
</ul>
32+
);
33+
}

src/components/menu/menuItem.tsx

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import './style.scss';
2+
import * as React from 'react';
3+
import { classNames, prefixClaName } from 'mo/common/className';
4+
import { Icon } from '../icon';
5+
6+
export const defaultMenuItemClassName = prefixClaName('menu-item');
7+
8+
export interface IMenuItem extends HTMLElementProps {
9+
/**
10+
* The name of icon
11+
*/
12+
icon?: string;
13+
/**
14+
* Item Name
15+
*/
16+
name?: ReactNode;
17+
/**
18+
* The description of keybinding
19+
* example: ⇧⌘P
20+
*/
21+
keybinding?: string;
22+
/**
23+
* Custom render
24+
*/
25+
render?: (data: IMenuItem) => ReactNode;
26+
onClick?: (e: React.MouseEvent, item?: IMenuItem) => void;
27+
sortIndex?: number;
28+
}
29+
30+
export function MenuItem(props: React.PropsWithChildren<IMenuItem>) {
31+
const { icon, className, onClick, keybinding, render, children, name } = props;
32+
const events = {
33+
onClick: function(e: React.MouseEvent) {
34+
if (onClick) {
35+
onClick(e, props)
36+
}
37+
}
38+
}
39+
return (
40+
<li className={classNames(defaultMenuItemClassName, className)} {...events}>
41+
<a className="menu-item-container">
42+
<Icon className="menu-item-check" type={icon || ''} />
43+
<span className="menu-item-label" title={name as string}>{ render ? render(props) : children }</span>
44+
{ keybinding ? <span className="keybinding">{keybinding}</span> : null }
45+
</a>
46+
</li>
47+
)
48+
}

src/components/menu/style.scss

+73-20
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,92 @@
22
$menu: 'menu';
33

44
#{prefix($menu)} {
5+
display: flex;
6+
list-style: none;
57
margin: 0;
6-
min-width: 130px;
8+
min-width: 200px;
79
padding: 0;
810

9-
#{prefix('action-bar')} {
11+
&.vertical {
12+
flex-direction: column;
1013
padding: 0.5em 0;
14+
15+
#{prefix('menu-item')} {
16+
min-width: 120px;
17+
}
18+
19+
.menu-item-indicator {
20+
&.codicon::before {
21+
margin-left: auto;
22+
margin-right: -20px;
23+
}
24+
}
1125
}
1226

13-
.action-bar-container {
14-
display: block;
27+
&.horizontal {
28+
flex-direction: row;
29+
30+
#{prefix('menu-item')} {
31+
min-width: 120px;
32+
}
1533
}
1634

17-
.action-item {
18-
border: thin solid transparent;
35+
.menu-item-container {
36+
align-items: center;
37+
cursor: default;
1938
display: flex;
20-
overflow: visible;
21-
position: static;
22-
text-indent: 1em;
23-
transform: none;
24-
}
39+
flex: 1 1 auto;
40+
font-size: inherit;
41+
height: 2em;
42+
position: relative;
43+
transition: transform 50ms ease;
44+
45+
.menu-item-check {
46+
font-size: inherit;
47+
height: 100%;
48+
height: 100%;
49+
position: absolute;
50+
width: 2em;
51+
}
2552

26-
.action-label {
27-
font-size: 13px;
28-
height: 1.8em;
53+
.codicon {
54+
align-items: center;
55+
display: flex;
56+
justify-content: center;
57+
}
2958
}
3059

31-
.action-label.codicon {
32-
line-height: 1.8em;
60+
.menu-item-indicator {
61+
padding: 0 1.8em;
62+
63+
&.codicon {
64+
align-items: center;
65+
display: flex;
66+
font-size: 16px;
67+
}
3368
}
3469

35-
.disabled {
36-
cursor: default;
37-
opacity: 0.4;
38-
pointer-events: none;
70+
.menu-item-label {
71+
align-items: center;
72+
background-position: center center;
73+
background-repeat: no-repeat;
74+
background-size: 16px;
75+
flex: auto;
76+
font-size: inherit;
77+
justify-content: center;
78+
padding: 0 2em;
79+
text-decoration: none;
3980
}
4081
}
82+
83+
#{prefix('menu-item')} {
84+
font-size: 13px;
85+
}
86+
87+
#{prefix('sub-menu')} {
88+
display: block;
89+
font-size: 13px;
90+
position: fixed;
91+
// transition-delay: 0.2s; Bug
92+
z-index: 1;
93+
}

src/components/menu/subMenu.tsx

+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import './style.scss';
2+
import * as React from 'react';
3+
import { classNames, prefixClaName } from 'mo/common/className';
4+
import { Icon } from '../icon';
5+
import { Menu } from './menu';
6+
import { useEffect } from 'react';
7+
import { findParentByClassName, getRelativePosition, TriggerEvent } from 'mo/common/dom';
8+
import { defaultMenuItemClassName, IMenuItem } from './menuItem';
9+
import { em2Px } from 'mo/common/css';
10+
11+
export enum MenuMode {
12+
Vertical = 'vertical',
13+
Horizontal = 'horizontal'
14+
};
15+
16+
export function isHorizontal(mode: MenuMode) {
17+
return mode === MenuMode.Horizontal;
18+
}
19+
20+
export function isVertical(mode: MenuMode) {
21+
return mode === MenuMode.Horizontal;
22+
}
23+
24+
export interface ISubMenu extends IMenuItem {
25+
/**
26+
* The event of show subMenu, default value is 'hover'
27+
*/
28+
trigger?: TriggerEvent;
29+
icon?: string;
30+
data?: ISubMenu[];
31+
mode?: MenuMode;
32+
}
33+
34+
const defaultSubMenuClassName = prefixClaName('sub-menu');
35+
36+
function hideSubMenu(target?: HTMLElement) {
37+
const container = target || document.body;
38+
const all = container.querySelectorAll<HTMLMenuElement>('.'+defaultSubMenuClassName);
39+
all?.forEach(ele => {
40+
ele.style.visibility = 'hidden';
41+
});
42+
}
43+
44+
const hideAll = () => {
45+
hideSubMenu();
46+
};
47+
48+
const hideAfterLeftWindow = () => {
49+
if (document.hidden) {
50+
hideSubMenu();
51+
}
52+
}
53+
54+
let timer;
55+
56+
export function SubMenu(props: React.PropsWithChildren<ISubMenu>) {
57+
const { className, name, render, data = [], mode = MenuMode.Vertical, icon, children, ...others } = props;
58+
const cNames = classNames(defaultSubMenuClassName, mode, className);
59+
const isAlignHorizontal = isHorizontal(mode);
60+
61+
const events = {
62+
onMouseOver: (event: React.MouseEvent<any, any>) => {
63+
clearTimeout(timer);
64+
65+
const nextMenuItem = findParentByClassName<HTMLLIElement>(event.target, defaultMenuItemClassName);
66+
const nextSubMenu = nextMenuItem?.querySelector<HTMLMenuElement>('.' + defaultSubMenuClassName);
67+
if (!nextMenuItem || !nextSubMenu) return;
68+
69+
const prevMenuItem = findParentByClassName<HTMLLIElement>(event.relatedTarget, defaultMenuItemClassName);
70+
const prevSubMenu = prevMenuItem?.querySelector<HTMLMenuElement>('.' + defaultSubMenuClassName);
71+
72+
if (prevMenuItem && prevSubMenu && !prevMenuItem.contains(nextMenuItem)) {
73+
hideAll();
74+
}
75+
76+
const domRect = nextMenuItem.getBoundingClientRect();
77+
nextSubMenu.style.visibility = 'visible';
78+
const pos = getRelativePosition(nextSubMenu, domRect);
79+
80+
if (isAlignHorizontal) pos.y = pos.y + domRect.height;
81+
else {
82+
pos.x = pos.x + domRect.width;
83+
// The vertical menu default has padding 0.5em so that need reduce the padding
84+
const fontSize = getComputedStyle(nextSubMenu).getPropertyValue('font-size');
85+
const paddingTop = em2Px(0.5, parseInt(fontSize.replace('px', ''), 10));
86+
pos.y = pos.y - paddingTop;
87+
}
88+
89+
nextSubMenu.style.cssText = `
90+
left: ${pos.x}px;
91+
top: ${pos.y}px;
92+
`;
93+
},
94+
onMouseOut: function(event: React.MouseEvent) {
95+
const nextMenuItem = findParentByClassName<HTMLLIElement>(event.relatedTarget, defaultMenuItemClassName) ;
96+
if (!nextMenuItem) return;
97+
98+
const prevMenuItem = event.currentTarget as HTMLLIElement;
99+
const prevSubMenu = prevMenuItem?.querySelector('.' + defaultSubMenuClassName);
100+
const nextSubMenu = nextMenuItem?.querySelector('.' + defaultSubMenuClassName);
101+
// Hide the prev subMenu when the next menuItem hasn't subMenu and the prev MenuItem
102+
// subMenu not contains it.
103+
if (!nextSubMenu && prevSubMenu && !prevMenuItem.contains(nextMenuItem)) {
104+
hideAll();
105+
// delayHide(prevMenuItem);
106+
}
107+
},
108+
onClick: function(event: React.MouseEvent) {
109+
event.stopPropagation();
110+
}
111+
};
112+
113+
useEffect(() => {
114+
window.addEventListener('contextmenu', hideAll);
115+
window.addEventListener('click', hideAll);
116+
window.addEventListener('visibilitychange', hideAfterLeftWindow);
117+
return () => {
118+
document.removeEventListener('contextmenu', hideAll);
119+
window.removeEventListener('click', hideAll);
120+
window.removeEventListener('visibilitychange', hideAfterLeftWindow);
121+
clearTimeout(timer);
122+
}
123+
}, [])
124+
125+
const chevronType = isAlignHorizontal ? 'down' : 'right';
126+
const subMenuContent = data.length > 0 ?
127+
<Menu className={cNames} style={{ visibility: 'hidden' }} data={data}/> :
128+
<Menu className={cNames} style={{ visibility: 'hidden' }}> { children } </Menu>;
129+
130+
console.log('mode', mode, isAlignHorizontal)
131+
132+
return (
133+
<li className={defaultMenuItemClassName} {...events} {...others}>
134+
<a className="menu-item-container">
135+
<Icon className="menu-item-check" type={icon || ''} />
136+
<span className="menu-item-label">{ render ? render(props) : name }</span>
137+
<Icon className="menu-item-indicator" type={`chevron-${chevronType}`}/>
138+
</a>
139+
{ subMenuContent }
140+
</li>
141+
)
142+
}

0 commit comments

Comments
 (0)