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

2023-11-27 09:53:52 浏览数 (1)

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

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

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

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

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

Menu

5.1 需求分析

  1. 水平菜单
  1. 垂直菜单

5.2 Demo

代码语言:txt复制
<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

代码语言:txt复制
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和水平、垂直菜单

代码语言:txt复制
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

代码语言:txt复制
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

代码语言:txt复制
interface IMenuContext {
  index: string;
  onSelect?: (selectedIndex: string) => void;
  mode?: MenuMode;
  defaultOpenSubMenus?: string[];
}

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

5.4.5 高亮逻辑

点击哪个item,哪个就高亮

代码语言:txt复制
// 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

代码语言:txt复制
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

代码语言:txt复制
  <Menu defaultIndex={0}>
        <MenuItem index={0}>aaa</MenuItem>
        <MenuItem index={1}>bbb</MenuItem>
      </Menu>

5.5.5 添加样式

  1. 在src/styles/_variables.scss添加样式变量
代码语言:txt复制
// 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. 导入到样式入口文件
代码语言:txt复制
// menu
@import "../components/Menu/style";
  1. 编写menu、menu item样式
代码语言:txt复制
.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
代码语言:txt复制
MenuItem.displayName = 'MenuItem'
  1. 写一个renderChildren方法,使用React.Children来遍历传进来的children,根据displayName是否是 MenuItem来判断,如果是则渲染children,否则报错
代码语言:txt复制
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上

代码语言:txt复制
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了

代码语言:txt复制
<Menu>
  <MenuItem>1</MenuItem>
  <MenuItem>12</MenuItem>
</Menu>

5.5.8 SubMenu基础开发

原理和MenuItem一样,不再赘述

代码语言:txt复制
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样式

代码语言:txt复制
.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

代码语言:txt复制
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控制显示隐藏
代码语言:txt复制
// 通过display控制显示隐藏
  .curry-submenu {
    display: none; // 开始隐藏
  }
 .curry-submenu.menu-opened {
    display: block; // 显示
  }
  1. 逻辑方面,通过state控制
代码语言:txt复制
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的值,当是水平菜单的时候,改成当鼠标移入移出来控制显示隐藏。当是垂直菜单的时候,通过点击来控制
代码语言:txt复制
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的类型
代码语言:txt复制
// 首先修改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类型
代码语言:txt复制
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类型
代码语言:txt复制
export interface SubMenuProps {
    index?: string; // number to  string
    title: string;
    className?: string;
    children: React.ReactNode;
    style?: React.CSSProperties;
}
  1. 以上类型就修完了。然后根据上面的改成1-1这种形式
代码语言:txt复制
  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属性表示哪些是默认展开
代码语言:txt复制
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组件
代码语言:txt复制
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的初始值。
代码语言:txt复制
 const openedSubMenus = context.defaultOpenSubMenus as Array<string>;
 const isOpened = (index && context.mode === 'vertical') ? openedSubMenus.includes(index) : false;

 const [menuOpen, setOpen] = useState(isOpened)
  1. 看下效果
代码语言:txt复制
  <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组件开发

持续更新

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

0 人点赞