feat(navigation): add Material You app shell
This commit is contained in:
16
src/features/navigation-bar/MaterialYouNavigation.ts
Normal file
16
src/features/navigation-bar/MaterialYouNavigation.ts
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
}}
|
||||
>
|
||||
|
||||
25
src/features/theme/services/MaterialYouNavigation.test.ts
Normal file
25
src/features/theme/services/MaterialYouNavigation.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user