Skip to content

滑动菜单

完成滑动菜单的样式

原先的svgsprites插件将所有的svg标签的样式属性给清除了,需要保留原有的属性值,需要在option中配置noOptimizeList用于存放不需要清除svg标签的文件名称。

tsx
import path from 'path'
import fs from 'fs'
import store from 'svgstore'
import { optimize } from 'svgo'
import type { Plugin, ViteDevServer } from 'vite'

interface Options {
  id?: string
  inputFolder?: string
  inline?: boolean
  noOptimizeList?: string[]
}
export const svgsprites = (options: Options = {}): Plugin => {
  const virtualModuleId = `virtual:svgsprites${options.id ? `-${options.id}` : ''}`
  const resolvedVirtualModuleId = `\0${virtualModuleId}`
  const { inputFolder = 'src/assets/icons', inline = false } = options

  const generateCode = () => {
    const sprites = store(options)
    const iconsDir = path.resolve(inputFolder)
    for (const file of fs.readdirSync(iconsDir)) {
      if (!file.endsWith('.svg'))
        continue
      const filepath = path.join(iconsDir, file)
      const svgId = path.parse(file).name
      const code = fs.readFileSync(filepath, { encoding: 'utf-8' })
      // noOptimizeList 筛除 不需要清除掉 svgId
      const symbol = options.noOptimizeList?.includes(svgId)
        ? code
        : optimize(code, {
          plugins: [
            'cleanupAttrs', 'removeDoctype', 'removeComments', 'removeTitle', 'removeDesc', 'removeEmptyAttrs',
            { name: 'removeAttrs', params: { attrs: '(data-name|fill)' } },
          ],
        }).data
      sprites.add(svgId, symbol)
    }
    return sprites.toString({ inline })
  }
  const handleFileCreationOrUpdate = (file: string, server: ViteDevServer) => {
    if (!file.includes(inputFolder))
      return
    const code = generateCode()
    server.ws.send('svgsprites:change', { code })
    const mod = server.moduleGraph.getModuleById(resolvedVirtualModuleId)
    if (!mod)
      return
    server.moduleGraph.invalidateModule(mod, undefined, Date.now())
  }

  return {
    name: 'svgsprites',
    configureServer(server) {
      server.watcher.on('add', (file) => {
        handleFileCreationOrUpdate(file, server)
      })
      server.watcher.on('change', (file) => {
        handleFileCreationOrUpdate(file, server)
      })
    },
    resolveId(id: string) {
      if (id === virtualModuleId)
        return resolvedVirtualModuleId
    },
    load(id: string) {
      if (id === resolvedVirtualModuleId) {
        const code = generateCode()
        return `!function(){
  const div = document.createElement('div')
  div.innerHTML = \`${code}\`
  const svg = div.getElementsByTagName('svg')[0]
  const updateSvg = (svg) => {
    if (!svg) { return }
    svg.style.position = 'absolute'
    svg.style.width = 0
    svg.style.height = 0
    svg.style.overflow = 'hidden'
    svg.setAttribute("aria-hidden", "true")
  }
  const insert = () => {
    if (document.body.firstChild) {
      document.body.insertBefore(div, document.body.firstChild)
    } else {
      document.body.appendChild(div)
    }
  }
  updateSvg(svg)
  if (document.body){
    insert()
  } else {
    document.addEventListener('DOMContentLoaded', insert)
  }
  if (import.meta.hot) {
    import.meta.hot.on('svgsprites:change', (data) => {
      const code = data.code
      div.innerHTML = code
      const svg = div.getElementsByTagName('svg')[0]
      updateSvg(svg)
    })
  }
}()`
      }
    },
  }
}

创建TopMenu文件夹存放CurrentUser.tsxMenu.tsx文件。

tsx
interface Props {
  className?: string
}
export const CurrentUser: React.FC<Props> = ({ className }) => {
  return (
    <div className={className} bg="#5C33BE" text-white w="100%" pt-32px pb-44px
      px-16px>
      <h2 text-24px>未登录用户</h2>
      <div text="#CEA1FF">点击这里登录</div>
    </div>
  )
}
tsx
import styled from 'styled-components'
import { Icon } from '../Icon'

interface Props {
  className?: string
}

const MyIcon = styled(Icon)`
  width: 32px; height: 32px; margin-right: 16px;
`

export const Menu: React.FC<Props> = ({ className }) => {
  return (
    <ul className={className} bg-white text-20px py-16px
      children-flex children-items-center children-px-16px
      children-py-8px children-mb-4px>
      <li><MyIcon name="chart" />统计图表</li>
      <li><MyIcon name="export" />导出数据</li>
      <li><MyIcon name="category" />自定义分类</li>
      <li><MyIcon name="noty" />记账提醒</li>
    </ul>
  )
}

然后在TopMenu.tsx文件中使用这两个组件,详细代码见链接

tsx
import { CurrentUser } from './TopMenu/CurrentUser'
import { Menu } from './TopMenu/Menu'

export const TopMenu: React.FC = () => {
  return (
    <div fixed top-0 left-0 w="70vw" max-w-20em h-screen flex flex-col b-3px b-red>
      <CurrentUser className="grow-0 shrink-0" />
      <Menu className="grow-1 shrink-1" />
    </div>
  )
}

点击外部关闭菜单

首先在app.scss设置几个需要使用的z-index层级默认值,并在src/shims.d.ts配置z-index在标签上的属性typez便于unocss样式的使用。

css
:root {
  --un-shadow-color: red;
  --z-default: 128;
  --z-menu: 256;
  --z-dialog: 512;
}
typescript
import * as React from 'react'
declare module 'react' {
  interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T> {
    flex?: boolean
      relative?: boolean
      text?: string
      grid?: boolean
      before?: string
      after?: string
      shadow?: boolean
      w?: string
      h?: string
      bg?: string
      rounded?: string
      fixed?: boolean
      b?: string
      z?: string
      block?: boolean
  }
    interface SVGProps<T> extends SVGAttributes<T>, ClassAttributes<T> {
    w?: string
      h?: string
      }
    }

然后修改TopMenu组件,使用设置好的层级添加遮罩层的div,点击遮罩层并关闭。

tsx
import { CurrentUser } from './TopMenu/CurrentUser'
import { Menu } from './TopMenu/Menu'

interface Props {
  onClickMask?: () => void
}
export const TopMenu: React.FC<Props> = (props) => {
  const { onClickMask } = props
  return (
    <>
      <div fixed top-0 left-0 w="100%" h="100%" className="bg-black:75"
        z="[calc(var(--z-menu)-1)]" onClick={onClickMask}
      />
      <div fixed top-0 left-0 w="70vw" max-w-20em h-screen flex flex-col
        z="[var(--z-menu)]">
        <CurrentUser className="grow-0 shrink-0" />
        <Menu className="grow-1 shrink-1" />
      </div>
    </>
  )
}

并在ItemsPage.tsx使用useMenuStore得到的setVisible来使得TopMenu的组件接受传入的PropsonclickMask方法来控制TopMenu的显示与否,详情代码见链接

给菜单添加滑动动画

修改TopMenu组件,使其直接收Props值为visible,并使用useSpring添加上动画,并设置了动画样式。

  • maskStyle(根据visible来设置opacity的变化,使得maskVisible为真为假)
  • menuStyles(用于菜单栏的显示的时候出现,关闭的时候向左侧滑出)
  • style(用于遮罩层的动画样式,当maskVisible为真时展示,假隐藏)

stylemenuStyles动画样式放入<animated.div></animated.div>中,详细代码见链接

tsx
import { animated, useSpring } from 'react-spring'
import React, { useState } from 'react'
import { CurrentUser } from './TopMenu/CurrentUser'
import { Menu } from './TopMenu/Menu'

interface Props {
  onClickMask?: () => void
  visible: boolean
}
export const TopMenu: React.FC<Props> = (props) => {
  const { onClickMask, visible } = props
  const [maskVisible, setMaskVisible] = useState(false)
  const maskStyles = useSpring({
    opacity: visible ? 1 : 0,
    onStart: ({ value }) => {
      if (value.opacity < 0.1)
        setMaskVisible(true)
    },
    onRest: ({ value }) => {
      if (value.opacity < 0.1)
        setMaskVisible(false)
    }
  })
  const menuStyles = useSpring({
    opacity: visible ? 1 : 0,
    transform: visible ? 'translateX(0%)' : 'translateX(-100%)',
  })
  const style = {
    ...maskStyles,
    visibility: (maskVisible ? 'visible' : 'hidden') as 'visible' | 'hidden'
  }
  return (
    <>
       <animated.div fixed top-0 left-0 w="100%" h="100%" className="bg-black:75"
        style={style}
        z="[calc(var(--z-menu)-1)]" onClick={onClickMask}
      />
       <animated.div fixed top-0 left-0 w="70vw" max-w-20em h-screen flex flex-col
        style={menuStyles}
        z="[var(--z-menu)]">
        <CurrentUser className="grow-0 shrink-0" />
        <Menu className="grow-1 shrink-1" />
      </animated.div>
    </>
  )
}

给菜单添加跳转动作

首先在router.tsx文件中添加要添加的路由,如下所示,详细代码见链接

tsx
{ path: '/items', element: <ItemsPage /> },
{ path: '/sign_in', element: <div>sign in</div> },
{ path: '/chart', element: <div>图表</div> },
{ path: '/export', element: <div>敬请期待</div> },
{ path: '/tags', element: <div>标签</div> },
{ path: '/noty', element: <div>敬请期待</div> }

修改CurrentUser.tsx组件,点击未登录的时候可以跳转到登录界面。

tsx
import { Link } from 'react-router-dom'

interface Props {
  className?: string
}
export const CurrentUser: React.FC<Props> = ({ className }) => {
  return (
    <Link to='/sign_in' block className={className} bg="#5C33BE" text-white w="100%" pt-32px pb-44px
      px-16px>
      <h2 text-24px>未登录用户</h2>
      <div text="#CEA1FF">点击这里登录</div>
    </Link>
  )
}

修改Menu.tsx组件,点击菜单栏的时候跳转至相对应的界面。

tsx
import { NavLink } from 'react-router-dom'
import styled from 'styled-components'
import { Icon } from '../Icon'

interface Props {
  className?: string
}

const MyIcon = styled(Icon)`
  width: 32px; height: 32px; margin-right: 16px;
`

const items = [
  { key: 'chart', icon: 'chart', text: '统计图表', to: '/chart' },
  { key: 'export', icon: 'export', text: '导出数据', to: '/export' },
  { key: 'tags', icon: 'category', text: '自定义标签', to: '/tags' },
  { key: 'noty', icon: 'noty', text: '记账提醒', to: '/noty' },
]

export const Menu: React.FC<Props> = ({ className }) => {
  return (
    <ul className={className} bg-white text-20px py-16px >
      {items.map(item =>
        <li key={item.key}>
          <NavLink flex items-center px-16px py-8px mb-4px to={item.to}>
              <MyIcon name={item.icon} />{item.text}
            </NavLink>
        </li>
      )}
    </ul>
  )
}

修复跳转后遮罩层关闭的bug

这是因为遮罩层的设置的maskVisible每次都是false,导致遮罩层直接不显示,并没有维持点击离开菜单来之前的状态来设定。

详细代码见链接