fix(ui): complete P5-00 shell readiness gates

This commit is contained in:
cesnimda
2026-09-21 21:38:11 +02:00
parent c325eb68c6
commit 926f2e5164
52 changed files with 6630 additions and 64 deletions
@@ -5,6 +5,7 @@ import {
Dialog,
DialogContent,
List,
ListItem,
ListItemButton,
ListItemText,
TextField,
@@ -135,21 +136,22 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
<List sx={{ mt: 1 }}>
{allItems.length === 0 ? (
<Box sx={{ px: 2, py: 3 }}>
<ListItem sx={{ px: 2, py: 3 }}>
<Typography sx={{ color: "text.secondary" }}>{t("noMatchingCommands")}</Typography>
</Box>
</ListItem>
) : (
allItems.map((item) => (
<ListItemButton
key={item.id}
onClick={() => {
item.action();
onClose();
}}
sx={{ borderRadius: 2 }}
>
<ListItemText primary={item.label} secondary={item.hint} />
</ListItemButton>
<ListItem key={item.id} disablePadding>
<ListItemButton
onClick={() => {
item.action();
onClose();
}}
sx={{ borderRadius: 2 }}
>
<ListItemText primary={item.label} secondary={item.hint} />
</ListItemButton>
</ListItem>
))
)}
</List>
+4
View File
@@ -13,6 +13,8 @@ export const translations = {
trash: "Trash",
settings: "Settings",
openNavigation: "Open navigation",
navigation: "Navigation",
applicationVersion: "Application version {version}",
expandSidebar: "Expand sidebar",
collapseSidebar: "Collapse sidebar",
addJob: "Add Job",
@@ -2464,6 +2466,8 @@ export const translations = {
trash: "Papirkurv",
settings: "Innstillinger",
openNavigation: "Åpne navigasjon",
navigation: "Navigasjon",
applicationVersion: "Applikasjonsversjon {version}",
expandSidebar: "Utvid sidemenyen",
collapseSidebar: "Skjul sidemenyen",
addJob: "Legg til jobb",
+56 -20
View File
@@ -17,6 +17,7 @@ import {
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
Menu,
MenuItem,
Toolbar,
@@ -24,6 +25,7 @@ import {
Typography,
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { Theme } from "@mui/material/styles";
import MenuIcon from "@mui/icons-material/Menu";
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
@@ -118,7 +120,7 @@ export default function AppShell({
children: React.ReactNode;
}) {
const { language, setLanguage, t } = useI18n();
const isMobile = useMediaQuery("(max-width:767.95px)");
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("md"));
const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => {
try {
return window.localStorage.getItem(DESKTOP_SIDEBAR_KEY) === "1";
@@ -127,6 +129,22 @@ export default function AppShell({
}
});
const drawerWidth = isMobile ? 254 : desktopNavCollapsed ? 84 : 254;
const navCollapsed = !isMobile && desktopNavCollapsed;
const [header, setHeader] = useState<HTMLElement | null>(null);
const [headerHeight, setHeaderHeight] = useState<number>();
useEffect(() => {
if (!isMobile && drawerOpen) onToggleDrawer(false);
}, [isMobile, drawerOpen, onToggleDrawer]);
useEffect(() => {
if (!header || typeof ResizeObserver === "undefined") return;
const measure = () => setHeaderHeight(Math.ceil(header.getBoundingClientRect().height));
measure();
const observer = new ResizeObserver(measure);
observer.observe(header);
return () => observer.disconnect();
}, [header]);
useEffect(() => {
try {
@@ -159,15 +177,15 @@ export default function AppShell({
);
const renderNavList = (groups: Array<[string, NavItem[]]>) => (
<Box sx={{ px: desktopNavCollapsed ? 0.75 : 1.25, pt: 1 }}>
<Box sx={{ px: navCollapsed ? 0.75 : 1.25, pt: 1 }}>
{groups.map(([section, rows]) => (
<Box key={section || "_"} sx={{ mb: desktopNavCollapsed ? 1 : 1.25 }}>
{section && !desktopNavCollapsed ? (
<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" }}>
{section}
</Typography>
) : null}
<List sx={{ px: desktopNavCollapsed ? 0.25 : 0.75, pt: desktopNavCollapsed ? 0.25 : 0.75 }}>
<List sx={{ px: navCollapsed ? 0.25 : 0.75, pt: navCollapsed ? 0.25 : 0.75 }}>
{rows.map((item) => {
const selected = item.to === activeTo;
return (
@@ -175,12 +193,14 @@ export default function AppShell({
<ListItemButton
selected={selected}
onClick={() => onNavigate(item.to)}
title={desktopNavCollapsed ? item.label : undefined}
aria-label={item.label}
aria-current={selected ? "page" : undefined}
title={navCollapsed ? item.label : undefined}
sx={{
borderRadius: 2,
minHeight: 44,
px: desktopNavCollapsed ? 1 : 1.5,
justifyContent: desktopNavCollapsed ? "center" : "flex-start",
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 },
@@ -193,14 +213,14 @@ export default function AppShell({
},
}}
>
<ListItemIcon sx={{ minWidth: desktopNavCollapsed ? 0 : 36, justifyContent: "center", color: selected ? SIDEBAR_SELECTED_ICON : SIDEBAR_TEXT_MUTED }}>
<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>
{!desktopNavCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} /> : null}
{!navCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} /> : null}
</ListItemButton>
</ListItem>
);
@@ -213,10 +233,10 @@ 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 sx={{ px: desktopNavCollapsed ? 1.5 : 2.25, py: desktopNavCollapsed ? 2 : 2.5, display: "flex", justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
<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 }} />
{!desktopNavCollapsed ? (
{!navCollapsed ? (
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: SIDEBAR_SELECTED_TEXT }}>
Jobbjakt
@@ -270,6 +290,7 @@ export default function AppShell({
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
<AppBar
ref={setHeader}
position="fixed"
color="inherit"
elevation={0}
@@ -277,7 +298,7 @@ export default function AppShell({
borderBottom: `1px solid ${muiTheme.vars.palette.grey[300]}`,
backgroundColor: muiTheme.vars.palette.background.default,
backgroundImage: "none",
zIndex: muiTheme.zIndex.drawer + 1,
zIndex: isMobile ? muiTheme.zIndex.appBar : muiTheme.zIndex.drawer + 1,
})}
>
<Toolbar
@@ -299,6 +320,8 @@ export default function AppShell({
size="small"
color="secondary"
aria-label={t("openNavigation")}
aria-haspopup="dialog"
aria-expanded={drawerOpen}
onClick={() => onToggleDrawer(true)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
>
@@ -311,7 +334,6 @@ export default function AppShell({
Jobbjakt
</Typography>
</Box>
{buildBadge}
</Box>
<StackLanguageToggle language={language} setLanguage={setLanguage} />
@@ -452,6 +474,12 @@ export default function AppShell({
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
{isMobile && buildVersion ? (
<ListSubheader disableSticky role="presentation" sx={{ maxWidth: 280, py: 1, lineHeight: 1.5, overflowWrap: "anywhere" }}>
<Typography variant="body2">{t("applicationVersion", { version: buildVersion })}</Typography>
{buildCommit ? <Typography variant="caption">commit {buildCommit}</Typography> : null}
</ListSubheader>
) : null}
<MenuItem
onClick={() => {
setUserMenuAnchor(null);
@@ -484,7 +512,7 @@ export default function AppShell({
<Drawer
variant="permanent"
sx={{
display: { xs: "none", md: "block" },
display: isMobile ? "none" : "block",
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: {
@@ -498,17 +526,23 @@ export default function AppShell({
}}
open
>
<Toolbar sx={{ minHeight: { xs: 68, md: 76 } }} />
<Box aria-hidden sx={{ height: headerHeight ?? { xs: 68, md: 76 }, flexShrink: 0 }} />
{drawerContent}
</Drawer>
<Drawer
variant="temporary"
open={drawerOpen}
open={isMobile && drawerOpen}
onClose={() => onToggleDrawer(false)}
ModalProps={{ keepMounted: true }}
slotProps={{
paper: { "aria-label": t("navigation") },
transition: { onEntered: (node: HTMLElement) => {
if (node === document.activeElement) node.querySelector<HTMLElement>('[role="button"]')?.focus();
} },
}}
sx={{
display: { xs: "block", md: "none" },
display: isMobile ? "block" : "none",
[`& .MuiDrawer-paper`]: {
width: drawerWidth,
borderRight: `1px solid ${SIDEBAR_BORDER}`,
@@ -525,13 +559,15 @@ export default function AppShell({
sx={{
flex: 1,
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))",
bgcolor: "background.default",
minHeight: "100vh",
minWidth: 0,
}}
>
<Box sx={{ mx: "auto", maxWidth: pageLayout ? `var(--app-page-width-${pageLayout})` : 1320, width: "100%", minWidth: 0 }}>
<Toolbar sx={{ minHeight: { xs: isMobile ? 124 : 68, md: 76 } }} />
<Box aria-hidden sx={{ height: headerHeight ?? { xs: isMobile ? 124 : 68, md: 76 } }} />
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: "var(--app-section-gap)", minWidth: 0 }}>
<Box sx={{ minWidth: 0 }}>
<Breadcrumbs sx={{ color: "text.secondary", mb: 0.5, '& .MuiBreadcrumbs-ol': { flexWrap: 'wrap' } }}>
@@ -45,3 +45,15 @@ test("[PRES-E07 PRES-J04] the capture command opens review without creating a jo
await waitFor(() => expect(screen.getByRole("textbox")).toHaveValue(""));
expect(screen.queryByText("Nordlys AS - Platform engineer")).not.toBeInTheDocument();
});
test("[P5-00 PRES-E07] command and empty results use valid list semantics", async () => {
render(<I18nProvider><QuickCommandDialog open onClose={jest.fn()} onNavigate={jest.fn()} onOpenAddJob={jest.fn()} /></I18nProvider>);
const list = screen.getByRole("list");
expect(screen.getAllByRole("listitem")).toHaveLength(6);
expect(Array.from(list.children).every(item => item.tagName === "LI")).toBe(true);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "no-such-result" } });
mockedApi.get.mockResolvedValue({ data: [] } as never);
await screen.findByText("No matching commands or records.");
expect(Array.from(list.children).every(item => item.tagName === "LI")).toBe(true);
expect(mockedApi.post).not.toHaveBeenCalled();
});
@@ -0,0 +1,72 @@
import React, { useState } from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { CssVarsProvider } from "@mui/material/styles";
import AppShell from "./layout/AppShell";
import { getTheme } from "./theme";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
import { ToastProvider, useToast } from "./toast";
jest.mock("@mui/material/useMediaQuery", () => ({ __esModule: true, default: () => true }));
for (const language of ["en", "nb"] as const) {
test(`[P5-00 PRES-E07 E08 A03] ${language} mobile names/current route ignore desktop collapse and retain metadata`, () => {
localStorage.setItem("uiLanguage", language);
localStorage.setItem("appShellDesktopSidebarCollapsed", "1");
const t = translations[language];
function Shell() {
const [open, setOpen] = useState(false);
return <AppShell pageTitle={t.settings} breadcrumbs={[]} pathname="/settings"
nav={[{ to: "/dashboard", label: t.dashboard, icon: <span /> }, { to: "/settings", label: t.settings, icon: <span /> }, { to: "/admin/users", label: "Hidden admin", icon: <span />, hidden: true }]}
navBottom={[]} drawerOpen={open} onToggleDrawer={setOpen} onNavigate={jest.fn()}
user={{ userName: "Åse" }} buildMetadata={{ version: "long-version-æøå", commitSha: "abc1234" }}>
<div>Existing page</div>
</AppShell>;
}
render(<CssVarsProvider theme={getTheme("light") as any}><I18nProvider><Shell /></I18nProvider></CssVarsProvider>);
expect(screen.queryByTestId("admin-version-badge")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: t.user }));
expect(screen.getByRole("menu")).toHaveTextContent("long-version-æøå");
expect(screen.getByRole("menu")).toHaveTextContent("abc1234");
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
fireEvent.click(screen.getByRole("button", { name: t.openNavigation }));
const drawer = screen.getByRole("dialog", { name: t.navigation });
expect(within(drawer).getByRole("button", { name: t.dashboard })).toHaveTextContent(t.dashboard);
expect(within(drawer).getByRole("button", { name: t.settings })).toHaveAttribute("aria-current", "page");
expect(within(drawer).queryByText("Hidden admin")).not.toBeInTheDocument();
expect(localStorage.getItem("appShellDesktopSidebarCollapsed")).toBe("1");
});
}
function ToastTrigger({ action }: { action?: () => void }) {
const { toast } = useToast();
return <button onClick={() => toast("Downloaded æøå", "success", action ? { label: "Undo", onClick: action } : undefined)}>Notify</button>;
}
test("[P5-00 PRES-E08 S05] toast retains its full existing display duration and dismiss action", () => {
jest.useFakeTimers();
try {
render(<ToastProvider><ToastTrigger /></ToastProvider>);
fireEvent.click(screen.getByRole("button", { name: "Notify" }));
act(() => jest.advanceTimersByTime(3499));
expect(screen.getByRole("alert")).toHaveTextContent("Downloaded æøå");
act(() => jest.advanceTimersByTime(1));
// Flush the existing exit transition after React observes the timeout.
act(() => jest.advanceTimersByTime(300));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Notify" }));
fireEvent.click(within(screen.getByRole("alert")).getByRole("button"));
act(() => jest.advanceTimersByTime(300));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
} finally { jest.useRealTimers(); }
});
test("[P5-00 PRES-E08] toast action is explicit and executes once", () => {
const action = jest.fn();
render(<ToastProvider><ToastTrigger action={action} /></ToastProvider>);
fireEvent.click(screen.getByRole("button", { name: "Notify" }));
expect(action).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Undo" }));
expect(action).toHaveBeenCalledTimes(1);
});
+5 -2
View File
@@ -518,11 +518,14 @@ export const getTheme = (_mode: "light" | "dark") => {
},
MuiTooltip: {
styleOverrides: {
tooltip: {
tooltip: ({ theme }: any) => ({
backgroundColor: theme.vars.palette.text.primary,
color: theme.vars.palette.background.paper,
borderRadius: 8,
fontWeight: 600,
fontSize: 12,
},
}),
arrow: ({ theme }: any) => ({ color: theme.vars.palette.text.primary }),
},
},
MuiChip: {
+22 -2
View File
@@ -1,6 +1,6 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Alert, Button, Snackbar } from "@mui/material";
import { Alert, Button, GlobalStyles, Snackbar } from "@mui/material";
type Severity = "success" | "info" | "warning" | "error";
@@ -24,6 +24,19 @@ const ToastContext = createContext<ToastApi | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [t, setT] = useState<Toast>({ open: false, message: "", severity: "info" });
const [element, setElement] = useState<HTMLDivElement | null>(null);
const [clearance, setClearance] = useState(0);
useEffect(() => {
if (!element) { setClearance(0); return; }
// Reserve the actual wrapped message height, bottom offset and a 24px gap.
const measure = () => setClearance(Math.ceil(element.getBoundingClientRect().height) + 48);
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [element]);
const toast = useCallback(
(message: string, severity: Severity = "info", action?: { label: string; onClick: () => void }) => {
@@ -42,12 +55,19 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
return (
<ToastContext.Provider value={value}>
<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))" },
}} />
{children}
<Snackbar
ref={setElement}
open={t.open}
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))" } }}
>
<Alert
onClose={() => setT((p) => ({ ...p, open: false }))}