feat(ui): redesign grouped application shell

This commit is contained in:
cesnimda
2026-09-28 17:26:02 +02:00
parent 57a6bd02c6
commit 8e56ceabf5
30 changed files with 398 additions and 92 deletions
+116 -58
View File
@@ -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>