Files
ad/molodetz/static/js/utils/ModalManager.js
T

85 lines
2.5 KiB
JavaScript
Raw Normal View History

2026-10-05 09:36:20 +02:00
// retoor <retoor@molodetz.nl>
export class ModalManager {
constructor() {
this.stack = [];
document.addEventListener("click", (event) => this.onClick(event));
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && this.stack.length) {
this.close(this.stack[this.stack.length - 1]);
}
});
window.addEventListener("popstate", () => {
if (this.stack.length) {
this.close(this.stack[this.stack.length - 1], false);
}
});
new MutationObserver(() => this.wire()).observe(document.body, { childList: true, subtree: true });
this.wire();
}
wire() {
document.querySelectorAll(".modal-overlay:not([data-wired])").forEach((overlay) => {
overlay.dataset.wired = "1";
overlay.setAttribute("role", overlay.getAttribute("role") || "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.addEventListener("keydown", (event) => this.trap(event, overlay));
});
}
onClick(event) {
const trigger = event.target.closest("[data-modal]");
if (trigger) {
event.preventDefault();
this.open(document.getElementById(trigger.dataset.modal));
return;
}
if (event.target.closest(".modal-close")) {
this.close(event.target.closest(".modal-overlay"));
return;
}
if (event.target.classList?.contains("modal-overlay")) {
this.close(event.target);
}
}
open(overlay) {
if (!overlay) {
return;
}
overlay.classList.add("visible");
this.stack.push(overlay);
history.pushState({ modal: overlay.id }, "");
overlay.querySelector("button, a, input, select, textarea")?.focus();
}
close(overlay, popHistory = true) {
if (!overlay) {
return;
}
overlay.classList.remove("visible");
this.stack = this.stack.filter((item) => item !== overlay);
if (popHistory && history.state?.modal === overlay.id) {
history.back();
}
}
trap(event, overlay) {
if (event.key !== "Tab") {
return;
}
const focusable = [...overlay.querySelectorAll("button, a[href], input, select, textarea")].filter((el) => !el.disabled);
if (!focusable.length) {
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}