前端反卷计划-组件库-05-Menu组件开发

90 阅读6分钟

Hi, 大家好!我是程序员库里。

今天开始分享如何从0搭建UI组件库。这也是前端反卷计划中的一项。

在接下来的日子,我会持续分享前端反卷计划中的每个知识点。

以下是前端反卷计划的内容:

image.png

image.png

目前这些内容持续更新到了我的 学习文档 中。感兴趣的欢迎一起学习!

Menu

5.1 需求分析

  1. 水平菜单

  1. 垂直菜单

5.2 Demo

<Menu defaultIndex='0' onSelect={(index) => {action(`clicked ${index} item`)}} >
    <MenuItem>
      cool link
    </MenuItem>
    <MenuItem disabled>
      disabled
    </MenuItem> 
    <MenuItem>
      cool link 2
    </MenuItem> 
  </Menu>
  
  
  <Menu defaultIndex='0' defaultOpenSubMenus={["2"]} onSelect={e => alert(e)}>
      <MenuItem>aaa</MenuItem>
      <MenuItem>bbb</MenuItem>
      <SubMenu title='aaa'>
        <MenuItem>ccc</MenuItem>
        <MenuItem>ddd</MenuItem>
      </SubMenu>
</Menu>

5.3 API

  1. Menu
参数说明类型默认值
defaultIndex第几项处于选中状态number0,第一个
mode水平还是垂直'horizontal''vertical'horizontal
onSelect选中事件(selectedIndex: number) => void;-
defaultOpenSubMenus设置子菜单的默认打开 只在纵向模式下生效
  1. MenuItem
参数说明类型默认值
index索引number
disabled是否是disabled状态booleanFALSE
  1. SubMenu
参数说明类型默认值
title名称string

5.4 开发

5.4.1 定义Menu Props

type MenuMode = 'horizontal' | 'vertical'
export interface MenuProps {
  /**默认 active 的菜单项的索引值 */
  defaultIndex?: string;
  className?: string;
  /**菜单类型 横向或者纵向 */
  mode?: MenuMode;
  style?: CSSProperties;
  /**点击菜单项触发的回掉函数 */
  onSelect?: (selectedIndex: string) => void;
  /**设置子菜单的默认打开 只在纵向模式下生效 */
  defaultOpenSubMenus?: string[];
  children?: React.ReactNode;
}

5.4.2 自定义style和水平、垂直菜单

const classes = classNames('curry-menu', className, {
    'menu-vertical': mode === 'vertical',
    'menu-horizontal': mode !== 'vertical',
  })
  
 return (
        <ul className={classes} style={style}>
            {children}
        </ul>
    )

5.4.3 定义MenuItem Props

export interface MenuItemProps {
  index?: string;
  disabled?: boolean;
  className?: string;
  style?: React.CSSProperties;
  children: React.ReactNode
}

const { index, disabled, className, style, children } = props
const classes = classNames('menu-item', className, {
    'is-disabled': disabled,  // 是否可点击
    'is-active': context.index === index // 是否选中
  })
  
return (
    <li className={classes} style={style} onClick={handleClick}>
      {children}
    </li>
  )

5.4.4 定义context

因为Menu组件的一些属性,需要在MenuItem组件中使用,所以这里使用context来传递props

interface IMenuContext {
  index: string;
  onSelect?: (selectedIndex: string) => void;
  mode?: MenuMode;
  defaultOpenSubMenus?: string[];
}

export const MenuContext = createContext<IMenuContext>({ index: '0' })

5.4.5 高亮逻辑

点击哪个item,哪个就高亮

// menu.tsx
export const MenuContext = createContext<IMenuContext>({ index: '0' })

const [currentActive, setActive] = useState(defaultIndex)


// 当点击某一项的时候,将当前的index和点击事件传到MenuItem中,这里同样使用context
const handleClick = (index: string) => {
    setActive(index)
    if (onSelect) {
      onSelect(index)
    }
  }
  // 传递给 menu item
  const passedContext: IMenuContext = {
    index: currentActive ? currentActive : '0',
    onSelect: handleClick,
    mode,
    defaultOpenSubMenus,
  }
  
  return (
    <ul className={classes} style={style} data-testid="test-menu">
      <MenuContext.Provider value={passedContext}>
        {chilren}
      </MenuContext.Provider>
    </ul>
  )

MenuItem

import React, { useContext } from "react";


import { MenuContext } from './menu'
const { index, disabled, className, style, children } = props
const context = useContext(MenuContext)

const classes = classnames(className, 'menu-item', {
        'is-disabled': disabled,
        'is-active': context.index === 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>
  )

经过上面代码,我们可以使用这样来编写。这里需要给menu item添加index

  <Menu defaultIndex={0}>
        <MenuItem index={0}>aaa</MenuItem>
        <MenuItem index={1}>bbb</MenuItem>
      </Menu>

5.5.5 添加样式

  1. 在src/styles/_variables.scss添加样式变量
// menu
$menu-border-width:            $border-width !default;
$menu-border-color:            $border-color !default;
$menu-box-shadow:              inset 0 1px 0 rgba($white, .15), 0 1px 1px rgba($black, .075) !default;
$menu-transition:              color .15s ease-in-out, border-color .15s ease-in-out !default;

// menu-item
$menu-item-padding-y:          .5rem !default;
$menu-item-padding-x:          1rem !default;
$menu-item-active-color:       $primary !default;
$menu-item-active-border-width: 2px !default;
$menu-item-disabled-color:     $gray-600 !default;
  1. 导入到样式入口文件
// menu
@import "../components/Menu/style";
  1. 编写menu、menu item样式
.curry-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;
    }
  }
  .curry-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;
      }
    }
  }
  .curry-submenu.menu-opened {
    //display: block;
  }
}
.menu-horizontal {
  >.menu-item {
    border-bottom: $menu-item-active-border-width solid transparent;
  }
  .curry-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;
    }
  }
}

效果如下:

5.5.6 改造children

children目前只能是MenuItem,如果是其他的,就报错

  1. 在MenuItem上加上displayName
MenuItem.displayName = 'MenuItem'
  1. 写一个renderChildren方法,使用React.Children来遍历传进来的children,根据displayName是否是 MenuItem来判断,如果是则渲染children,否则报错
import { MenuItemProps } from './menuItem'

const renderChildren = () => {
        return React.Children.map(children, (child, index) => {
            const childElement = child as React.FunctionComponentElement<MenuItemProps>;
            const { displayName } = childElement.type;
            if (displayName === 'MenuItem') {
                return child;
            } 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>
    )

5.5.7 改造index

上面需要给每个menu item传入index,这里改成不需要传index

在渲染childrend的时候,使用React.cloneElement将index克隆到child上

const renderChildren = () => {
        return React.Children.map(children, (child, index) => {
            const childElement = child as React.FunctionComponentElement<MenuItemProps>;
            const { displayName } = childElement.type;
            if (displayName === 'MenuItem') {
               // 这里使用React.cloneElement
                return React.cloneElement(childElement, {
                    index
                })
            } else {
                console.error('Warning: Menu has a child which is not a MenuItem component')
            }
        })
    }

这样就不用在menu item上传index了

<Menu>
  <MenuItem>1</MenuItem>
  <MenuItem>12</MenuItem>
</Menu>

5.5.8 SubMenu基础开发

原理和MenuItem一样,不再赘述

import React, { useContext, FunctionComponentElement } from "react";
import classnames from 'classnames';
import { MenuItemProps } from './menuItem'
import { MenuContext } from './menu'

export interface SubMenuProps {
    index?: number;
    title: string;
    className?: string;
    children: React.ReactNode;
    style?: React.CSSProperties;
}

const SubMenu: React.FC<SubMenuProps> = ({ index, style, 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, index) => {
            const childElement = child as FunctionComponentElement<MenuItemProps>
            if (childElement.type.displayName === 'MenuItem') {
                return childElement
            } else {
                console.error('Warning: SubMenu has a child which is not a MenuItem component')
            }
        })
        return (
            <ul className="curry-submenu">
                {childrenComponent}
            </ul>
        )
    }
    return (
        <li key={index} className={classes} style={style}>
            <div className="submenu-title">
                {title}
            </div>
            {renderChildren()}
        </li>
    )
}

SubMenu.displayName = 'SubMenu'

export default SubMenu;

5.5.9 添加SubMenu样式

.curry-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;
    }
  }
  .curry-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;
      }
    }
  }
  .curry-submenu.menu-opened {
    //display: block;
  }
}
.menu-horizontal {
  >.menu-item {
    border-bottom: $menu-item-active-border-width solid transparent;
  }
  .curry-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;
    }
  }
}

5.5.10 在Menu里添加SubMenu

const renderChildren = () => {
        return React.Children.map(children, (child, index) => {
            const childElement = child as React.FunctionComponentElement<MenuItemProps>;
            const { displayName } = childElement.type;
            // 增加这个逻辑:displayName === 'SubMenu' 
            if (displayName === 'MenuItem' || displayName === 'SubMenu') {
                return React.cloneElement(childElement, {
                    index
                })
            } else {
                console.error('Warning: Menu has a child which is not a MenuItem component')
            }
        })
    }

目前效果如下:

水平方向:

垂直方向:

5.5.11 SubMenu显示隐藏

  1. css方面通过display控制显示隐藏
// 通过display控制显示隐藏
  .curry-submenu {
    display: none; // 开始隐藏
  }
 .curry-submenu.menu-opened {
    display: block; // 显示
  }
  1. 逻辑方面,通过state控制
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",
});
  1. 判断mode的值,当是水平菜单的时候,改成当鼠标移入移出来控制显示隐藏。当是垂直菜单的时候,通过点击来控制
const handleClick = (e: React.MouseEvent) => {
    e.preventDefault();
    setOpen(!menuOpen);
  };
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  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);
          },
        }
      : {};

目前效果:

水平菜单:

1.默认是隐藏的

2.当鼠标移动上去后,显示菜单

3.当鼠标移出后,隐藏菜单

垂直菜单:

1.默认菜单是隐藏的

2.当点击的时候,显示出来

3.当再次点击的时候,隐藏菜单

5.5.12 将index改造成树形结构

submenu和menuitem目前都是通过index来索引的,所以submenu的点击没有效果。解决方案是:去掉index,改成类似:1-1,1-2这种方案。

  1. 修改menu组件的index的类型
// 首先修改menu组件的defaultIndex的类型,由数字改成字符串
export interface MenuProps {
    defaultIndex?: string; // 由number 改成string
    className?: string;
    mode?: MenuMode;
    style?: React.CSSProperties;
    onSelect?: SelectCallback;
    children?: React.ReactNode
}

// 修改IMenuContext下的index类型
interface IMenuContext {
    index: string; // number 改成string
    onSelect?: SelectCallback;
    mode?: MenuMode;
}

// 修改默认值,
export const MenuContext = createContext<IMenuContext>({
    index: '0', // 0 改成 '0'
})

// index从number改成string
const handleClick = (index: string) => {
        setActive(index)
        onSelect && onSelect(index)
    }
// selectedIndex从number改成string
type SelectCallback = (selectedIndex: string) => void;

// 0 改成 '0'
   const passedContext: IMenuContext = {
        index: currentActive || '0',
        onSelect: handleClick,
        mode
    }
// 0 改成 '0'
Menu.defaultProps = {
    defaultIndex: '0',
    mode: 'horizontal'
}


//   index: index.toString()
    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')
            }
        })
    }
  1. 修改MenuItem的index类型

export interface MenuItemProps {
    index?: string; // number 改成  string
    disabled?: boolean;
    className?: string;
    style?: React.CSSProperties;
    children?: React.ReactNode;
}

// number 改成 string
 const handleClick = () => {
        if (context.onSelect && !disabled && (typeof index === 'string')) {
            context.onSelect(index)
        }
    }
  1. 修改submenu的index类型
export interface SubMenuProps {
    index?: string; // number to  string
    title: string;
    className?: string;
    children: React.ReactNode;
    style?: React.CSSProperties;
}
  1. 以上类型就修完了。然后根据上面的改成1-1这种形式
  const renderChildren = () => {
        const subMenuClasses = classnames('curry-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 (
            <ul className={subMenuClasses}>
                {childrenComponent}
            </ul>
        )
    }

效果如下:

5.5.13 垂直菜单默认展开

  1. 增加defaultOpenSubMenus属性表示哪些是默认展开
export interface MenuProps {
    defaultIndex?: string;
    className?: string;
    mode?: MenuMode;
    style?: React.CSSProperties;
    onSelect?: SelectCallback;
    children?: React.ReactNode;
    defaultOpenSubMenus?: string[]; // 新增,控制菜单默认展开
}

Menu.defaultProps = {
    defaultIndex: '0',
    mode: 'horizontal',
    defaultOpenSubMenus: [] // 默认值
}
  1. 通过context来将defaultOpenSubMenus传到submenu组件
interface IMenuContext {
    index: string;
    onSelect?: SelectCallback;
    mode?: MenuMode;
    defaultOpenSubMenus?: string[]; // 新增
}

 const passedContext: IMenuContext = {
        index: currentActive || '0',
        onSelect: handleClick,
        mode,
        defaultOpenSubMenus // 新增
    }
  1. 在submenu组件中通过context获取defaultOpenSubMenus。定义一个isOpened变量,来控制是否默认展开,这个逻辑是:当index存在并且是垂直菜单的时候,看defaultOpenSubMenus是否包含index,是的话返回true,否则false。然后将isOpened当成默认值传给menuOpen的初始值。
 const openedSubMenus = context.defaultOpenSubMenus as Array<string>;
 const isOpened = (index && context.mode === 'vertical') ? openedSubMenus.includes(index) : false;

 const [menuOpen, setOpen] = useState(isOpened)
  1. 看下效果
  <Menu defaultIndex='0' mode="vertical" defaultOpenSubMenus={["2"]} onSelect={e => alert(e)}>
        <MenuItem>aaa</MenuItem>
        <MenuItem>bbb</MenuItem>
        <SubMenu title='aaa'>
          <MenuItem>ccc</MenuItem>
          <MenuItem>ddd</MenuItem>
        </SubMenu>
      </Menu>

系列篇

前端反卷计划-组件库-01-环境搭建

前端反卷计划-组件库-02-storybook

前端反卷计划-组件库-03-组件样式

前端反卷计划-组件库-04-Button组件开发

持续更新

目前这些内容持续更新到了我的 学习文档 中。感兴趣的欢迎一起学习!