feat(ui): redesign grouped application shell
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
AppBar,
|
||||
@@ -43,6 +43,8 @@ export type NavItem = {
|
||||
hidden?: boolean;
|
||||
section?: string;
|
||||
badgeCount?: number;
|
||||
children?: NavItem[];
|
||||
activeFor?: string[];
|
||||
};
|
||||
|
||||
function initialsFrom(s?: string) {
|
||||
@@ -69,6 +71,19 @@ export function activeNavTo(pathname: string, tos: string[]): string | null {
|
||||
return best;
|
||||
}
|
||||
|
||||
export function activeNavItemTo(pathname: string, items: NavItem[]): string | null {
|
||||
const matches: Array<{ to: string; matchLength: number }> = [];
|
||||
const visit = (item: NavItem) => {
|
||||
for (const candidate of [item.to, ...(item.activeFor ?? [])]) {
|
||||
const owns = pathname === candidate || pathname.startsWith(candidate + "/");
|
||||
if (owns) matches.push({ to: item.to, matchLength: candidate.length });
|
||||
}
|
||||
item.children?.filter((child) => !child.hidden).forEach(visit);
|
||||
};
|
||||
items.filter((item) => !item.hidden).forEach(visit);
|
||||
return matches.sort((a, b) => b.matchLength - a.matchLength)[0]?.to ?? null;
|
||||
}
|
||||
|
||||
// Preserve the existing inverse navigation surface in both themes.
|
||||
const SIDEBAR_BG = "var(--palette-navigation-background)";
|
||||
const SIDEBAR_BORDER = "var(--palette-navigation-border)";
|
||||
@@ -132,6 +147,9 @@ export default function AppShell({
|
||||
const navCollapsed = !isMobile && desktopNavCollapsed;
|
||||
const [header, setHeader] = useState<HTMLElement | null>(null);
|
||||
const [headerHeight, setHeaderHeight] = useState<number>();
|
||||
const [secondaryMenu, setSecondaryMenu] = useState<{ anchor: HTMLElement; item: NavItem } | null>(null);
|
||||
const pageTitleRef = useRef<HTMLHeadingElement>(null);
|
||||
const pendingFocusPath = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile && drawerOpen) onToggleDrawer(false);
|
||||
@@ -154,6 +172,29 @@ export default function AppShell({
|
||||
}
|
||||
}, [desktopNavCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingFocusPath.current) return;
|
||||
const shouldFocus = pendingFocusPath.current === pathname;
|
||||
pendingFocusPath.current = null;
|
||||
if (shouldFocus) pageTitleRef.current?.focus({ preventScroll: true });
|
||||
}, [pathname]);
|
||||
|
||||
const navigateFromShell = (to: string) => {
|
||||
if (pathname === to) {
|
||||
pageTitleRef.current?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
pendingFocusPath.current = to;
|
||||
setSecondaryMenu(null);
|
||||
onNavigate(to);
|
||||
};
|
||||
|
||||
const runShellRouteAction = (to: string, action?: () => void) => {
|
||||
if (pathname === to) pageTitleRef.current?.focus({ preventScroll: true });
|
||||
else pendingFocusPath.current = to;
|
||||
action?.();
|
||||
};
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groupItems = (items: NavItem[]) => {
|
||||
const map = new Map<string, NavItem[]>();
|
||||
@@ -172,7 +213,7 @@ export default function AppShell({
|
||||
|
||||
// Compute the one active destination across BOTH nav lists, so the most specific route wins.
|
||||
const activeTo = useMemo(
|
||||
() => activeNavTo(pathname, [...nav, ...navBottom].map((i) => i.to)),
|
||||
() => activeNavItemTo(pathname, [...nav, ...navBottom]),
|
||||
[pathname, nav, navBottom],
|
||||
);
|
||||
|
||||
@@ -181,47 +222,66 @@ export default function AppShell({
|
||||
{groups.map(([section, rows]) => (
|
||||
<Box key={section || "_"} sx={{ mb: navCollapsed ? 1 : 1.25 }}>
|
||||
{section && !navCollapsed ? (
|
||||
<Typography variant="caption" sx={{ px: 1.25, color: SIDEBAR_TEXT_MUTED, fontWeight: 600, textTransform: "uppercase" }}>
|
||||
<Typography variant="caption" sx={{ px: 1.25, color: SIDEBAR_TEXT_MUTED, fontWeight: 700, letterSpacing: "0.04em" }}>
|
||||
{section}
|
||||
</Typography>
|
||||
) : null}
|
||||
<List sx={{ px: navCollapsed ? 0.25 : 0.75, pt: navCollapsed ? 0.25 : 0.75 }}>
|
||||
<List aria-label={section || t("navigation")} sx={{ px: navCollapsed ? 0.25 : 0.75, pt: navCollapsed ? 0.25 : 0.75 }}>
|
||||
{rows.map((item) => {
|
||||
const selected = item.to === activeTo;
|
||||
const selected = item.to === activeTo || Boolean(item.children?.some((child) => child.to === activeTo));
|
||||
const parentButton = (
|
||||
<ListItemButton
|
||||
selected={selected}
|
||||
onClick={(event) => navCollapsed && item.children?.length
|
||||
? setSecondaryMenu({ anchor: event.currentTarget, item })
|
||||
: navigateFromShell(item.to)}
|
||||
aria-label={item.label}
|
||||
aria-current={item.to === activeTo ? "page" : selected ? "location" : undefined}
|
||||
aria-haspopup={navCollapsed && item.children?.length ? "menu" : undefined}
|
||||
aria-expanded={navCollapsed && secondaryMenu?.item.to === item.to ? true : undefined}
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
minHeight: 44,
|
||||
px: navCollapsed ? 1 : 1.25,
|
||||
justifyContent: navCollapsed ? "center" : "flex-start",
|
||||
border: "1px solid transparent",
|
||||
color: SIDEBAR_TEXT_MUTED,
|
||||
position: "relative",
|
||||
"&:hover": { backgroundColor: "var(--palette-navigation-hover)", color: SIDEBAR_TEXT },
|
||||
"&.Mui-selected": { backgroundColor: SIDEBAR_SELECTED_BG, color: SIDEBAR_SELECTED_TEXT },
|
||||
"&.Mui-selected::before": { content: '""', position: "absolute", insetInlineStart: 0, top: 9, bottom: 9, width: 3, borderRadius: 2, bgcolor: SIDEBAR_SELECTED_ICON },
|
||||
"&.Mui-selected:hover": { backgroundColor: SIDEBAR_SELECTED_BG },
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: navCollapsed ? 0 : 36, justifyContent: "center", color: selected ? SIDEBAR_SELECTED_ICON : SIDEBAR_TEXT_MUTED }}>
|
||||
{item.badgeCount && item.badgeCount > 0 ? (
|
||||
<Badge color="error" badgeContent={item.badgeCount > 99 ? "99+" : item.badgeCount}>{item.icon}</Badge>
|
||||
) : item.icon}
|
||||
</ListItemIcon>
|
||||
{!navCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: selected ? 700 : 600 }} /> : null}
|
||||
</ListItemButton>
|
||||
);
|
||||
return (
|
||||
<ListItem key={item.to} disablePadding sx={{ mb: 0.5 }}>
|
||||
<ListItemButton
|
||||
selected={selected}
|
||||
onClick={() => onNavigate(item.to)}
|
||||
aria-label={item.label}
|
||||
aria-current={selected ? "page" : undefined}
|
||||
title={navCollapsed ? item.label : undefined}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
minHeight: 44,
|
||||
px: navCollapsed ? 1 : 1.5,
|
||||
justifyContent: navCollapsed ? "center" : "flex-start",
|
||||
border: "1px solid transparent",
|
||||
color: SIDEBAR_TEXT_MUTED,
|
||||
"&:hover": { backgroundColor: "var(--palette-navigation-hover)", color: SIDEBAR_TEXT },
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: SIDEBAR_SELECTED_BG,
|
||||
color: SIDEBAR_SELECTED_TEXT,
|
||||
},
|
||||
"&.Mui-selected:hover": {
|
||||
backgroundColor: SIDEBAR_SELECTED_BG,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: navCollapsed ? 0 : 36, justifyContent: "center", color: selected ? SIDEBAR_SELECTED_ICON : SIDEBAR_TEXT_MUTED }}>
|
||||
{item.badgeCount && item.badgeCount > 0 ? (
|
||||
<Badge color="error" badgeContent={item.badgeCount > 99 ? "99+" : item.badgeCount}>
|
||||
{item.icon}
|
||||
</Badge>
|
||||
) : item.icon}
|
||||
</ListItemIcon>
|
||||
{!navCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} /> : null}
|
||||
</ListItemButton>
|
||||
<ListItem key={item.to} disablePadding sx={{ mb: 0.375, display: "block" }}>
|
||||
{navCollapsed ? <Tooltip title={item.label} placement="right">{parentButton}</Tooltip> : parentButton}
|
||||
{!navCollapsed && item.children?.length ? (
|
||||
<List disablePadding aria-label={item.label} sx={{ mb: 0.5 }}>
|
||||
{item.children.filter((child) => !child.hidden).map((child) => {
|
||||
const childSelected = child.to === activeTo;
|
||||
return <ListItem key={child.to} disablePadding>
|
||||
<ListItemButton
|
||||
selected={childSelected}
|
||||
aria-current={childSelected ? "page" : undefined}
|
||||
onClick={() => navigateFromShell(child.to)}
|
||||
sx={{ minHeight: 36, borderRadius: 1.5, pl: 5.75, pr: 1.25, py: 0.5, color: SIDEBAR_TEXT_MUTED, "&:hover": { backgroundColor: "var(--palette-navigation-hover)", color: SIDEBAR_TEXT }, "&.Mui-selected": { backgroundColor: SIDEBAR_SELECTED_BG, color: SIDEBAR_SELECTED_TEXT }, "&.Mui-selected:hover": { backgroundColor: SIDEBAR_SELECTED_BG } }}
|
||||
>
|
||||
<ListItemText primary={child.label} primaryTypographyProps={{ variant: "body2", fontWeight: childSelected ? 700 : 500 }} />
|
||||
{child.badgeCount && child.badgeCount > 0 ? <Badge color="error" badgeContent={child.badgeCount > 99 ? "99+" : child.badgeCount} /> : null}
|
||||
</ListItemButton>
|
||||
</ListItem>;
|
||||
})}
|
||||
</List>
|
||||
) : null}
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
@@ -232,7 +292,7 @@ export default function AppShell({
|
||||
);
|
||||
|
||||
const drawerContent = (
|
||||
<Box sx={{ "--app-focus-color": "var(--palette-focus-inverse)", height: "100%", display: "flex", flexDirection: "column", backgroundColor: SIDEBAR_BG }}>
|
||||
<Box component="nav" aria-label={t("navigation")} sx={{ "--app-focus-color": "var(--palette-focus-inverse)", flex: 1, minHeight: 0, overflowY: "auto", display: "flex", flexDirection: "column", backgroundColor: SIDEBAR_BG }}>
|
||||
<Box sx={{ px: navCollapsed ? 1.5 : 2.25, py: navCollapsed ? 2 : 2.5, display: "flex", justifyContent: navCollapsed ? "center" : "flex-start" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: navCollapsed ? "center" : "flex-start" }}>
|
||||
<JobbjaktMark style={{ width: 30, height: 30, flexShrink: 0 }} />
|
||||
@@ -251,13 +311,17 @@ export default function AppShell({
|
||||
|
||||
<Divider sx={{ borderColor: SIDEBAR_BORDER }} />
|
||||
|
||||
{renderNavList(grouped.top)}
|
||||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box sx={{ flex: 1, minHeight: 0, overflowY: "auto" }}>{renderNavList(grouped.top)}</Box>
|
||||
|
||||
<Divider sx={{ borderColor: SIDEBAR_BORDER }} />
|
||||
|
||||
{renderNavList(grouped.bottom)}
|
||||
|
||||
<Menu anchorEl={secondaryMenu?.anchor} open={Boolean(secondaryMenu)} onClose={() => setSecondaryMenu(null)} anchorOrigin={{ vertical: "top", horizontal: "right" }}>
|
||||
{secondaryMenu ? [secondaryMenu.item, ...(secondaryMenu.item.children ?? [])].filter((item) => !item.hidden).map((item) => (
|
||||
<MenuItem key={item.to} selected={item.to === activeTo} onClick={() => navigateFromShell(item.to)}>{item.label}</MenuItem>
|
||||
)) : null}
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -289,6 +353,9 @@ export default function AppShell({
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", minHeight: "100vh" }}>
|
||||
<Box component="a" href="#app-main-content" sx={{ position: "fixed", top: 8, left: 8, zIndex: (theme) => theme.zIndex.tooltip + 1, px: 2, py: 1, borderRadius: 1, bgcolor: "background.paper", color: "text.primary", transform: "translateY(-160%)", "&:focus": { transform: "translateY(0)" } }}>
|
||||
{t("skipToMainContent")}
|
||||
</Box>
|
||||
<AppBar
|
||||
ref={setHeader}
|
||||
position="fixed"
|
||||
@@ -368,7 +435,7 @@ export default function AppShell({
|
||||
size="small"
|
||||
title={t("settings")}
|
||||
aria-label={t("settings")}
|
||||
onClick={onOpenSettings}
|
||||
onClick={() => runShellRouteAction("/settings", onOpenSettings)}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
||||
>
|
||||
<SettingsOutlinedIcon fontSize="small" />
|
||||
@@ -434,7 +501,7 @@ export default function AppShell({
|
||||
size="small"
|
||||
title={t("settings")}
|
||||
aria-label={t("settings")}
|
||||
onClick={onOpenSettings}
|
||||
onClick={() => runShellRouteAction("/settings", onOpenSettings)}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<SettingsOutlinedIcon fontSize="small" />
|
||||
@@ -483,7 +550,7 @@ export default function AppShell({
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setUserMenuAnchor(null);
|
||||
onOpenProfile?.();
|
||||
runShellRouteAction("/profile", onOpenProfile);
|
||||
}}
|
||||
>
|
||||
{t("profileMenu")}
|
||||
@@ -491,7 +558,7 @@ export default function AppShell({
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setUserMenuAnchor(null);
|
||||
onOpenSettings?.();
|
||||
runShellRouteAction("/settings", onOpenSettings);
|
||||
}}
|
||||
>
|
||||
{t("settingsMenu")}
|
||||
@@ -556,6 +623,8 @@ export default function AppShell({
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
id="app-main-content"
|
||||
tabIndex={-1}
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: "var(--app-page-gutter)",
|
||||
@@ -577,18 +646,7 @@ export default function AppShell({
|
||||
</Typography>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
{pageLayout ? <PageHeader title={pageTitle} description={pageSubtitle} /> : (
|
||||
<>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>
|
||||
{pageTitle}
|
||||
</Typography>
|
||||
{pageSubtitle ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, overflowWrap: "anywhere" }}>
|
||||
{pageSubtitle}
|
||||
</Typography>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<PageHeader title={pageTitle} description={pageSubtitle} titleRef={pageTitleRef} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user