From fbcd13c44d442ba092d26fb63564055d2e7b9020 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 18 May 2026 21:06:47 +0200 Subject: [PATCH] fix(a11y): focus trap + returnFocus on DS::Dialog Native .showModal() moves focus inside the dialog on open but doesn't trap Tab / Shift+Tab, and focus restoration on close is inconsistent across engines. Add three things to the dialog controller: - Capture document.activeElement before showModal() so the trigger is recoverable when the dialog closes (ESC, backdrop click, explicit close button, programmatic close all route through the native close event). - Wrap Tab inside the dialog so a keyboard user can't tab out into the scrim-covered page behind. - Restore focus to the captured trigger on the close event. If the trigger has been detached (Turbo morphed it out), skip silently rather than throw. Verified manually: opening the new-goal modal moves focus to the name input; ESC restores focus to the "New goal" link; Tab wraps from the last focusable back to the first. --- app/components/DS/dialog_controller.js | 68 +++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/app/components/DS/dialog_controller.js b/app/components/DS/dialog_controller.js index 2092700c6..f668693a2 100644 --- a/app/components/DS/dialog_controller.js +++ b/app/components/DS/dialog_controller.js @@ -1,5 +1,14 @@ import { Controller } from "@hotwired/stimulus"; +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "textarea:not([disabled])", + "input:not([disabled]):not([type=hidden])", + "select:not([disabled])", + "[tabindex]:not([tabindex='-1'])", +].join(", "); + // Connects to data-controller="dialog" export default class extends Controller { static targets = ["content"] @@ -11,12 +20,26 @@ export default class extends Controller { }; connect() { + this._priorFocus = null; + this._onKeydown = this.#onKeydown.bind(this); + this._onClose = this.#onClose.bind(this); + + this.element.addEventListener("keydown", this._onKeydown); + this.element.addEventListener("close", this._onClose); + if (this.element.open) return; if (this.autoOpenValue) { + this._priorFocus = document.activeElement; this.element.showModal(); + this.#focusInitial(); } } - + + disconnect() { + this.element.removeEventListener("keydown", this._onKeydown); + this.element.removeEventListener("close", this._onClose); + } + // If the user clicks anywhere outside of the visible content, close the dialog clickOutside(e) { if (this.disableClickOutsideValue) return; @@ -34,6 +57,49 @@ export default class extends Controller { } } + // Move focus to the first focusable child unless the dialog already + // declared one via the autofocus attribute. Native `.showModal()` + // is supposed to do this but the behavior varies across engines. + #focusInitial() { + if (this.element.querySelector("[autofocus]")) return; + this.#focusables()[0]?.focus(); + } + + // Tab/Shift+Tab wrap inside the dialog so focus can't leak to the page + // behind. Without this an a11y user can tab into the backdrop'd content + // and lose the modal context entirely. + #onKeydown(e) { + if (e.key !== "Tab") return; + const focusables = this.#focusables(); + if (focusables.length === 0) { + e.preventDefault(); + return; + } + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + + #onClose() { + const prior = this._priorFocus; + this._priorFocus = null; + if (prior && typeof prior.focus === "function" && document.body.contains(prior)) { + prior.focus(); + } + } + + #focusables() { + return Array.from(this.element.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (el) => el.offsetParent !== null || el === document.activeElement, + ); + } + // When the dialog lives inside a top-level , // emptying the frame on close stops Turbo's page cache from snapshotting // an open dialog and reopening it on browser back.