Hi, 大家好!我是程序员库里。
今天开始分享如何从0搭建UI组件库。这也是前端反卷计划中的一项。
在接下来的日子,我会持续分享前端反卷计划中的每个知识点。
以下是前端反卷计划的内容:
目前这些内容持续更新到了我的 学习文档 中。感兴趣的欢迎一起学习!
Menu
5.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
- Menu
参数 | 说明 | 类型 | 默认值 | |
---|---|---|---|---|
defaultIndex | 第几项处于选中状态 | number | 0,第一个 | |
mode | 水平还是垂直 | 'horizontal' | 'vertical' | horizontal |
onSelect | 选中事件 | (selectedIndex: number) => void; | ||
defaultOpenSubMenus | 设置子菜单的默认打开 只在纵向模式下生效 |
- MenuItem
参数 | 说明 | 类型 | 默认值 |
---|---|---|---|
index | 索引 | number | |
disabled | 是否是disabled状态 | boolean | FALSE |
- 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
代码语言:txt复制因为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 高亮逻辑
代码语言:txt复制点击哪个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>
)
代码语言:txt复制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
代码语言:txt复制 <Menu defaultIndex={0}>
<MenuItem index={0}>aaa</MenuItem>
<MenuItem index={1}>bbb</MenuItem>
</Menu>
5.5.5 添加样式
- 在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;
- 导入到样式入口文件
// menu
@import "../components/Menu/style";
- 编写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,如果是其他的,就报错
- 在MenuItem上加上displayName
MenuItem.displayName = 'MenuItem'
- 写一个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上
代码语言: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基础开发
代码语言:txt复制原理和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样式
代码语言: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显示隐藏
- css方面通过display控制显示隐藏
// 通过display控制显示隐藏
.curry-submenu {
display: none; // 开始隐藏
}
.curry-submenu.menu-opened {
display: block; // 显示
}
- 逻辑方面,通过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",
});
- 判断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这种方案。
- 修改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')
}
})
}
- 修改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)
}
}
- 修改submenu的index类型
export interface SubMenuProps {
index?: string; // number to string
title: string;
className?: string;
children: React.ReactNode;
style?: React.CSSProperties;
}
- 以上类型就修完了。然后根据上面的改成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 垂直菜单默认展开
- 增加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: [] // 默认值
}
- 通过context来将defaultOpenSubMenus传到submenu组件
interface IMenuContext {
index: string;
onSelect?: SelectCallback;
mode?: MenuMode;
defaultOpenSubMenus?: string[]; // 新增
}
const passedContext: IMenuContext = {
index: currentActive || '0',
onSelect: handleClick,
mode,
defaultOpenSubMenus // 新增
}
- 在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)
- 看下效果
<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组件开发
持续更新
目前这些内容持续更新到了我的 学习文档 中。感兴趣的欢迎一起学习!