mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
fix(a11y): focus trap + returnFocus on DS::Dialog
Native <dialog>.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.
This commit is contained in:
@@ -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 `<dialog>.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 <turbo-frame id="modal">,
|
||||
// emptying the frame on close stops Turbo's page cache from snapshotting
|
||||
// an open dialog and reopening it on browser back.
|
||||
|
||||
Reference in New Issue
Block a user