Mithril modal component: focus not trapped and screen reader announces nothing on open
0 reputation · 27 May 2021, 10:58 UTC
0 reputation · 27 May 2021, 10:58 UTC
I am building a modal dialog as a Mithril component and trying to make it behave correctly for keyboard and screen-reader users. My understanding is that Mithril renders vnodes to plain HTML without injecting ARIA roles, labels, or live regions, and that the framework ships no focus-trapping or focus-return utilities, so everything must be declared in the vnode attrs and handled in lifecycle methods such as oncreate and onupdate.
The goal is a dialog that, when opened, receives role="dialog" with an accessible name, moves focus into the modal, keeps Tab and Shift+Tab cycling inside it, closes on Escape, and returns focus to the triggering element on close. I am unsure which parts belong in attrs versus lifecycle hooks, and whether focus restoration needs to survive redraws triggered by m.route or state changes while the modal is open.
Assume Mithril 2.x. I plan to verify with an axe-core audit, manual keyboard walkthrough, and DOM inspection after m.mount.
Is there a documented or community-accepted pattern for focus trapping in Mithril lifecycle methods? Should aria-modal and focus management be re-asserted on every onupdate, or only oncreate? Are there known pitfalls where a redraw drops focus out of the dialog?
The modal should declare its ARIA role and name once (in the vnode attrs or oncreate) and manage focus entirely in lifecycle hooks. Re‑asserting static ARIA attributes on every onupdate is unnecessary and can cause extra screen‑reader announcements.
role="dialog" and aria-modal="true" are static HTML attributes; they need only be set once when the element is first created.aria-label or aria-labelledby) must also be set once; changing it dynamically requires an update, but otherwise it stays constant.keydown listener in oncreate that intercepts Tab, Shift+Tab and Escape, moving focus to the first/last focusable child or closing the dialog.event.target or a passed reference) and call focus() on the first focusable inner element (or the dialog itself with tabindex="-1").onbeforeremove or onremove after any exit animation.oncreate must be removed in onbeforeremove/onremove to avoid duplicates if the component is remounted.m('div.dialog', { role: 'dialog', 'aria-modal': 'true', 'aria-label': modalTitle }, innerContent)
oncreate(vnode):
vnode.dom.triggerEl = opts.triggerEl;vnode.dom.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')) and call focus() on it.keydown listener to vnode.dom that:
onbeforeremove(vnode) (or onremove if no exit animation):
keydown listener.vnode.dom.triggerEl still exists, call vnode.dom.triggerEl.focus().m.route or state changes while open, the stored reference must be refreshed on open; otherwise focus may be restored to a stale element.Are you storing the triggering element reference in a way that survives redraws (e.g., passed as a prop or kept in a closure)? If not, please confirm how you retain that reference so we can advise on the exact placement.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.