ReviewOS

stacks/stx

feat(router): SPA navigation does no focus management and no route announcement

#1862
Closed glennmichael123 opened this 15 days ago · 2 comments
15 days ago

Summary

The SPA swap runtime updates the URL, the document title and the scroll position, and then stops. Focus is left wherever the swapped-away DOM put it — in practice on a detached node, so the browser resets it to <body> — and nothing announces that the page changed. A keyboard user must tab from the top of the document after every navigation; a screen-reader user gets no indication a navigation happened at all beyond a title change they may not hear.

Impact (real app)

Every client-side navigation in analyticshq is affected: login → register, dashboard → /sites/new, and the whole marketing nav — 276 link occurrences across the resources/ tree. There are no aria-live regions and no skip link in the app (grep -rn "aria-live" resources/ and grep -rn "skip-link|Skip to" resources/ both return nothing), because there was nothing to reach for: the swap is owned by the router, so an app cannot fix this without reimplementing it. This is not a case of the app not using an available primitive — the primitive does not exist.

Evidence in stx

In packages/router/src/:

  • grep -ril "focus" packages/router/src/ → no files. Same for announce, aria-live, live-region, liveRegion, tabindex, restoreFocus, autofocus.
  • packages/router/src/client.ts:1229,1249,1255,1391 — the only addEventListener calls: click, popstate, mouseover (prefetch), DOMContentLoaded. No focus handling anywhere in the 1396-line runtime.
  • packages/router/src/client.ts:736 and :1094 — post-swap work is if(o.scrollToTop&&!hash)window.scrollTo({top:0,behavior:'instant'}).
  • packages/router/src/client.ts:157 (applyTitle) and :1099 — the title is set; nothing else runs.
  • packages/router/src/client.ts:45 — the options object: {container, loadingClass, viewTransitions, cache, scrollToTop, prefetch, progress, progressColor, progressHeight, interceptAllLinks, prefetchCacheMax}. No focus or announce option to opt into.
  • packages/router/src/index.ts:1-45 — nothing route-a11y-shaped is exported.

Prior art

  • Nuxt — <NuxtRouteAnnouncer> is shipped and mounted by the default app template; it renders a visually-hidden polite live region that receives the new route's title on every navigation.
  • Angular — RouterModule focus options plus CDK LiveAnnouncer.
  • Remix / React Router — <ScrollRestoration> alongside documented focus-management guidance (move focus to the route's heading or a wrapper with tabindex="-1").
  • Ember — ember-a11y-refocus is the community-standard addon and does exactly both halves.

Ask

  1. After a successful swap, move focus into the new content region — the swap container, or a [data-stx-focus-target] if the page names one — by setting tabindex="-1" on it and calling .focus({ preventScroll: true }). Should be on by default, with focusOnNavigate: false to opt out.
  2. Ship a route announcer: a single visually-hidden <div aria-live="polite" aria-atomic="true"> created once by the runtime and updated with the new document title on each navigation. On by default, announceRoute: false to opt out.
  3. Add a focusTarget option so an app can point at its own <h1> or main landmark.
  4. Document a skip-link recipe that works with the swap (the link's target must survive or be re-established after each swap).

Related: #1789, #1833

15 days ago

Implemented in 0767f89.

Confirmed the gap is total: grep -n 'focus\|aria-live\|announce' over the whole 1400-line router returned nothing.

Focus moves to the hash target if there is one, otherwise to the container, which gets tabindex="-1" so it is focusable by script but stays out of the tab order. A tabindex the page already set is left alone. It runs before page scripts execute, so a page that focuses its own control on mount still wins. preventScroll keeps it from fighting the scrollToTop and hash-scroll decisions made two lines earlier.

The announcement is a single reused aria-live="polite" region carrying the destination's h1, falling back to document.title — the heading names the destination better than a title usually does, since titles carry a site-name suffix.

Two details that are easy to get wrong and are covered by tests:

  • The region is cleared before being set, because several screen readers ignore a live-region update whose text is identical to what is already there. Without it, navigating between two pages that share a heading announces nothing.
  • It is hidden with the clip technique, not display:none or visibility:hidden — both are skipped by screen readers, which would make the region a silent no-op that still looks correct in the DOM.

Both behaviours default on, opt-out via routeFocus / announceRoute. ROUTER_REV 4 -> 5.

10 tests, 7 failing without the change; the 3 that pass either way are the opt-out paths and the pre-set-tabindex case.

15 days ago

Re-verified at 47a7482c77 and closing.

focusAfterNavigation and announceRoute are both live in packages/router/src/client.ts, both default-on (routeFocus: true, announceRoute: true), and packages/router/test/route-focus-announce.test.ts is 10 pass / 0 fail. Four router commits have landed on top of 0767f893bf since — including the route-manifest interception change in be29fef7b2 — and the behaviour still holds.

Sign in to comment on this issue.