Skip link and main landmark on every route with TopNav #224

Merged
robbertbos merged 5 commits from no-skip-link into main 2026-08-05 21:08:50 +00:00
Owner

Closes #223.

TopNav renders at the top of all eight authenticated routes, so a keyboard or screen-reader user tabbed through the whole navigation block again on every page. That is WCAG 2.2 SC 2.4.1 (Bypass Blocks, level A), and thereby EN 301 549 clause 9.2.4.1.

What changed

A shared PageShell.vue wraps TopNav in nldd-skip-link ("Direct naar de inhoud") and puts the page content in a <main>:

<nldd-skip-link text="Direct naar de inhoud">
  <TopNav />
</nldd-skip-link>
<main></main>

The seven plain routes (Inbox, Leeslijst, Instellingen, Concepten, Uitgaande post, Notities, Voorwaarden) go through it. Welkom keeps nldd-page, which supplies its own <main> from its shadow root, and carries the wrapper in its header slot; activating it focuses the slotted content, verified in the browser.

Extracting the shell also removes the seven copies of <div class="page-shell"> and makes the skip link something a new route gets by construction.

Login and AccountVerwijderd render no TopNav, so they need no skip link - but an axe scan showed both reporting landmark-one-main and two region violations, with their heading and body text outside any landmark. Each is a single card, so the card became the <main>.

Two constraints the shape depends on

  • Without href, nldd-skip-link focuses its own nextElementSibling (adding tabindex="-1" and removing it again on blur), so <main> has to stay directly after the wrapper. On Inbox that moves SkipTourButton and RetentionIntroBanner inside <main>.
  • The wrapper is TopNav's containing block and exactly as tall as it, so the header's own position: sticky had nowhere left to stick. That rule is moved from TopNav.vue to the wrapper rather than duplicated - the app's CSS shrinks by a line. Document-origin rules beat the shadow tree's :host { position: relative }, so no shadow-DOM reach-in is needed. topnav-sticky.spec.ts still passes and now checks the wrapper as the sticky layer.

nldd-page[sticky-header] would supply both the <main> and the sticky layer from the design system, but it takes over scrolling from the document, which breaks window.scrollTo in Inbox.endTour(), the router scroll and drag-autoscroll. The exception is root-scroll mode, which comes from nldd-app-view - tracked in #203, where I left a correction: on 0.8.77 a bare-page app derives root automatically, so that adoption is cheaper than the issue assumes. Worth doing, but as its own change.

Keeping the nav sticky is deliberate. NLDD's guidance is "de standaard is: geen sticky", so, measured: Leeslijst is 26347px over a 757px viewport - ~35 screens back to the only navigation in the app.

Verification

  • PageShell.test.ts pins the sibling contract, so a component inserted between wrapper and <main> fails the build.
  • skipLink.test.ts enumerates the route files that import TopNav and asserts each offers a skip link - a new route cannot reintroduce the gap silently.
  • skip-link.spec.ts (e2e) tabs to the link, asserts it becomes visible (opacity: 1) while focused, presses Enter and asserts document.activeElement is MAIN; plus the sibling check on all seven routes.
  • landmarks.spec.ts (e2e, new) runs axe-core over all nine routes for landmark-one-main and region. Verified it fails when <main> is turned back into a <div>. axe goes in via addInitScript, since the app's CSP blocks an injected inline script.

Before: landmark-one-main on every route. After: clean on all nine.

A full axe scan still reports page-has-heading-one on Inbox, Leeslijst and Instellingen. That is a heading-structure question needing a design call, filed as #230; landmarks.spec.ts is scoped to the two landmark rules so that gap stays visible instead of being allowlisted away.

npm run typecheck, 663 vitest tests, npm run build and eslint (0 errors) are green. Playwright: 67 passed, 1 failed - encrypted-export-roundtrip, which fails identically on the untouched tree (a checkbox inside RecoveryKeyModal) and passes in CI, so it is pre-existing and unrelated.

Note the skip link is the second tab stop rather than the first when MmStatusBanner shows its "Verbind nu" link. That banner is a single alert action, not a repeated block, so 2.4.1 is satisfied; the e2e test allows for it explicitly.

Closes #223. TopNav renders at the top of all eight authenticated routes, so a keyboard or screen-reader user tabbed through the whole navigation block again on every page. That is **WCAG 2.2 SC 2.4.1 (Bypass Blocks, level A)**, and thereby EN 301 549 clause 9.2.4.1. ## What changed A shared `PageShell.vue` wraps `TopNav` in `nldd-skip-link` ("Direct naar de inhoud") and puts the page content in a `<main>`: ```html <nldd-skip-link text="Direct naar de inhoud"> <TopNav /> </nldd-skip-link> <main>…</main> ``` The seven plain routes (Inbox, Leeslijst, Instellingen, Concepten, Uitgaande post, Notities, Voorwaarden) go through it. Welkom keeps `nldd-page`, which supplies its own `<main>` from its shadow root, and carries the wrapper in its `header` slot; activating it focuses the slotted content, verified in the browser. Extracting the shell also removes the seven copies of `<div class="page-shell">` and makes the skip link something a new route gets by construction. Login and AccountVerwijderd render no TopNav, so they need no skip link - but an axe scan showed both reporting `landmark-one-main` and two `region` violations, with their heading and body text outside any landmark. Each is a single card, so the card became the `<main>`. ## Two constraints the shape depends on - **Without `href`, `nldd-skip-link` focuses its own `nextElementSibling`** (adding `tabindex="-1"` and removing it again on blur), so `<main>` has to stay directly after the wrapper. On Inbox that moves `SkipTourButton` and `RetentionIntroBanner` inside `<main>`. - **The wrapper is TopNav's containing block** and exactly as tall as it, so the header's own `position: sticky` had nowhere left to stick. That rule is *moved* from `TopNav.vue` to the wrapper rather than duplicated - the app's CSS shrinks by a line. Document-origin rules beat the shadow tree's `:host { position: relative }`, so no shadow-DOM reach-in is needed. `topnav-sticky.spec.ts` still passes and now checks the wrapper as the sticky layer. `nldd-page[sticky-header]` would supply both the `<main>` and the sticky layer from the design system, but it takes over scrolling from the document, which breaks `window.scrollTo` in `Inbox.endTour()`, the router scroll and drag-autoscroll. The exception is root-scroll mode, which comes from `nldd-app-view` - tracked in #203, where I left a correction: on 0.8.77 a bare-page app derives `root` automatically, so that adoption is cheaper than the issue assumes. Worth doing, but as its own change. Keeping the nav sticky is deliberate. NLDD's guidance is "de standaard is: geen sticky", so, measured: Leeslijst is 26347px over a 757px viewport - **~35 screens** back to the only navigation in the app. ## Verification - `PageShell.test.ts` pins the sibling contract, so a component inserted between wrapper and `<main>` fails the build. - `skipLink.test.ts` enumerates the route files that import TopNav and asserts each offers a skip link - a new route cannot reintroduce the gap silently. - `skip-link.spec.ts` (e2e) tabs to the link, asserts it becomes visible (`opacity: 1`) while focused, presses Enter and asserts `document.activeElement` is `MAIN`; plus the sibling check on all seven routes. - `landmarks.spec.ts` (e2e, new) runs axe-core over all nine routes for `landmark-one-main` and `region`. Verified it fails when `<main>` is turned back into a `<div>`. axe goes in via `addInitScript`, since the app's CSP blocks an injected inline script. Before: `landmark-one-main` on every route. After: clean on all nine. A full axe scan still reports `page-has-heading-one` on Inbox, Leeslijst and Instellingen. That is a heading-structure question needing a design call, filed as #230; `landmarks.spec.ts` is scoped to the two landmark rules so that gap stays visible instead of being allowlisted away. `npm run typecheck`, 663 vitest tests, `npm run build` and eslint (0 errors) are green. Playwright: 67 passed, 1 failed - `encrypted-export-roundtrip`, which fails identically on the untouched tree (a checkbox inside RecoveryKeyModal) and passes in CI, so it is pre-existing and unrelated. Note the skip link is the second tab stop rather than the first when `MmStatusBanner` shows its "Verbind nu" link. That banner is a single alert action, not a repeated block, so 2.4.1 is satisfied; the e2e test allows for it explicitly.
Add a skip link and a main landmark to every route with TopNav
All checks were successful
CI / release-scripts (pull_request) Successful in 7s
security-scan / SBOM (trivy) (pull_request) Successful in 9s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 13s
security-scan / JS SCA (npm audit) (pull_request) Successful in 18s
security-scan / Python SAST (bandit) (pull_request) Successful in 19s
CI / pre-commit (pull_request) Successful in 28s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 42s
CI / backend-test (pull_request) Successful in 52s
test-build / build (backend) (pull_request) Successful in 1m0s
test-build / build (frontend) (pull_request) Successful in 1m5s
CI / frontend-test (pull_request) Successful in 1m11s
test-build / build (pull_request) Successful in 0s
CI / e2e (pull_request) Successful in 3m27s
CI / backend-test-postgres (pull_request) Successful in 4m35s
a97886bd7d
TopNav renders at the top of all eight authenticated routes, so a keyboard
or screen-reader user tabbed through the whole navigation block again on
every page. That is WCAG 2.2 SC 2.4.1 (Bypass Blocks, level A) and thereby
EN 301 549 clause 9.2.4.1.

A shared PageShell wraps TopNav in nldd-skip-link and puts the page content
in a <main>. The seven plain routes go through it; Welkom keeps nldd-page,
which supplies its own <main>, and carries the wrapper in its header slot.

Two constraints the shape depends on:

- Without href, nldd-skip-link focuses its own nextElementSibling, so <main>
  has to stay directly after the wrapper. On Inbox that moves SkipTourButton
  and RetentionIntroBanner inside <main>.
- The wrapper is TopNav's containing block and exactly as tall as it, which
  would clamp the nav's `position: sticky` to nothing. The stickiness moves
  to the wrapper.

Covered by unit tests for the sibling contract, a guard over the route files
so a new route cannot reintroduce the gap, and an e2e test that tabs to the
link and asserts focus lands on <main>.
Move the nav's sticky rule onto the shell instead of adding one
All checks were successful
CI / release-scripts (pull_request) Successful in 6s
security-scan / SBOM (trivy) (pull_request) Successful in 11s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 15s
security-scan / Python SAST (bandit) (pull_request) Successful in 23s
security-scan / JS SCA (npm audit) (pull_request) Successful in 19s
CI / pre-commit (pull_request) Successful in 30s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 43s
CI / backend-test (pull_request) Successful in 58s
test-build / build (backend) (pull_request) Successful in 1m8s
test-build / build (frontend) (pull_request) Successful in 1m21s
CI / frontend-test (pull_request) Successful in 1m27s
test-build / build (pull_request) Successful in 0s
CI / e2e (pull_request) Successful in 3m49s
CI / backend-test-postgres (pull_request) Successful in 4m26s
6fee476958
The skip-link wrapper is TopNav's containing block and exactly as tall as
it, so the header's own `position: sticky` no longer had anywhere to stick.
Move that rule to the wrapper rather than leaving a dead one behind, and
drop the flex rules on <main>: page-shell already stacks its children and
carries the min-height.
Give the two shell-less routes a main landmark, and guard it with axe
Some checks failed
CI / release-scripts (pull_request) Successful in 6s
security-scan / SBOM (trivy) (pull_request) Successful in 10s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 13s
security-scan / Python SAST (bandit) (pull_request) Successful in 19s
security-scan / JS SCA (npm audit) (pull_request) Successful in 20s
CI / pre-commit (pull_request) Successful in 29s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 39s
CI / backend-test-postgres (pull_request) Has been cancelled
CI / e2e (pull_request) Has been cancelled
CI / backend-test (pull_request) Successful in 56s
test-build / build (backend) (pull_request) Successful in 1m1s
test-build / build (frontend) (pull_request) Successful in 1m7s
test-build / build (pull_request) Successful in 0s
CI / frontend-test (pull_request) Successful in 1m17s
f052be07ad
An axe scan over every route showed Login and AccountVerwijderd reporting
landmark-one-main plus two region violations: neither renders TopNav, so
neither goes through PageShell, and their heading and body text sat outside
any landmark. Both are a single card, so the card becomes the <main>.

The new spec pins landmark-one-main and region across all nine routes. It is
scoped to those two rules: a full scan still reports page-has-heading-one on
Inbox, Leeslijst and Instellingen, which is a heading-structure question of
its own. axe goes in through addInitScript because the app's CSP blocks an
injected inline script.
Tighten the changelog entry for the skip link
Some checks failed
CI / release-scripts (pull_request) Successful in 11s
security-scan / SBOM (trivy) (pull_request) Successful in 12s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 18s
security-scan / Python SAST (bandit) (pull_request) Successful in 27s
security-scan / JS SCA (npm audit) (pull_request) Successful in 27s
CI / pre-commit (pull_request) Successful in 38s
test-build / build (backend) (pull_request) Has been cancelled
test-build / build (pull_request) Has been cancelled
test-build / build (frontend) (pull_request) Has been cancelled
CI / frontend-test (pull_request) Has been cancelled
CI / e2e (pull_request) Has been cancelled
CI / backend-test-postgres (pull_request) Has been cancelled
CI / backend-test (pull_request) Has been cancelled
security-scan / Python SCA (pip-audit) (pull_request) Has been cancelled
3ca9a4cbec
robbertbos force-pushed no-skip-link from 3ca9a4cbec
Some checks failed
CI / release-scripts (pull_request) Successful in 11s
security-scan / SBOM (trivy) (pull_request) Successful in 12s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 18s
security-scan / Python SAST (bandit) (pull_request) Successful in 27s
security-scan / JS SCA (npm audit) (pull_request) Successful in 27s
CI / pre-commit (pull_request) Successful in 38s
test-build / build (backend) (pull_request) Has been cancelled
test-build / build (pull_request) Has been cancelled
test-build / build (frontend) (pull_request) Has been cancelled
CI / frontend-test (pull_request) Has been cancelled
CI / e2e (pull_request) Has been cancelled
CI / backend-test-postgres (pull_request) Has been cancelled
CI / backend-test (pull_request) Has been cancelled
security-scan / Python SCA (pip-audit) (pull_request) Has been cancelled
to 0ba2f3e52e
Some checks failed
security-scan / SBOM (trivy) (pull_request) Successful in 18s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 30s
security-scan / JS SCA (npm audit) (pull_request) Successful in 35s
security-scan / Python SAST (bandit) (pull_request) Successful in 40s
CI / e2e (pull_request) Failing after 3m20s
CI / backend-test (pull_request) Successful in 1m16s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 57s
CI / frontend-test (pull_request) Successful in 1m25s
test-build / build (backend) (pull_request) Successful in 1m5s
test-build / build (pull_request) Successful in 0s
test-build / build (frontend) (pull_request) Successful in 52s
CI / backend-test-postgres (pull_request) Successful in 4m36s
CI / release-scripts (pull_request) Successful in 8s
CI / pre-commit (pull_request) Successful in 42s
2026-08-05 20:48:56 +00:00
Compare
robbertbos force-pushed no-skip-link from 0ba2f3e52e
Some checks failed
security-scan / SBOM (trivy) (pull_request) Successful in 18s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 30s
security-scan / JS SCA (npm audit) (pull_request) Successful in 35s
security-scan / Python SAST (bandit) (pull_request) Successful in 40s
CI / e2e (pull_request) Failing after 3m20s
CI / backend-test (pull_request) Successful in 1m16s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 57s
CI / frontend-test (pull_request) Successful in 1m25s
test-build / build (backend) (pull_request) Successful in 1m5s
test-build / build (pull_request) Successful in 0s
test-build / build (frontend) (pull_request) Successful in 52s
CI / backend-test-postgres (pull_request) Successful in 4m36s
CI / release-scripts (pull_request) Successful in 8s
CI / pre-commit (pull_request) Successful in 42s
to da0875e0f4
All checks were successful
security-scan / Filesystem scan (trivy fs) (push) Successful in 14s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 40s
security-scan / SBOM (trivy) (push) Successful in 11s
CI / backend-test (push) Successful in 1m15s
publish-main / build (backend) (push) Successful in 1m23s
publish-main / build (frontend) (push) Successful in 1m26s
publish-main / build (push) Successful in 0s
CI / frontend-test (push) Successful in 1m32s
test-build / build (frontend) (pull_request) Successful in 1m9s
CI / e2e (push) Successful in 4m24s
test-build / build (backend) (pull_request) Successful in 1m6s
CI / backend-test (pull_request) Successful in 1m8s
CI / backend-test-postgres (push) Successful in 4m43s
test-build / build (pull_request) Successful in 0s
CI / frontend-test (pull_request) Successful in 1m19s
CI / backend-test-postgres (pull_request) Successful in 4m26s
CI / e2e (pull_request) Successful in 4m28s
CI / release-scripts (pull_request) Successful in 7s
security-scan / SBOM (trivy) (pull_request) Successful in 11s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 13s
security-scan / JS SCA (npm audit) (pull_request) Successful in 16s
CI / release-scripts (push) Successful in 9s
security-scan / Python SAST (bandit) (pull_request) Successful in 22s
CI / pre-commit (pull_request) Successful in 32s
security-scan / JS SCA (npm audit) (push) Successful in 23s
security-scan / Python SAST (bandit) (push) Successful in 31s
security-scan / Python SCA (pip-audit) (push) Successful in 50s
CI / pre-commit (push) Successful in 48s
2026-08-05 21:03:09 +00:00
Compare
robbertbos deleted branch no-skip-link 2026-08-05 21:08:50 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
robbertbos/waggle!224
No description provided.