Route Focus & Announcements
Every route change moves focus to the new screen's heading, restores it on the way back, and announces the change β so a screen reader never falls silent or lands on <body>.
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
- Make each heading focusable
ts heading.setAttribute("tabindex", "-1"); heading.dataset["focusId"] = headingId(route);A heading is not focusable otherwise, so every screen’s heading carries
tabindex="-1"and adata-focus-id. - Ask where focus belongs
ts import { focusForRoute } from "praisonai-mobile/ui/a11y/focus"; const target = focusForRoute(previous, next, nav); // { kind: "element", id } | { kind: "restore", fallbackId } | { kind: "none" }focusForRoutereturns aFocusTarget; the renderer applies it. - Announce the change
ts import { screenAnnouncement } from "praisonai-mobile/ui/a11y/focus"; assertive.textContent = screenAnnouncement(strings, route);Written into the
assertivelive 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.
FocusTarget | When | What the renderer does |
|---|---|---|
{ kind: "none" } | a re-render or a push of the route already on top | leaves focus where it is |
{ kind: "element", id } | first paint, a push, or a replace | focuses the heading by id |
{ kind: "restore", fallbackId } | a pop | restores the saved element, or the heading if it is gone |
// focusForRoute β the pop case returns to where the user was.
if (from === null) return { kind: "element", id: headingId(to) };
if (sameRoute(from, to)) return { kind: "none" };
if (nav === "pop") return { kind: "restore", fallbackId: headingId(to) };
return { kind: "element", id: headingId(to) };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.
// main.ts β save the control the user is leaving from, on a push only.
if (nav === "push") {
const active = doc.activeElement;
restoreFocus = active instanceof HTMLElement ? active : null;
}// applyFocus β restore the saved element, or fall back to the heading.
case "restore": {
const saved = restoreFocus;
if (saved !== null && saved.isConnected) saved.focus();
else byFocusId(target.fallbackId)?.focus();
restoreFocus = null;
return;
}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.
const nav: Navigation =
stack.length < previousDepth ? "pop" :
stack.length > previousDepth ? "push" : "replace";| Comparison | Navigation |
|---|---|
stack.length < previousDepth | pop |
stack.length > previousDepth | push |
stack.length === previousDepth | replace |
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.
// showRoute β announce, then move focus.
if (focus.kind === "element" && focus.id !== "") {
assertive.textContent = screenAnnouncement(strings, route);
}
applyFocus(focus);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.
// main.ts β delegated on root so sibling screens are reachable.
root.addEventListener("click", (event) => { /* ... */ });Best Practices
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
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
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
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
Sibling screens are not descendants of the chat screen. Bind the click listener to root so taps on settings and chats rows are heard.