diff options
Diffstat (limited to 'src/layouts')
| -rw-r--r-- | src/layouts/BasicLayout.module.css | 56 | ||||
| -rw-r--r-- | src/layouts/BasicLayout.tsx | 225 |
2 files changed, 281 insertions, 0 deletions
diff --git a/src/layouts/BasicLayout.module.css b/src/layouts/BasicLayout.module.css new file mode 100644 index 0000000..a010ca5 --- /dev/null +++ b/src/layouts/BasicLayout.module.css @@ -0,0 +1,56 @@ +.main { + display: flex; + flex-direction: column; +} + +.content { + flex: 1; + min-width: 0; +} + +.footer { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border-top: 1px solid var(--app-shell-border-color); +} + +.menuItem { + justify-content: flex-start; +} + +.menuItem .icon[data-position='left'] { + width: calc(var(--collapsed-navigation-width) - 4 * var(--mantine-spacing-xs)); + flex-shrink: 0; + justify-content: center; + margin: 0; +} + +.label { + padding-inline-start: var(--mantine-spacing-xs); +} + +.menuItem[data-collapsed] .label { + visibility: hidden; + flex: 0 0 auto; + width: 0; + padding-inline-start: 0; +} + +.brand { + height: var(--app-shell-header-height); + flex-shrink: 0; + display: flex; + align-items: center; + border-bottom: 1px solid var(--app-shell-border-color); + white-space: nowrap; +} + +.brand[data-collapsed] { + justify-content: center; +} + +.menuItem[data-collapsed] .icon[data-position='right'] { + display: none; +} diff --git a/src/layouts/BasicLayout.tsx b/src/layouts/BasicLayout.tsx new file mode 100644 index 0000000..6af8564 --- /dev/null +++ b/src/layouts/BasicLayout.tsx @@ -0,0 +1,225 @@ +import { useEffect, useState } from 'react' +import { + AppShell, + ActionIcon, + Box, + Burger, + Drawer, + Group, + NavLink, + Stack, + Text, + Tooltip, + rem, + useMantineTheme, +} from '@mantine/core' +import { useDisclosure, useMediaQuery } from '@mantine/hooks' +import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react' +import { NavLink as RouterNavLink, Outlet, useLocation, useMatches } from 'react-router' +import { pageRoutes } from '../routes.ts' +import type { PageRoute } from '../routes.ts' +import classes from './BasicLayout.module.css' + +const collapsedNavbarWidth = rem(64) + +type NavigationProps = { + onNavigate?: () => void + collapsed?: boolean + routes?: PageRoute[] + parentPath?: string +} + +function NavigationItem({ + route, + parentPath, + collapsed, + onNavigate, +}: { + route: PageRoute + parentPath: string + collapsed: boolean + onNavigate?: () => void +}) { + const matches = useMatches() + const active = matches.some((match) => match.id === route.id) + const selected = matches.at(-1)?.id === route.id + const [opened, setOpened] = useState(active) + + if (!route.handle) return null + + const { title: label, icon: Icon } = route.handle + const to = !route.path + ? parentPath + : route.path.startsWith('/') + ? route.path + : `${parentPath.replace(/\/$/, '')}/${route.path}` + const hasChildren = route.children?.some((child) => child.handle) + const sharedProps = { + label, + noWrap: true, + p: 'xs', + 'aria-label': label, + leftSection: <Icon size={20} aria-hidden="true" />, + active, + 'data-collapsed': collapsed || undefined, + classNames: { + root: classes.menuItem, + section: classes.icon, + body: classes.label, + }, + } + + return ( + <Tooltip label={label} position="right" disabled={!collapsed} + events={{ hover: true, focus: true, touch: false }}> + {hasChildren ? ( + <NavLink + {...sharedProps} + component="button" + variant={selected ? 'light' : 'subtle'} + type="button" + opened={opened} + onChange={setOpened} + childrenOffset={collapsed ? 0 : 'xs'} + > + <Navigation + routes={route.children} + parentPath={to} + collapsed={collapsed} + onNavigate={onNavigate} + /> + </NavLink> + ) : ( + <NavLink + {...sharedProps} + component={RouterNavLink} + to={to} + end + onClick={onNavigate} + /> + )} + </Tooltip> + ) +} + +function Navigation({ + onNavigate, + collapsed = false, + routes = pageRoutes, + parentPath = '/', +}: NavigationProps) { + const { key } = useLocation() + + return ( + <Stack gap="xs" style={{ '--collapsed-navigation-width': collapsedNavbarWidth }}> + {routes.map((route) => ( + <NavigationItem + key={`${route.id}:${key}`} + route={route} + parentPath={parentPath} + collapsed={collapsed} + onNavigate={onNavigate} + /> + ))} + </Stack> + ) +} + +export function BasicLayout() { + const theme = useMantineTheme() + const desktop = useMediaQuery(`(min-width: ${theme.breakpoints.sm})`) + const [opened, { toggle, close }] = useDisclosure(false) + const [sidebarOpened, { toggle: toggleSidebar }] = useDisclosure(true) + const { key } = useLocation() + const matches = useMatches() + const title = matches + .map(({ handle }) => ( + handle && typeof handle === 'object' && 'title' in handle && typeof handle.title === 'string' + ? handle.title + : undefined + )) + .findLast((value) => value !== undefined) ?? 'Template React' + + useEffect(() => { + close() + }, [key, close]) + + useEffect(() => { + if (desktop) close() + }, [desktop, close]) + + return ( + <AppShell + layout="alt" + header={{ height: 60 }} + navbar={{ + width: sidebarOpened ? 240 : collapsedNavbarWidth, + breakpoint: 'sm', + collapsed: { mobile: true }, + }} + > + <AppShell.Navbar id="desktop-navigation" aria-label="主导航"> + <Text fw={700} size="lg" px="xs" className={classes.brand} + data-collapsed={!sidebarOpened || undefined} aria-label="Template React"> + {sidebarOpened ? 'Template React' : 'TR'} + </Text> + <Box p="xs"> + <Navigation collapsed={!sidebarOpened} /> + </Box> + </AppShell.Navbar> + + <AppShell.Header> + <Group h="100%" px="md"> + <ActionIcon + onClick={toggleSidebar} + visibleFrom="sm" + size="lg" + variant="subtle" + color="gray" + aria-label={sidebarOpened ? '折叠菜单' : '展开菜单'} + aria-expanded={sidebarOpened} + aria-controls="desktop-navigation" + > + {sidebarOpened + ? <CaretLeftIcon size={20} aria-hidden="true" /> + : <CaretRightIcon size={20} aria-hidden="true" />} + </ActionIcon> + <Burger + opened={opened} + onClick={toggle} + hiddenFrom="sm" + size="sm" + aria-label={opened ? '关闭菜单' : '打开菜单'} + aria-expanded={opened} + aria-controls="mobile-navigation" + /> + <Text fw={600}>{title}</Text> + </Group> + </AppShell.Header> + + <AppShell.Main component="div" className={classes.main}> + <Box component="main" p="md" className={classes.content}> + <Outlet /> + </Box> + <Box component="footer" px="md" py="xs" className={classes.footer}> + <Text size="xs" c="dimmed" ta="center"> + © {new Date().getFullYear()} Template React. 仅供示例使用。 + </Text> + </Box> + </AppShell.Main> + + <Drawer + opened={opened && !desktop} + onClose={close} + title="Template React" + position="left" + size="100%" + closeButtonProps={{ 'aria-label': '关闭菜单' }} + > + <nav id="mobile-navigation" aria-label="移动端主导航"> + <Navigation onNavigate={close} /> + </nav> + </Drawer> + </AppShell> + ) +} |
