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

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

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

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

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

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

image.png

image.png

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

Menu

5.1 需求分析

  1. 水平菜单

image.png

  1. 垂直菜单

image.png

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 第几项处于选中状态 number 0,第一个
mode 水平还是垂直 'horizontal' 'vertical' horizontal
onSelect 选中事件 (selectedIndex: number) => void; -
defaultOpenSubMenus 设置子菜单的默认打开 只在纵向模式下生效
  1. MenuItem
参数 说明 类型 默认值
index 索引 number
disabled 是否是disabled状态 boolean FALSE
  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;
    }
  }
}

效果如下:

image.png

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')
            }
        })
    }

目前效果如下:

水平方向:

image.png

垂直方向:

image.png

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.默认是隐藏的

image.png

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

image.png

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

image.png

垂直菜单:

1.默认菜单是隐藏的

image.png

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

image.png

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

image.png

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>
        )
    }

效果如下:

image.png

image.png

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>

image.png

系列篇

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

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

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

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

持续更新

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

相关文章
|
1月前
|
前端开发
前端通过input标签封装Upload组件实现文件上传
前端通过input标签封装Upload组件实现文件上传
58 0
|
2月前
|
前端开发 数据可视化 JavaScript
前端图形学实战: 从零开发一款轻量级滑动验证码组件(vue3 + vite版)
前端图形学实战: 从零开发一款轻量级滑动验证码组件(vue3 + vite版)
90 0
|
1月前
|
前端开发 JavaScript API
前端框架与库
前端框架与库
41 2
|
3月前
|
存储 前端开发 JavaScript
前端代码托管:存储库管理综合指南
前端代码托管:存储库管理综合指南
64 0
|
3月前
|
前端开发
elementui-upload组件自定义样式上传(upload中常用的属性,但是网络上却找不到教程)(解决bug删除之后再次上传会上传删除的图片)专注后端工程师的前端速成
elementui-upload组件自定义样式上传(upload中常用的属性,但是网络上却找不到教程)(解决bug删除之后再次上传会上传删除的图片)专注后端工程师的前端速成
68 0
|
3月前
|
前端开发 JavaScript 测试技术
掌握函数式组件:迈向现代化前端开发的关键步骤(下)
掌握函数式组件:迈向现代化前端开发的关键步骤(下)
掌握函数式组件:迈向现代化前端开发的关键步骤(下)
|
3月前
|
缓存 前端开发 JavaScript
掌握函数式组件:迈向现代化前端开发的关键步骤(上)
掌握函数式组件:迈向现代化前端开发的关键步骤(上)
掌握函数式组件:迈向现代化前端开发的关键步骤(上)
|
3月前
|
负载均衡 前端开发 Java
字节后端面试题(前端发送请求到后端的过程(MVC),网关gateway作用,怎么解决跨域,各微服务组件作用)
字节后端面试题(前端发送请求到后端的过程(MVC),网关gateway作用,怎么解决跨域,各微服务组件作用)
130 0
|
8月前
|
Web App开发 前端开发 JavaScript
前端学习笔记202307学习笔记第五十七天-模拟面试笔记react-fiber解决了什么问题
前端学习笔记202307学习笔记第五十七天-模拟面试笔记react-fiber解决了什么问题
95 0
|
8月前
|
前端开发 定位技术
前端学习笔记202305学习笔记第二十三天-地图单线程配置
前端学习笔记202305学习笔记第二十三天-地图单线程配置
65 0
前端学习笔记202305学习笔记第二十三天-地图单线程配置