W-Design组件库:Menu 组件

155 阅读6分钟

组件效果

image.png

  1. 支持横向、纵向菜单类型,禁用配置
  2. 横向houver出下拉框,竖向点击出下拉框

组件结构

menu

src/components/Menu/menu.tsx

  1. w-design-menu、外部传入的className、根据mode属性生成对应的样式
  2. props.children不支持map方法,所以用React.Children
  3. 为了知道当前点击时那个选项以及选中时的样式、以及回调函数回传,需要在遍历React.Children时保留原有的自身属性再加上索引,所以要用cloneElement,并且判断子节点的displayName需要是MenuItem、SubMenu,否则就报错
  4. 由于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

  1. 根据传入的参数classNamecontext.index、menuOpen生成一个类名数组classes,context.indexmenu组件遍历赋值的index比较是否相同动态生成is-activeis-opened控制打开下拉时箭头方向,is-vertical是控制点击下拉菜单在垂直时才触发
  2. mode为垂直并且openedSubMenus数组有包含当前subMenu组件,menuOpen初始值为true,否则为false
  3. 当横向时添加onMouseEnter、onMouseLeave事件控制显示、隐藏下拉菜单,当垂直时添加onClick事件控制显示、隐藏下拉菜单
  4. subMenu组件包裹menuItem组件时也需要index,所以使用Children.map+cloneElement,下拉菜单用封装的Transition组件包裹使其有过渡效果
  5. 设置SubMenu组件displayNameSubMenu,使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

  1. 根据传入的参数classNamecontext.index、disabled生成一个类名数组classes
  2. 当上下文的onSelect有传、非禁用、index存在时,将index回传给menu组件
  3. 设置MenuItem组件displayNameMenuItem,使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

  1. 最外层元素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类设置下拉菜单基本样式
  2. menu-horizontal类设置水平时,下拉框设置为absolute
  3. menu-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

  1. beforeEach函数会在每个测试案例之前执行一次,创建菜单、激活、禁用元素
  2. 因为要通过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')
  })
})