写一个顶部导航栏的下拉菜单
背景
前段时间, 接了一个优化下拉菜单的需求, 因此整合了下刚学的知识Hooks
、CSS in JS
、CSSTransition
正好练练手, 下面👇是实现的效果图, 做的不好的地方还请见谅☕️☕️
需求
- 点击具有下拉菜单的图标会展示下拉菜单
- 以滑动的形式切换一级菜单和二级菜单
- 当鼠标从菜单区域移除以后菜单自动关闭
实现细节
下面就开工☕️☕️
const [state, dispatch] = useReducer(reducer, initialState)
const { open } = state;
这里的open
属性主要控制下拉菜单, 涉及到了<NavItem />
组件打开自身的下拉菜单和鼠标从下拉菜单区域<DropdownMenu />
出来的时候触发关闭事件, 有以下几种解决方案:
-
使用useContext、createContext 共享组件的状态, 子组件触发事件后改变父组件的状态以达到打开和关闭Menu, 但是有一个问题是假如具有DropdownMenu的
<NavItem />
的数量多了起来, 需要在父组件和子组件编写控制open的Fucntion会随之增多 -
只需要在
<NavItem />
编写open属性的state(不需要将open共享给父组件和Children), 在大页面添加遮罩层组件<Mask />
, 设置其z-index小于<DropdownMenu />
, 通过useEffect获取Mask的DOM元素,并为其添加点击事件(open置为false) -
使用useReducer, 类似于小型的Redux, 去管理状态和触发对应的action, 可以更好简化代码结构
这里我采用了 useReducer
解决跨组件的事件订阅
// reducer.ts 状态管理函数
export const initialState: { open: boolean } = {
// 下拉Menu开启/关闭
open: false,
}
interface State {
open: boolean;
}
export interface Action {
type: String,
open: boolean,
}
export const reducer = (state: State, action: Action) => {
switch (action.type) {
case 'close':
return {...state, open: action.open}
case 'setOpen':
return {...state, open: action.open}
default:
throw new Error()
}
}
// index.tsx 项目结构文件
import { useState, memo, useReducer, Dispatch } from "react";
import { ReactElement } from "react";
import { Navbar, ItemNav, Dropdown, Item } from "./style";
import { reducer, initialState } from "./reducer";
import { ReactComponent as Add } from "assets/add.svg"; // 将svg以组件的形式导入
import { ReactComponent as User } from "assets/user.svg";
import { ReactComponent as Setting } from "assets/setting.svg";
import { ReactComponent as Arrow } from "assets/arrow.svg";
import { CSSTransition } from "react-transition-group";
import { Action } from "./reducer";
const RightNavBar = () => {
const [state, dispatch] = useReducer(reducer, initialState)
const { open } = state;
return (
<Navbar>
<NavItem icon={<Add />} />
<NavItem icon={<User />} open={open} dispatch={dispatch}>
{/* 下拉menu */}
<DropdownMenu dispatch={dispatch}/>
</NavItem>
</Navbar>
)
}
interface NavItemProps {
icon: ReactElement;
open?: boolean;
dispatch?: Dispatch<Action>
}
// 这里是使用React.memo是为了当父组件的state改变的时候, 第一个NavItem不需要re-render
const NavItem = React.memo(
({icon, children, open, dispatch}:React.PropsWithChildren<NavItemProps>) => {
return (
<NavItemLi>
<a
href="#"
className="icon-button"
onClick={(e) => {
dispatch && dispatch({type: 'setOpen', open: !open})
}}>
{icon}
</a>
{open && children}
</NavItemLi>
)
})
// 下拉Menu
const DropdownMenu = ({dispatch}: {dispatch: Dispatch<Action>}) => {
const [activeMenu, setActiveMenu] = useState<String>('main') // 切换一级/二级Menu
const DropdownItem = ({ leftIcon, children, rightIcon, goToMenu } : React.PropsWithChildren<{ goToMenu?: String, leftIcon?: ReactElement, rightIcon?: ReactElement }>) => {
return (
<Item onClick={() => goToMenu && setActiveMenu(goToMenu)}> // 点击切换
<span className="icon-left">{leftIcon}</span>
{children}
<span className="icon-right">{rightIcon}</span>
</Item>
)
}
// CSSTransition原理并不是直接为任何内容设置动画, 而是根据动画的状态添加和删除类
return (
<Dropdown onMouseLeave={() => {dispatch({type: 'close', open: false})}}> // 鼠标移出下来菜单区域
/* 一级菜单 */
<CSSTransition
in={activeMenu === 'main'}
unmountOnExit
timeout={500}
classNames="menu-primary"
>
<div className="menu">
<DropdownItem>个人信息</DropdownItem>
<DropdownItem leftIcon={<Setting />} goToMenu="setting">设置</DropdownItem>
</div>
</CSSTransition>
/* 二级菜单 */
<CSSTransition
in={activeMenu === 'setting'}
unmountOnExit
timeout={500}
classNames="menu-secondary"
>
<div className="menu">
<DropdownItem leftIcon={<Arrow />} goToMenu="main" />
<DropdownItem>设置内容1</DropdownItem>
<DropdownItem>设置内容2</DropdownItem>
<DropdownItem>设置内容3</DropdownItem>
<DropdownItem>设置内容4</DropdownItem>
</div>
</CSSTransition>
</Dropdown>
)
}
export default RightNavBar;
👇下面是CSS in JS, 编写对应的样式文件
// style.ts 样式文件
import styled from "@emotion/styled";
// 导航栏菜单
export const Navbar = styled.div`
height: 100%;
display: flex;
`
// 导航栏菜单选项
export const NavItemLi = styled.li`
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
> .icon-button {
width: 36px;
height: 36px;
transition: filter 300ms; // 设置动画
&:hover {
filter: brightness(1.2); // 鼠标放置上面亮度变成1.2
}
> svg {
width: 100%;
height: 100%;
}
}
`
// 下拉菜单
export const Dropdown = styled.div`
position: absolute;
top: 60px;
width: 300px;
background: #fff;
border-radius: var(--border-radius);
border: 1px solid rgba(0, 0, 0, 0.45);
transform: translateX(-42%);
overflow: hidden; // 任何与容器重叠的子元素都会隐藏
z-index: 10;
// CSSTransition 添加动画
> .menu-primary-enter {
position: absolute;
transform: translateX(-100%);
}
> .menu-primary-enter-active {
transform: translateX(0%);
transition: all 500ms ease;
}
> .menu-primary-exit {
position: absolute;
}
> .menu-primary-exit-active {
transform: translateX(-110%);
transition: all 500ms ease;
}
.menu-secondary-enter {
transform: translateX(110%);
}
.menu-secondary-enter-active {
transform: translateX(0);
transition: all 500ms ease;
}
.menu-secondary-exit {
transform: translateX(0%);
}
.menu-secondary-exit-active {
transform: translateX(110%);
transition: all 500ms ease;
}
`
// 下拉菜单选项
export const Item = styled.a`
font-weight: 500;
color: #9c9494cc;
margin: 5px;
height: 40px;
display: flex;
align-items: center;
padding: 1rem;
&:hover {
background-color: #ecedf1;
}
.icon-left {
width: 20px;
height: 20px;
margin-right: 1.5rem;
> svg {
width: 100%;
height: 100%;
}
}
`
到这里就结束了☕️☕️