Skip to content

Commit 747c180

Browse files
committed
test: add RTL tests for DashboardLayout slot composition and landmarks
Closes #673 Adds composition coverage for the authenticated shell: children land in main#main-content, header/sidebar slots render, skip-to-content targets main, and empty children still keep chrome up. Documents the regions in docs/dashboard-layout.md. Error-boundary isolation stays in the existing DashboardLayout.error-boundary.test.tsx.
1 parent faf01ee commit 747c180

2 files changed

Lines changed: 175 additions & 0 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import React from "react";
2+
import { render, screen, within } from "@testing-library/react";
3+
import { describe, expect, it, vi } from "vitest";
4+
import DashboardLayout from "./DashboardLayout";
5+
6+
vi.mock("./SideNav", () => ({
7+
SideNav: () => (
8+
<nav aria-label="Primary" data-testid="mock-sidenav">
9+
SideNav
10+
</nav>
11+
),
12+
}));
13+
14+
vi.mock("@/components/shared/layout/TopNav", () => ({
15+
default: () => <div data-testid="mock-topnav">TopNav</div>,
16+
}));
17+
18+
describe("DashboardLayout — slot composition", () => {
19+
it("renders children into the main content region", () => {
20+
render(
21+
<DashboardLayout>
22+
<div data-testid="page-body">Markets page</div>
23+
</DashboardLayout>,
24+
);
25+
26+
const main = screen.getByRole("main");
27+
expect(within(main).getByTestId("page-body")).toHaveTextContent(
28+
"Markets page",
29+
);
30+
});
31+
32+
it("renders the header slot with TopNav", () => {
33+
render(
34+
<DashboardLayout>
35+
<span>content</span>
36+
</DashboardLayout>,
37+
);
38+
39+
const header = screen.getByRole("banner");
40+
expect(within(header).getByTestId("mock-topnav")).toBeInTheDocument();
41+
});
42+
43+
it("renders the sidebar navigation slot", () => {
44+
render(
45+
<DashboardLayout>
46+
<span>content</span>
47+
</DashboardLayout>,
48+
);
49+
50+
expect(screen.getByTestId("mock-sidenav")).toBeInTheDocument();
51+
expect(
52+
screen.getByRole("navigation", { name: "Primary" }),
53+
).toBeInTheDocument();
54+
});
55+
56+
it("renders an empty main region when no children are provided", () => {
57+
render(<DashboardLayout>{null}</DashboardLayout>);
58+
59+
const main = screen.getByRole("main");
60+
expect(main).toBeInTheDocument();
61+
expect(main).toBeEmptyDOMElement();
62+
});
63+
64+
it("keeps chrome slots present when children are omitted", () => {
65+
render(<DashboardLayout>{undefined}</DashboardLayout>);
66+
67+
expect(screen.getByTestId("mock-sidenav")).toBeInTheDocument();
68+
expect(screen.getByTestId("mock-topnav")).toBeInTheDocument();
69+
expect(screen.getByRole("main")).toBeInTheDocument();
70+
});
71+
});
72+
73+
describe("DashboardLayout — landmarks and skip link", () => {
74+
it("exposes a main landmark with the skip-target id", () => {
75+
render(
76+
<DashboardLayout>
77+
<p>body</p>
78+
</DashboardLayout>,
79+
);
80+
81+
const main = screen.getByRole("main");
82+
expect(main).toHaveAttribute("id", "main-content");
83+
});
84+
85+
it("renders a skip-to-content link that points at main", () => {
86+
render(
87+
<DashboardLayout>
88+
<p>body</p>
89+
</DashboardLayout>,
90+
);
91+
92+
const skip = screen.getByRole("link", { name: /skip to main content/i });
93+
expect(skip).toHaveAttribute("href", "#main-content");
94+
});
95+
96+
it("places the skip link before the chrome so keyboard users reach it first", () => {
97+
const { container } = render(
98+
<DashboardLayout>
99+
<p>body</p>
100+
</DashboardLayout>,
101+
);
102+
103+
const skip = screen.getByRole("link", { name: /skip to main content/i });
104+
const sidenav = screen.getByTestId("mock-sidenav");
105+
// Document order: skip link precedes the sidebar.
106+
expect(
107+
skip.compareDocumentPosition(sidenav) &
108+
Node.DOCUMENT_POSITION_FOLLOWING,
109+
).toBeTruthy();
110+
expect(container.firstElementChild?.contains(skip)).toBe(true);
111+
});
112+
113+
it("composes header and main inside the primary column", () => {
114+
render(
115+
<DashboardLayout>
116+
<div data-testid="page-body">body</div>
117+
</DashboardLayout>,
118+
);
119+
120+
const header = screen.getByRole("banner");
121+
const main = screen.getByRole("main");
122+
expect(header.compareDocumentPosition(main) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
123+
});
124+
});

docs/dashboard-layout.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# DashboardLayout regions
2+
3+
How the authenticated shell is composed.
4+
5+
Source: [`components/shared/layout/DashboardLayout.tsx`](../components/shared/layout/DashboardLayout.tsx)
6+
7+
Tests:
8+
9+
- Composition / landmarks: [`DashboardLayout.test.tsx`](../components/shared/layout/DashboardLayout.test.tsx)
10+
- Error isolation: [`DashboardLayout.error-boundary.test.tsx`](../components/shared/layout/DashboardLayout.error-boundary.test.tsx)
11+
12+
## Regions
13+
14+
```
15+
┌──────────┬────────────────────────────┐
16+
│ │ header (TopNav) │
17+
│ SideNav ├────────────────────────────┤
18+
│ (nav) │ main#main-content │
19+
│ │ {children} │
20+
└──────────┴────────────────────────────┘
21+
```
22+
23+
| Region | Element | Role / landmark | Failure mode |
24+
| -------- | ------------------------------- | ---------------------- | ------------------------------------- |
25+
| Skip | `<a href="#main-content">` | link | Always present; `sr-only` until focus |
26+
| Sidebar | `<SideNav />` | navigation (in SideNav)| `sidenav-fallback` via error boundary |
27+
| Header | `<header><TopNav /></header>` | banner | `topnav-fallback` via error boundary |
28+
| Content | `<main id="main-content">` | main | Always mounts; children optional |
29+
30+
## Skip link
31+
32+
The first focusable control is "Skip to main content". It targets
33+
`#main-content` so keyboard users bypass the sidebar and top nav.
34+
35+
## Error isolation
36+
37+
`SideNav` and `TopNav` each sit inside a `LayoutRegionBoundary`. If either
38+
throws during render the rest of the shell stays up — the main content region
39+
is never unmounted by a chrome failure. See the error-boundary tests.
40+
41+
## Collapsed / expanded rail
42+
43+
The layout shell itself does not own collapsed-rail state. Rail expansion is
44+
owned by `SideNav` / its context. Composition tests assert the sidebar *slot*
45+
is present; rail width behaviour is covered by SideNav's own suite.
46+
47+
## Empty children
48+
49+
`DashboardLayout` accepts any `ReactNode`. Passing `null` / `undefined` still
50+
renders the chrome and an empty `<main>` — useful for loading routes that
51+
suspend their body.

0 commit comments

Comments
 (0)