🗂 用 React Hook + ChakraUI 实现一个丝滑菜单组件
前言
ChakraUI 是一个简单、模块化且有可访问性的组件库。由于 ChakraUI 组件库的定位是趋向于提供 UI 组件的,所以在业务方面的功能会比较少,不像 antd 那样可以开箱即用。因此最近在基于 ChakraUI 组件库进行一些更上层的组件封装。
这篇文章介绍的是 菜单导航组件 的实现,先看最终看实现效果:
实现过程
数据结构设计
首先根据前面的实现效果可以看到,我们的菜单一共只有两级,且子级是不带有图标的。并没有设计为可以无限嵌套的菜单,因此数据的定义也比较简单:
export type MenuList = {
icon: React.ReactNode;
title: string;
path: string;
subMenu?: { title: string; path?: string }[];
}[];
- icon: 图标,这里用的
React.ReactNode
是希望传入图标能灵活些 - path: 菜单的跳转路径
- subMenu: 子级菜单
功能实现
父菜单样式
定义好了数据结构,我们就从最简单的样式开始实现。首先要实现外层的菜单样式:
import { Box, BoxProps, Flex, Icon } from "@chakra-ui/react";
import { MenuList } from "./types";
import { BiAlarm } from "react-icons/bi";
import { FiChevronDown } from "react-icons/fi";
const testMenuList: MenuList = [
{
title: "BiAlarm",
icon: <BiAlarm size="16px"></BiAlarm>,
subMenu: [
{ title: "子级标题1" },
{ title: "子级标题2" },
{ title: "子级标题3" },
],
},
{
title: "BiAlarm1",
icon: <BiAlarm size="16px"></BiAlarm>,
},
];
const menuItemClass: BoxProps = {
p: "4",
cursor: "pointer",
whiteSpace: "nowrap",
transition: "all 0.2s",
fontSize: "sm",
_hover: {
bg: "gray.50",
},
};
const Menu: React.FC<{ menuList: MenuList }> = () => {
return (
<Box h="100vh" boxShadow="lg" position="relative" transition="width 0.2s">
{testMenuList.map((menuItem, index) => {
return (
<Box key={menuItem.title}>
<Flex align="center" justify="space-between" {...menuItemClass}>
<Flex align="center" overflow="hidden">
<Box mr="3">{menuItem.icon}</Box>
{<Box fontSize="sm">{menuItem.title}</Box>}
</Flex>
{menuItem.subMenu && (
<Icon
transition="transform 0.2s ease-in-out"
as={FiChevronDown}
/>
)}
</Flex>
</Box>
);
})}
</Box>
);
};
export default Menu;
这里我们先把外层菜单的样式写好,然后 写了一份测试数据 testMenuList
用于遍历预览效果,在上面我将菜单的一些基础样式抽离为 menuItemClass
是为了一会儿可以在子级也用上。目前的效果如下图:
可以看到展开的图标已经可以根据是否存在 subMenu
进行展示了。
子菜单样式
子菜单就更简单了,在父级菜单项的底下加上以下代码:
<Box>
{menuItem.subMenu?.map((subMenuItem) => {
return (
<Box {...menuItemClass} pl="8" key={subMenuItem.title}>
{subMenuItem.title}
</Box>
);
})}
</Box>
这里复用了前面外层菜单的样式 menuItemClass
,因为 ChakraUI 中的样式都是以组件 props 的形式传入的,因此我们可以维护为一个对象进行复用。实现效果如下图:
子级菜单也实现好了,接下来我们开始实现功能。
子菜单折叠功能
首先我们实现一个子级的菜单折叠功能,这里我单独抽离了一个 hook 将展开相关的逻辑放在一起:
import { useState } from "react";
const useExpand = () => {
const [expandedMenu, setExpandedMenu] = useState<number[]>([]);
const isExpanded = (index: number) => expandedMenu.includes(index);
const setMenuExpand = (index: number) => {
const existIndex = expandedMenu.indexOf(index);
if (existIndex === -1) {
// expand
setExpandedMenu([...expandedMenu, index]);
} else {
// collapse
setExpandedMenu(expandedMenu.filter((item) => item !== index));
}
};
return { isExpanded, setMenuExpand };
};
export default useExpand;
useExpand
中定义了一个变量和两个方式:
expandedMenu
代表当前展开的菜单,由于可以同时展开多个菜单项因此使用数组来存储,isExpanded
用于判断菜单项是否展开。setMenuExpand
则用于展开指定菜单项
然后我们回到组件中调用这个 hook 并添加一些判断的逻辑:
import { Box, BoxProps, Flex, Icon } from "@chakra-ui/react";
import { MenuList } from "./types";
import { BiAlarm } from "react-icons/bi";
import { FiChevronDown } from "react-icons/fi";
import useExpand from "./hooks/useExpand";
const testMenuList: MenuList = [
{
title: "BiAlarm",
icon: <BiAlarm size="16px"></BiAlarm>,
subMenu: [
{ title: "子级标题1" },
{ title: "子级标题2" },
{ title: "子级标题3" },
],
},
{
title: "BiAlarm1",
icon: <BiAlarm size="16px"></BiAlarm>,
},
];
const menuItemClass: BoxProps = {
p: "4",
cursor: "pointer",
whiteSpace: "nowrap",
transition: "all 0.2s",
fontSize: "sm",
_hover: {
bg: "gray.50",
},
};
const Menu: React.FC<{ menuList: MenuList }> = () => {
const { isExpanded, setMenuExpand } = useExpand();
return (
<Box
w="200px"
h="100vh"
boxShadow="lg"
position="relative"
transition="width 0.2s"
>
{testMenuList.map((menuItem, index) => {
return (
<Box key={menuItem.title}>
<Flex
align="center"
justify="space-between"
{...menuItemClass}
onClick={() => {
if (menuItem.subMenu) setMenuExpand(index);
}}
>
<Flex align="center" overflow="hidden">
<Box mr="3">{menuItem.icon}</Box>
{<Box fontSize="sm">{menuItem.title}</Box>}
</Flex>
{menuItem.subMenu && (
<Icon
transition="transform 0.2s ease-in-out"
transform={`rotate(${isExpanded(index) ? -180 : 0}deg)`}
as={FiChevronDown}
/>
)}
</Flex>
{isExpanded(index) &&
menuItem.subMenu?.map((subMenuItem) => {
return (
<Box {...menuItemClass} pl="8" key={subMenuItem.title}>
{subMenuItem.title}
</Box>
);
})}
</Box>
);
})}
</Box>
);
};
export default Menu;
在上面的代码中,我们通过 setMenuExpand(index)
设置当前的展开菜单项,再通过 isExpanded(index)
来判断菜单是否展开,如果展开则将图标旋转 180 度指向上,并隐藏子菜单。实现效果如下:
这时候大家可以看到展开时子菜单直接隐藏和显示会非常僵硬,没有丝滑的感觉,因此我们再补充一个展开的动画,实现的代码如下:
import { AnimatePresence, motion } from "framer-motion";
<AnimatePresence>
{isExpanded(index) && (
<motion.div
style={{ overflow: "hidden" }}
initial={{ height: "0px", opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: "0px", opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
{menuItem.subMenu?.map((subMenuItem) => {
return (
<Box {...menuItemClass} pl="8" key={subMenuItem.title}>
{subMenuItem.title}
</Box>
);
})}
</motion.div>
)}
</AnimatePresence>
这里我们使用了 framer-motion 这个动画库:
motion.div
标签来帮助我们实现动画效果,在组件刚进入和离开时将透明度和高度设置为 0,然后慢慢展开。AnimatePresence
标签可以防止内部的组件没有展示离开的动画就直接销毁,它能将组件的销毁时机延后到离开动画执行结束。
我们再看下效果:
菜单激活功能
是不是看着丝滑了很多呢?接下来我们继续实现菜单点击后的激活效果,这里我还是单独创建了一个 hook 来耦合相关逻辑:
import { useState } from "react";
const useActive = () => {
const [activatedMenu, setActivatedMenu] = useState<[number, number]>([
-1, -1,
]);
const setMenuActive = (index: number, subIndex?: number) => {
if (typeof subIndex !== "undefined") {
setActivatedMenu([index, subIndex]);
} else {
setActivatedMenu([index, -1]);
}
};
return { activatedMenu, setMenuActive };
};
export default useActive;
activatedMenu
代表当前已激活的菜单项,由于有两层,所以我定义了一个两个数字的元组,第一项代表外层的激活菜单,第二项代表内层的激活菜单,未激活时值为-1
setMenuActive
用来设置当前的激活菜单,通过传入的索引值进行判断并设置
然后继续在组件中调用 useActive
,并通过返回值去设置一些样式:
import { Box, BoxProps, Flex, Icon, TextProps } from "@chakra-ui/react";
import { MenuList } from "./types";
import { BiAlarm } from "react-icons/bi";
import { FiChevronDown } from "react-icons/fi";
import { AnimatePresence, motion } from "framer-motion";
import useActive from "./hooks/useActive";
import useExpand from "./hooks/useExpand";
const testMenuList: MenuList = [
{
title: "BiAlarm",
icon: <BiAlarm size="16px"></BiAlarm>,
subMenu: [
{ title: "子级标题1" },
{ title: "子级标题2" },
{ title: "子级标题3" },
],
},
{
title: "BiAlarm1",
icon: <BiAlarm size="16px"></BiAlarm>,
},
];
+ const activeClass: TextProps = {
+ color: "purple.400",
+ fontWeight: "semibold",
+ };
+ const unActiveClass: TextProps = {
+ color: "gray.500",
+ fontWeight: "400",
+ };
const menuItemClass: BoxProps = {
p: "4",
cursor: "pointer",
whiteSpace: "nowrap",
transition: "all 0.2s",
fontSize: "sm",
_hover: {
bg: "gray.50",
},
};
const Menu: React.FC<{ menuList: MenuList }> = () => {
const { activatedMenu, setMenuActive } = useActive();
+ const { isExpanded, setMenuExpand } = useExpand();
+ const onMenuClick = (index: number, item: MenuList[number]) => {
+ if (item.subMenu) {
+ setMenuExpand(index);
+ } else {
+ setMenuActive(index);
+ }
+ };
return (
<Box
w="200px"
h="100vh"
boxShadow="lg"
position="relative"
transition="width 0.2s"
>
{testMenuList.map((menuItem, index) => {
return (
<Box key={menuItem.title}>
<Flex
align="center"
justify="space-between"
{...menuItemClass}
+ {...(activatedMenu[0] === index ? activeClass : unActiveClass)}
onClick={() => {
+ onMenuClick(index, menuItem);
}}
>
<Flex align="center" overflow="hidden">
<Box mr="3">{menuItem.icon}</Box>
<Box fontSize="sm">{menuItem.title}</Box>
</Flex>
{menuItem.subMenu && (
<Icon
transition="transform 0.2s ease-in-out"
transform={`rotate(${isExpanded(index) ? -180 : 0}deg)`}
as={FiChevronDown}
/>
)}
</Flex>
<AnimatePresence>
{isExpanded(index) && (
<motion.div
style={{ overflow: "hidden" }}
initial={{ height: "0px", opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: "0px", opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
{menuItem.subMenu?.map((subMenuItem, subIndex) => {
return (
<Box
{...menuItemClass}
+ {...(activatedMenu[1] === subIndex
+ ? activeClass
+ : unActiveClass)}
pl="8"
key={subMenuItem.title}
+ onClick={() => setMenuActive(index, subIndex)}
>
{subMenuItem.title}
</Box>
);
})}
</motion.div>
)}
</AnimatePresence>
</Box>
);
})}
</Box>
);
};
export default Menu;
这里代码已经比较多了,我用 diff
来标注新增的内容,这里我新增了 activeClass
和 unActiveClass
两个表示激活和未激活状态的样式属性,然后通过 activatedMenu
判断菜单项是否激活,并将菜单展开和菜单激活的方法单独抽离为 onMenuClick
。
实现后的效果如下:
当点击无子菜单的菜单项会直接激活,否则就展开菜单。如果点击子菜单项,父菜单项和子菜单项都会获得激活效果。
菜单栏整体折叠功能
接下来我们实现整个菜单栏的折叠功能,这里和上面一样,我单独写了一个 hook 来实现菜单栏整体折叠相关的功能:
import { Tooltip, Flex, useBoolean, Icon } from "@chakra-ui/react";
import { AnimatePresence, motion } from "framer-motion";
import { FiChevronRight } from "react-icons/fi";
const useCollapse = () => {
const [isMenuCollapse, { toggle: toggleMenuCollapse }] = useBoolean(false);
const [isMenuCollapseBtnShow, { on: showCollapseBtn, off: hideCollapseBtn }] =
useBoolean(false);
const collapseButton = (
<AnimatePresence>
{isMenuCollapseBtnShow && (
<motion.div
style={{
position: "absolute",
top: "58px",
right: "-10px",
cursor: "pointer",
zIndex: "1",
}}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={{ duration: 0.2 }}
>
<Tooltip
label={isMenuCollapse ? "Expand" : "Collapse"}
hasArrow
placement="right"
>
<Flex
role="group"
id={`menu-${isMenuCollapse ? "expand" : "collapse"}-btn`}
align="center"
justify="center"
w="20px"
h="20px"
bgColor="purple.400"
borderRadius="50%"
onClick={toggleMenuCollapse}
_hover={{
border: "1px",
borderColor: "purple.400",
bgColor: "white",
}}
>
<Icon
w="14px"
h="14px"
transition="transform 0.2s ease-in-out"
transform={`rotate(${isMenuCollapse ? 0 : -180}deg)`}
as={FiChevronRight}
color="white"
_groupHover={{
color: "purple.400",
}}
/>
</Flex>
</Tooltip>
</motion.div>
)}
</AnimatePresence>
);
return {
isMenuCollapse,
isMenuCollapseBtnShow,
showCollapseBtn,
hideCollapseBtn,
collapseButton,
};
};
export default useCollapse;
这个 hook 会稍微复杂一些,主要是我将控制菜单折叠的按钮也直接写在了 hook 中,因为这个按钮与 hook 内的功能耦合比较深,下面分析下这个 hook 中的变量:
isMenuCollapse
表示菜单栏是否折叠isMenuCollapseBtnShow
表示折叠栏按钮是否展示collapseButton
则是一个 element ,表示用于折叠的按钮
在上面的代码中,我同样使用了 <AnimatePresence>
和 <motion.div>
标签来实现按钮出现和隐藏的动画效果。状态的定义我使用 chakraUI 内置的 useBoolean
hook,它可以用于定义布尔类型状态并提供了以下三个函数:
on
: 设置为ture
,off
: 设置为false
toggle
: 设置为相反值
这里还有一个 ChakraUI 的使用技巧,我们可以使用 role="group"
与 groupHover
来实现当父元素 hover 时,子元素也可以与父元素同时设置 hover 效果:
<Flex
+ role="group"
id={`menu-${isMenuCollapse ? "expand" : "collapse"}-btn`}
align="center"
justify="center"
w="20px"
h="20px"
bgColor="purple.400"
borderRadius="50%"
onClick={toggleMenuCollapse}
+ _hover={{
+ border: "1px",
+ borderColor: "purple.400",
+ bgColor: "white",
+ }}
>
<Icon
w="14px"
h="14px"
transition="transform 0.2s ease-in-out"
transform={`rotate(${isMenuCollapse ? 0 : -180}deg)`}
as={FiChevronRight}
color="white"
+ _groupHover={{
+ color: "purple.400",
+ }}
/>
</Flex>
然后我们继续在原本的菜单组件中调用 useCollapse
:
const Menu: React.FC<{ menuList: MenuList }> = () => {
const { activatedMenu, setMenuActive } = useActive();
const { isExpanded, setMenuExpand } = useExpand();
+ const { collapseButton, showCollapseBtn, hideCollapseBtn, isMenuCollapse } =
+ useCollapse();
const onMenuClick = (index: number, item: MenuList[number]) => {
if (item.subMenu) {
setMenuExpand(index);
} else {
setMenuActive(index);
}
};
return (
<Box
+ w={isMenuCollapse ? "50px" : "200px"}
h="100vh"
boxShadow="lg"
position="relative"
transition="width 0.2s"
+ onMouseEnter={() => showCollapseBtn()}
+ onMouseLeave={() => hideCollapseBtn()}
>
+ {collapseButton}
{testMenuList.map((menuItem, index) => {
return (
<Box key={menuItem.title}>
<Flex
align="center"
justify="space-between"
{...menuItemClass}
{...(activatedMenu[0] === index ? activeClass : unActiveClass)}
onClick={() => {
onMenuClick(index, menuItem);
}}
>
<Flex align="center" overflow="hidden">
<Box mr="3">{menuItem.icon}</Box>
+ {!isMenuCollapse && <Box fontSize="sm">{menuItem.title}</Box>}
</Flex>
{menuItem.subMenu && !isMenuCollapse && (
<Icon
transition="transform 0.2s ease-in-out"
transform={`rotate(${isExpanded(index) ? -180 : 0}deg)`}
as={FiChevronDown}
/>
)}
</Flex>
<AnimatePresence>
+ {isExpanded(index) && !isMenuCollapse && (
<motion.div
style={{ overflow: "hidden" }}
initial={{ height: "0px", opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: "0px", opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
{menuItem.subMenu?.map((subMenuItem, subIndex) => {
return (
<Box
{...menuItemClass}
{...(activatedMenu[1] === subIndex
? activeClass
: unActiveClass)}
pl="8"
key={subMenuItem.title}
onClick={() => setMenuActive(index, subIndex)}
>
{subMenuItem.title}
</Box>
);
})}
</motion.div>
)}
</AnimatePresence>
</Box>
);
})}
</Box>
);
};
export default Menu;
这一步我们将折叠按钮渲染在菜单中,并且通过 onMouseEnter={() => showCollapseBtn()}
和 onMouseLeave={() => hideCollapseBtn()}
实现了只有鼠标移入菜单才显示折叠按钮,然后根据 isMenuCollapse
状态增加了一些判断。实现的效果如下:
折叠状态弹出子菜单
现在我们在将菜单栏折叠后,子菜单就无法显示了,我们没法在折叠状态下点击其他的子菜单,所以我们可以加一个弹出气泡来展示子菜单:
<Popover
trigger="hover"
placement="right"
closeOnBlur={false}
offset={[0, 16]}
>
<PopoverTrigger>
<Flex
align="center"
justify="space-between"
{...menuItemClass}
{...(activatedMenu[0] === index
? activeClass
: unActiveClass)}
onClick={() => {
onMenuClick(index, menuItem);
}}
>
<Flex align="center" overflow="hidden">
<Box mr="3">{menuItem.icon}</Box>
{!isMenuCollapse && (
<Box fontSize="sm">{menuItem.title}</Box>
)}
</Flex>
{menuItem.subMenu && !isMenuCollapse && (
<Icon
transition="transform 0.2s ease-in-out"
transform={`rotate(${isExpanded(index) ? -180 : 0}deg)`}
as={FiChevronDown}
/>
)}
</Flex>
</PopoverTrigger>
<PopoverContent
w="max-content"
hidden={!isMenuCollapse || !menuItem.subMenu}
>
<PopoverArrow />
{renderSubMenu(menuItem.subMenu, index, {
pl: "3",
p: "3"
})}
</PopoverContent>
</Popover>
这里我使用了 ChakraUI 的 Popover
组件来实现弹出的菜单。通过设置 hidden={!isMenuCollapse || !menuItem.subMenu}
在菜单未折叠的时候隐藏了气泡 之所以使用 hidden
而不是直接条件判断,是因为不想组件被频繁的销毁挂载。
由于子菜单需要在父菜单内和弹出气泡内同时使用,因此我将它在组件抽离为单独的变量进行复用:
onst renderSubMenu = (
subMenu: MenuList[number]["subMenu"],
index: number,
props?: BoxProps
) => {
return subMenu?.map((subMenuItem, subIndex) => {
return (
<Box
{...menuItemClass}
{...(activatedMenu[1] === subIndex ? activeClass : unActiveClass)}
pl="8"
key={subMenuItem.title}
onClick={() => setMenuActive(index, subIndex)}
{...props}
>
{subMenuItem.title}
</Box>
);
});
};
在这个函数中,除了子菜单 subMenu
和父菜单索引 index
外,还增加了一个 props
参数用于渲染 subMenu 时调整子菜单的样式。
为什么这里不单独将子菜单抽成一个组件呢?因为 子菜单内依赖的数据很多都是与父菜单耦合 的,如果抽离组件就需要通过 props 去传数据。而且 子菜单都是在当前组件内 使用,并不需要在其他地方复用,因此就直接写成一个函数进行调用了,相比写成组件这样更加方便些。最终实现的效果是这样的:
到了这一步基本功能已经都完成了,但细心的彦祖已经发现了折叠状态下我看没法看到菜单项的标题了,只能看到一个图标,如果存在一些有多种意义的图标那在折叠状态下的使用体验就不太好了。因此,咱们可以添加一个 Tooltip 提示用于折叠状态下菜单标题的展示。
折叠状态 Tooltip 提示
添加 Tooltip 有一个注意点,具有子菜单的菜单项在折叠后 hover 时不应该显示 Tooltip
,而是正常显示 Popover
Tooltip 和 Popover 不能与 trigger 元素共享同一个 DOM 元素
如果我们直接在 Popover
内使用 Tooltip
组件,那么 Popover
在 hover 时是无法弹出的,只能弹出 Tooltip
。因此我们两个单独的元素分别用于触发 Popover
和 Tooltip
。在我们的场景中,这个元素就是外层菜单项,因此我们可以将外层菜单项给抽离出来,就像子菜单项那样:
const renderMenuItem = (menuItem: MenuList[number], index: number) => {
return (
<Flex
align="center"
justify="space-between"
{...menuItemClass}
{...(activatedMenu[0] === index ? activeClass : unActiveClass)}
onClick={() => {
onMenuClick(index, menuItem);
}}
>
<Flex align="center" overflow="hidden">
<Box mr="3">{menuItem.icon}</Box>
{!isMenuCollapse && <Box fontSize="sm">{menuItem.title}</Box>}
</Flex>
{menuItem.subMenu && !isMenuCollapse && (
<Icon
transition="transform 0.2s ease-in-out"
transform={`rotate(${isExpanded(index) ? -180 : 0}deg)`}
as={FiChevronDown}
/>
)}
</Flex>
);
};
然后添加 Toolltip 组件及相关的逻辑判断:
<Popover
trigger="hover"
placement="right"
closeOnBlur={false}
offset={[0, 16]}
>
{menuItem.subMenu ? (
<PopoverTrigger>
{renderMenuItem(menuItem, index)}
</PopoverTrigger>
) : (
<Tooltip
hasArrow
placement="right"
label={menuItem.title}
aria-label={menuItem.title}
isDisabled={!isMenuCollapse}
>
{renderMenuItem(menuItem, index)}
</Tooltip>
)}
<PopoverContent
w="max-content"
hidden={!isMenuCollapse || !menuItem.subMenu}
>
<PopoverArrow />
<PopoverHeader
p="3"
color="gray.600"
fontSize="sm"
fontWeight="semibold"
>
{menuItem.title}
</PopoverHeader>
{renderSubMenu(menuItem.subMenu, index, {
pl: "3",
p: "3",
})}
</PopoverContent>
</Popover>
除了 Tooptip
外,我还在 Popover
组件中增加了 Header
,使得具有子菜单的菜单项在弹出时也可以看到标题。最终效果如下:
总结
这次的组件封装其实更多是趋向于样式方面,暂时还没加入路由跳转等更加接近业务的功能,主要提供一个样式上的实现思路。具体的功能大家可以基于业务去进行补充。
后续这类组件封装的文章可能会出一个系列,也准备把这些组件都开源了,如果有使用或打算使用 ChakraUI
进行项目搭建的同学欢迎插眼关注。如果文章对你有帮助除了收藏之余可以点个赞 👍,respect。
转载自:https://juejin.cn/post/7216548158972756025