feat(ui): redesign grouped application shell
@@ -1,6 +1,6 @@
|
||||
# Phase 5 — Representative screens / major UI redesign plan
|
||||
|
||||
**Current successor: [P5-00 readiness implementation](p5-00/README.md).** The five bounded shell conditions are implemented and P5-00-D01 is closed with a deterministic test clock and **916/916** backend rerun. P5-01 has an explicit **GO**. No P5-01 visible redesign, B07 or production work has started. F04 remains open.
|
||||
**Completed successor: [P5-01 AppShell and grouped navigation](p5-01/README.md).** The first major visible redesign groups every existing destination without changing the 31-pattern route/provider contract. Its inherited backend prerequisite repair established **918/918**, and the P5-01 shell route/focus/accessibility contract is protected. P5-02 and B07 have not started. F04 remains open.
|
||||
|
||||
Historical planning checkpoint: source `2a2f9e48052c562ef599825458d0d3a6f9f9fc61`, inspected on 2026-09-13 on `codex/ui-redesign-phase4`. That checkpoint was documentation-only; the successor record above owns implementation/validation status.
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# P5-01 — AppShell and grouped navigation
|
||||
|
||||
Source checkpoint: `38cf37e`, branch `codex/ui-redesign-phase4`. Validated locally on Windows with the disposable API/SQLite and Chromium environment. The containing checkpoint is discoverable with `git log -- docs/redesign/phase-5/p5-01`.
|
||||
|
||||
This slice is the first major visible Phase 5 redesign. It implements the approved grouped shell in [NAVIGATION_MIGRATION](../NAVIGATION_MIGRATION.md) without changing the router, providers, authentication boundary, route patterns or business workflows. P5-02 utilities, P5-03 Jobs presentation, P5-05 quick preview, B07 and later redesign work are not included. F04 remains open.
|
||||
|
||||
## Scope and result
|
||||
|
||||
- Dashboard remains an immediate primary destination.
|
||||
- **Job search** owns Jobs plus Discover, Follow-ups, Board, Companies, Offers and Trash. Job email remains a distinct destination in the same group.
|
||||
- **Career** owns Career Profile and CVs. Longest-path ownership keeps `/career/builder/:id` with CVs, never Career Profile.
|
||||
- **Admin** remains permission-filtered and retains Audit log, Users and System routes.
|
||||
- **Account** owns Settings with Profile and Connected accounts as explicit secondary destinations; the existing account menu remains available.
|
||||
- The expanded sidebar displays primary and secondary hierarchy. The persisted desktop rail uses labelled MUI tooltips and a keyboard-operable secondary menu. The mobile drawer keeps full labels and a scrollable hierarchy.
|
||||
- The permanent drawer allocates navigation only the height remaining below the app bar; the upper destination group scrolls when necessary and Account links remain reachable at 900px desktop height.
|
||||
- A selected secondary destination identifies itself with `aria-current="page"`; its owner is marked `aria-current="location"`. The visual selected indicator and keyboard focus remain separate.
|
||||
- The shell now has a visible-on-focus skip link, one named navigation landmark, one named main target and one shared `PageHeader` h1. A successful shell route selection moves focus to the new h1. Escape from the drawer still returns focus through the existing MUI modal behavior; a cancelled dirty-editor transition does not produce a false route-focus change.
|
||||
- Existing desktop collapse persistence remains separate from temporary mobile-open state and retains the accepted 1024px boundary from P5-00.
|
||||
|
||||
No new route, navigation framework, store, API, dependency, branding asset, notification destination, Documents destination or Admin dashboard was introduced. The existing quick search, notifications, account menu, language controls and contextual Add Job action remain in place for P5-02.
|
||||
|
||||
## Changed owners
|
||||
|
||||
| File | Bounded responsibility |
|
||||
|---|---|
|
||||
| `job-tracker-ui/src/App.tsx` | Re-express the existing route destinations as the approved grouped navigation tree; retain callbacks, permissions and route elements. `/applications/:id` remains an explicit Jobs ownership alias. |
|
||||
| `job-tracker-ui/src/layout/AppShell.tsx` | Render hierarchy in expanded/mobile modes and an accessible menu in rail mode; compute parent/child ownership; add skip/main/route-heading focus semantics; apply the visible selected indicator and bound the permanent drawer below the app bar. |
|
||||
| `job-tracker-ui/src/layout/PageLayout.tsx` | Allow the existing shared `PageHeader` title to receive the shell focus ref. It does not alter section layout or consumer workflow behavior. |
|
||||
| `job-tracker-ui/src/components/QuickCommandDialog.tsx` | Preserve the inherited command-dialog initial-focus contract after the shell transition by focusing the existing search field when the MUI transition has entered. The command catalogue and execution behavior are unchanged. |
|
||||
| `job-tracker-ui/src/prompt.tsx`, `confirm.tsx`, `prompt.test.tsx` | Keep existing dialog keyboard entry deterministic; the prompt input is focused once after opening and the confirmation cancel action regains initial focus. No dialog result or action changed. |
|
||||
| `job-tracker-ui/src/i18n/translations.ts` | English/Bokmål group names and skip-link label only. |
|
||||
| `job-tracker-ui/src/sidebar-active-nav.test.ts` | Non-nested Jobs ownership, legacy application ownership and Career/CV specificity. |
|
||||
| `job-tracker-ui/src/app-shell-navigation.test.tsx` | Group/current semantics, rail menu operation and successful route-heading focus. |
|
||||
| `job-tracker-ui/e2e/shell-readiness.spec.ts` | Four real-App light/dark × English/Bokmål journeys across expanded, rail and phone navigation. |
|
||||
| `job-tracker-ui/e2e/accessibility.spec.ts`, `redesign-preservation.spec.ts`, `smoke.spec.ts`, `csv-export.spec.ts` | Give long real-browser journeys sufficient time, target the exact created job ID, and set up API-owned data before opening the Dashboard so unrelated concurrent startup requests do not race setup writes. The separate UI login smoke case still protects sign-in. No domain assertions or routes were removed. |
|
||||
|
||||
`PageLayout.tsx` is necessary because focus must land on the actual shared page heading rather than a duplicate or wrapper; its optional ref is unused elsewhere. The dialog changes preserve the inherited initial-focus contract exposed by the loaded shell browser run. A transition callback on the prompt was removed after it disturbed the mobile Tab wrap in the production build; a one-time open effect preserves initial focus in both dev and static-export modes. No dialog outcome or domain workflow changed.
|
||||
|
||||
## Preservation trace
|
||||
|
||||
| PRES → existing behavior | P5-01 change | Automated evidence | Visual/UX evidence |
|
||||
|---|---|---|---|
|
||||
| **PRES-E07** → authenticated shell, every existing destination, collapse/drawer and active route | Same destinations grouped under readable owners; rail children remain keyboard reachable | `sidebar-active-nav`, `app-shell-navigation`, all existing route-contract and shell-readiness coverage | Expanded/rail/mobile captures in all four theme/locale pairs |
|
||||
| **PRES-E08** → focus, theme, locale and responsive shell behavior | Skip/main target, one h1, current parent/child semantics and route-heading focus | Unit focus contract; real route selection to Trash/Board; P5-00 drawer entry/Escape and dirty-CV fixtures retained | Focused rail menu and mobile drawer; light/dark and EN/NB |
|
||||
| **PRES-E05/E06/E09** → auth/session disposal, permissions and protected shell | No provider/auth/session change; hidden admin rows remain filtered | Complete frontend/backend/account-isolation/browser baseline | Standard/admin shell states; no private data added to navigation |
|
||||
| **PRES-S02/S03** → notification and Settings/account entry | Existing header utilities retained; Settings owns existing Profile/Connections links | Existing notification, Settings and account-switch tests plus grouped-shell tests | Account group in expanded/mobile shell |
|
||||
| **PRES-A01–A03** → protected Admin audit/users/system and metadata | Same three permission-gated routes, now under a stable Admin group | Existing admin/API permission tests and complete browser baseline | Admin group in both locales/themes and narrow drawer |
|
||||
| **PRES-J/W/C/M route entry contracts** | Presentation/ownership only; all approved entries and compatibility paths remain routed by unchanged `Routes` | Existing 31-pattern route contract plus complete domain suites; direct Jobs/Career ownership cases added | Actual populated Board and Trash states; no domain forms or mutations changed |
|
||||
|
||||
Mapping a capability does not claim exhaustive coverage of that capability. This slice protects navigation entry and shell behavior; domain behavior remains owned by its existing tests and later gated slices.
|
||||
|
||||
## Accessibility, responsive and visual validation
|
||||
|
||||
The real-App fixture uses synthetic owned data and blocks external browser traffic. It verifies:
|
||||
|
||||
- expanded desktop group labels, exact current parent/child semantics and fully visible Account destinations at 900px height;
|
||||
- rail activation by keyboard and access to every Jobs secondary destination through a labelled MUI menu;
|
||||
- mobile drawer labels, current state, route selection, close and h1 focus;
|
||||
- skip-link target and named navigation/main regions;
|
||||
- light/dark and English/Bokmål at 1440px and 390px, retaining P5-00 checks at 320/767/768/900/1023/1024px;
|
||||
- axe WCAG A/AA scans for the loaded grouped navigation and mobile dialog.
|
||||
|
||||
The first browser run found a serious list-structure violation: a secondary `<ul>` was a direct child of its owning `<ul>`. The final implementation nests the secondary list inside the owning `<li>`; all four reruns then reported zero violations for the scoped scans. Visual review also found the permanent drawer's full-height content extending below the app bar and clipping Account links; a remaining-height flex layout and full-viewport assertion now cover that boundary. Incomplete axe nodes, manual screen-reader/touch/zoom/forced-colors limits and the inherited capture-quality caveat remain evidence limits rather than passes.
|
||||
|
||||
Representative evidence is stored under `visual/`: expanded shell, rail menu and mobile drawer for all four theme/locale pairs. Review confirmed useful density, readable hierarchy, selected/focus distinction, no new horizontal overflow and continued access at phone width. The historical post-axe missing-glyph fragments remain a capture limitation when present; no unrelated typography repair is inferred from them.
|
||||
|
||||
## Validation baseline
|
||||
|
||||
Inherited P5-00 baseline: backend **916**, frontend **359**, browser **66 actual passes + P2-D01 expected failure**, rate-limit **9**, ingress **6**, PDF **1**, configuration **3**. A separately committed prerequisite repair, `57a6bd0`, made default Rules settings and workspace checklist initialization safe under concurrent first reads; its two backend regressions establish **918/918** before this shell checkpoint.
|
||||
|
||||
Final P5-01 candidate:
|
||||
|
||||
| Check | Result / change |
|
||||
|---|---|
|
||||
| Backend Release tests | **918/918 passed**, no skips; +2 from the separate initialization repair, no P5-01 backend change |
|
||||
| Frontend Jest | **365 passed / 77 suites**, +6 behavioral cases (three route-ownership and three shell-navigation cases) |
|
||||
| Main Chromium browser | **70 actual passes + unchanged P2-D01 expected failure**, zero unexpected failures or flaky cases; +4 grouped-shell journeys over P5-00. Production static export, `PLAYWRIGHT_STATIC_EXPORT=true`, `PLAYWRIGHT_PREBUILT_EXPORT=true`, `npm.cmd run test:e2e -- --reporter=list,json`; result in ignored `.artifacts/p5-01-browser-static-accepted.json` |
|
||||
| TypeScript / edited E2E TypeScript / lint | Passed (`npm.cmd run typecheck`; `node node_modules/typescript/bin/tsc --project ../.artifacts/p5-00-e2e-tsconfig.json --noEmit`; `npm.cmd run lint`) |
|
||||
| Frontend production build | Passed |
|
||||
| API Release build | Passed, zero warnings/errors |
|
||||
| Rate-limit / ingress / PDF / configuration | **9 / 6 / 1 / 3 passed**, respectively. Playwright security configurations use disposable local APIs; `python scripts/test-ingress.py` validates configuration. |
|
||||
| Ancillary local checks | Supply-chain / model-evaluation harness / extension **3 / 5 / 3 passed**; no live-provider claim |
|
||||
| Secret / whitespace / documentation links | Tracked-secret scan passed; `git diff --check` and staged checks passed; **133** current/root Markdown documents, zero missing local targets (historical archive excluded) |
|
||||
|
||||
The backend command was `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore` with xUnit collections/threads serialized; the API Release build used `-warnaserror`. The frontend production build set the local test API base URL and disabled telemetry. The complete browser run used a prebuilt production static export and the repository's disposable backend. No expected-failure marker, retry or exclusion was added. P2-D01 remains separately identified. Test-result artifacts contain disposable synthetic data only and are not committed.
|
||||
|
||||
## Deferred issues and next gate
|
||||
|
||||
- **P5-00-D02 remains open**: browser Back from the loaded Job Workspace preserves the query but does not restore row focus. The explicit workspace Back control remains protected. This blocks P5-03/P5-08 affected navigation acceptance, not P5-01.
|
||||
- **Initialization gate resolved separately:** React development-mode duplicate GETs exposed competing first-read inserts in Rules settings and workspace checklist seeding at the clean inherited checkpoint. Commit `57a6bd0` makes both owners reload the winning row after a unique-key race and adds two backend regression cases. P5-01 itself contains no backend or database change.
|
||||
- **SQLite write contention remains open (P4-04-O02):** one complete browser run returned 500 while test setup created a company and a Free user and published a CV. The failures occurred on three different existing API writes, outside the grouped-shell code. The CSV case passed ten isolated repeats; API setup now authenticates through the existing API before rendering a Dashboard with concurrent background reads/writes. This narrows the browser fixtures to the workflows they actually assert; it does not claim server-side concurrent writes are safe or close P4-04-O02. A separate ten-repeat Free-user probe exposed two navigation aborts when clearing cookies on a live signed-in page; API setup also removes that test-only race. The failed full-run traces remain in ignored local artifacts and are not represented as passes.
|
||||
- **P3-O02 command failure/pending isolation** remains the P5-02 acceptance gate. P5-01 retains the current catalogue and behavior.
|
||||
- P4-05-O01 table localization blocks P5-03 acceptance; Table/Board parity blocks P5-04b; package proof blocks P5-08b changes; stale CV revision and rendering fidelity block P5-09–14.
|
||||
- P2-D01 and F04 remain open with their original classifications. No B07, backend contract or production verification work occurred.
|
||||
- Final product name/logo/brand color remain substitutable decisions. The current provisional identity is unchanged.
|
||||
|
||||
P5-02 may start only after this checkpoint is committed cleanly and its command failure/pending-isolation gate is characterized and protected. P5-03 must not start until P5-00-D02 and its own localization gate are resolved.
|
||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -4,6 +4,7 @@ Status reconciled: 2026-09-21.
|
||||
|
||||
## Current repository work
|
||||
|
||||
- **P5-01 — AppShell/grouped navigation complete:** existing destinations are grouped under Dashboard, Job search, Career, Admin and Account; expanded/rail/mobile access, parent/child current semantics, skip/main and route-heading focus have automated and visual evidence. Final baseline: 918 backend, 365 frontend, 70 browser passes plus unchanged P2-D01 expected failure. [Record](../redesign/phase-5/p5-01/README.md). P5-02 remains separate; no route/provider/business change or B07 work.
|
||||
- **P5-00 — complete, P5-01 GO:** [record](../redesign/phase-5/p5-00/README.md) covers the five bounded shell gates, shared 1024px navigation boundary and open-drawer resize lifecycle. P5-00-D01 is closed with the existing fixed clock injected into the test; backend rerun is 916/916. No grouped-navigation redesign or B07 work in that checkpoint. F04 remains open.
|
||||
- **P5-00-D02 — open Jobs browser-Back focus debt:** after the workspace actually renders, browser Back preserves the query but does not restore row focus. The explicit workspace Back button carries the existing focus contract. Corrected browser evidence distinguishes them; no application behavior changed. Resolve before affected P5-03/08 navigation migration. [Evidence](../redesign/phase-5/p5-00/README.md#inherited-density-navigation-oracle-corrected).
|
||||
- **Phase 5 plan:** [bounded plan](../redesign/phase-5/README.md) maps all 57 contracts/31 routes. Board integration, native package and CV revision/rendering gates apply only to dependent slices. F04 remains open.
|
||||
|
||||
@@ -30,6 +30,7 @@ test("public entry points meet automated WCAG A and AA checks", async ({ page })
|
||||
});
|
||||
|
||||
test("primary authenticated workspaces meet automated WCAG A and AA checks", async ({ page }) => {
|
||||
test.setTimeout(60_000); // Six complete axe scans exceed Playwright's default 30s on slower hosts.
|
||||
await login(page);
|
||||
|
||||
for (const route of ["/dashboard", "/jobs", "/kanban", "/career", "/settings", "/admin/system"]) {
|
||||
|
||||
@@ -5,11 +5,7 @@ const apiUrl = "http://localhost:5302/api";
|
||||
|
||||
test("CSV export has safe cells and headers, and browser download preserves its UTF-8 bytes", async ({ page }) => {
|
||||
await page.addInitScript(() => window.localStorage.setItem("uiLanguage", "en"));
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username or email", { exact: true }).fill("e2e@example.test");
|
||||
await page.getByLabel("Current password", { exact: true }).fill("E2ePassword123!");
|
||||
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
expect((await page.request.post(`${apiUrl}/auth/login`, { data: { email: "e2e@example.test", password: "E2ePassword123!" } })).status()).toBe(200);
|
||||
const me = await (await page.request.get(`${apiUrl}/auth/me`)).json();
|
||||
const csrf = (await page.context().cookies()).find(cookie => cookie.name === "XSRF-TOKEN")!.value;
|
||||
const headers = { "X-CSRF-TOKEN": csrf, "X-Session-Context": me.sessionContext };
|
||||
|
||||
@@ -38,6 +38,7 @@ const test = base.extend<{ headers: Record<string, string> }>({
|
||||
});
|
||||
|
||||
test("[PRES-C01 PRES-C06 PRES-C07 PRES-C10] mobile CV override persists without changing the canonical profile or sibling CV", async ({ page, headers }) => {
|
||||
test.setTimeout(60_000); // Real API setup, mobile editing, preview and reload share this journey.
|
||||
const create = async (name: string) => {
|
||||
const response = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name } });
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
@@ -309,6 +309,46 @@ for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb
|
||||
}
|
||||
});
|
||||
|
||||
test(`[P5-01 PRES-E07 E08 A03] ${suffix} grouped shell, rail menu and route focus`, async ({ page }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
const t = translations[language];
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/kanban");
|
||||
const navigation = page.getByRole("navigation", { name: t.navigation });
|
||||
await expect(navigation.getByText(t.jobSearch, { exact: true })).toBeVisible();
|
||||
await expect(navigation.getByText(t.career, { exact: true })).toBeVisible();
|
||||
await expect(navigation.getByRole("button", { name: t.jobApplications, exact: true })).toHaveAttribute("aria-current", "location");
|
||||
await expect(navigation.getByRole("button", { name: t.kanbanBoard, exact: true })).toHaveAttribute("aria-current", "page");
|
||||
await expect(navigation.getByRole("button", { name: t.connectedAccounts, exact: true })).toBeInViewport({ ratio: 1 });
|
||||
await expect(page.getByRole("link", { name: t.skipToMainContent, exact: true })).toHaveAttribute("href", "#app-main-content");
|
||||
await capture(page, info, `grouped-shell-${suffix}`);
|
||||
await scan(page, info, `grouped-shell-${suffix}`, "nav");
|
||||
|
||||
await page.getByRole("button", { name: t.collapseSidebar, exact: true }).click();
|
||||
const jobsRail = page.getByRole("button", { name: t.jobApplications, exact: true });
|
||||
await jobsRail.focus();
|
||||
await jobsRail.press("Enter");
|
||||
const railMenu = page.getByRole("menu");
|
||||
await expect(railMenu.getByRole("menuitem", { name: t.trash, exact: true })).toBeVisible();
|
||||
await capture(page, info, `grouped-rail-${suffix}`);
|
||||
await railMenu.getByRole("menuitem", { name: t.trash, exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/trash$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: t.trash, exact: true })).toBeFocused();
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const trigger = page.getByRole("button", { name: t.openNavigation, exact: true });
|
||||
await trigger.click();
|
||||
const drawer = page.getByRole("dialog", { name: t.navigation });
|
||||
await expect(drawer.getByText(t.jobSearch, { exact: true })).toBeVisible();
|
||||
await expect(drawer.getByRole("button", { name: t.trash, exact: true })).toHaveAttribute("aria-current", "page");
|
||||
await capture(page, info, `grouped-mobile-${suffix}`);
|
||||
await scan(page, info, `grouped-mobile-${suffix}`, '[role="dialog"]');
|
||||
await drawer.getByRole("button", { name: t.kanbanBoard, exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/kanban$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: t.kanbanBoard, exact: true })).toBeFocused();
|
||||
await expect(drawer).not.toBeVisible();
|
||||
});
|
||||
|
||||
if ((mode === "light" && language === "en") || (mode === "dark" && language === "nb")) {
|
||||
test(`[P5-00 PRES-E07 E09 C06 S02 S03] ${suffix} dirty editor survives shell utilities and cancel`, async ({ page, context, owner }, info) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
@@ -47,6 +47,10 @@ async function login(page: Page) {
|
||||
await loginAs(page, "e2e@example.test", "E2ePassword123!");
|
||||
}
|
||||
|
||||
async function loginForApiSetup(page: Page) {
|
||||
expect((await page.request.post(`${apiUrl}/auth/login`, { data: { email: "e2e@example.test", password: "E2ePassword123!" } })).status()).toBe(200);
|
||||
}
|
||||
|
||||
async function csrfHeader(page: Page) {
|
||||
const cookie = (await page.context().cookies()).find((value) => value.name === "XSRF-TOKEN");
|
||||
expect(cookie, "login should issue a CSRF cookie").toBeTruthy();
|
||||
@@ -126,7 +130,7 @@ test("a Free account keeps manual work available while AI actions stay honestly
|
||||
const email = `free-${suffix}@example.test`;
|
||||
const password = "FreeUserPassword123!";
|
||||
|
||||
await login(page);
|
||||
await loginForApiSetup(page);
|
||||
const headers = await csrfHeader(page);
|
||||
const createUser = await page.request.post(`${apiUrl}/users`, {
|
||||
headers,
|
||||
@@ -189,11 +193,12 @@ test("a saved job can be created through the reviewed UI flow", async ({ page })
|
||||
});
|
||||
|
||||
test("the dedicated application workspace survives deep links, long data and unsaved navigation", async ({ page }) => {
|
||||
test.setTimeout(60_000); // API setup and the complete loaded workspace journey exceed 30s on slower hosts.
|
||||
const suffix = Date.now().toString();
|
||||
const companyName = `Application Workspace Company With A Deliberately Long Name ${suffix}`;
|
||||
const title = `Principal Platform Reliability Engineer For Distributed Customer Systems ${suffix}`;
|
||||
|
||||
await login(page);
|
||||
await loginForApiSetup(page);
|
||||
const headers = await csrfHeader(page);
|
||||
let companyResponse = await page.request.post(`${apiUrl}/companies`, {
|
||||
headers,
|
||||
@@ -243,7 +248,7 @@ test("the dedicated application workspace survives deep links, long data and uns
|
||||
const job = await jobResponse.json();
|
||||
|
||||
await page.goto("/jobs");
|
||||
const applicationRow = page.getByRole("row", { name: new RegExp(`Open ${title}`, "i") });
|
||||
const applicationRow = page.locator(`tr[data-job-row-id="${job.id}"]`);
|
||||
await applicationRow.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}$`));
|
||||
@@ -294,7 +299,7 @@ test("the dedicated application workspace survives deep links, long data and uns
|
||||
|
||||
await page.getByRole("button", { name: "Back to applications" }).click();
|
||||
await expect(page).toHaveURL(/\/jobs$/);
|
||||
await expect(page.getByRole("row", { name: new RegExp(`Open ${title}`, "i") })).toBeFocused();
|
||||
await expect(applicationRow).toBeFocused();
|
||||
|
||||
await page.goto("/jobs/2147483647");
|
||||
const missingJobAlert = page.getByRole("alert").filter({ hasText: /Not Found|Could not open this application/i });
|
||||
@@ -387,7 +392,7 @@ test("Career Workspace loads from the authenticated application shell", async ({
|
||||
});
|
||||
|
||||
test("a public CV renders anonymously and downloads as PDF", async ({ page }, testInfo) => {
|
||||
await login(page);
|
||||
await loginForApiSetup(page);
|
||||
const headers = await csrfHeader(page);
|
||||
const create = await page.request.post(`${apiUrl}/cv/variants`, {
|
||||
headers,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||