项目使用MUI 5实现,过程中需要使用到侧边导航,翻看了整个文档,最终决定使用accordion来实现多层级的侧边导航
先看效果图
先看完整代码
import React from 'react'
import { styled } from '@mui/material/styles'
import type { AccordionProps } from '@mui/material/Accordion'
import type { AccordionSummaryProps } from '@mui/material/AccordionSummary'
import MuiAccordionSummary from '@mui/material/AccordionSummary'
import MuiAccordionDetails from '@mui/material/AccordionDetails'
import { menuList } from './config'
import IconArrow from '~icons/common/arrow.svg'
const Accordion = styled((props: AccordionProps) => <Mui.Accordion disableGutters elevation={0} square {...props} />)(
({ theme }) => ({
'&:not(:last-child)': {
borderBottom: 0
},
'&:before': {
display: 'none'
}
})
)
const AccordionSummary = styled((props: AccordionSummaryProps) => <MuiAccordionSummary {...props} />)(({ theme }) => ({
backgroundColor: 'rgba(255, 255, 255, .05)',
'& .MuiAccordionSummary-expandIconWrapper': {
transform: 'rotate(90deg)'
},
'& .MuiAccordionSummary-expandIconWrapper.Mui-expanded': {
transform: 'rotate(180deg)'
}
}))
const AccordionDetails = styled(MuiAccordionDetails)(({ theme }) => ({
padding: theme.spacing(2),
cursor: 'pointer',
display: 'flex',
alignItems: 'center'
}))
const activeStyles = {
backgroundColor: 'rgba(38, 91, 50, 0.1)',
transition: 'background-color .6s',
color: '#265B32'
}
interface NavData {
key: string
level: number
name: string
icon: string
path?: string
children?: AnyObject[]
}
function Menu() {
const navigate = useNavigate()
const { pathname, state } = useLocation()
const [expanded, setExpanded] = React.useState<string | false>('panel1')
const [currentNav, setCurrentNav] = useState('')
const [selected, setSelected] = useState<string[]>([])
const handleExpanded = (panel: string) => (event: React.SyntheticEvent, newExpanded: boolean) => {
setExpanded(newExpanded ? panel : false)
}
const handleClick = (item: AnyObject) => () => {
if (!item.children) {
if (item.path !== pathname) {
navigate(item.path, { state: item.key })
setCurrentNav(item.path)
}
}
}
function getAllParentArr(data: AnyObject[], key: string) {
for (const i in data) {
if (data[i].key === key) {
return [data[i]]
}
if (data[i]?.children) {
const node = getAllParentArr(data[i].children, key) as AnyObject[]
if (node) {
return node.concat(data[i])
}
}
}
}
useEffect(() => {
setCurrentNav(pathname)
const result = getAllParentArr(menuList, state) as AnyObject[]
if (result && result.length) {
setSelected(result.map((item: AnyObject) => item.key))
}
}, [pathname])
function renderMenu(data: AnyObject[], level: number) {
if (data) {
return data.map((value: any) => {
value.level = level + 1
return renderSubMenu(value, value.level)
})
}
}
function renderSubMenu(data: NavData, level: number) {
if (!data.children) {
return (
<AccordionDetails
className={`hover:text-green-700 `}
style={currentNav === data.path ? activeStyles : {}}
key={data.key}
onClick={handleClick(data)}
sx={{ pl: data.level }}
>
{data.level === 1 && data.icon}
<span className="text-size-14px ml-8px">{data.name}</span>
</AccordionDetails>
)
} else {
return (
<Accordion defaultExpanded={true} onChange={handleExpanded(data.key)} key={data.key}>
<AccordionSummary
expandIcon={data.children && <IconArrow />}
className={`hover:text-green-700`}
id={data.key}
onClick={handleClick(data)}
sx={{ pl: data.level }}
>
<div className="flex items-center">
<div className={` ${selected.includes(data.key) ? 'text-green-700' : ''}`}>
{data.level === 1 && data.icon}
</div>
<span
className={`font-rm text-size-14px ${data.level === 1 ? 'ml-4px' : 'ml-10px'} ${
selected.includes(data.key) ? 'text-green-700' : ''
}`}
>
{data.name}
</span>
</div>
</AccordionSummary>
{data.children && renderMenu(data.children, level + 1)}
</Accordion>
)
}
}
return (
<div className="relative bg-white w-246px h-100vh border-r-grey-2 b-r b-r-dashed flex-shrink-0">
<div className="py-24px ml-16px font-rb text-size-20px">STC</div>
{renderMenu(menuList, 0)}
</div>
)
}
export default Menu
代码分析
1.实现accordion组件自定义样式
// 通过styled设置Accordion\AccordionSummary\AccordionDetails样式
const Accordion = styled((props: AccordionProps) => <Mui.Accordion disableGutters elevation={0} square {...props} />)(
({ theme }) => ({
'&:not(:last-child)': {
borderBottom: 0
},
'&:before': {
display: 'none'
}
})
)
const AccordionSummary = styled((props: AccordionSummaryProps) => <MuiAccordionSummary {...props} />)(({ theme }) => ({
backgroundColor: 'rgba(255, 255, 255, .05)',
'& .MuiAccordionSummary-expandIconWrapper': {
transform: 'rotate(90deg)'
},
'& .MuiAccordionSummary-expandIconWrapper.Mui-expanded': {
transform: 'rotate(180deg)'
}
}))
const AccordionDetails = styled(MuiAccordionDetails)(({ theme }) => ({
padding: theme.spacing(2),
cursor: 'pointer',
display: 'flex',
alignItems: 'center'
}))
2. 通过数据创建需要递归生成的accordion
// level 用来进行下级菜单的位置缩进
function renderMenu(data: AnyObject[], level: number) {
if (data) {
return data.map((value: any) => {
value.level = level + 1
return renderSubMenu(value, value.level)
})
}
}
function renderSubMenu(data: NavData, level: number) {
// 没有children的直接通过AccordionDetails渲染
if (!data.children) {
return (
<AccordionDetails
className={`hover:text-green-700 `}
style={currentNav === data.path ? activeStyles : {}}
key={data.key}
onClick={handleClick(data)}
sx={{ pl: data.level }}
>
{data.level === 1 && data.icon}
<span className="text-size-14px ml-8px">{data.name}</span>
</AccordionDetails>
)
} else {
return (
// 有children的通过Accordion渲染,可进行展开收缩
<Accordion defaultExpanded={true} onChange={handleExpanded(data.key)} key={data.key}>
<AccordionSummary
expandIcon={data.children && <IconArrow />}
className={`hover:text-green-700`}
id={data.key}
onClick={handleClick(data)}
sx={{ pl: data.level }}
>
<div className="flex items-center">
<div className={` ${selected.includes(data.key) ? 'text-green-700' : ''}`}>
{data.level === 1 && data.icon}
</div>
<span
className={`font-rm text-size-14px ${data.level === 1 ? 'ml-4px' : 'ml-10px'} ${
selected.includes(data.key) ? 'text-green-700' : ''
}`}
>
{data.name}
</span>
</div>
</AccordionSummary>
// 递归继续渲染子级
{data.children && renderMenu(data.children, level + 1)}
</Accordion>
)
}
}
3. 逻辑部分
// 控制导航展开或关闭
const handleExpanded = (panel: string) => (event: React.SyntheticEvent, newExpanded: boolean) => {
setExpanded(newExpanded ? panel : false)
}
// 点击当前导航,修改路由,并通过state存储key, 用于每次刷新页面时高亮已选中的导航所有父级
const handleClick = (item: AnyObject) => () => {
if (!item.children) {
if (item.path !== pathname) {
navigate(item.path, { state: item.key })
setCurrentNav(item.path)
}
}
}
// 通过子级key获取所有父元素key,用于高亮展示当前导航的所有父级文案
function getAllParentArr(data: AnyObject[], key: string) {
for (const i in data) {
if (data[i].key === key) {
return [data[i]]
}
if (data[i]?.children) {
const node = getAllParentArr(data[i].children, key) as AnyObject[]
if (node) {
return node.concat(data[i])
}
}
}
}
// 路由改变时重置当前导航相关的所有父级
useEffect(() => {
setCurrentNav(pathname)
const result = getAllParentArr(menuList, state) as AnyObject[]
if (result && result.length) {
setSelected(result.map((item: AnyObject) => item.key))
}
}, [pathname])
4. menuList部分结构
import Icon from '~icons/icon.svg'
export const menuList = [
{
key: 'k-1',
name: 'Key1',
icon: <Icon />,
children: [
{
key: 'k-11',
name: 'Key11',
children: [
{
key: 'k-111',
name: 'Key111',
children: [
{
key: 'k-1111',
name: 'Key111',
path: '/key111'
}
]
}
]
}
]
},
{
key: 'demo',
name: 'Demo',
icon: <Icon />,
path: '/demo'
}
]