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)',
|
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}
|
||||||
|
|||||||
@@ -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} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
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