Skip to content

a11y(2.4.3): route navigation — move focus to the main landmark on afterNavigate, not just scroll#3538

Merged
ardiewen merged 5 commits into
mainfrom
wcag/2.4.3-route-change-focus
Jun 26, 2026
Merged

a11y(2.4.3): route navigation — move focus to the main landmark on afterNavigate, not just scroll#3538
ardiewen merged 5 commits into
mainfrom
wcag/2.4.3-route-change-focus

Conversation

@rosanusi

Copy link
Copy Markdown
Contributor

Description

Fixes WCAG 2.2 SC 2.4.3 (Focus Order) for route-change navigation in src/routes/(app)/+layout.svelte.

The afterNavigate callback scrolled #content to the top but never moved keyboard focus. After every in-app navigation, focus fell to document.body — forcing keyboard users to Tab from the document beginning and depriving screen-reader users of any route-change announcement.

Fix: resolve #content once, reuse for both the existing scrollTo and a new focus({ preventScroll: true }) call.

 afterNavigate(() => {
-  document.getElementById('content')?.scrollTo(0, 0);
+  const main = document.getElementById('content');
+  main?.scrollTo(0, 0);
+  main?.focus({ preventScroll: true });
 });

preventScroll: true prevents the browser re-scrolling the element into view after our own scrollTo(0, 0) has already positioned it.

<main id="content" tabindex="-1"> is already present in src/lib/holocene/main-content-container.svelte (the dependency from 2.4.1-skip-link-target-focus.md is met).

Parallel fix: the identical change is applied to cloud-ui-main in a companion PR (the +layout.svelte route shell does not cascade via the @temporalio/ui tarball).

Screenshots

No visual change. Focus position changes after route transitions.

Design

N/A.

Testing

  • Load the workflows list. Tab to a workflow row → press Enter → detail page loads. Press Tab once. Confirm focus is inside <main>, not at the skip-nav link.
  • Namespace switch — confirm same behaviour.
  • Command-palette navigation — activate a route via Cmd+K → confirm Tab lands inside <main> on the loaded page.
  • Browser back button — navigate back, press Tab — confirm focus is inside <main>.
  • Screen reader (VoiceOver): navigate between routes — confirm "main, region" is announced after each transition.
  • Regression: #content is scrolled to top on each route change (existing scrollTo preserved).
  • Regression: initial page load focus unchanged (SvelteKit default — afterNavigate does not fire on first load).

Checklist

Docs

No documentation changes required.

A11y-Audit-Ref: 2.4.3-route-change-focus

After every route navigation focus was falling to document.body, forcing
keyboard users to re-navigate from the document top. Moving focus to
<main id="content"> gives SR users a route-change announcement and lands
keyboard users just before the page's first interactive element.

preventScroll: true avoids re-scrolling after the preceding scrollTo(0, 0).
Depends on <main tabindex="-1"> which is already present in
main-content-container.svelte.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
holocene Ready Ready Preview, Comment Jun 25, 2026 4:41pm

Request Review

@github-actions github-actions Bot added a11y Accessibility audit PR a11y:bucket-3 Bucket 3: engineer required a11y:sc-2.4.3 labels Jun 11, 2026
The unconditional main.focus() fired on every navigation, including
same-pathname query-param navs done via updateQueryParameters
(keepFocus: true). Filtering, paginating, sorting, and searching list
views would yank focus out to <main> mid-interaction, defeating
keepFocus and regressing the very keyboard/SR users 2.4.3 targets.

Guard on the navigation target: skip initial hydration (type 'enter')
and same-pathname query updates; only move focus on an actual route
(pathname) change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pe 'enter'

Because the app runs SPA (ssr=false) and splits (login)/(app) route
groups, the (app) layout frequently mounts AFTER the post-login/root
client redirect. Its first afterNavigate then fires with type 'goto'
(from /login or /), never 'enter', so the type==='enter' guard missed
it and focus was stolen on what users see as first load (visible ring
around <main>).

Track the layout's first navigation with a flag and skip it instead —
robust to both direct hydration ('enter') and post-redirect entry
('goto'). Same-pathname query-param navs are still skipped to respect
keepFocus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The initialNavigation flag was insufficient. OSS ui's root route (/)
renders a Loading page and redirects to workflows via goto() in onMount
— a second, programmatic navigation. So afterNavigate fires twice on
first load: 'enter' on / (consumes the flag), then 'goto' from / to
/workflows (still focuses). The flag just deferred the focus-steal by
one navigation.

The / route exists only to load and redirect away, so any navigation
from / is the bootstrap hop, not a user action. Guard on that instead.
type==='enter' covers direct deep links; same-pathname covers query
navs. (cloud-ui needs none of this — its root redirects in load, so it
lands as 'enter'.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ardiewen

Copy link
Copy Markdown
Contributor

Pushed two follow-up commits to harden the afterNavigate guard:

  • Skip same-pathname query navs — the original unconditional focus() fired on every navigation, including updateQueryParameters calls (keepFocus: true). Filtering/sorting/paginating list views was yanking focus to <main>, defeating keepFocus.
  • Skip the bootstrap redirecttype === 'enter' alone wasn't enough in OSS. The root route (/) renders <Loading /> and redirects to workflows via goto() in onMount, so afterNavigate fires twice on first load (enter on /, then goto from /). Guarding on from === '/' suppresses the focus-steal on the workflows landing page.

Final guard: skip enter, skip navs from === '/', skip same-pathname navs; focus <main> on genuine route changes.

Note: the description's "afterNavigate does not fire on first load" is inaccurate — it fires as enter, and again as goto via the onMount redirect. Worth updating so the three guard conditions read clearly.

@ardiewen ardiewen marked this pull request as ready for review June 25, 2026 16:20
@ardiewen ardiewen requested a review from a team as a code owner June 25, 2026 16:20
@ardiewen ardiewen merged commit 836d7c5 into main Jun 26, 2026
17 checks passed
@ardiewen ardiewen deleted the wcag/2.4.3-route-change-focus branch June 26, 2026 18:48
temporal-cicd Bot pushed a commit that referenced this pull request Jul 2, 2026
Auto-generated version bump from 2.51.1 to 2.52.0

Bump type: minor

Changes included:
- [`9c6fcd54`](9c6fcd5) Add direct SSO redirect and backend logout (#3590)
- [`feab0d90`](feab0d9) a11y(4.1.3): Toaster role="log" → per-toast role="status"/"alert" for correct AT announcement semantics (#3560)
- [`d7bbfb01`](d7bbfb0) a11y(2.5.8): toast — replace bare close button with IconButton (36×36 px) (#3553)
- [`4efd6ab6`](4efd6ab) a11y(3.3.1): Holocene input primitives — add aria-invalid, aria-describedby, and uniform live-region error announcement (#3554)
- [`0600b700`](0600b70) Use green only (#3599)
- [`94107549`](9410754) Clarify activity pause timeout behavior (#3600)
- [`9f88d5a8`](9f88d5a) Bump node version to 22.23.1 or newer to fix issue from previous security release (#3604)
- [`ca4104ae`](ca4104a) Update reason copy and inputs (#3588)
- [`51db73fa`](51db73f) Update batch operation reason input hint (#3605)
- [`836d7c55`](836d7c5) a11y(2.4.3): route navigation — move focus to the main landmark on afterNavigate, not just scroll (#3538)
- [`4ee95696`](4ee9569) Improve re-renders for large encoded event histories (#3592)
- [`fa5e7aa1`](fa5e7aa) feature/schedules (#3603)
- [`0d9467fd`](0d9467f) Default includeHeartbeatDetails and includeLastFailure to true for getActivityExecution and pollActivityExecution (#3521)
- [`e5bc514e`](e5bc514) fix schedules: duplicate key (#3612)
- [`f1eb489f`](f1eb489) Remove preview badge (#3581)
- [`1e5173b4`](1e5173b) Add existing versions to worker create deployment version form (#3601)
- [`a8552bae`](a8552ba) fix schedules -e.trim not a function (#3613)
- [`0442f847`](0442f84) Fix width of ComputeBadge in VersionTableRow (#3615)
- [`ddbeb54f`](ddbeb54) fix(cloud-nav): animate and stabilize side-nav collapse (#3606)
- [`3cfc684b`](3cfc684) fix - schedules recent/upcoming runs - sort issue (#3616)
- [`92b8875b`](92b8875) Move timestamp out of translate (#3617)
- [`901258af`](901258a) Fix SAA and WF consistency (#3614)
- [`99593d97`](99593d9) DT-4001 - standalone nexus operations (#3496)
- [`f10d1bf1`](f10d1bf) Clamp codeblock to container width (#3621)
- [`0a9d93ce`](0a9d93c) Remove unnecessary bottom-0 (#3623)
- [`9dabb522`](9dabb52) chore(deps-dev): bump vite from 6.4.2 to 6.4.3 (#3610)
- [`5c1854d3`](5c1854d) News Feed (#3596)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a11y:bucket-3 Bucket 3: engineer required a11y:sc-2.4.3 a11y Accessibility audit PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants