feat(ui): implement approved A3 hybrid navigation

This commit is contained in:
cesnimda
2026-11-03 08:47:19 +01:00
parent 0d6de71403
commit 992608c77f
72 changed files with 1492 additions and 20 deletions
+66 -4
View File
@@ -12,6 +12,7 @@ import {
Chip,
Divider,
Drawer,
GlobalStyles,
IconButton,
List,
ListItem,
@@ -32,6 +33,7 @@ import MenuIcon from "@mui/icons-material/Menu";
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import JobbjaktMark from "../assets/JobbjaktMark";
import { PageHeader, type PageWidth } from "./PageLayout";
@@ -137,7 +139,8 @@ export default function AppShell({
}) {
const { language, setLanguage, t } = useI18n();
const notificationCountId = useId();
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("md"));
const mobileDrawerId = useId();
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("sm"));
const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => {
try {
return window.localStorage.getItem(DESKTOP_SIDEBAR_KEY) === "1";
@@ -149,9 +152,12 @@ export default function AppShell({
const navCollapsed = !isMobile && desktopNavCollapsed;
const [header, setHeader] = useState<HTMLElement | null>(null);
const [headerHeight, setHeaderHeight] = useState<number>();
const [bottomNavigation, setBottomNavigation] = useState<HTMLElement | null>(null);
const [bottomNavigationHeight, setBottomNavigationHeight] = useState(0);
const [secondaryMenu, setSecondaryMenu] = useState<{ anchor: HTMLElement; item: NavItem } | null>(null);
const pageTitleRef = useRef<HTMLHeadingElement>(null);
const pendingFocusPath = useRef<string | null>(null);
const focusAfterDrawerNavigation = useRef(false);
useEffect(() => {
if (!isMobile && drawerOpen) onToggleDrawer(false);
@@ -166,6 +172,16 @@ export default function AppShell({
return () => observer.disconnect();
}, [header]);
useEffect(() => {
if (!bottomNavigation) { setBottomNavigationHeight(0); return; }
const measure = () => setBottomNavigationHeight(Math.ceil(bottomNavigation.getBoundingClientRect().height));
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(bottomNavigation);
return () => observer.disconnect();
}, [bottomNavigation]);
useEffect(() => {
try {
window.localStorage.setItem(DESKTOP_SIDEBAR_KEY, desktopNavCollapsed ? "1" : "0");
@@ -182,12 +198,16 @@ export default function AppShell({
}, [pathname]);
const navigateFromShell = (to: string) => {
setSecondaryMenu(null);
if (isMobile && drawerOpen) {
focusAfterDrawerNavigation.current = true;
onToggleDrawer(false);
}
if (pathname === to) {
pageTitleRef.current?.focus({ preventScroll: true });
return;
}
pendingFocusPath.current = to;
setSecondaryMenu(null);
onNavigate(to);
};
@@ -218,6 +238,22 @@ export default function AppShell({
() => activeNavItemTo(pathname, [...nav, ...navBottom]),
[pathname, nav, navBottom],
);
const mobilePrimary = useMemo(() => {
const visible: NavItem[] = [];
const visit = (item: NavItem) => {
if (item.hidden) return;
visible.push(item); item.children?.forEach(visit);
};
[...nav, ...navBottom].forEach(visit);
return ["/dashboard", "/jobs", "/discover", "/career"]
.map(to => visible.find(item => item.to === to)).filter((item): item is NavItem => Boolean(item));
}, [nav, navBottom]);
const mobileActive = mobilePrimary.find(item => [item.to, ...(item.activeFor ?? [])]
.some(to => pathname === to || pathname.startsWith(`${to}/`)))?.to;
const mobileLabels: Record<string, string> = {
"/dashboard": t("dashboard"), "/jobs": t("navigationApplications"),
"/discover": t("navigationDiscover"), "/career": t("career"),
};
const desktopNavigationRef = useRef<HTMLDivElement>(null);
const mobileNavigationRef = useRef<HTMLDivElement>(null);
// New destinations can extend the existing scrollable list below its initial viewport.
@@ -364,6 +400,7 @@ export default function AppShell({
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
<GlobalStyles styles={{ ":root": { "--app-bottom-nav-height": `${isMobile ? Math.max(72, bottomNavigationHeight) : 0}px` } }} />
<Box component="span" id={notificationCountId} role="status" aria-live="polite" aria-atomic="true" sx={visuallyHidden}>
{notificationsCount === undefined ? t("notificationsUnreadCountUnavailable") : t("notificationsUnreadCount", { count: notificationsCount })}
</Box>
@@ -403,6 +440,7 @@ export default function AppShell({
aria-label={t("openNavigation")}
aria-haspopup="dialog"
aria-expanded={drawerOpen}
aria-controls={mobileDrawerId}
onClick={() => onToggleDrawer(true)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
>
@@ -621,9 +659,14 @@ export default function AppShell({
onClose={() => onToggleDrawer(false)}
ModalProps={{ keepMounted: true }}
slotProps={{
paper: { "aria-label": t("navigation") },
paper: { id: mobileDrawerId, "aria-label": t("navigation") },
transition: { onEntered: (node: HTMLElement) => {
if (node === document.activeElement || !node.contains(document.activeElement)) node.querySelector<HTMLElement>('[role="button"]')?.focus();
}, onExited: () => {
if (focusAfterDrawerNavigation.current) {
focusAfterDrawerNavigation.current = false;
pageTitleRef.current?.focus({ preventScroll: true });
}
} },
}}
sx={{
@@ -648,7 +691,7 @@ export default function AppShell({
"--app-shell-header-height": `${headerHeight ?? (isMobile ? 124 : 76)}px`,
p: "var(--app-page-gutter)",
// Keep the usual feedback lane stable before a delayed toast arrives.
pb: "calc(var(--app-page-gutter) + max(96px, var(--app-toast-clearance, 0px)) + env(safe-area-inset-bottom, 0px))",
pb: "calc(var(--app-page-gutter) + var(--app-bottom-nav-height, 0px) + max(96px, var(--app-toast-clearance, 0px)) + env(safe-area-inset-bottom, 0px))",
bgcolor: "background.default",
minHeight: "100vh",
minWidth: 0,
@@ -672,6 +715,25 @@ export default function AppShell({
{children}
</Box>
</Box>
{isMobile ? <Box component="nav" ref={setBottomNavigation} aria-label={t("navigationPrimary")}
sx={{ position: "fixed", bottom: 0, insetInline: 0, zIndex: theme => theme.zIndex.appBar,
display: "flex", gap: 0.5, minHeight: 72, p: 1, px: 0.5, pb: "max(8px, env(safe-area-inset-bottom, 0px))",
bgcolor: "background.paper", borderTop: "1px solid", borderColor: "divider" }}>
{mobilePrimary.map(item => <Button key={item.to} onClick={() => navigateFromShell(item.to)}
aria-current={mobileActive === item.to ? "page" : undefined}
sx={{ flex: "1 1 auto", minWidth: 44, minHeight: 56, p: 0.25, gap: 0.25, flexDirection: "column", fontSize: 11, letterSpacing: 0,
lineHeight: 1.25, overflowWrap: "anywhere", boxShadow: "none", color: mobileActive === item.to ? "primary.main" : "text.secondary",
bgcolor: mobileActive === item.to ? "action.selected" : "transparent" }}>
{item.icon}<span>{mobileLabels[item.to]}</span>
</Button>)}
<Button onClick={() => onToggleDrawer(true)} aria-haspopup="dialog" aria-controls={mobileDrawerId}
aria-expanded={drawerOpen} aria-current={!mobileActive ? "page" : undefined}
sx={{ flex: "1 1 auto", minWidth: 44, minHeight: 56, p: 0.25, gap: 0.25, flexDirection: "column", fontSize: 11, letterSpacing: 0,
lineHeight: 1.25, overflowWrap: "anywhere", boxShadow: "none", color: !mobileActive ? "primary.main" : "text.secondary",
bgcolor: !mobileActive ? "action.selected" : "transparent" }}>
<MoreHorizIcon fontSize="small" /><span>{t("navigationMore")}</span>
</Button>
</Box> : null}
</Box>
);
}