feat(navigation): add Material You app shell

This commit is contained in:
2026-07-23 01:03:56 +08:00
parent b62c7e6f31
commit 7a9520be9e
6 changed files with 114 additions and 30 deletions

View File

@@ -0,0 +1,16 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
export const MATERIAL_YOU_NAVIGATION = {
railWidth: 88,
expandedWidth: 280,
bottomBarMinHeight: 80,
touchTarget: 48,
activeIndicatorRadius: 999,
landmarkLabel: 'Primary navigation',
} as const;

View File

@@ -117,9 +117,11 @@ export function DefaultNavBar() {
pt: 'env(safe-area-inset-top)',
width: `calc(100% - ${actualNavBarWidth}px)`,
zIndex: theme.zIndex.drawer,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Toolbar sx={{ position: 'relative' }}>
<Toolbar sx={{ position: 'relative', minHeight: { xs: 64, sm: 72 } }}>
{!isMobileWidth && (
<Stack
sx={{
@@ -130,7 +132,11 @@ export function DefaultNavBar() {
alignItems: 'center',
}}
>
<IconButton aria-label="open drawer" onClick={() => setIsCollapsed(false)} color="inherit">
<IconButton
aria-label="Expand navigation"
onClick={() => setIsCollapsed(false)}
color="inherit"
>
<MenuIcon />
</IconButton>
</Stack>
@@ -156,12 +162,13 @@ export function DefaultNavBar() {
</IconButton>
)}
<Typography
variant="h5"
variant="h6"
component="h1"
noWrap
sx={{
textOverflow: 'ellipsis',
flexGrow: 1,
letterSpacing: '-0.01em',
}}
>
{title}

View File

@@ -11,7 +11,6 @@ import List from '@mui/material/List';
import IconButton from '@mui/material/IconButton';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import Divider from '@mui/material/Divider';
import { styled } from '@mui/material/styles';
import { useCallback, useRef } from 'react';
import Box from '@mui/material/Box';
@@ -20,21 +19,18 @@ import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
import type { NavbarItem } from '@/features/navigation-bar/NavigationBar.types.ts';
import { NavigationBarItem } from '@/features/navigation-bar/components/NavigationBarItem.tsx';
import { MATERIAL_YOU_NAVIGATION } from '@/features/navigation-bar/MaterialYouNavigation.ts';
const DrawerHeader = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
padding: theme.spacing(0, 1),
minHeight: 72,
padding: theme.spacing(1),
// necessary for content to be below app bar
...theme.mixins.toolbar,
}));
const MIN_WIDTH_COLLAPSED = undefined;
const MAX_WIDTH_COLLAPSED = 120;
const MIN_WIDTH_EXTENDED = 240;
const MAX_WIDTH_EXTENDED = 400;
export const DesktopSideBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) => {
const { isCollapsed, setIsCollapsed, navBarWidth, setNavBarWidth } = useNavBarContext();
const getOptionForDirection = useGetOptionForDirection();
@@ -52,11 +48,15 @@ export const DesktopSideBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) =
width: navBarWidth,
'& .MuiDrawer-paper': {
zIndex: (theme) => theme.zIndex.drawer - 1,
overflowX: 'hidden',
transition: (theme) => theme.transitions.create('width'),
},
}}
slotProps={{
paper: {
ref,
component: 'nav',
'aria-label': MATERIAL_YOU_NAVIGATION.landmarkLabel,
},
}}
>
@@ -64,17 +64,19 @@ export const DesktopSideBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) =
sx={{
pt: 'env(safe-area-inset-top)',
pl: 'env(safe-area-inset-left)',
minWidth: isCollapsed ? MIN_WIDTH_COLLAPSED : MIN_WIDTH_EXTENDED,
maxWidth: isCollapsed ? MAX_WIDTH_COLLAPSED : MAX_WIDTH_EXTENDED,
width: isCollapsed
? MATERIAL_YOU_NAVIGATION.railWidth
: MATERIAL_YOU_NAVIGATION.expandedWidth,
}}
>
<DrawerHeader>
<IconButton onClick={() => setIsCollapsed(true)}>
{getOptionForDirection(<ChevronLeftIcon />, <ChevronRightIcon />)}
</IconButton>
<DrawerHeader sx={{ justifyContent: isCollapsed ? 'center' : 'flex-end' }}>
{!isCollapsed && (
<IconButton aria-label="Collapse navigation" onClick={() => setIsCollapsed(true)}>
{getOptionForDirection(<ChevronLeftIcon />, <ChevronRightIcon />)}
</IconButton>
)}
</DrawerHeader>
<Divider />
<List sx={{ p: 1 }} dense={isCollapsed}>
<List sx={{ px: 1.5, py: 1, display: 'grid', gap: 0.5 }} dense={isCollapsed}>
{navBarItems.map((navBarItem) => (
<NavigationBarItem key={navBarItem.path} {...navBarItem} />
))}

View File

@@ -16,6 +16,7 @@ import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
import type { NavbarItem } from '@/features/navigation-bar/NavigationBar.types.ts';
import { NavigationBarItem } from '@/features/navigation-bar/components/NavigationBarItem.tsx';
import Stack from '@mui/material/Stack';
import { MATERIAL_YOU_NAVIGATION } from '@/features/navigation-bar/MaterialYouNavigation.ts';
export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) => {
const theme = useTheme();
@@ -30,6 +31,8 @@ export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] })
return (
<Paper
component="nav"
aria-label={MATERIAL_YOU_NAVIGATION.landmarkLabel}
ref={ref}
sx={{
position: 'fixed',
@@ -40,15 +43,18 @@ export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] })
pl: 'env(safe-area-inset-left)',
pr: 'env(safe-area-inset-right)',
zIndex: theme.zIndex.drawer - 1,
minHeight: MATERIAL_YOU_NAVIGATION.bottomBarMinHeight,
borderRadius: '28px 28px 0 0',
backgroundColor: 'background.paper',
}}
style={{
...(theme.applyStyles('dark', {
'--Paper-overlay': 'unset',
}) as CSSProperties),
}}
elevation={3}
elevation={0}
>
<Stack sx={{ flexDirection: 'row' }}>
<Stack sx={{ minHeight: MATERIAL_YOU_NAVIGATION.bottomBarMinHeight, flexDirection: 'row', px: 0.5 }}>
{navBarItems.map((item) => (
<NavigationBarItem
key={item.path}
@@ -56,7 +62,7 @@ export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] })
slots={{
listItemLink: {
sx: {
py: 1,
py: 0.75,
},
},
}}

View File

@@ -21,6 +21,7 @@ import Badge from '@mui/material/Badge';
import Typography from '@mui/material/Typography';
import { useLingui } from '@lingui/react/macro';
import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
import { MATERIAL_YOU_NAVIGATION } from '@/features/navigation-bar/MaterialYouNavigation.ts';
export const NavigationBarItem = ({
path,
@@ -49,8 +50,20 @@ export const NavigationBarItem = ({
const { listItemProps, listItemIconProps } = useMemo(
() => ({
listItemProps: isCollapsed ? { p: 0.5, display: 'flex', flexDirection: 'column' } : {},
listItemIconProps: isCollapsed ? { justifyContent: 'center' } : {},
listItemProps: isCollapsed
? { px: 0.5, py: 0.25, display: 'flex', flexDirection: 'column', gap: 0.25 }
: { px: 1.5, py: 1 },
listItemIconProps: isCollapsed
? {
width: 64,
minWidth: 64,
height: 32,
borderRadius: MATERIAL_YOU_NAVIGATION.activeIndicatorRadius,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: isActive ? 'primary.main' : 'transparent',
}
: { minWidth: MATERIAL_YOU_NAVIGATION.touchTarget },
}),
[isCollapsed],
);
@@ -59,7 +72,15 @@ export const NavigationBarItem = ({
<ListItemLink
{...slots?.listItemLink}
selected={!isCollapsed && isActive}
sx={{ p: 0, m: 0, ...slots?.listItemLink?.sx }}
aria-current={isActive ? 'page' : undefined}
sx={{
minHeight: MATERIAL_YOU_NAVIGATION.touchTarget,
p: 0,
m: 0,
borderRadius: isCollapsed ? 2 : MATERIAL_YOU_NAVIGATION.activeIndicatorRadius,
overflow: 'hidden',
...slots?.listItemLink?.sx,
}}
to={path}
>
<CustomTooltip
@@ -77,9 +98,12 @@ export const NavigationBarItem = ({
<Badge badgeContent={badgeInfo?.count} color="primary">
<Icon
sx={{
color: isActive ? 'primary.dark' : undefined,
...theme.applyStyles('dark', {
color: isActive ? 'primary.light' : undefined,
color: isCollapsed && isActive ? 'primary.contrastText' : undefined,
...(!isCollapsed && {
color: isActive ? 'primary.dark' : undefined,
...theme.applyStyles('dark', {
color: isActive ? 'primary.light' : undefined,
}),
}),
}}
/>
@@ -92,9 +116,13 @@ export const NavigationBarItem = ({
lines={1}
variant={isCollapsed ? 'caption' : undefined}
sx={{
color: isActive ? 'primary.dark' : undefined,
...theme.applyStyles('dark', {
color: isActive ? 'primary.light' : undefined,
color: isCollapsed && isActive ? 'text.primary' : undefined,
fontWeight: isActive ? 700 : 500,
...(!isCollapsed && {
color: isActive ? 'primary.dark' : undefined,
...theme.applyStyles('dark', {
color: isActive ? 'primary.light' : undefined,
}),
}),
}}
>

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { MATERIAL_YOU_NAVIGATION } from '@/features/navigation-bar/MaterialYouNavigation.ts';
describe('Material You navigation tokens', () => {
it('uses accessible rail and bottom-bar dimensions', () => {
assert.equal(MATERIAL_YOU_NAVIGATION.railWidth, 88);
assert.equal(MATERIAL_YOU_NAVIGATION.expandedWidth, 280);
assert.equal(MATERIAL_YOU_NAVIGATION.bottomBarMinHeight, 80);
assert.ok(MATERIAL_YOU_NAVIGATION.touchTarget >= 44);
});
it('uses pill indicators and an accessible landmark label', () => {
assert.equal(MATERIAL_YOU_NAVIGATION.activeIndicatorRadius, 999);
assert.equal(MATERIAL_YOU_NAVIGATION.landmarkLabel, 'Primary navigation');
});
});