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
+6 -5
View File
@@ -329,19 +329,20 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
const nav: NavItem[] = [
{ to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" /> },
{ to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" />, section: t("jobSearch") },
{ to: "/jobs", activeFor: ["/applications"], label: t("jobApplications"), icon: <WorkOutlineIcon fontSize="small" />, section: t("jobSearch"), children: [
{ to: "/discover", label: t("discoverJobs"), icon: <SearchIcon fontSize="small" /> },
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: reminderCount },
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" /> },
{ to: "/offers", label: t("offerComparisonNav"), icon: <CompareArrowsIcon fontSize="small" /> },
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" /> },
] },
{ to: "/correspondence", label: t("correspondenceInbox"), icon: <MailOutlineIcon fontSize="small" />, section: t("jobSearch") },
{ to: "/discover", label: t("discoverJobs"), icon: <SearchIcon fontSize="small" />, section: t("jobSearch") },
{ to: "/career", label: t("careerWorkspaceProfileTab"), icon: <AccountCircleIcon fontSize="small" />, section: t("career") },
{ to: "/career/builder", label: t("cvBuilder"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("career") },
{ to: "/documents", label: t("documentsTitle"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("career") },
{ to: "/analytics", label: t("analytics"), icon: <InsightsOutlinedIcon fontSize="small" />, section: t("insights") },
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: reminderCount, section: t("workspace") },
{ to: "/correspondence", label: t("correspondenceInbox"), icon: <MailOutlineIcon fontSize="small" />, section: t("workspace") },
{ to: "/analytics", label: t("analytics"), icon: <InsightsOutlinedIcon fontSize="small" />, section: t("workspace") },
{ to: "/operations", label: t("operations"), icon: <MemoryIcon fontSize="small" />, section: t("workspace") },
];
const navBottom: NavItem[] = [
+75
View File
@@ -0,0 +1,75 @@
import React, { useState } from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, within, waitFor } from "@testing-library/react";
import { I18nProvider } from "./i18n/I18nProvider";
import JobTrackerThemeProvider from "./ThemeProvider";
import AppShell, { type NavItem } from "./layout/AppShell";
let mockMobile = true;
jest.mock("@mui/material/useMediaQuery", () => ({ __esModule: true, default: () => mockMobile }));
const nav: NavItem[] = [
{ to: "/dashboard", label: "Dashboard", icon: <span /> },
{ to: "/jobs", label: "Jobs", icon: <span />, activeFor: ["/applications"], children: [{ to: "/companies", label: "Companies", icon: <span /> }] },
{ to: "/discover", label: "Discovery", icon: <span /> },
{ to: "/career", label: "Career profile", icon: <span />, children: [{ to: "/career/builder", label: "CV Builder", icon: <span /> }] },
];
const bottom: NavItem[] = [
{ to: "/settings", label: "Settings", icon: <span /> },
{ to: "/admin/users", label: "Hidden admin", icon: <span />, hidden: true },
];
function Shell({ initial = "/dashboard" }: { initial?: string }) {
const [pathname, setPath] = useState(initial);
const [open, setOpen] = useState(false);
return <JobTrackerThemeProvider preference="light" systemPrefersDark={false}><I18nProvider>
<AppShell pageTitle={pathname} breadcrumbs={[]} pathname={pathname} nav={nav} navBottom={bottom} drawerOpen={open}
onToggleDrawer={setOpen} onNavigate={to => { setOpen(false); setPath(to); }}><button>Page action</button></AppShell>
</I18nProvider></JobTrackerThemeProvider>;
}
beforeEach(() => { localStorage.clear(); mockMobile = true; });
test.each([
["en", "Primary navigation", "Applications", "Discover", "Career", "More"],
["nb", "Hovednavigasjon", "Søknader", "Finn jobber", "Karriere", "Mer"],
])("%s hybrid navigation has five labelled destinations and retains secondary tools", (language, label, applications, discover, career, more) => {
localStorage.setItem("uiLanguage", language);
render(<Shell initial="/companies" />);
const primary = screen.getByRole("navigation", { name: label });
expect(within(primary).getAllByRole("button")).toHaveLength(5);
for (const name of [applications, discover, career, more]) expect(within(primary).getByRole("button", { name })).toBeVisible();
expect(within(primary).getByRole("button", { name: more })).toHaveAttribute("aria-current", "page");
fireEvent.click(within(primary).getByRole("button", { name: more }));
const drawer = screen.getByRole("dialog");
expect(within(drawer).getByRole("button", { name: "Companies" })).toHaveAttribute("aria-current", "page");
expect(within(drawer).queryByText("Hidden admin")).not.toBeInTheDocument();
fireEvent.click(within(drawer).getByRole("button", { name: "Settings" }));
expect(screen.getByRole("heading", { level: 1, name: "/settings" })).toHaveFocus();
});
test.each(["/career/builder/42", "/applications/42", "/jobs/42"])("%s selects its primary destination and keyboard route focus survives", initial => {
localStorage.setItem("uiLanguage", "en");
render(<Shell initial={initial} />);
const primary = screen.getByRole("navigation", { name: "Primary navigation" });
expect(within(primary).getAllByRole("button").filter(button => button.getAttribute("aria-current") === "page")).toHaveLength(1);
expect(within(primary).getByRole("button", { name: initial.startsWith("/career") ? "Career" : "Applications" })).toHaveAttribute("aria-current", "page");
fireEvent.click(within(primary).getByRole("button", { name: "Discover" }));
expect(screen.getByRole("heading", { name: "/discover", level: 1 })).toHaveFocus();
});
test("desktop retains labelled collapse, persistence, and all secondary destinations", () => {
mockMobile = false; localStorage.setItem("uiLanguage", "en");
render(<Shell />);
expect(screen.queryByRole("navigation", { name: "Primary navigation" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
expect(localStorage.getItem("appShellDesktopSidebarCollapsed")).toBe("1");
fireEvent.click(screen.getByRole("button", { name: "Jobs" }));
expect(within(screen.getByRole("menu")).getByRole("menuitem", { name: "Companies" })).toBeVisible();
});
test("choosing the current destination from More closes the drawer and focuses its heading", async () => {
localStorage.setItem("uiLanguage", "en"); render(<Shell initial="/companies" />);
fireEvent.click(within(screen.getByRole("navigation", { name: "Primary navigation" })).getByRole("button", { name: "More" }));
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Companies" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("heading", { level: 1, name: "/companies" })).toHaveFocus();
});
+8
View File
@@ -185,6 +185,10 @@ export const translations = {
settings: "Settings",
openNavigation: "Open navigation",
navigation: "Navigation",
navigationPrimary: "Primary navigation",
navigationApplications: "Applications",
navigationDiscover: "Discover",
navigationMore: "More",
skipToMainContent: "Skip to main content",
jobSearch: "Job search",
career: "Career",
@@ -3306,6 +3310,10 @@ export const translations = {
settings: "Innstillinger",
openNavigation: "Åpne navigasjon",
navigation: "Navigasjon",
navigationPrimary: "Hovednavigasjon",
navigationApplications: "Søknader",
navigationDiscover: "Finn jobber",
navigationMore: "Mer",
skipToMainContent: "Hopp til hovedinnhold",
jobSearch: "Jobbsøk",
career: "Karriere",
+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>
);
}
+2 -2
View File
@@ -58,7 +58,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
<GlobalStyles styles={{
":root": { "--app-toast-clearance": `${clearance}px` },
// Native focus/scrollIntoView must also leave the feedback lane clear.
html: { scrollPaddingBottom: "calc(max(96px, var(--app-toast-clearance, 0px)) + env(safe-area-inset-bottom, 0px))" },
html: { scrollPaddingBottom: "calc(var(--app-bottom-nav-height, 0px) + max(96px, var(--app-toast-clearance, 0px)) + env(safe-area-inset-bottom, 0px))" },
}} />
{children}
<Snackbar
@@ -67,7 +67,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
autoHideDuration={3500}
onClose={() => setT((p) => ({ ...p, open: false }))}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
sx={{ bottom: { xs: "calc(8px + env(safe-area-inset-bottom, 0px))", sm: "calc(24px + env(safe-area-inset-bottom, 0px))" } }}
sx={{ bottom: { xs: "calc(var(--app-bottom-nav-height, 0px) + 8px + env(safe-area-inset-bottom, 0px))", sm: "calc(var(--app-bottom-nav-height, 0px) + 24px + env(safe-area-inset-bottom, 0px))" } }}
>
<Alert
onClose={() => setT((p) => ({ ...p, open: false }))}