feat(ui): redesign grouped application shell
This commit is contained in:
+16
-13
@@ -290,25 +290,28 @@ 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" />, section: t("manage") },
|
||||
{ to: "/jobs", label: t("jobApplications"), icon: <WorkOutlineIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/discover", label: t("discoverJobs"), icon: <SearchIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: reminderCount, section: t("manage") },
|
||||
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/offers", label: t("offerComparisonNav"), icon: <CompareArrowsIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/correspondence", label: t("correspondenceInbox"), icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/career", label: t("careerWorkspace"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/career/builder", label: t("cvBuilder"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" /> },
|
||||
{ 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: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" /> },
|
||||
{ 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: "/career", label: t("careerWorkspaceProfileTab"), icon: <AccountCircleIcon fontSize="small" />, section: t("career") },
|
||||
{ to: "/career/builder", label: t("cvBuilder"), icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("career") },
|
||||
];
|
||||
|
||||
const navBottom: NavItem[] = [
|
||||
{ to: "/admin/audit", label: t("auditLog"), icon: <ShieldIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
|
||||
{ to: "/admin/users", label: t("users"), icon: <AccountCircleIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
|
||||
{ to: "/admin/system", label: t("system"), icon: <MemoryIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
|
||||
{ to: "/profile", label: t("profile"), icon: <AccountCircleIcon fontSize="small" />, section: t("account") },
|
||||
{ to: "/settings", label: t("settings"), icon: <SettingsIcon fontSize="small" />, section: t("account") },
|
||||
{ to: "/settings", label: t("settings"), icon: <SettingsIcon fontSize="small" />, section: t("account"), children: [
|
||||
{ to: "/profile", label: t("profile"), icon: <AccountCircleIcon fontSize="small" /> },
|
||||
{ to: "/settings/connected-accounts", label: t("connectedAccounts"), icon: <MailOutlineIcon fontSize="small" /> },
|
||||
] },
|
||||
];
|
||||
|
||||
const rightActions = (
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useState } from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { CssVarsProvider } from "@mui/material/styles";
|
||||
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import AppShell, { type NavItem } from "./layout/AppShell";
|
||||
import { getTheme } from "./theme";
|
||||
|
||||
jest.mock("@mui/material/useMediaQuery", () => ({ __esModule: true, default: () => false }));
|
||||
|
||||
const nav: NavItem[] = [
|
||||
{ to: "/dashboard", label: "Dashboard", icon: <span /> },
|
||||
{ to: "/jobs", label: "Jobs", icon: <span />, section: "Job search", children: [
|
||||
{ to: "/kanban", label: "Board", icon: <span /> },
|
||||
{ to: "/trash", label: "Trash", icon: <span /> },
|
||||
] },
|
||||
{ to: "/career", label: "Career profile", icon: <span />, section: "Career" },
|
||||
];
|
||||
|
||||
function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <CssVarsProvider theme={getTheme("light") as any} defaultMode="light"><I18nProvider>{children}</I18nProvider></CssVarsProvider>;
|
||||
}
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
test("[P5-01 PRES-E07 E08] grouped navigation exposes the current child without marking the parent as the page", () => {
|
||||
render(<Providers><AppShell pageTitle="Board" breadcrumbs={[]} pathname="/kanban" nav={nav} navBottom={[]} onNavigate={jest.fn()} onToggleDrawer={jest.fn()} drawerOpen={false}><div>Board content</div></AppShell></Providers>);
|
||||
expect(screen.getByRole("navigation", { name: "Navigation" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Board" })).toHaveAttribute("aria-current", "page");
|
||||
expect(screen.getByRole("button", { name: "Jobs" })).toHaveAttribute("aria-current", "location");
|
||||
expect(screen.getAllByText("Job search").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Career").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("[P5-01 PRES-E07 E08] collapsed secondary destinations remain keyboard-operable", () => {
|
||||
localStorage.setItem("appShellDesktopSidebarCollapsed", "1");
|
||||
const navigate = jest.fn();
|
||||
render(<Providers><AppShell pageTitle="Jobs" breadcrumbs={[]} pathname="/jobs" nav={nav} navBottom={[]} onNavigate={navigate} onToggleDrawer={jest.fn()} drawerOpen={false}><div>Jobs content</div></AppShell></Providers>);
|
||||
const jobs = screen.getByRole("button", { name: "Jobs" });
|
||||
expect(jobs).toHaveAttribute("aria-haspopup", "menu");
|
||||
fireEvent.keyDown(jobs, { key: "Enter" });
|
||||
fireEvent.click(jobs);
|
||||
const menu = screen.getByRole("menu");
|
||||
fireEvent.click(within(menu).getByRole("menuitem", { name: "Trash" }));
|
||||
expect(navigate).toHaveBeenCalledWith("/trash");
|
||||
});
|
||||
|
||||
test("[P5-01 PRES-E08] successful shell navigation focuses the new route heading and exposes a skip link", () => {
|
||||
function Harness() {
|
||||
const [path, setPath] = useState("/dashboard");
|
||||
return <AppShell pageTitle={path === "/career" ? "Career profile" : "Dashboard"} breadcrumbs={[]} pathname={path} nav={nav} navBottom={[]} onNavigate={setPath} onToggleDrawer={jest.fn()} drawerOpen={false}><div>Page content</div></AppShell>;
|
||||
}
|
||||
render(<Providers><Harness /></Providers>);
|
||||
expect(screen.getByRole("link", { name: "Skip to main content" })).toHaveAttribute("href", "#app-main-content");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Career profile" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Career profile" })).toHaveFocus();
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -47,6 +47,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
const [query, setQuery] = useState("");
|
||||
const [jobs, setJobs] = useState<JobSearchItem[]>([]);
|
||||
const [companies, setCompanies] = useState<CompanySearchItem[]>([]);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -115,12 +116,13 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} slotProps={{ paper: { "aria-label": t("quickSearch") } }} fullWidth maxWidth="sm">
|
||||
<Dialog open={open} onClose={onClose} slotProps={{ paper: { "aria-label": t("quickSearch") }, transition: { onEntered: () => searchInputRef.current?.focus() } }} fullWidth maxWidth="sm">
|
||||
<DialogContent sx={{ p: 1.5 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, pt: 0.5 }}>
|
||||
<SearchIcon fontSize="small" />
|
||||
<TextField
|
||||
autoFocus
|
||||
inputRef={searchInputRef}
|
||||
variant="standard"
|
||||
fullWidth
|
||||
placeholder={t("searchPlaceholder")}
|
||||
|
||||
@@ -24,6 +24,7 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
const i18n = useOptionalI18n();
|
||||
const messageId = useId();
|
||||
const resolverRef = useRef<((value: boolean) => void) | null>(null);
|
||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [state, setState] = useState<ConfirmState>({
|
||||
open: false,
|
||||
message: "",
|
||||
@@ -60,7 +61,10 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
<ConfirmContext.Provider value={value}>
|
||||
{children}
|
||||
<Dialog open={state.open} onClose={() => closeWith(false)} aria-describedby={messageId} fullWidth maxWidth="xs"
|
||||
slotProps={{ paper: { sx: { borderRadius: 3, border: 1, borderColor: "divider", backgroundImage: "none" } } }}>
|
||||
slotProps={{
|
||||
paper: { sx: { borderRadius: 3, border: 1, borderColor: "divider", backgroundImage: "none" } },
|
||||
transition: { onEntered: () => cancelButtonRef.current?.focus() },
|
||||
}}>
|
||||
<DialogTitle sx={{ pb: 1, fontWeight: 800 }}>{state.title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity={state.destructive ? "warning" : "info"} variant="outlined" sx={{ mb: 2 }}>
|
||||
@@ -71,7 +75,7 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
<Typography id={messageId} sx={{ color: "text.secondary" }}>{state.message}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
|
||||
<Button autoFocus onClick={() => closeWith(false)}>{state.cancelLabel}</Button>
|
||||
<Button ref={cancelButtonRef} autoFocus onClick={() => closeWith(false)}>{state.cancelLabel}</Button>
|
||||
<Button color={state.destructive ? "error" : "primary"} variant="contained" onClick={() => closeWith(true)}>
|
||||
{state.confirmLabel}
|
||||
</Button>
|
||||
|
||||
@@ -14,6 +14,9 @@ export const translations = {
|
||||
settings: "Settings",
|
||||
openNavigation: "Open navigation",
|
||||
navigation: "Navigation",
|
||||
skipToMainContent: "Skip to main content",
|
||||
jobSearch: "Job search",
|
||||
career: "Career",
|
||||
applicationVersion: "Application version {version}",
|
||||
expandSidebar: "Expand sidebar",
|
||||
collapseSidebar: "Collapse sidebar",
|
||||
@@ -2467,6 +2470,9 @@ export const translations = {
|
||||
settings: "Innstillinger",
|
||||
openNavigation: "Åpne navigasjon",
|
||||
navigation: "Navigasjon",
|
||||
skipToMainContent: "Hopp til hovedinnhold",
|
||||
jobSearch: "Jobbsøk",
|
||||
career: "Karriere",
|
||||
applicationVersion: "Applikasjonsversjon {version}",
|
||||
expandSidebar: "Utvid sidemenyen",
|
||||
collapseSidebar: "Skjul sidemenyen",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ type HeaderProps = {
|
||||
actions?: React.ReactNode;
|
||||
level?: "h1" | "h2" | "h3";
|
||||
titleId?: string;
|
||||
titleRef?: React.Ref<HTMLHeadingElement>;
|
||||
};
|
||||
|
||||
export function PageHeader({ title, description, actions, level = "h1", titleId }: HeaderProps) {
|
||||
export function PageHeader({ title, description, actions, level = "h1", titleId, titleRef }: HeaderProps) {
|
||||
return (
|
||||
<Box component="header" sx={{ display: "flex", flexWrap: "wrap", alignItems: "flex-start", gap: 2, minWidth: 0 }}>
|
||||
<Box sx={{ flex: "1 1 16rem", minWidth: 0 }}>
|
||||
<Typography id={titleId} component={level} variant={level === "h1" ? "pageTitle" : "sectionTitle"} sx={{ overflowWrap: "anywhere" }}>
|
||||
<Typography ref={titleRef} tabIndex={titleRef ? -1 : undefined} id={titleId} component={level} variant={level === "h1" ? "pageTitle" : "sectionTitle"} sx={{ overflowWrap: "anywhere" }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{description ? <Typography variant="pageDescription" component="p" color="text.secondary" sx={{ mt: 1, maxWidth: "75ch", overflowWrap: "anywhere" }}>
|
||||
@@ -31,7 +32,7 @@ export function PageHeader({ title, description, actions, level = "h1", titleId
|
||||
}
|
||||
|
||||
// Presentation only: domain components keep their loading, draft and mutation owners.
|
||||
export function PageSection({ title, description, actions, children, level = "h2", surface = false }: Omit<HeaderProps, "titleId" | "level"> & {
|
||||
export function PageSection({ title, description, actions, children, level = "h2", surface = false }: Omit<HeaderProps, "titleId" | "level" | "titleRef"> & {
|
||||
children?: React.ReactNode;
|
||||
level?: "h2" | "h3";
|
||||
surface?: boolean;
|
||||
|
||||
@@ -24,6 +24,7 @@ test('renders app-owned prompt dialog', async () => {
|
||||
expect(await screen.findByText(/choose a new name/i)).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('resume.pdf')).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: 'Choose a new name' })).toHaveAccessibleDescription('10/180');
|
||||
expect(screen.getByRole('textbox', { name: 'Choose a new name' })).toHaveFocus();
|
||||
expect(screen.getByRole('dialog', { name: 'Rename attachment' })).toHaveAccessibleDescription('Choose a new name');
|
||||
expect(screen.getByRole('button', { name: /rename/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useCallback, useContext, useId, useMemo, useState } from "react";
|
||||
import React, { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from "@mui/material";
|
||||
import { useOptionalI18n } from "./i18n/I18nProvider";
|
||||
|
||||
@@ -24,6 +24,7 @@ const PromptContext = createContext<PromptContextValue | null>(null);
|
||||
export function PromptProvider({ children }: { children: React.ReactNode }) {
|
||||
const i18n = useOptionalI18n();
|
||||
const messageId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [state, setState] = useState<PromptState>({
|
||||
open: false,
|
||||
title: "Enter value",
|
||||
@@ -34,6 +35,12 @@ export function PromptProvider({ children }: { children: React.ReactNode }) {
|
||||
});
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.open) return;
|
||||
const frame = requestAnimationFrame(() => inputRef.current?.focus());
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [state.open]);
|
||||
|
||||
const closeWith = useCallback((next: string | null) => {
|
||||
setState((prev) => {
|
||||
prev.resolver?.(next);
|
||||
@@ -66,7 +73,7 @@ export function PromptProvider({ children }: { children: React.ReactNode }) {
|
||||
<DialogTitle sx={{ pb: 1, fontWeight: 800 }}>{state.title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography id={messageId} sx={{ color: "text.secondary", mb: 1.5 }}>{state.message}</Typography>
|
||||
<TextField autoFocus fullWidth value={value} onChange={(e) => setValue(e.target.value)} inputProps={{ maxLength: 180, "aria-labelledby": messageId }} helperText={`${value.length}/180`} />
|
||||
<TextField inputRef={inputRef} autoFocus fullWidth value={value} onChange={(e) => setValue(e.target.value)} inputProps={{ maxLength: 180, "aria-labelledby": messageId }} helperText={`${value.length}/180`} />
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
|
||||
<Button onClick={() => closeWith(null)}>{state.cancelLabel}</Button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { activeNavTo } from "./layout/AppShell";
|
||||
import React from "react";
|
||||
import { activeNavItemTo, activeNavTo, type NavItem } from "./layout/AppShell";
|
||||
|
||||
// Route ownership under test (the real sidebar entries):
|
||||
// /career -> Career Workspace
|
||||
@@ -41,3 +42,27 @@ describe("sidebar active nav ownership", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("grouped sidebar ownership", () => {
|
||||
const items: NavItem[] = [
|
||||
{ to: "/jobs", activeFor: ["/applications"], label: "Jobs", icon: React.createElement("span"), children: [
|
||||
{ to: "/kanban", label: "Board", icon: React.createElement("span") },
|
||||
{ to: "/trash", label: "Trash", icon: React.createElement("span") },
|
||||
] },
|
||||
{ to: "/career", label: "Career profile", icon: React.createElement("span") },
|
||||
{ to: "/career/builder", label: "CVs", icon: React.createElement("span") },
|
||||
];
|
||||
|
||||
test("non-nested Jobs destinations select their actual secondary owner", () => {
|
||||
expect(activeNavItemTo("/kanban", items)).toBe("/kanban");
|
||||
expect(activeNavItemTo("/trash", items)).toBe("/trash");
|
||||
});
|
||||
|
||||
test("legacy application details retain Jobs ownership", () => {
|
||||
expect(activeNavItemTo("/applications/42", items)).toBe("/jobs");
|
||||
});
|
||||
|
||||
test("CV editor routes never select Career Profile", () => {
|
||||
expect(activeNavItemTo("/career/builder/42", items)).toBe("/career/builder");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user