组件效果
- 支持横向、纵向菜单类型,禁用配置
- 横向houver出下拉框,竖向点击出下拉框
组件结构
menu
src/components/Menu/menu.tsx
- w-design-menu、外部传入的className、根据mode属性生成对应的样式
props.children不支持map方法,所以用React.Children- 为了知道当前点击时那个选项以及选中时的样式、以及回调函数回传,需要在遍历
React.Children时保留原有的自身属性再加上索引,所以要用cloneElement,并且判断子节点的displayName需要是MenuItem、SubMenu,否则就报错 - 由于
menu组件包裹subMenu组件,subMenu组件包裹menuItem组件,方便爷孙组件传递数据,所以使用useContext
import classNames from 'classnames'
import { IMenuContext, MenuItemProps, MenuProps } from './types'
import {
Children,
FC,
FunctionComponentElement,
cloneElement,
createContext,
useState,
} from 'react'
export const MenuContext = createContext<IMenuContext>({ index: '0' })
const Menu: FC<MenuProps> = (props) => {
const {
className,
mode,
style,
children,
defaultIndex,
onSelect,
defaultOpenSubMenus,
} = props
const classes = classNames('w-design-menu', className, {
'menu-vertical': mode === 'vertical',
'menu-horizontal': mode !== 'vertical',
})
const handleClick = (index: string) => {
setCurrentActive(index)
onSelect && onSelect(index)
}
const [currentActive, setCurrentActive] = useState(defaultIndex)
const passedContext: IMenuContext = {
index: currentActive ? currentActive : '0',
onSelect: handleClick,
mode,
defaultOpenSubMenus,
}
const renderChildren = () => {
return Children.map(children, (child, index) => {
const childElement = child as FunctionComponentElement<MenuItemProps>
const { displayName } = childElement.type
if (displayName === 'MenuItem' || displayName === 'SubMenu') {
return 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
subMenu
src/components/Menu/subMenu.tsx
- 根据传入的参数
className和context.index、menuOpen生成一个类名数组classes,context.index跟menu组件遍历赋值的index比较是否相同动态生成is-active,is-opened控制打开下拉时箭头方向,is-vertical是控制点击下拉菜单在垂直时才触发 - 当
mode为垂直并且openedSubMenus数组有包含当前subMenu组件,menuOpen初始值为true,否则为false - 当横向时添加
onMouseEnter、onMouseLeave事件控制显示、隐藏下拉菜单,当垂直时添加onClick事件控制显示、隐藏下拉菜单 subMenu组件包裹menuItem组件时也需要index,所以使用Children.map+cloneElement,下拉菜单用封装的Transition组件包裹使其有过渡效果- 设置
SubMenu组件的displayName为SubMenu,使menu组件可以判断
import React, {
Children,
FC,
FunctionComponentElement,
useContext,
useState,
} from 'react'
import { MenuItemProps, SubMenuProps } from './types'
import classNames from 'classnames'
import { MenuContext } from './menu'
import { Icon } from '../Icon'
import { Transition } from '../Transition'
const SubMenu: FC<SubMenuProps> = (props) => {
const { index, title, className, children } = props
const context = useContext(MenuContext)
const openedSubMenus = context.defaultOpenSubMenus as string[]
const isOpened =
index && context.mode === 'vertical'
? openedSubMenus.includes(index)
: false
const [menuOpen, setMenuOpen] = useState(isOpened)
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()
setMenuOpen(!menuOpen)
}
let timer: any
const handleMouse = (e: React.MouseEvent, toggle: boolean) => {
clearTimeout(timer)
e.preventDefault()
timer = setTimeout(() => {
setMenuOpen(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 subMenuElement = classNames('w-design-submenu', {
'menu-opened': menuOpen,
})
const childrenComponent = 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: Menu has a child which is not a MenuItem component'
)
}
})
return (
<Transition in={menuOpen} animation="zoom-in-top" timeout={300}>
<ul className={subMenuElement}>{childrenComponent}</ul>
</Transition>
)
}
return (
<li 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
menuItem
src/components/Menu/menuItem.tsx
- 根据传入的参数
className和context.index、disabled生成一个类名数组classes - 当上下文的
onSelect有传、非禁用、index存在时,将index回传给menu组件 - 设置
MenuItem组件的displayName为MenuItem,使menu、subMenu组件可以判断
import { FC, useContext } from 'react'
import { MenuItemProps } from './types'
import classNames from 'classnames'
import { MenuContext } from './menu'
const MenuItem: 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 && index) {
context.onSelect(index)
}
}
return (
<li className={classes} style={style} onClick={handleClick}>
{children}
</li>
)
}
MenuItem.displayName = 'MenuItem'
export default MenuItem
组件类型
export type MenuMode = 'horizontal' | 'vertical'
export type SelectCallback = (selectedIndex: string) => void
export interface MenuProps {
defaultIndex?: string
className?: string
mode?: MenuMode
style?: React.CSSProperties
onSelect?: SelectCallback
children?: React.ReactNode
defaultOpenSubMenus?: string[]
}
export interface MenuItemProps {
index?: string
disabled?: boolean
className?: string
style?: React.CSSProperties
children?: React.ReactNode
}
export interface IMenuContext {
index: string
onSelect?: SelectCallback
mode?: MenuMode
defaultOpenSubMenus?: string[]
}
export interface SubMenuProps {
index?: string
title: string
className?: string
children?: React.ReactNode
}
组件样式
src/components/Menu/_style.scss
- 最外层元素
w-design-menu类设置成flex布局
1.1.menu-item类设置下一级menuItem组件的基础、伪类样式
1.2.submenu-item类设置subMenu组件的标题、下拉箭头样式
1.3.is-vertical类设置垂直时点击下拉框角度,!important用来覆盖hover时的样式
1.4.w-design-submenu类设置下拉菜单基本样式 menu-horizontal类设置水平时,下拉框设置为absolutemenu-vertical类设置垂直方向
.w-design-menu {
// 设置flex布局,实现元素在一行中排列
display: flex;
// 设置flex容器换行
flex-wrap: wrap;
// 设置元素左边距为0
padding-left: 0;
// 设置底部边距为30px
margin-bottom: 30px;
// 设置列表项无样式
list-style: none;
// 设置底部边框宽度为$menu-border-width,边框颜色为$menu-border-color
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 0.25s ease-in-out;
margin-left: 3px;
}
// 鼠标悬停
&:hover {
// 箭头图标旋转180度
.arrow-icon {
transform: rotate(180deg);
}
}
}
.is-vertical {
.arrow-icon {
// 旋转0度
transform: rotate(0deg) !important;
}
}
.is-vertical.is-opened {
.arrow-icon {
// 旋转180度
transform: rotate(180deg) !important;
}
}
.w-design-submenu {
// 无列表项标记
list-style: none;
// 左外边距设置为0
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;
}
// 设置子菜单的样式
.w-design-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的方向为column
flex-direction: column;
// 设置边框的底部边框为0px
border-bottom: 0px;
// 设置外边距
margin: 10px 20px;
// 设置右边框
border-right: $menu-border-width solid $menu-border-color;
// 设置左边框
> .menu-item {
// 设置左边框的左边框为0px
border-left: $menu-item-active-border-width solid transparent;
// 设置左边框的右边框为0px
&.is-active,
&:hover {
// 设置边框的底部边框为0px
border-bottom: 0px;
// 设置左边框的右边框为$menu-item-active-border-width solid $menu-item-active-color
border-left: $menu-item-active-border-width solid $menu-item-active-color;
}
}
}
组件测试
src/components/Menu/menu.test.tsx
beforeEach函数会在每个测试案例之前执行一次,创建菜单、激活、禁用元素- 因为要通过
getByTestId('test-menu')获取到元素,但是在beforeEach函数中已经获取过会报错,cleanup函数可以清理测试环境
/* eslint-disable testing-library/no-render-in-setup */
/* eslint-disable testing-library/render-result-naming-convention */
/* eslint-disable testing-library/prefer-screen-queries */
import {
RenderResult,
cleanup,
fireEvent,
render,
} from '@testing-library/react'
import Menu from './menu'
import MenuItem from './menuItem'
import { MenuProps } from './types'
const testProps: MenuProps = {
defaultIndex: '0',
onSelect: jest.fn(),
className: 'test',
}
const testVerProps: MenuProps = {
defaultIndex: '0',
mode: 'vertical',
}
const generateMenu = (props: MenuProps) => {
return (
<Menu {...props}>
<MenuItem>active</MenuItem>
<MenuItem disabled>disabled</MenuItem>
<MenuItem>xyz</MenuItem>
</Menu>
)
}
let wrapper: RenderResult,
menuElement: HTMLElement,
activeElement: HTMLElement,
disabledElement: HTMLElement
describe('test Menu and MenuItem component in default(horizontal) mode', () => {
beforeEach(() => {
// 渲染一个菜单,传入测试属性
wrapper = render(generateMenu(testProps))
// 获取菜单元素
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()
// 检查菜单元素是否有test类
expect(menuElement).toHaveClass('w-design-menu test')
// 检查菜单元素下是否有3个li元素
// eslint-disable-next-line testing-library/no-node-access
expect(menuElement.getElementsByTagName('li').length).toEqual(3)
// 检查激活元素是否有is-active类
expect(activeElement).toHaveClass('menu-item is-active')
// 检查禁用元素是否有is-disabled类
expect(disabledElement).toHaveClass('menu-item is-disabled')
})
it('click items should change active and call the right callback', () => {
// 获取第三个元素
const thirdItem = wrapper.getByText('xyz')
// 触发第三个元素的点击事件
fireEvent.click(thirdItem)
// 预期第三个元素有is-active类
expect(thirdItem).toHaveClass('is-active')
// 预期activeElement没有is-active类
expect(activeElement).not.toHaveClass('is-active')
// 预期testProps.onSelect被调用,参数为2
expect(testProps.onSelect).toHaveBeenCalledWith('2')
// 触发disabledElement的点击事件
fireEvent.click(disabledElement)
// 预期disabledElement没有is-active类
expect(disabledElement).not.toHaveClass('is-active')
// 预期testProps.onSelect没有被调用,参数为1
expect(testProps.onSelect).not.toHaveBeenCalledWith('1')
})
it('should render vertical mode when mode is set to vertical', () => {
// 清理函数
cleanup()
// 渲染测试菜单
const wrapper = render(generateMenu(testVerProps))
// 获取测试菜单元素
const menuElement = wrapper.getByTestId('test-menu')
// 预期菜单元素具有menu-vertical类
expect(menuElement).toHaveClass('menu-vertical')
})
})