1、需求展示
2、源码展示
test.tsx
export const defaultMenu = () => (
<Menu defaultIndex='0' onSelect={(index) => {action(`clicked ${index} item`)}} >
<MenuItem>
cool link
</MenuItem>
<MenuItem disabled>
disabled
</MenuItem>
<MenuItem>
cool link 2
</MenuItem>
</Menu>
)
menu.tsx
import React, { FC, useState, createContext, CSSProperties } from 'react'
import classNames from 'classnames'
import { MenuItemProps } from './menuItem'
type MenuMode = 'horizontal' | 'vertical'
export interface MenuProps {
/**默认 active 的菜单项的索引值 */
defaultIndex?: string;
className?: string;
/**菜单类型 横向或者纵向 */
mode?: MenuMode;
style?: CSSProperties;
/**点击菜单项触发的回掉函数 */
onSelect?: (selectedIndex: string) => void;
/**设置子菜单的默认打开 只在纵向模式下生效 */
defaultOpenSubMenus?: string[];
}
interface IMenuContext {
index: string;
onSelect?: (selectedIndex: string) => void;
mode?: MenuMode;
defaultOpenSubMenus?: string[];
}
export const MenuContext = createContext<IMenuContext>({index: '0'})
/**
* 为网站提供导航功能的菜单。支持横向纵向两种模式,支持下拉菜单。
* ~~~js
* import { Menu } from 'vikingship'
* ~~~
*/
export const Menu: FC<MenuProps> = (props) => {
const { className, mode, style, children, defaultIndex, onSelect, defaultOpenSubMenus } = props
const [ currentActive, setActive ] = useState(defaultIndex)
const classes = classNames('viking-menu', className, {
'menu-vertical': mode === 'vertical',
'menu-horizontal': mode !== 'vertical',
})
const handleClick = (index: string) => {
setActive(index)
if(onSelect) {
onSelect(index)
}
}
const passedContext: IMenuContext = {
index: currentActive ? currentActive : '0',
onSelect: handleClick,
mode,
defaultOpenSubMenus,
}
const renderChildren = () => {
return React.Children.map(children, (child, index) => {
const childElement = child as React.FunctionComponentElement<MenuItemProps>
const { displayName } = childElement.type
if (displayName === 'MenuItem' || displayName === 'SubMenu') {
return React.cloneElement(childElement, {
index: index.toString()
})
} else {
console.error("Warning: Menu has a child which is not a MenuItem component")
}
})
}
return (
<ul className={classes} style={style} data-testid="test-menu">
<MenuContext.Provider value={passedContext}>
{renderChildren()}
</MenuContext.Provider>
</ul>
)
}
Menu.defaultProps = {
defaultIndex: '0',
mode: 'horizontal',
defaultOpenSubMenus: [],
}
export default Menu;
menuItem.tsx
import React, { useContext } from 'react'
import classNames from 'classnames'
import { MenuContext } from './menu'
export interface MenuItemProps {
index?: string;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
}
const MenuItem: React.FC<MenuItemProps> = (props) => {
const { index, disabled, className, style, children } = props
const context = useContext(MenuContext)
const classes = classNames('menu-item', className, {
'is-disabled': disabled,
'is-active': context.index === index
})
const handleClick = () => {
if (context.onSelect && !disabled && (typeof index === 'string')) {
context.onSelect(index)
}
}
return (
<li className={classes} style={style} onClick={handleClick}>
{children}
</li>
)
}
MenuItem.displayName = 'MenuItem'
export default MenuItem
subMenu.tsx
import React,{ useContext, useState, FunctionComponentElement } from 'react'
import classNames from 'classnames'
import { MenuContext } from './menu'
import { MenuItemProps } from './menuItem'
import Icon from '../Icon/icon'
import Transition from '../Transition/transition'
export interface SubMenuProps {
index?: string;
title: string;
className?: string;
}
const SubMenu: React.FC<SubMenuProps> = ({ index, title, children, className}) => {
const context = useContext(MenuContext)
const openedSubMenus = context.defaultOpenSubMenus as Array<string>
const isOpend = (index && context.mode === 'vertical') ? openedSubMenus.includes(index) : false
const [ menuOpen, setOpen ] = useState(isOpend)
const classes = classNames('menu-item submenu-item', className, {
'is-active': context.index === index,
'is-opened': menuOpen,
'is-vertical': context.mode === 'vertical'
})
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
setOpen(!menuOpen)
}
let timer: any
const handleMouse = (e: React.MouseEvent, toggle: boolean) => {
clearTimeout(timer)
e.preventDefault()
timer = setTimeout(() => {
setOpen(toggle)
}, 300)
}
const clickEvents = context.mode === 'vertical' ? {
onClick: handleClick
} : {}
const hoverEvents = context.mode !== 'vertical' ? {
onMouseEnter: (e: React.MouseEvent) => { handleMouse(e, true)},
onMouseLeave: (e: React.MouseEvent) => { handleMouse(e, false)}
} : {}
const renderChildren = () => {
const subMenuClasses = classNames('viking-submenu', {
'menu-opened': menuOpen
})
const childrenComponent = React.Children.map(children, (child, i) => {
const childElement = child as FunctionComponentElement<MenuItemProps>
if (childElement.type.displayName === 'MenuItem') {
return React.cloneElement(childElement, {
index: `${index}-${i}`
})
} else {
console.error("Warning: SubMenu has a child which is not a MenuItem component")
}
})
return (
<Transition
in={menuOpen}
timeout={300}
animation="zoom-in-top"
>
<ul className={subMenuClasses}>
{childrenComponent}
</ul>
</Transition>
)
}
return (
<li key={index} className={classes} {...hoverEvents}>
<div className="submenu-title" {...clickEvents}>
{title}
<Icon icon="angle-down" className="arrow-icon"/>
</div>
{renderChildren()}
</li>
)
}
SubMenu.displayName = 'SubMenu'
export default SubMenu
_style.scss
.viking-menu {
display: flex;
flex-wrap: wrap;
padding-left: 0;
margin-bottom: 30px;
list-style: none;
border-bottom: $menu-border-width solid $menu-border-color;
box-shadow: $menu-box-shadow;
>.menu-item {
padding: $menu-item-padding-y $menu-item-padding-x;
cursor: pointer;
transition: $menu-transition;
&:hover, &:focus {
text-decoration: none;
}
&.is-disabled {
color: $menu-item-disabled-color;
pointer-events: none;
cursor: default;
}
&.is-active, &:hover {
color: $menu-item-active-color;
border-bottom: $menu-item-active-border-width solid $menu-item-active-color;
}
}
.submenu-item {
position: relative;
.submenu-title {
display: flex;
align-items: center;
}
.arrow-icon {
transition: transform .25s ease-in-out;
margin-left: 3px;
}
&:hover {
.arrow-icon {
transform: rotate(180deg);
}
}
}
.is-vertical {
.arrow-icon {
transform: rotate(0deg) !important;
}
}
.is-vertical.is-opened {
.arrow-icon {
transform: rotate(180deg) !important;
}
}
.viking-submenu {
//display: none;
list-style:none;
padding-left: 0;
white-space: nowrap;
//transition: $menu-transition;
.menu-item {
padding: $menu-item-padding-y $menu-item-padding-x;
cursor: pointer;
transition: $menu-transition;
color: $body-color;
&.is-active, &:hover {
color: $menu-item-active-color !important;
}
}
}
.viking-submenu.menu-opened {
//display: block;
}
}
.menu-horizontal {
>.menu-item {
border-bottom: $menu-item-active-border-width solid transparent;
}
.viking-submenu {
position: absolute;
background: $white;
z-index: 100;
top: calc(100% + 8px);
left: 0;
border: $menu-border-width solid $menu-border-color;
box-shadow: $submenu-box-shadow;
}
}
.menu-vertical {
flex-direction: column;
border-bottom: 0px;
margin: 10px 20px;
border-right: $menu-border-width solid $menu-border-color;
>.menu-item {
border-left: $menu-item-active-border-width solid transparent;
&.is-active, &:hover {
border-bottom: 0px;
border-left: $menu-item-active-border-width solid $menu-item-active-color;
}
}
}