Files
lettersoup-online/002_source/cms/src/components/dashboard/layout/mobile-nav.tsx
louiscklaw 6c931c1fe8 build ok,
2025-04-14 09:26:24 +08:00

283 lines
9.3 KiB
TypeScript

import * as React from 'react';
import RouterLink from 'next/link';
import { usePathname } from 'next/navigation';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Drawer from '@mui/material/Drawer';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { ArrowSquareOut as ArrowSquareOutIcon } from '@phosphor-icons/react/dist/ssr/ArrowSquareOut';
import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown';
import { CaretRight as CaretRightIcon } from '@phosphor-icons/react/dist/ssr/CaretRight';
import type { NavItemConfig } from '@/types/nav';
import { paths } from '@/paths';
import { isNavItemActive } from '@/lib/is-nav-item-active';
import { Logo } from '@/components/core/logo';
import { icons } from './nav-icons';
import { WorkspacesSwitch } from './workspaces-switch';
export interface MobileNavProps {
onClose?: () => void;
open?: boolean;
items?: NavItemConfig[];
}
export function MobileNav({ items = [], open, onClose }: MobileNavProps): React.JSX.Element {
const pathname = usePathname();
return (
<Drawer
PaperProps={{
sx: {
'--MobileNav-background': 'var(--mui-palette-neutral-950)',
'--MobileNav-color': 'var(--mui-palette-common-white)',
'--NavGroup-title-color': 'var(--mui-palette-neutral-400)',
'--NavItem-color': 'var(--mui-palette-neutral-300)',
'--NavItem-hover-background': 'rgba(255, 255, 255, 0.04)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-400)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-600)',
'--NavItem-expand-color': 'var(--mui-palette-neutral-400)',
'--NavItem-children-border': 'var(--mui-palette-neutral-700)',
'--NavItem-children-indicator': 'var(--mui-palette-neutral-400)',
'--Workspaces-background': 'var(--mui-palette-neutral-950)',
'--Workspaces-border-color': 'var(--mui-palette-neutral-700)',
'--Workspaces-title-color': 'var(--mui-palette-neutral-400)',
'--Workspaces-name-color': 'var(--mui-palette-neutral-300)',
'--Workspaces-expand-color': 'var(--mui-palette-neutral-400)',
bgcolor: 'var(--MobileNav-background)',
color: 'var(--MobileNav-color)',
display: 'flex',
flexDirection: 'column',
maxWidth: '100%',
width: 'var(--MobileNav-width)',
zIndex: 'var(--MobileNav-zIndex)',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
},
}}
onClose={onClose}
open={open}
>
<Stack spacing={2} sx={{ p: 2 }}>
<div>
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-flex' }}>
<Logo color="light" height={32} width={122} />
</Box>
</div>
<WorkspacesSwitch />
</Stack>
<Box component="nav" sx={{ flex: '1 1 auto', p: 2 }}>
{renderNavGroups({ items, onClose, pathname })}
</Box>
</Drawer>
);
}
function renderNavGroups({
items,
onClose,
pathname,
}: {
items: NavItemConfig[];
onClose?: () => void;
pathname: string;
}): React.JSX.Element {
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
acc.push(
<Stack component="li" key={curr.key} spacing={1.5}>
{curr.title ? (
<div>
<Typography sx={{ color: 'var(--NavGroup-title-color)', fontSize: '0.875rem', fontWeight: 500 }}>
{curr.title}
</Typography>
</div>
) : null}
<div>{renderNavItems({ depth: 0, items: curr.items, onClose, pathname })}</div>
</Stack>
);
return acc;
}, []);
return (
<Stack component="ul" spacing={2} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{children}
</Stack>
);
}
function renderNavItems({
depth = 0,
items = [],
onClose,
pathname,
}: {
depth: number;
items?: NavItemConfig[];
onClose?: () => void;
pathname: string;
}): React.JSX.Element {
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
const { items: childItems, key, ...item } = curr;
const forceOpen = childItems
? Boolean(childItems.find((childItem) => childItem.href && pathname.startsWith(childItem.href)))
: false;
acc.push(
<NavItem depth={depth} forceOpen={forceOpen} key={key} onClose={onClose} pathname={pathname} {...item}>
{childItems ? renderNavItems({ depth: depth + 1, items: childItems, onClose, pathname }) : null}
</NavItem>
);
return acc;
}, []);
return (
<Stack component="ul" data-depth={depth} spacing={1} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{children}
</Stack>
);
}
interface NavItemProps extends Omit<NavItemConfig, 'items'> {
children?: React.ReactNode;
depth: number;
forceOpen?: boolean;
onClose?: () => void;
pathname: string;
}
function NavItem({
children,
depth,
disabled,
external,
forceOpen = false,
href,
icon,
label,
matcher,
onClose,
pathname,
title,
}: NavItemProps): React.JSX.Element {
const [open, setOpen] = React.useState<boolean>(forceOpen);
const active = isNavItemActive({ disabled, external, href, matcher, pathname });
const Icon = icon ? icons[icon] : null;
const ExpandIcon = open ? CaretDownIcon : CaretRightIcon;
const isBranch = children && !href;
const showChildren = Boolean(children && open);
return (
<Box component="li" data-depth={depth} sx={{ userSelect: 'none' }}>
<Box
{...(isBranch
? {
onClick: (): void => {
setOpen(!open);
},
onKeyUp: (event: React.KeyboardEvent<HTMLElement>): void => {
if (event.key === 'Enter' || event.key === ' ') {
setOpen(!open);
}
},
role: 'button',
}
: {
...(href
? {
component: external ? 'a' : RouterLink,
href,
target: external ? '_blank' : undefined,
rel: external ? 'noreferrer' : undefined,
onClick: (): void => {
onClose?.();
},
}
: { role: 'button' }),
})}
sx={{
alignItems: 'center',
borderRadius: 1,
color: 'var(--NavItem-color)',
cursor: 'pointer',
display: 'flex',
flex: '0 0 auto',
gap: 1,
p: '6px 16px',
position: 'relative',
textDecoration: 'none',
whiteSpace: 'nowrap',
...(disabled && {
bgcolor: 'var(--NavItem-disabled-background)',
color: 'var(--NavItem-disabled-color)',
cursor: 'not-allowed',
}),
...(active && {
bgcolor: 'var(--NavItem-active-background)',
color: 'var(--NavItem-active-color)',
...(depth > 0 && {
'&::before': {
bgcolor: 'var(--NavItem-children-indicator)',
borderRadius: '2px',
content: '" "',
height: '20px',
left: '-14px',
position: 'absolute',
width: '3px',
},
}),
}),
...(open && { color: 'var(--NavItem-open-color)' }),
'&:hover': {
...(!disabled &&
!active && { bgcolor: 'var(--NavItem-hover-background)', color: 'var(--NavItem-hover-color)' }),
},
}}
tabIndex={0}
>
<Box sx={{ alignItems: 'center', display: 'flex', justifyContent: 'center', flex: '0 0 auto' }}>
{Icon ? (
<Icon
fill={active ? 'var(--NavItem-icon-active-color)' : 'var(--NavItem-icon-color)'}
fontSize="var(--icon-fontSize-md)"
weight={forceOpen || active ? 'fill' : undefined}
/>
) : null}
</Box>
<Box sx={{ flex: '1 1 auto' }}>
<Typography
component="span"
sx={{ color: 'inherit', fontSize: '0.875rem', fontWeight: 500, lineHeight: '28px' }}
>
{title}
</Typography>
</Box>
{label ? <Chip color="primary" label={label} size="small" /> : null}
{external ? (
<Box sx={{ alignItems: 'center', display: 'flex', flex: '0 0 auto' }}>
<ArrowSquareOutIcon color="var(--NavItem-icon-color)" fontSize="var(--icon-fontSize-sm)" />
</Box>
) : null}
{isBranch ? (
<Box sx={{ alignItems: 'center', display: 'flex', flex: '0 0 auto' }}>
<ExpandIcon color="var(--NavItem-expand-color)" fontSize="var(--icon-fontSize-sm)" />
</Box>
) : null}
</Box>
{showChildren ? (
<Box sx={{ pl: '24px' }}>
<Box sx={{ borderLeft: '1px solid var(--NavItem-children-border)', pl: '12px' }}>{children}</Box>
</Box>
) : null}
</Box>
);
}