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
tabindex="-1" and a data-focus-id.2
Ask where focus belongs
focusForRoute returns a FocusTarget; the renderer applies it.3
Announce the change
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 snapshotsdoc.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.
How It Works
A push-then-back cycle saves focus on the way in and restores it on the way out.Navigation is classified by stack depth
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
Theassertive 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 delegatedclick 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.
Best Practices
Give every screen heading a tabindex and a focus id
Give every screen heading a tabindex and a focus id
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>.Save focus before the DOM changes, on push only
Save focus before the DOM changes, on push only
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.Check isConnected before restoring
Check isConnected before restoring
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.Announce on every route change
Announce on every route change
Write
screenAnnouncement(strings, route) into the assertive region so the change is heard even when focus lands somewhere with a short name.Delegate clicks on root
Delegate clicks on root
Sibling screens are not descendants of the chat screen. Bind the click listener to
root so taps on settings and chats rows are heard.Related
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.

