feat(ui): implement approved A3 hybrid navigation
This commit is contained in:
@@ -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[] = [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }))}
|
||||
|
||||
Reference in New Issue
Block a user