Skip to main content
Every time the app changes screens it moves focus to the new heading, snapshots where you were so a Back gesture returns you there, and announces the change — so a screen reader user is never left reading the screen they just left, or worse, nothing at all. a11y/focus and a11y/names are called on every route change — the decision is pure, and the renderer’s three lines (move, save, restore) are the only part a unit test cannot reach.

Quick Start

1

Make each heading focusable

A heading is not focusable otherwise, so every screen’s heading carries tabindex="-1" and a data-focus-id.
2

Ask where focus belongs

focusForRoute returns a FocusTarget; the renderer applies it.
3

Announce the change

Written into the assertive live region so a screen reader hears the change even when focus lands on a short-named element.

The FocusTarget

focusForRoute(from, to, nav) answers with one of three shapes.
A push of the route already on top is { kind: "none" }, not a focus move. Moving focus on a re-render would yank the caret out of the composer mid-sentence every time the transcript published — which on a streaming turn is several times a second.

Save on push, restore on pop

The app snapshots doc.activeElement before the DOM changes, but only on a push — a pop consumes the saved target, and a replace is not a place to come back to.
The saved element may be gone — popping back after deleting the chat you were viewing means the row you came from no longer exists. isConnected is checked, and focus falls back to the destination’s heading via fallbackId rather than focusing nothing. Restoring to a detached node silently focuses <body>.

How It Works

A push-then-back cycle saves focus on the way in and restores it on the way out. The router reports a stack; the app compares its length to the previous depth to tell a push from a pop.
previousDepth is seeded at 1 after the boot-time replace({ name: "chat", chatId: "" }), so the first real navigation classifies correctly. The router’s default chats root is replaced with the chat screen the app actually opens on — otherwise pushing chats later would be swallowed as a push of the route already on top.

Every route change is announced

The assertive live region gets screenAnnouncement(strings, route) on every change, so a route change is never silent to a screen reader even when focus lands on a short-named element.
screenAnnouncement composes strings.announceScreen(routeTitle(strings, route)) from a11y/names — the same routeTitle a heading uses, so the spoken name and the visible heading cannot drift apart.

The click listener is on root, not the screen

The delegated click listener is registered on root, not on the chat screen — the settings and chats screens are siblings of the chat screen, so a tap on the settings or chats screen would otherwise never be heard.
Registering the listener on the chat screen element instead of root means a tap on a chats-screen row — a sibling node — never fires the handler, and open-chat silently does nothing.

Best Practices

tabindex="-1" makes a heading focusable; data-focus-id={headingId(route)} lets the renderer find it. Without both, a route change drops focus to <body>.
Snapshot doc.activeElement ahead of the mount so a later pop can return to it. A pop consumes the saved target; a replace is not a place to come back to.
The row you came from may have been deleted. Fall back to the destination heading via fallbackId rather than focusing a detached node — which silently focuses nothing.
Write screenAnnouncement(strings, route) into the assertive region so the change is heard even when focus lands somewhere with a short name.
Sibling screens are not descendants of the chat screen. Bind the click listener to root so taps on settings and chats rows are heard.

i18n & A11y

Live regions, focusAfterDisable, and accessible names.

History & Reopen

The chats route and the open-chat flow.

Architecture

The route→screen seam and the retained chat screen.

Overview

The top bar and native navigation.