Migrating to Mithril 2.x routing: how should focus and screen reader announcements be handled after m.route.set?
0 reputation · 16 May 2022, 19:10 UTC
0 reputation · 16 May 2022, 19:10 UTC
We are migrating a single-page app from Mithril 1.x to 2.x. The routing API changed: route parameters now arrive via the routed component's attrs instead of m.route.param(), and navigation goes through m.route.set(path). The code migration itself is clear, but we have hit an unresolved design decision around accessibility in the user-facing workflow.
Client-side navigation with m.route.set triggers a redraw and swaps the routed view, but nothing moves keyboard focus or announces the page change to assistive technology. Sighted users see the new view; screen reader users apparently stay on whatever element had focus before, which may no longer exist in the DOM.
We are considering wrapping routed content in a <main> landmark and using the routed component's oncreate hook to move focus to a top-level heading on each navigation. What is unclear is whether this should live in every routed component, in a shared layout component, or in a route-change hook, and how it interacts with Mithril's auto-redraw timing.
35275 reputation · 17 May 2022, 02:41 UTC
Put the focus logic in one shared layout component's oncreate (or onupdate) hook, focus the <main> landmark with tabindex="-1", and update document.title in the same hook. An aria-live region can complement this but cannot replace it. m.route.prefix is a URL-only concern and has no effect on any of this.
m.route.set triggers an asynchronous redraw, so focus must be moved only after the new routed view's DOM actually exists. The lifecycle hook that guarantees this is oncreate on the vnode whose element you want to focus — it runs after that element is in the DOM. Calling focus() inside view() would fire on every redraw, stealing focus during unrelated updates.
To avoid duplicating the logic in every routed component, wrap all routes in a single layout via m.route with a render function:
m.route(root, "/home", {
"/home": { render: () => m(Layout, m(Home)) },
"/about": { render: () => m(Layout, m(About)) },
});
// Layout.js
export default {
oncreate(vnode) {
document.title = vnode.attrs.title + " | My App";
vnode.dom.querySelector("main").focus();
},
view(vnode) {
return m("main", { tabindex: "-1" }, vnode.children);
},
};One caveat: if the same layout DOM element persists across route changes, oncreate fires only once. In that case use onupdate instead, or key the routed content (e.g., m(Layout, { key: m.route.get() }, ...)) so the element is recreated per navigation. Which behavior you get depends on your exact Mithril 2.x patch version and structure, so verify with the pinned version.
Focus the <main> landmark with tabindex="-1". This is the widely recommended SPA pattern: it moves keyboard and screen reader context to the start of the new content without adding the element to the tab order. Focusing a top-level <h1 tabindex="-1"> is an accepted equivalent. Without tabindex="-1", focusing a non-interactive element fails silently in some browsers.
An aria-live region alone is weaker: it announces text but does not move keyboard focus, so keyboard users remain on a stale or removed element. Use one only as a supplement (e.g., announcing the new page title). Updating document.title in the same hook gives screen readers a second, free cue that navigation occurred.
Purely a URL/server concern. It controls whether routes live in the hash, query string, or pathname; it does not change redraw timing, the DOM lifecycle, or announcements. You can switch it and confirm focus behavior is identical.
<main>, and Tab should proceed through the new page's controls.m.route.set: document.activeElement should be the intended main/h1 with tabindex="-1".If focus lands correctly on the first navigation but not subsequent ones, that points to the oncreate-vs-onupdate persistence issue above — share whether your layout element is recreated per route if you need a definitive pick between the two.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.