ember-a11y automated focus vs manual {{focus-ring}} for nested outlet hierarchies
0 reputation · 28 Jun 2020, 23:25 UTC
0 reputation · 28 Jun 2020, 23:25 UTC
An Ember 4.x application uses a four-level nested route hierarchy (application → dashboard → settings → profile) where each level renders its own outlet. The team needs consistent keyboard focus behavior after route transitions to satisfy WCAG 2.4.3 Focus Order, but the two documented approaches produce different outcomes in this structure.
ember-a11y v4+ automatically restores focus to the first focusable element in the leaf route's template after every transition, requiring no per-route code. However, when a nested template contains only non-interactive headings before any focusable control, the addon may land focus on a heading, violating focus-order expectations. The alternative—calling focus() on a specific element via the {{focus-ring}} modifier in afterModel—lets developers target a precise outlet header or primary action, but runs before the template renders, risking focus loss if the target is inside an {{#if}} or {{#await}} block.
Both methods interfere with native browser focus behavior in different ways: ember-a11y calls preventDefault on focus events, while manual focus() can create double focus rings when combined with HTML autofocus. No core RFC settles whether route-level focus management should be convention-driven (addon) or configuration-driven (manual).
:profile_id) versus full hierarchy replacements?A thoughtful contribution can make all the difference. Be the first to share one.
Use comments to ask for clarification. Post a solution as an answer.
35,775 reputation · 29 Jun 2020, 06:39 UTC
One critical distinction between these approaches appears during param-only transitions (e.g., /profile/1 to /profile/2). Because the route identity remains the same, automated tools like ember-a11y may not trigger a focus reset if the leaf template is reused without a full teardown.
To ensure predictable behavior in Ember 4.x, consider these verification steps:
focus(), verify the target isn't wrapped in a conditional block. Since afterModel runs before the Glimmer VM renders the DOM, targets inside {{#if}} blocks will likely be null.document.activeElement in the browser console immediately after a dynamic segment change to confirm if focus stayed on the previous element or reset to the body.For nested hierarchies, a hybrid approach—using a wrapper with tabindex="-1" as a programmatic target—often resolves the conflict between automated leaf-scanning and manual precision.