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

View File

@@ -11,7 +11,6 @@ import List from '@mui/material/List';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import Divider from '@mui/material/Divider';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import Box from '@mui/material/Box'; 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 { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
import type { NavbarItem } from '@/features/navigation-bar/NavigationBar.types.ts'; import type { NavbarItem } from '@/features/navigation-bar/NavigationBar.types.ts';
import { NavigationBarItem } from '@/features/navigation-bar/components/NavigationBarItem.tsx'; import { NavigationBarItem } from '@/features/navigation-bar/components/NavigationBarItem.tsx';
import { MATERIAL_YOU_NAVIGATION } from '@/features/navigation-bar/MaterialYouNavigation.ts';
const DrawerHeader = styled('div')(({ theme }) => ({ const DrawerHeader = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'flex-end', justifyContent: 'flex-end',
padding: theme.spacing(0, 1), minHeight: 72,
padding: theme.spacing(1),
// necessary for content to be below app bar // necessary for content to be below app bar
...theme.mixins.toolbar, ...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[] }) => { export const DesktopSideBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) => {
const { isCollapsed, setIsCollapsed, navBarWidth, setNavBarWidth } = useNavBarContext(); const { isCollapsed, setIsCollapsed, navBarWidth, setNavBarWidth } = useNavBarContext();
const getOptionForDirection = useGetOptionForDirection(); const getOptionForDirection = useGetOptionForDirection();
@@ -52,11 +48,15 @@ export const DesktopSideBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) =
width: navBarWidth, width: navBarWidth,
'& .MuiDrawer-paper': { '& .MuiDrawer-paper': {
zIndex: (theme) => theme.zIndex.drawer - 1, zIndex: (theme) => theme.zIndex.drawer - 1,
overflowX: 'hidden',
transition: (theme) => theme.transitions.create('width'),
}, },
}} }}
slotProps={{ slotProps={{
paper: { paper: {
ref, ref,
component: 'nav',
'aria-label': MATERIAL_YOU_NAVIGATION.landmarkLabel,
}, },
}} }}
> >
@@ -64,17 +64,19 @@ export const DesktopSideBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) =
sx={{ sx={{
pt: 'env(safe-area-inset-top)', pt: 'env(safe-area-inset-top)',
pl: 'env(safe-area-inset-left)', pl: 'env(safe-area-inset-left)',
minWidth: isCollapsed ? MIN_WIDTH_COLLAPSED : MIN_WIDTH_EXTENDED, width: isCollapsed
maxWidth: isCollapsed ? MAX_WIDTH_COLLAPSED : MAX_WIDTH_EXTENDED, ? MATERIAL_YOU_NAVIGATION.railWidth
: MATERIAL_YOU_NAVIGATION.expandedWidth,
}} }}
> >
<DrawerHeader> <DrawerHeader sx={{ justifyContent: isCollapsed ? 'center' : 'flex-end' }}>
<IconButton onClick={() => setIsCollapsed(true)}> {!isCollapsed && (
{getOptionForDirection(<ChevronLeftIcon />, <ChevronRightIcon />)} <IconButton aria-label="Collapse navigation" onClick={() => setIsCollapsed(true)}>
</IconButton> {getOptionForDirection(<ChevronLeftIcon />, <ChevronRightIcon />)}
</IconButton>
)}
</DrawerHeader> </DrawerHeader>
<Divider /> <List sx={{ px: 1.5, py: 1, display: 'grid', gap: 0.5 }} dense={isCollapsed}>
<List sx={{ p: 1 }} dense={isCollapsed}>
{navBarItems.map((navBarItem) => ( {navBarItems.map((navBarItem) => (
<NavigationBarItem key={navBarItem.path} {...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 type { NavbarItem } from '@/features/navigation-bar/NavigationBar.types.ts';
import { NavigationBarItem } from '@/features/navigation-bar/components/NavigationBarItem.tsx'; import { NavigationBarItem } from '@/features/navigation-bar/components/NavigationBarItem.tsx';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import { MATERIAL_YOU_NAVIGATION } from '@/features/navigation-bar/MaterialYouNavigation.ts';
export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) => { export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] }) => {
const theme = useTheme(); const theme = useTheme();
@@ -30,6 +31,8 @@ export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] })
return ( return (
<Paper <Paper
component="nav"
aria-label={MATERIAL_YOU_NAVIGATION.landmarkLabel}
ref={ref} ref={ref}
sx={{ sx={{
position: 'fixed', position: 'fixed',
@@ -40,15 +43,18 @@ export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] })
pl: 'env(safe-area-inset-left)', pl: 'env(safe-area-inset-left)',
pr: 'env(safe-area-inset-right)', pr: 'env(safe-area-inset-right)',
zIndex: theme.zIndex.drawer - 1, zIndex: theme.zIndex.drawer - 1,
minHeight: MATERIAL_YOU_NAVIGATION.bottomBarMinHeight,
borderRadius: '28px 28px 0 0',
backgroundColor: 'background.paper',
}} }}
style={{ style={{
...(theme.applyStyles('dark', { ...(theme.applyStyles('dark', {
'--Paper-overlay': 'unset', '--Paper-overlay': 'unset',
}) as CSSProperties), }) 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) => ( {navBarItems.map((item) => (
<NavigationBarItem <NavigationBarItem
key={item.path} key={item.path}
@@ -56,7 +62,7 @@ export const MobileBottomBar = ({ navBarItems }: { navBarItems: NavbarItem[] })
slots={{ slots={{
listItemLink: { listItemLink: {
sx: { 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 Typography from '@mui/material/Typography';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx'; import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
import { MATERIAL_YOU_NAVIGATION } from '@/features/navigation-bar/MaterialYouNavigation.ts';
export const NavigationBarItem = ({ export const NavigationBarItem = ({
path, path,
@@ -49,8 +50,20 @@ export const NavigationBarItem = ({
const { listItemProps, listItemIconProps } = useMemo( const { listItemProps, listItemIconProps } = useMemo(
() => ({ () => ({
listItemProps: isCollapsed ? { p: 0.5, display: 'flex', flexDirection: 'column' } : {}, listItemProps: isCollapsed
listItemIconProps: isCollapsed ? { justifyContent: 'center' } : {}, ? { 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], [isCollapsed],
); );
@@ -59,7 +72,15 @@ export const NavigationBarItem = ({
<ListItemLink <ListItemLink
{...slots?.listItemLink} {...slots?.listItemLink}
selected={!isCollapsed && isActive} 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} to={path}
> >
<CustomTooltip <CustomTooltip
@@ -77,9 +98,12 @@ export const NavigationBarItem = ({
<Badge badgeContent={badgeInfo?.count} color="primary"> <Badge badgeContent={badgeInfo?.count} color="primary">
<Icon <Icon
sx={{ sx={{
color: isActive ? 'primary.dark' : undefined, color: isCollapsed && isActive ? 'primary.contrastText' : undefined,
...theme.applyStyles('dark', { ...(!isCollapsed && {
color: isActive ? 'primary.light' : undefined, color: isActive ? 'primary.dark' : undefined,
...theme.applyStyles('dark', {
color: isActive ? 'primary.light' : undefined,
}),
}), }),
}} }}
/> />
@@ -92,9 +116,13 @@ export const NavigationBarItem = ({
lines={1} lines={1}
variant={isCollapsed ? 'caption' : undefined} variant={isCollapsed ? 'caption' : undefined}
sx={{ sx={{
color: isActive ? 'primary.dark' : undefined, color: isCollapsed && isActive ? 'text.primary' : undefined,
...theme.applyStyles('dark', { fontWeight: isActive ? 700 : 500,
color: isActive ? 'primary.light' : undefined, ...(!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');
});
});