【UI组件库】Menu组件

需求分析

在这里插入图片描述

  1. Menu组件分为横向、纵向,横向/纵向分为有下拉菜单栏的、没有下拉菜单栏的
  2. 属性为active的菜单栏是高亮的,其余不高亮
  3. 菜单项是可以被disabled,disabled会展示特殊样式,点击不会响应
  4. 点击某一菜单项,可能会弹出下拉菜单栏

基本样式

思路:外面用一个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.mapReact.Children.forEach,如果在props.children中遇到一些不合规则的类型,React.Children.mapReact.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就可以了。

  1. src/components/Menu/_style.scss中为.viking-submenu添加:display: none;
    并添加以下代码:
.viking-submenu.menu-opened {
	display: block;
}
  1. 在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()
  })
})
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值