No skip link: keyboard users cannot bypass TopNav (WCAG 2.4.1, level A) #223

Closed
opened 2026-08-05 16:17:22 +00:00 by robbertbos · 0 comments
Owner

TopNav renders at the top of every authenticated route, so a keyboard or screen-reader user tabs through the whole navigation block again on each page before reaching the content. There is no way to bypass it.

That is WCAG 2.2 success criterion 2.4.1 Bypass Blocks, level A, and therefore also EN 301 549 clause 9.2.4.1, which applies to this project.

Current state, measured on main (961d3d5)

Count
Route files rendering TopNav 8 (Inbox, ReadingList, Settings, ConceptBerichten, Notities, UitgaandePost, Voorwaarden, Welkom)
<main> elements in frontend/src 0
<nav> elements 0
role="main" / role="navigation" 0
Skip links 0

The 29 grep hits for "skip" in frontend/src are all skipped, skipAuthRedirect and similar - none is a skip link.

Note that Welkom.vue uses nldd-page, which supplies a <main> from its own shadow root. The other seven routes render a plain <div class="page-shell">.

Proposed fix

nldd-skip-link ships in 0.8.77 (export ./skip-link) and needs no app shell. Read its contract before implementing: it wraps the skippable content in its default slot, and without href it moves focus to its nextElementSibling, putting a tabindex="-1" on that element itself. So the shape is:

<nldd-skip-link>
  <TopNav ... />
</nldd-skip-link>
<main>
  ...
</main>

Two things worth knowing up front:

  • Use the variant without href. The href variant renders a real <a> with a fragment, and the router's scrollBehavior ignores to.hash, which makes the page jump to the top instead.
  • <main> must be the next sibling. On Inbox that means SkipTourButton and RetentionIntroBanner move inside <main>.

Also add 'nldd-skip-link' to NLDD_TAGS in frontend/src/test-setup.ts, or Vue logs an unknown-element warning in every test that renders a route.

Adding <main> on its own is not a separate success criterion - landmarks are a technique (ARIA11) for 1.3.1 and 2.4.1, not a criterion of their own. The citable failure here is 2.4.1.

jest-axe and @axe-core/cli are declared in frontend/package.json but are never invoked - not by an npm script and not by CI. They are a nominal safety net rather than a real one, which is worse than having none, because it reads as covered. Being removed separately; if automated a11y checking is wanted later it should be wired up deliberately rather than left as a dependency that looks like it runs.

`TopNav` renders at the top of every authenticated route, so a keyboard or screen-reader user tabs through the whole navigation block again on each page before reaching the content. There is no way to bypass it. That is **WCAG 2.2 success criterion 2.4.1 Bypass Blocks, level A**, and therefore also EN 301 549 clause 9.2.4.1, which applies to this project. ## Current state, measured on `main` (961d3d5) | | Count | |---|---| | Route files rendering `TopNav` | 8 (`Inbox`, `ReadingList`, `Settings`, `ConceptBerichten`, `Notities`, `UitgaandePost`, `Voorwaarden`, `Welkom`) | | `<main>` elements in `frontend/src` | 0 | | `<nav>` elements | 0 | | `role="main"` / `role="navigation"` | 0 | | Skip links | 0 | The 29 grep hits for "skip" in `frontend/src` are all `skipped`, `skipAuthRedirect` and similar - none is a skip link. Note that `Welkom.vue` uses `nldd-page`, which supplies a `<main>` from its own shadow root. The other seven routes render a plain `<div class="page-shell">`. ## Proposed fix `nldd-skip-link` ships in 0.8.77 (export `./skip-link`) and needs no app shell. Read its contract before implementing: it **wraps** the skippable content in its default slot, and without `href` it moves focus to its `nextElementSibling`, putting a `tabindex="-1"` on that element itself. So the shape is: ```html <nldd-skip-link> <TopNav ... /> </nldd-skip-link> <main> ... </main> ``` Two things worth knowing up front: - **Use the variant without `href`.** The `href` variant renders a real `<a>` with a fragment, and the router's `scrollBehavior` ignores `to.hash`, which makes the page jump to the top instead. - **`<main>` must be the next sibling.** On `Inbox` that means `SkipTourButton` and `RetentionIntroBanner` move inside `<main>`. Also add `'nldd-skip-link'` to `NLDD_TAGS` in `frontend/src/test-setup.ts`, or Vue logs an unknown-element warning in every test that renders a route. Adding `<main>` on its own is not a separate success criterion - landmarks are a technique (ARIA11) for 1.3.1 and 2.4.1, not a criterion of their own. The citable failure here is 2.4.1. ## Out of scope, but related `jest-axe` and `@axe-core/cli` are declared in `frontend/package.json` but are never invoked - not by an npm script and not by CI. They are a nominal safety net rather than a real one, which is worse than having none, because it reads as covered. Being removed separately; if automated a11y checking is wanted later it should be wired up deliberately rather than left as a dependency that looks like it runs.
Sign in to join this conversation.
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#223
No description provided.