需求分析
- Menu组件分为横向、纵向,横向/纵向分为有下拉菜单栏的、没有下拉菜单栏的
- 属性为active的菜单栏是高亮的,其余不高亮
- 菜单项是可以被disabled,disabled会展示特殊样式,点击不会响应
- 点击某一菜单项,可能会弹出下拉菜单栏
基本样式
思路:外面用一个Menu包起来,里面就是一个个的菜单选项menuItem,也就是说,Menu组件中是一个个的菜单选项menuItem
新建src/components/Menu/menu.tsx,代码如下:
import React, { FC, useState, createContext, CSSProperties } from 'react'
import classNames from 'classnames'
type MenuMode = 'horizontal' | 'vertical'
export interface MenuProps {
/**默认 active 的菜单项的索引值 高亮的菜单项的索引值*/
defaultIndex?: string;
className?: string;
/**菜单类型 横向或者纵向 */
mode?: MenuMode;
style?: CSSProperties;// CSSProperties内置的
/**点击菜单项触发的回调函数 */
onSelect?: (selectedIndex: string) => void;
}
export const Menu: FC<MenuProps> = (props) => {
const { className, mode, style, children, defaultIndex, onSelect, defaultOpenSubMenus } = props
const classes = classNames('viking-menu', className, {// 添加类名
'menu-vertical': mode === 'vertical',
'menu-horizontal': mode !== 'vertical',
})
return (
<ul className={classes} style={style}>
{children}
</ul>
)
}
Menu.defaultProps = {
defaultIndex: '0',
mode: 'horizontal',
defaultOpenSubMenus: [],
}
export default Menu;
menuItem.tsx
import React, { useContext } from 'react'
import classNames from 'classnames'
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 classes = classNames('menu-item', className, {
'is-disabled': disabled,
})
const handleClick = () => {
if (context.onSelect && !disabled && (typeof index === 'string')) {
context.onSelect(index)
}
}
return (
<li className={classes} style={style}>
{children}
</li>
)
}
MenuItem.displayName = 'MenuItem'
export default MenuItem
在App.tsx中使用:
import React from 'react'
import Menu from './components/Menu/menu.tsx'
import MenuItem from './components/Menu/menuItem.tsx'
<Menu defaultItem={0}>
<MenuItem index={0}>
cool link
</MenuItem>
<MenuItem index={1}>
cool link2
</MenuItem>
<MenuItem index={2}>
cool link3
</MenuItem>
</Menu>
执行npm start
后展示如下:
父组件给子组件传入数据和方法
我们需要把父组件的一些属性传递给子组件,比如我们现在选择的是哪个index,等等,那我们需要用context,引入:import React, { FC, useState, createContext } from 'react'
// 规定一下传给子组件的context应该长什么样
interface IMenuContext {
index: string;
onSelect?: (selectedIndex: string) => void;
mode?: MenuMode;
defaultOpenSubMenus?: string[];
}
// 创建context 初始值index: '0'
export const MenuContext = createContext<IMenuContext>({index: '0'})
由于我们点击MenuItem会切换active的状态,而且active有且只有一个,那么就需要在父组件中添加state,指示当前active是哪个,上面我们已经引入useState了,就不引入了,接下来创建state:const [ currentActive, setActive ] = useState(defaultIndex)
。
定义传递给子组件的context
const handleClick = (index: string) => {
setActive(index)// 点击后,active进行相应的变化
if(onSelect) {
onSelect(index)// 调用父组件的onSelect方法
}
}
const passedContext: IMenuContext = {// 传递给子组件的context
index: currentActive ? currentActive : '0',// 判断子组件是否高亮
onSelect: handleClick,
mode: mode,
}
将passedContext传递给子组件
return (
<ul className={classes} style={style}>
<MenuContext.Provider value={passedContext}>
{renderChildren()}
</MenuContext.Provider>
</ul>
)
src/components/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;// CSSProperties内置的
/**点击菜单项触发的回调函数 */
onSelect?: (selectedIndex: string) => void;
/**设置子菜单的默认打开 只在纵向模式下生效 */
defaultOpenSubMenus?: string[];
}
interface IMenuContext {// 规定传给子组件的context应该长什么样
index: string;
onSelect?: (selectedIndex: string) => void;
mode?: MenuMode;
defaultOpenSubMenus?: string[];
}
export const MenuContext = createContext<IMenuContext>({index: '0'})// 创建context 初始值index: '0'
export const Menu: FC<MenuProps> = (props) => {
const { className, mode, style, children, defaultIndex, onSelect, defaultOpenSubMenus } = props
// 由于我们点击MenuItem会切换active的状态,而且active有且只有一个,
// 那么就需要在父组件中添加state,指示当前active是哪个
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)// 点击后,active进行相应的变化
if(onSelect) {
onSelect(index)// 调用父组件的onSelect方法
}
}
const passedContext: IMenuContext = {// 传递给子组件的context
index: currentActive ? currentActive : '0',// 判断子组件是否高亮
onSelect: handleClick,
mode,
defaultOpenSubMenus,
}
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 { MenuContext } from './menu'
,然后使用:const context = useContext(MenuContext)
给classname添加一个表示高亮的classname——is-active
const classes = classNames('menu-item', className, {
'is-disabled': disabled,
'is-active': context.index === index// 父组件传过来的当前高亮的索引=该item的索引
})
点击菜单项后执行父组件的onSelect方法
const handleClick = () => {
if (context.onSelect && !disabled && (typeof index === 'string')) {
context.onSelect(index)// 调用父组件的方法
}
}
return (
<li className={classes} style={style} onClick={handleClick}>
{children}
</li>
)
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// 父组件传过来的当前高亮的索引=该item的索引
})
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'// react内置的静态属性,可以帮我们判断类型
export default MenuItem
设置样式
在src/styles/_variables.scss中定义menu和menu-item的变量,新建src/components/Menu/_style.scss并在src/styles/index.scss中引入。_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;
}
}
}
.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;
}
}
}
.menu-vertical设置的样式如下:
改进
在测试时,代码如下:
<Menu defaultItem={0}>
<MenuItem index={0}>
cool link
</MenuItem>
<MenuItem index={1}>
cool link2
</MenuItem>
<MenuItem index={2}>
cool link3
</MenuItem>
</Menu>
每个MenuItem标签都有index,每次都自己手写太麻烦了,我们如何让代码自动给我们加上?
前面渲染的时候直接使用的this.props.children:
return (
<li className={classes} style={style} onClick={handleClick}>
{children}
</li>
)
现在我们需要获得操控props.children的能力,如果直接在props.children上调用map方法,然后检查每个child就行了,但是props.children可以是任意数据类型,如果他是一个函数,你在函数上调用map方法会报错,为了解决这个问题,react推出React.Children.map
和React.Children.forEach
,如果在props.children中遇到一些不合规则的类型,React.Children.map
和React.Children.forEach
会直接跳过这些类型。
首先我们要给menuItem添加一个displayName,他是react内置的静态属性,可以帮我们判断类型,在menuItem.tsx中添加代码:MenuItem.displayName = 'MenuItem'
然后使用,在menu.tsx中添加代码:
const renderChildren = () => {
// React.Children.map接收两个参数,第一个是props.children,第二个是回调函数,回调函数第一个参数是child,第二个参数是index
return React.Children.map(children, (child, index) => {
const childElement = child as React.FunctionComponentElement<MenuItemProps>
const { displayName } = childElement.type
if (displayName === 'MenuItem') {
//React.cloneElement克隆一个元素,第一个参数是你想克隆的元素,第二个参数是对象,是你想设置的参数
//也就是说,如果遍历到的这个节点是MenuItem或SubMenu,那么复制这个节点并为其添加index属性
return React.cloneElement(childElement, {
index: index.toString()
})
} else {
console.error("Warning: Menu has a child which is not a MenuItem component")
}
})
}
展示下拉菜单SubMenu
SubMenu组件代表下拉菜单的内容,在SubMenu中可以添加很多选项卡MenuItem,SubMenu组件的title属性表示SubMenu组件在Menu组件中显示的内容
SubMenu在样式上使用子绝父相,src/components/Menu/_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 {
list-style:none;
padding-left: 0;
white-space: nowrap;
.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;
}
}
}
}
.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;
}
}
}
src/components/Menu/subMenu.tsx中代码如下:
import React, {useContext, FunctionComponentElement } from 'react'
import className from 'classname'
import {MenuContext } from './menu'
import {MenuItemProps} from './menuItem'
export interface SubMenuProps {
index?: string;
title: string;
className?: string;
}
const SubMenu: React.FC<SubMenuProps> = ({index, title, className, children}) => {
const context = useContext(MenuContext)
const classes = classNames('menu-item submenu-item', className, {
'is-active': context.index === index
})
const renderChildren = () => {// 下拉菜单的内容
const childrenComponent = React.children.map(children, (child, i) => {
const childElement = child as FunctionComponentElement<MenuItemProps>
if(childElement.type.displayName === 'MenuItem' || childElement.type.displayName === 'SubMenu'){
return childElement
}else{
console.error("Warning: SubMenu has a child which is not a MenuItem component")
}
})
return (
<ul className='viking-submenu'>
{childrenComponent}
</ul>
)
}
return (
<li className={classes} key={index}>
<div className='submenu-title'>
{title}
</div>
{renderChildren()}
</li>
)
}
SubMenu.displayName = 'SubMenu'
export default SubMenu
使用时,在app.tsx中写代码:
<Menu defaultIndex={0} onSelect={(index) => {alert(index)}}>
<MenuItem>
cool lonk
</MenuItem>
<MenuItem disabled>
cool lonk 2
</MenuItem>
<SubMenu title='dropdown'>
<MenuItem>
dropdown 1
</MenuItem>
<MenuItem>
dropdown 2
</MenuItem>
</SubMenu>
<MenuItem>
cool lonk 3
</MenuItem>
</Menu>
横向菜单栏如下所示
使用时,在app.tsx中写代码:
<Menu defaultIndex='0' onSelect={(index) => {alert(index)}} mode='vertical'>
<MenuItem>
cool lonk
</MenuItem>
<MenuItem disabled>
cool lonk 2
</MenuItem>
<SubMenu title='dropdown'>
<MenuItem>
dropdown 1
</MenuItem>
<MenuItem>
dropdown 2
</MenuItem>
</SubMenu>
<MenuItem>
cool lonk 3
</MenuItem>
</Menu>
纵向菜单栏如下所示:
动态下拉菜单
上面我们只实现了下拉菜单的展示,这一节我们来实现交互,让下拉菜单可以自如的展开和关闭。刚开始我们把下拉菜单设置为display: none;
,然后在特定的鼠标事件触发时,使用useState来控制他的开关,添加特定的class就可以了。
- src/components/Menu/_style.scss中为
.viking-submenu
添加:display: none;
并添加以下代码:
.viking-submenu.menu-opened {
display: block;
}
- 在subMenu.tsx中引入useState:
import React, {useContext, useState, FunctionComponentElement } from 'react'
,然后使用:const [menuOpen, setOpen] = useState(false)
,设置事件:
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
setOpen(!menuOpen);
}
为下拉菜单subMenu添加类名:
const subMenuClasses = className('viking-submenu', {
'menu-opened': menuOpen
})
之前下拉菜单栏的类名是写死了的,现在我们把renderChildren替换成我们上面的类名
return (
<ul className={subMenuClasses}>
{childrenComponent}
</ul>
)
src/components/Menu/subMenu.tsx中添加点击事件,点击后展示下拉菜单
return (
<li className={classes} key={index} onClick={handleClick}>
<div className='submenu-title'>
{title}
</div>
{renderChildren()}
</li>
)
src/components/Menu/subMenu.tsx中代码如下:
import React, {useContext, useState, FunctionComponentElement } from 'react'
import className from 'classname'
import {MenuContext } from './menu'
import {MenuItemProps} from './menuItem'
export interface SubMenuProps {
index?: string;
title: string;
className?: string;
}
const SubMenu: React.FC<SubMenuProps> = ({index, title, className, children}) => {
const context = useContext(MenuContext)
const [menuOpen, setOpen] = useState(false)
const classes = classNames('menu-item submenu-item', className, {
'is-active': context.index === index
})
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
setOpen(!menuOpen);
}
const renderChildren = () => {// 下拉菜单的内容
const childrenComponent = React.children.map(children, (child, i) => {
const childElement = child as FunctionComponentElement<MenuItemProps>
const subMenuClasses = className('viking-submenu', {
'menu-opened': menuOpen
})
if(childElement.type.displayName === 'MenuItem' || childElement.type.displayName === 'SubMenu'){
return childElement
}else{
console.error("Warning: SubMenu has a child which is not a MenuItem component")
}
})
return (
<ul className={subMenuClasses}>
{childrenComponent}
</ul>
)
}
return (
<li className={classes} key={index} onClick={handleClick}>
<div className='submenu-title'>
{title}
</div>
{renderChildren()}
</li>
)
}
SubMenu.displayName = 'SubMenu'
export default SubMenu
但是在横向菜单栏中,我们一般hover时就出现下拉菜单,鼠标离开以后就消失,纵向菜单栏中点击后出现下拉菜单。在subMenu.tsx中添加handleMouse,根据不同的模式传入不同的事件,
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} : {}
// 在横向菜单栏中,hover时出现下拉菜单,鼠标离开以后就消失
const hoverEvents = context.mode !== 'vertical' ? {
onMouseEnter: (e: React.MouseEvent) => {handleMouse(e, true)},
onMouseLeave: (e: React.MouseEvent) => {handleMouse(e, false)},
} : {}
为按钮添加hoverEvents事件或clickEvents事件
return (
<li className={classes} key={index} {...hoverEvents}>
<div className='submenu-title' {...clickEvents}>
{title}
</div>
{renderChildren()}
</li>
)
src/components/Menu/subMenu.tsx中代码如下:
import React, {useContext, useState, FunctionComponentElement } from 'react'
import className from 'classname'
import {MenuContext } from './menu'
import {MenuItemProps} from './menuItem'
export interface SubMenuProps {
index?: string;
title: string;
className?: string;
}
const SubMenu: React.FC<SubMenuProps> = ({index, title, className, children}) => {
const context = useContext(MenuContext)
const [menuOpen, setOpen] = useState(false)
const classes = classNames('menu-item submenu-item', className, {
'is-active': context.index === index
})
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} : {}
// 在横向菜单栏中,hover时出现下拉菜单,鼠标离开以后就消失
const hoverEvents = context.mode !== 'vertical' ? {
onMouseEnter: (e: React.MouseEvent) => {handleMouse(e, true)},
onMouseLeave: (e: React.MouseEvent) => {handleMouse(e, false)},
} : {}
const renderChildren = () => {// 下拉菜单的内容
const childrenComponent = React.children.map(children, (child, i) => {
const childElement = child as FunctionComponentElement<MenuItemProps>
const subMenuClasses = className('viking-submenu', {
'menu-opened': menuOpen
})
if(childElement.type.displayName === 'MenuItem' || childElement.type.displayName === 'SubMenu'){
return childElement
}else{
console.error("Warning: SubMenu has a child which is not a MenuItem component")
}
})
return (
<ul className={subMenuClasses}>
{childrenComponent}
</ul>
)
}
return (
<li className={classes} key={index} {...hoverEvents}>
<div className='submenu-title' {...clickEvents}>
{title}
</div>
{renderChildren()}
</li>
)
}
SubMenu.displayName = 'SubMenu'
export default SubMenu
下拉菜单SubMenu下的item也是MenuItem,但是我们点击MenuItem时,他的onSelect没有触发,因为在menuItem中,我们没有使用cloneElement把index传进去,index已经被第一级的MenuItem占用了,解决方法:把index换成字符串,SubMenu下的MenuItem使用x-x
这种形式。
在subMenu.tsx中使用cloneElement并为克隆的标签添加x-x
这种形式的index,代码如下:
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")
}
})
在纵向菜单栏中,子菜单SubMenu默认展开。menu.tsx中的defaultOpenSubMenus就是用于控制子菜单默认展开的变量,它会被Menu组件传给subMenu组件。在subMenu组件中使用defaultOpenSubMenus:
const openedSubMenus = context.defaultOpenSubMenus as Array<string>
const isOpend = (index && context.mode === 'vertical') ? openedSubMenus.includes(index) :false
const [menuOpen, setOpen] = useState(isOpend)
subMenu.tsx中代码如下:
import React, {useContext, useState, FunctionComponentElement } from 'react'
import className from 'classname'
import {MenuContext } from './menu'
import {MenuItemProps} from './menuItem'
export interface SubMenuProps {
index?: string;
title: string;
className?: string;
}
const SubMenu: React.FC<SubMenuProps> = ({index, title, className, children}) => {
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
})
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} : {}
// 在横向菜单栏中,hover时出现下拉菜单,鼠标离开以后就消失
const hoverEvents = context.mode !== 'vertical' ? {
onMouseEnter: (e: React.MouseEvent) => {handleMouse(e, true)},
onMouseLeave: (e: React.MouseEvent) => {handleMouse(e, false)},
} : {}
const renderChildren = () => {// 下拉菜单的内容
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 (
<ul className={subMenuClasses}>
{childrenComponent}
</ul>
)
}
return (
<li className={classes} key={index} {...hoverEvents}>
<div className='submenu-title' {...clickEvents}>
{title}
</div>
{renderChildren()}
</li>
)
}
SubMenu.displayName = 'SubMenu'
export default SubMenu
使用时,在app.tsx中写代码:
<Menu defaultIndex='0' onSelect={(index) => {alert(index)}} mode='vertical' defaultOpenSubMenus={['2']}>// defaultOpenSubMenus={['2']}默认第2项展开
<MenuItem>
cool lonk
</MenuItem>
<MenuItem disabled>
cool lonk 2
</MenuItem>
<SubMenu title='dropdown'>
<MenuItem>
dropdown 1
</MenuItem>
<MenuItem>
dropdown 2
</MenuItem>
</SubMenu>
<MenuItem>
cool lonk 3
</MenuItem>
</Menu>
测试
import React from 'react'
import { render, RenderResult, fireEvent, wait } from '@testing-library/react'
import Menu, {MenuProps} from './menu'
import MenuItem from './menuItem'
import SubMenu from './subMenu'
jest.mock('../Icon/icon', () => {
return () => {
return <i className="fa" />
}
})
jest.mock('react-transition-group', () => {
return {
CSSTransition: (props: any) => {
return props.children
}
}
})
const testProps: MenuProps = {
defaultIndex: '0',
onSelect: jest.fn(),
className: 'test'
}
const testVerProps: MenuProps = {
defaultIndex: '0',
mode: 'vertical',
defaultOpenSubMenus: ['4']
}
const generateMenu = (props: MenuProps) => {
return (
<Menu {...props}>
<MenuItem>
active
</MenuItem>
<MenuItem disabled>
disabled
</MenuItem>
<MenuItem>
xyz
</MenuItem>
<SubMenu title="dropdown">
<MenuItem>
drop1
</MenuItem>
</SubMenu>
<SubMenu title="opened">
<MenuItem>
opened1
</MenuItem>
</SubMenu>
</Menu>
)
}
const createStyleFile = () => {// 添加样式用于测试
const cssFile: string = `
.viking-submenu {
display: none;
}
.viking-submenu.menu-opened {
display:block;
}
`
const style = document.createElement('style')
style.type = 'text/css'
style.innerHTML = cssFile
return style
}
let wrapper: RenderResult, wrapper2: RenderResult, menuElement: HTMLElement, activeElement: HTMLElement, disabledElement: HTMLElement
describe('test Menu and MenuItem component in default(horizontal) mode', () => {
beforeEach(() => {// 在每个case开始前都会运行
wrapper = render(generateMenu(testProps))
wrapper.container.append(createStyleFile())// 把我们写的css样式文件插入到运行的case中
menuElement= wrapper.getByTestId('test-menu')
activeElement = wrapper.getByText('active')
disabledElement = wrapper.getByText('disabled')
})
it('should render correct Menu and MenuItem based on default props', () => {
expect(menuElement).toBeInTheDocument()
expect(menuElement).toHaveClass('viking-menu test')
//:scope属于css伪类,他将作为选择符匹配元素的参考点,在下面这行代码中,:scope变成了menuElement本身
//:scope > li就是选择menuElement下面的第一级的li
expect(menuElement.querySelectorAll(':scope > li').length).toEqual(5)
expect(activeElement).toHaveClass('menu-item is-active')
expect(disabledElement).toHaveClass('menu-item is-disabled')
})
//在每个case结束,会自动调用clearup()
it('click items should change active and call the right callback', () => {
const thirdItem = wrapper.getByText('xyz')
fireEvent.click(thirdItem)
expect(thirdItem).toHaveClass('is-active')
expect(activeElement).not.toHaveClass('is-active')
expect(testProps.onSelect).toHaveBeenCalledWith('2')
fireEvent.click(disabledElement)
expect(disabledElement).not.toHaveClass('is-active')
expect(testProps.onSelect).not.toHaveBeenCalledWith('1')
})
it('should show dropdown items when hover on subMenu', async () => {
//queryByText:会返回null或htmlElement,因为drop1可能不存在,所以使用这个方法
expect(wrapper.queryByText('drop1')).not.toBeVisible()// drop1不出现
const dropdownElement = wrapper.getByText('dropdown')
fireEvent.mouseEnter(dropdownElement)
// 我们在subMenu中的handleMouse中写了个定时器,我们测试时,测试的代码需要等定时器执行完毕再判断结果,
// 所以我们使用async await配合wait,wait()接收一个函数作为参数,这个函数中的断言会一直被执行,直到通过或timeout报错
await wait(() => {
expect(wrapper.queryByText('drop1')).toBeVisible()
})
fireEvent.click(wrapper.getByText('drop1'))
expect(testProps.onSelect).toHaveBeenCalledWith('3-0')
fireEvent.mouseLeave(dropdownElement)
await wait(() => {
expect(wrapper.queryByText('drop1')).not.toBeVisible()
})
})
})
describe('test Menu and MenuItem component in vertical mode', () => {
beforeEach(() => {
wrapper2 = render(generateMenu(testVerProps))
wrapper2.container.append(createStyleFile())
})
it('should render vertical mode when mode is set to vertical', () => {
const menuElement = wrapper2.getByTestId('test-menu')
expect(menuElement).toHaveClass('menu-vertical')
})
it('should show dropdown items when click on subMenu for vertical mode', () => {
const dropDownItem = wrapper2.queryByText('drop1')
expect(dropDownItem).not.toBeVisible()
fireEvent.click(wrapper2.getByText('dropdown'))
expect(dropDownItem).toBeVisible()
})
it('should show subMenu dropdown when defaultOpenSubMenus contains SubMenu index', () => {
expect(wrapper2.queryByText('opened1')).toBeVisible()
})
})