mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 15:59:02 +00:00
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.
111 lines
3.2 KiB
JavaScript
111 lines
3.2 KiB
JavaScript
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"]
|
|
|
|
static values = {
|
|
autoOpen: { type: Boolean, default: false },
|
|
reloadOnClose: { type: Boolean, default: false },
|
|
disableClickOutside: { type: Boolean, default: false },
|
|
};
|
|
|
|
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;
|
|
if (!this.contentTarget.contains(e.target)) {
|
|
this.close();
|
|
}
|
|
}
|
|
|
|
close() {
|
|
this.element.close();
|
|
this.#clearParentModalFrame();
|
|
|
|
if (this.reloadOnCloseValue) {
|
|
Turbo.visit(window.location.href);
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
#clearParentModalFrame() {
|
|
const frame = this.element.closest('turbo-frame[id="modal"]');
|
|
if (frame) frame.innerHTML = "";
|
|
}
|
|
}
|