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.

Diagram

push

pop

replace

🧭 route change

push / pop / replace

πŸ’Ύ save activeElement

↩️ restore or fallback

🎯 focus heading

πŸ“’ assertive announcement

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
    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 a data-focus-id.

  2. 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" }

    focusForRoute returns a FocusTarget; the renderer applies it.

  3. Announce the change
    ts
    import { screenAnnouncement } from "praisonai-mobile/ui/a11y/focus";
    
    assertive.textContent = screenAnnouncement(strings, route);

    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.

FocusTargetWhenWhat the renderer does
{ kind: "none" }a re-render or a push of the route already on topleaves focus where it is
{ kind: "element", id }first paint, a push, or a replacefocuses the heading by id
{ kind: "restore", fallbackId }a poprestores the saved element, or the heading if it is gone
ts
// 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.

ts
// 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;
}
ts
// 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.

Diagram
DOMfocusForRouteshowRouteRouterUserDOMfocusForRouteshowRouteRouterUsertap Settings (push)subscribe(stack)save doc.activeElementfocusForRoute(chat, settings, “push”){ kind: “element”, id: heading:settings }assertive = screenAnnouncementfocus settings headingBack gesture (pop)subscribe(stack)focusForRoute(settings, chat, “pop”){ kind: “restore”, fallbackId }restore saved element (or fallback)

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.

ts
const nav: Navigation =
  stack.length < previousDepth ? "pop" :
  stack.length > previousDepth ? "push" : "replace";
ComparisonNavigation
stack.length < previousDepthpop
stack.length > previousDepthpush
stack.length === previousDepthreplace

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.

ts
// 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.

ts
// 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.


Related