一个顶部导航栏的下拉菜单

2,199 阅读3分钟

写一个顶部导航栏的下拉菜单

背景

前段时间, 接了一个优化下拉菜单的需求, 因此整合了下刚学的知识HooksCSS in JSCSSTransition正好练练手, 下面👇是实现的效果图, 做的不好的地方还请见谅☕️☕️

navbar-menu.gif

需求

  1. 点击具有下拉菜单的图标会展示下拉菜单
  2. 以滑动的形式切换一级菜单和二级菜单
  3. 当鼠标从菜单区域移除以后菜单自动关闭

实现细节

下面就开工☕️☕️

const [state, dispatch] = useReducer(reducer, initialState)
const { open } = state;

这里的open属性主要控制下拉菜单, 涉及到了<NavItem />组件打开自身的下拉菜单和鼠标从下拉菜单区域<DropdownMenu />出来的时候触发关闭事件, 有以下几种解决方案:

  1. 使用useContext、createContext 共享组件的状态, 子组件触发事件后改变父组件的状态以达到打开和关闭Menu, 但是有一个问题是假如具有DropdownMenu的<NavItem />的数量多了起来, 需要在父组件和子组件编写控制open的Fucntion会随之增多

  2. 只需要在<NavItem />编写open属性的state(不需要将open共享给父组件和Children), 在大页面添加遮罩层组件<Mask />, 设置其z-index小于<DropdownMenu />, 通过useEffect获取Mask的DOM元素,并为其添加点击事件(open置为false)

  3. 使用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%;
        }
    }
`

到这里就结束了☕️☕️

dianzan.jpeg