Rgulu-ui 构建自己的react ui库之Menu组件

326 阅读3分钟

最近在学习张老师和方方的轮子课程,本系列文章是对react造轮子组件的思路的梳理

menu组件的样式

菜单的基本结构如下

  • 支持点击hover高亮
  • 支持disabled
  • 支持下拉菜单
  • 支持横向和纵向两种模式
  • 横向的下拉菜单是hover之后弹出
  • 纵向的下拉菜单是click之后弹出或者设置属性进行自动展开

Menu组件的使用方式

Menu组件的Html语义化话设计

 <Menu
      defaultIndex={'2'}
      onSelect={index => {
        console.log(index);
      }}
    >
      <MenuItem>item 1</MenuItem>
      <MenuItem>item 2</MenuItem>
      <SubMenu title={'dropdown'}>
        <MenuItem>dropdown item1</MenuItem>
        <MenuItem>dropdown item2</MenuItem>
      </SubMenu>
      <MenuItem>item3</MenuItem>
 </Menu>

Menu组件相当是一个容器,子元素必须是MenuItem或者是SubMenu,且每一项都有一个默认的index属性,默认按照以下规则:

0 MenuItem
1 SubMenu
	1-0 MenuItem
	1-1 MenuItem
2 MenuItem
3 SubMenu
	3-0 MenuItem
	3-1 MenuItem
	3-2 MenuItem

Menu组件提供了一个onSelect回掉函数,当点击MenuItem的时候可以进行调用。提供了两种模式 'horizontal' | 'vertical'。当在纵向模式下,又提供了子菜单默认展开的功能。

Menu组件的属性

interface MenuProps {
  defaultIndex?: string; // 默认index 默认高亮哪一个MenuItem
  className?: string;
  mode?: 'horizontal' | 'vertical'; // 两种模式
  style?: React.CSSProperties;
  onSelect?: (selectedIndex: string) => void; //像外面暴露一个回掉
  defaultOpenSubMenus?: string[]; // 默认展开哪些子菜单
}

MenuItem组件的属性

interface MenuItemProps {
  index?: string; // 当前MenuItem的index,和defaultIndex做比较进行高亮
  disabled?: boolean;// 是否是禁用状态
  className?: string;
  style?: React.CSSProperties;
}

SubMenu组件的属性

interface SubMenuProps {
  index?: string; // 当前SubMenu的index
  title: string; // 子菜单的title
  className?: string;
}

需要把Menu组件的defaultIndex等属性传递给子组件,因为使用的是children,就不能直接通过标签属性的方式进行传递,就需要使用useContext。下面是需要传递的属性。

menu的主要功能实现

active的实现

// Menu组件
// 需要传递的Context类型
interface IMenuContext {
  index: string;
  onSelect?: (selectedIndex: string) => void;
  mode?: 'horizontal' | 'vertical';
  defaultOpenSubMenus?: string[];
}
// 创建context
export const MenuContext = createContext<IMenuContext>({ index: '0' });
// 点击MenuItem需要切换active状态,active只有一个,在Menu组件创建state指示当前的active是哪一个。
const [currentActive, setActive] = useState(defaultIndex);
const handleClick = (index: string) => {
  // 点击MenuItem 更换index,实现切换active,此index是子组件传递过来的
  setActive(index);
  // 提供给用户的回掉函数
  onSelect && onSelect(index);
};
// 传递给子组件 Context
const passedContext: IMenuContext = {
  index: currentActive ? currentActive : '0',
  onSelect: handleClick,
  mode,
  defaultOpenSubMenus,
};
<MenuContext.Provider value={passedContext}>
  ...
</MenuContext.Provider>

MenuItem

const context = useContext(MenuContext);
const classes = classNames('gulu-menu-item', className, {
    disabled: disabled,
  	// 从父组件中拿到currentIndex和子组件的index进行比较,如果一致就是active
    active: context.index === index,
  });
// 绑定点击函数,传入自己的index,父组件根据传入的index,改变currentIndex,然后在传入子组件,与子组件的index进行比较
const handleClick = () => {
  if (context.onSelect && !disabled && typeof index === 'string') {
    context.onSelect(index);
  }
};
 <li className={classes} style={style} onClick={handleClick}>
      {children}
  </li>

判断子组件的类型, 并且给MenuItem和SubMenu传入index

Menu组件

  
// 判读哪些子元素不是MenuItem和SubMenu
  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(),// 给MenuItem和SubMenu传入index
        });
      } else {
        console.warn('Menu 的子元素至少有一个不是 MenuItem 或者 SubMenu');
      }
    });
  };

Submenu

  const renderChildren = () => {
    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.warn('SubMenu 的子元素至少有一个不是 MenuItem');
      }
    });
    return (
        <ul >{childrenComponent}</ul>
    );
  };

Submenu的隐藏与展开

 .gulu-submenu {
    display: none;
	}
  // 只要有这两个类就可以让他显示出来
  .gulu-submenu.menu-opened {
    display: block;
  }

为submenu添加menu-opened类,点击的时候会显示和隐藏

// submenu
const [menuOpen, setOpen] = useState(false);
const handleClick = (e: React.MouseEvent) => {
    e.preventDefault();
    setOpen(!menuOpen);
  };
// renderChildren函数 添加类名字
 const subMenuClasses = classNames('gulu-submenu', {
      'menu-opened': menuOpen,
    });
 <ul className={subMenuClasses}>{childrenComponent}</ul>
//组件返回
 <li key={index} className={classes} >
      <div className={'gulu-submenu-title'} onClick={handleClick} >
        {title}
      </div>
      {renderChildren()}
  </li>

submenu 横向模式下hover出现,纵向模式下点击出现

// submenu  
	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),
        }
      : {};
// 组件返回
<li key={index} className={classes} {...hoverEvents}>
      <div className={'gulu-submenu-title'} {...clickEvents}>
        {title}
      </div>
      {renderChildren()}
 </li>

submenu 纵向模式默认展开

// submenu  
	const openedSubMenus = context.defaultOpenSubMenus as Array<string>;
  const isOpened =
    index && context.mode === 'vertical'
      ? openedSubMenus.includes(index) //判断openedSubMenus是否包含index
      : false;
  const [menuOpen, setOpen] = useState(isOpened);