@tristan2828/ui-foundation 4.14.0 → 4.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -515,8 +515,9 @@ is a filled-in example while the Widgets demo is still here).
515
515
  `NAV_ENTRIES` so the shell smoke test covers the new screen, add
516
516
  `/<entity>/new` to `e2e/a11y.spec.ts`'s `formRoutes`, and add the view
517
517
  of a full record and of the sparse one to its `viewRoutes` (the table
518
- page is picked up from the sidebar automatically; the form and the view
519
- aren't in it). With fields edited in place, copy `e2e/a11y.spec.ts`'s
518
+ page comes from `src/nav.ts`, which the suite is given as `nav`: each
519
+ nav page is a test of its own, so the suite's time per test stays flat
520
+ as the app grows; the form and the view aren't in the nav). With fields edited in place, copy `e2e/a11y.spec.ts`'s
520
521
  "editing in place" block for them: a field open, saving and refused,
521
522
  the rich-text editor, an open list, a list edited in place (an item
522
523
  open, one being added, a refused change), and the leave prompt, in
@@ -1,4 +1,5 @@
1
1
  import { type Page } from '@playwright/test';
2
+ import { type NavEntry } from '../components/app/app-shell-nav.js';
2
3
  import type { MswOverride } from '../mocks/override.js';
3
4
  export type { MswHandle, MswOverride } from '../mocks/override.js';
4
5
  /**
@@ -44,16 +45,26 @@ export type A11ySuiteOptions = {
44
45
  viewRoutes?: readonly string[];
45
46
  /** Screens outside the app shell, e.g. '/login' (and '/register' if the app has it). */
46
47
  loggedOutRoutes: readonly string[];
48
+ /**
49
+ * The app's nav, the same `NavEntry[]` passed to `AppShell` (`src/nav.ts`).
50
+ * Given, each page in it is a test of its own, so the suite's time per
51
+ * test stays flat as the app grows, and one more test checks the
52
+ * rendered sidebar links exactly those pages. Left out, the pages are
53
+ * found in the sidebar at run time, in one test whose timeout grows with
54
+ * their number.
55
+ */
56
+ nav?: readonly NavEntry[];
47
57
  };
48
58
  /**
49
59
  * Accessibility of the real screens in light and dark mode: every page in
50
- * the Primary nav (discovered, so a new entity's table is covered with no
51
- * edit), the given form routes, and the logged-out screens. axe's
60
+ * the Primary nav (one test each from `nav`, or discovered in the sidebar
61
+ * when `nav` is left out), the given form routes, and the logged-out
62
+ * screens. axe's
52
63
  * color-contrast rule against the rendered dark DOM is also what keeps the
53
64
  * dark-mode tokens readable. The theme follows the OS setting, so
54
65
  * emulateMedia switches it.
55
66
  */
56
- export declare function defineA11ySuite({ formRoutes, viewRoutes, loggedOutRoutes }: A11ySuiteOptions): void;
67
+ export declare function defineA11ySuite({ formRoutes, viewRoutes, loggedOutRoutes, nav }: A11ySuiteOptions): void;
57
68
  export type MockModeBannerSuiteOptions = {
58
69
  /** Screens to check, logged-out ones included (e.g. '/login'). */
59
70
  routes: readonly string[];
@@ -13,6 +13,7 @@
13
13
  // are optional peer dependencies, needed only by apps that import this.
14
14
  import AxeBuilder from '@axe-core/playwright';
15
15
  import { expect, test } from '@playwright/test';
16
+ import { isNavSection } from '../components/app/app-shell-nav.js';
16
17
  /**
17
18
  * Forces a response for the page's *first* load: set before navigation, so
18
19
  * it wins the race against the app's own first fetch. For states forced
@@ -92,36 +93,66 @@ async function navRoutes(page, isMobile) {
92
93
  ? (await page.getByRole('button', { name: 'Toggle Sidebar' }).first().click(), page.getByRole('dialog'))
93
94
  : page.getByRole('navigation', { name: 'Primary' });
94
95
  await expect(nav.getByRole('link').first()).toBeVisible();
96
+ // By element, not role: a link inside a folded sidebar section is hidden
97
+ // (AppShell's collapsible sections) and role queries skip it.
95
98
  const hrefs = await nav
96
- .getByRole('link')
99
+ .locator('a[href]')
97
100
  .evaluateAll((links) => links.map((link) => link.href));
98
101
  // Only this app's pages: a sidebarExtra group may link to other sites.
99
102
  const origin = new URL(page.url()).origin;
100
103
  return [...new Set(hrefs.filter((href) => new URL(href).origin === origin).map((href) => new URL(href).pathname))];
101
104
  }
105
+ // A page's share of the one discovering test's time: load, data, axe.
106
+ const NAV_PAGE_BUDGET_MS = 10_000;
107
+ function navEntryRoutes(nav) {
108
+ return [...new Set(nav.flatMap((entry) => (isNavSection(entry) ? entry.items : [entry]).map((item) => item.to)))];
109
+ }
110
+ async function expectNavPageAccessible(page, route, colorScheme) {
111
+ await page.goto(route);
112
+ await expect(page.locator('html')).toHaveClass(colorScheme);
113
+ // Wait for data, not the loading skeleton, so the real UI is checked.
114
+ await expect(page.locator('[data-state="loading"]')).toHaveCount(0);
115
+ await expectNoAxeViolations(page);
116
+ }
102
117
  /**
103
118
  * Accessibility of the real screens in light and dark mode: every page in
104
- * the Primary nav (discovered, so a new entity's table is covered with no
105
- * edit), the given form routes, and the logged-out screens. axe's
119
+ * the Primary nav (one test each from `nav`, or discovered in the sidebar
120
+ * when `nav` is left out), the given form routes, and the logged-out
121
+ * screens. axe's
106
122
  * color-contrast rule against the rendered dark DOM is also what keeps the
107
123
  * dark-mode tokens readable. The theme follows the OS setting, so
108
124
  * emulateMedia switches it.
109
125
  */
110
- export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes }) {
126
+ export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes, nav }) {
127
+ const listed = nav && navEntryRoutes(nav);
128
+ if (listed) {
129
+ test('the sidebar links exactly the pages in nav', async ({ page, isMobile }) => {
130
+ const rendered = await navRoutes(page, isMobile);
131
+ expect([...rendered].sort(), 'The sidebar and the nav given to defineA11ySuite differ: pass the same NavEntry[] AppShell gets').toEqual([...listed].sort());
132
+ });
133
+ }
111
134
  for (const colorScheme of ['light', 'dark']) {
112
135
  test.describe(`accessibility (${colorScheme})`, () => {
113
136
  test.beforeEach(async ({ page }) => {
114
137
  await page.emulateMedia({ colorScheme });
115
138
  });
116
- test('every nav page has zero axe violations', async ({ page, isMobile }) => {
117
- for (const route of await navRoutes(page, isMobile)) {
118
- await page.goto(route);
119
- await expect(page.locator('html')).toHaveClass(colorScheme);
120
- // Wait for data, not the loading skeleton, so the real UI is checked.
121
- await expect(page.locator('[data-state="loading"]')).toHaveCount(0);
122
- await test.step(route, () => expectNoAxeViolations(page));
139
+ if (listed) {
140
+ for (const route of listed) {
141
+ test(`nav page ${route} has zero axe violations`, async ({ page }) => {
142
+ await expectNavPageAccessible(page, route, colorScheme);
143
+ });
123
144
  }
124
- });
145
+ }
146
+ else {
147
+ test('every nav page has zero axe violations', async ({ page, isMobile }, testInfo) => {
148
+ const routes = await navRoutes(page, isMobile);
149
+ // One test for every page: its time grows with the app.
150
+ test.setTimeout(testInfo.timeout + routes.length * NAV_PAGE_BUDGET_MS);
151
+ for (const route of routes) {
152
+ await test.step(route, () => expectNavPageAccessible(page, route, colorScheme));
153
+ }
154
+ });
155
+ }
125
156
  for (const route of [...formRoutes, ...loggedOutRoutes]) {
126
157
  test(`${route} has zero axe violations`, async ({ page }) => {
127
158
  await page.goto(route);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.14.0",
3
+ "version": "4.15.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {