Skip to content

Commit

Permalink
feat(menu): add menu component
Browse files Browse the repository at this point in the history
  • Loading branch information
wewoor committed Nov 20, 2020
1 parent 4bd9b7e commit 1fe6831
Show file tree
Hide file tree
Showing 7 changed files with 500 additions and 41 deletions.
21 changes: 3 additions & 18 deletions src/components/menu/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,3 @@
import './style.scss';
import * as React from 'react';
import { classNames, prefixClaName } from 'mo/common/className';
import ActionBar, { IActionBar, IActionBarItem } from 'mo/components/actionbar';

export interface IMenuItem extends IActionBarItem {}
export interface IMenu extends IActionBar {}

export function Menu(props: IMenu) {
const { className, ...others } = props;
const claNames = classNames(prefixClaName('menu'), className);

return (
<menu className={claNames}>
<ActionBar {...others} />
</menu>
);
}
export * from './menu';
export * from './menuItem';
export * from './subMenu';
33 changes: 33 additions & 0 deletions src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import './style.scss';
import * as React from 'react';
import { classNames, prefixClaName } from 'mo/common/className';
import { MenuItem } from './menuItem';
import { ISubMenu, MenuMode, SubMenu } from './subMenu';

export interface IMenu extends ISubMenu {}

export const defaultMenuClassName = 'menu';

export function Menu(props: React.PropsWithChildren<IMenu>) {
const { className, mode = MenuMode.Vertical, data = [], children, ...others } = props;
let content = children;
const claNames = classNames(prefixClaName(defaultMenuClassName), mode, className);

if (data.length > 0) {
const renderMenusByData = (menus: IMenu[]) => {
return menus.map((item: IMenu) => {
if (item.data && item.data.length > 0) {
return <SubMenu mode={mode} {...item}>{ renderMenusByData(item.data) }</SubMenu>
}
return <MenuItem key={item.id} {...item}>{item.name}</MenuItem>
})
}
content = renderMenusByData(data);
}

return (
<ul className={claNames} {...others}>
{ content }
</ul>
);
}
48 changes: 48 additions & 0 deletions src/components/menu/menuItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import './style.scss';
import * as React from 'react';
import { classNames, prefixClaName } from 'mo/common/className';
import { Icon } from '../icon';

export const defaultMenuItemClassName = prefixClaName('menu-item');

export interface IMenuItem extends HTMLElementProps {
/**
* The name of icon
*/
icon?: string;
/**
* Item Name
*/
name?: ReactNode;
/**
* The description of keybinding
* example: ⇧⌘P
*/
keybinding?: string;
/**
* Custom render
*/
render?: (data: IMenuItem) => ReactNode;
onClick?: (e: React.MouseEvent, item?: IMenuItem) => void;
sortIndex?: number;
}

export function MenuItem(props: React.PropsWithChildren<IMenuItem>) {
const { icon, className, onClick, keybinding, render, children, name } = props;
const events = {
onClick: function(e: React.MouseEvent) {
if (onClick) {
onClick(e, props)
}
}
}
return (
<li className={classNames(defaultMenuItemClassName, className)} {...events}>
<a className="menu-item-container">
<Icon className="menu-item-check" type={icon || ''} />
<span className="menu-item-label" title={name as string}>{ render ? render(props) : children }</span>
{ keybinding ? <span className="keybinding">{keybinding}</span> : null }
</a>
</li>
)
}
93 changes: 73 additions & 20 deletions src/components/menu/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,92 @@
$menu: 'menu';

#{prefix($menu)} {
display: flex;
list-style: none;
margin: 0;
min-width: 130px;
min-width: 200px;
padding: 0;

#{prefix('action-bar')} {
&.vertical {
flex-direction: column;
padding: 0.5em 0;

#{prefix('menu-item')} {
min-width: 120px;
}

.menu-item-indicator {
&.codicon::before {
margin-left: auto;
margin-right: -20px;
}
}
}

.action-bar-container {
display: block;
&.horizontal {
flex-direction: row;

#{prefix('menu-item')} {
min-width: 120px;
}
}

.action-item {
border: thin solid transparent;
.menu-item-container {
align-items: center;
cursor: default;
display: flex;
overflow: visible;
position: static;
text-indent: 1em;
transform: none;
}
flex: 1 1 auto;
font-size: inherit;
height: 2em;
position: relative;
transition: transform 50ms ease;

.menu-item-check {
font-size: inherit;
height: 100%;
height: 100%;
position: absolute;
width: 2em;
}

.action-label {
font-size: 13px;
height: 1.8em;
.codicon {
align-items: center;
display: flex;
justify-content: center;
}
}

.action-label.codicon {
line-height: 1.8em;
.menu-item-indicator {
padding: 0 1.8em;

&.codicon {
align-items: center;
display: flex;
font-size: 16px;
}
}

.disabled {
cursor: default;
opacity: 0.4;
pointer-events: none;
.menu-item-label {
align-items: center;
background-position: center center;
background-repeat: no-repeat;
background-size: 16px;
flex: auto;
font-size: inherit;
justify-content: center;
padding: 0 2em;
text-decoration: none;
}
}

#{prefix('menu-item')} {
font-size: 13px;
}

#{prefix('sub-menu')} {
display: block;
font-size: 13px;
position: fixed;
// transition-delay: 0.2s; Bug
z-index: 1;
}
142 changes: 142 additions & 0 deletions src/components/menu/subMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import './style.scss';
import * as React from 'react';
import { classNames, prefixClaName } from 'mo/common/className';
import { Icon } from '../icon';
import { Menu } from './menu';
import { useEffect } from 'react';
import { findParentByClassName, getRelativePosition, TriggerEvent } from 'mo/common/dom';
import { defaultMenuItemClassName, IMenuItem } from './menuItem';
import { em2Px } from 'mo/common/css';

export enum MenuMode {
Vertical = 'vertical',
Horizontal = 'horizontal'
};

export function isHorizontal(mode: MenuMode) {
return mode === MenuMode.Horizontal;
}

export function isVertical(mode: MenuMode) {
return mode === MenuMode.Horizontal;
}

export interface ISubMenu extends IMenuItem {
/**
* The event of show subMenu, default value is 'hover'
*/
trigger?: TriggerEvent;
icon?: string;
data?: ISubMenu[];
mode?: MenuMode;
}

const defaultSubMenuClassName = prefixClaName('sub-menu');

function hideSubMenu(target?: HTMLElement) {
const container = target || document.body;
const all = container.querySelectorAll<HTMLMenuElement>('.'+defaultSubMenuClassName);
all?.forEach(ele => {
ele.style.visibility = 'hidden';
});
}

const hideAll = () => {
hideSubMenu();
};

const hideAfterLeftWindow = () => {
if (document.hidden) {
hideSubMenu();
}
}

let timer;

export function SubMenu(props: React.PropsWithChildren<ISubMenu>) {
const { className, name, render, data = [], mode = MenuMode.Vertical, icon, children, ...others } = props;
const cNames = classNames(defaultSubMenuClassName, mode, className);
const isAlignHorizontal = isHorizontal(mode);

const events = {
onMouseOver: (event: React.MouseEvent<any, any>) => {
clearTimeout(timer);

const nextMenuItem = findParentByClassName<HTMLLIElement>(event.target, defaultMenuItemClassName);
const nextSubMenu = nextMenuItem?.querySelector<HTMLMenuElement>('.' + defaultSubMenuClassName);
if (!nextMenuItem || !nextSubMenu) return;

const prevMenuItem = findParentByClassName<HTMLLIElement>(event.relatedTarget, defaultMenuItemClassName);
const prevSubMenu = prevMenuItem?.querySelector<HTMLMenuElement>('.' + defaultSubMenuClassName);

if (prevMenuItem && prevSubMenu && !prevMenuItem.contains(nextMenuItem)) {
hideAll();
}

const domRect = nextMenuItem.getBoundingClientRect();
nextSubMenu.style.visibility = 'visible';
const pos = getRelativePosition(nextSubMenu, domRect);

if (isAlignHorizontal) pos.y = pos.y + domRect.height;
else {
pos.x = pos.x + domRect.width;
// The vertical menu default has padding 0.5em so that need reduce the padding
const fontSize = getComputedStyle(nextSubMenu).getPropertyValue('font-size');
const paddingTop = em2Px(0.5, parseInt(fontSize.replace('px', ''), 10));
pos.y = pos.y - paddingTop;
}

nextSubMenu.style.cssText = `
left: ${pos.x}px;
top: ${pos.y}px;
`;
},
onMouseOut: function(event: React.MouseEvent) {
const nextMenuItem = findParentByClassName<HTMLLIElement>(event.relatedTarget, defaultMenuItemClassName) ;
if (!nextMenuItem) return;

const prevMenuItem = event.currentTarget as HTMLLIElement;
const prevSubMenu = prevMenuItem?.querySelector('.' + defaultSubMenuClassName);
const nextSubMenu = nextMenuItem?.querySelector('.' + defaultSubMenuClassName);
// Hide the prev subMenu when the next menuItem hasn't subMenu and the prev MenuItem
// subMenu not contains it.
if (!nextSubMenu && prevSubMenu && !prevMenuItem.contains(nextMenuItem)) {
hideAll();
// delayHide(prevMenuItem);
}
},
onClick: function(event: React.MouseEvent) {
event.stopPropagation();
}
};

useEffect(() => {
window.addEventListener('contextmenu', hideAll);
window.addEventListener('click', hideAll);
window.addEventListener('visibilitychange', hideAfterLeftWindow);
return () => {
document.removeEventListener('contextmenu', hideAll);
window.removeEventListener('click', hideAll);
window.removeEventListener('visibilitychange', hideAfterLeftWindow);
clearTimeout(timer);
}
}, [])

const chevronType = isAlignHorizontal ? 'down' : 'right';
const subMenuContent = data.length > 0 ?
<Menu className={cNames} style={{ visibility: 'hidden' }} data={data}/> :
<Menu className={cNames} style={{ visibility: 'hidden' }}> { children } </Menu>;

console.log('mode', mode, isAlignHorizontal)

return (
<li className={defaultMenuItemClassName} {...events} {...others}>
<a className="menu-item-container">
<Icon className="menu-item-check" type={icon || ''} />
<span className="menu-item-label">{ render ? render(props) : name }</span>
<Icon className="menu-item-indicator" type={`chevron-${chevronType}`}/>
</a>
{ subMenuContent }
</li>
)
}
Loading

0 comments on commit 1fe6831

Please sign in to comment.