@pageblock/utils
Version:
A modern utility library for PageBlock and Webflow, providing reusable components and utilities for web applications and Webflow sites
343 lines (342 loc) • 12.1 kB
JavaScript
class Modal {
constructor(options = {}) {
this.options = {
debug: options.debug || false,
...options
};
this.modalState = /* @__PURE__ */ new WeakMap();
this.zIndexCounter = 1e3;
this.activeModals = [];
this.initialize();
}
/**
* Debug logging function
* @private
*/
log(...args) {
if (this.options.debug) {
console.log("[Modal]", ...args);
}
}
initialize() {
const allModalContainers = document.querySelectorAll('pageblock-modal[data-modal-id], [data-pb-modal="container"][data-modal-id]');
allModalContainers.forEach((container) => {
this.initializeModalContainer(container);
});
const legacySheets = document.querySelectorAll('[data-pb-modal="sheet"][data-modal-id]');
legacySheets.forEach((sheet) => {
if (!sheet.closest("pageblock-modal")) {
this.initializeLegacyModal(sheet);
}
});
document.addEventListener("click", this.handleDocumentClick.bind(this));
document.addEventListener("keydown", this.handleKeyDown.bind(this));
document.addEventListener("pb:modal:open", this.handleCustomOpenEvent.bind(this));
document.addEventListener("pb:modal:close", this.handleCustomCloseEvent.bind(this));
this.log("Modal system initialized");
}
initializeModalContainer(container) {
const modalId = container.getAttribute("data-modal-id");
if (!modalId) {
console.warn("Modal container found without data-modal-id attribute", container);
return;
}
const modal = container.querySelector('[data-pb-modal="sheet"]');
const overlay = container.querySelector('[data-pb-modal="overlay"]');
if (!modal || !overlay) {
console.warn(`Modal components missing in container with ID: ${modalId}`, container);
return;
}
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-hidden", "true");
const animation = container.getAttribute("data-animation");
const variant = container.getAttribute("data-variant");
this.modalState.set(container, {
id: modalId,
isOpen: false,
previousFocus: null,
animation: animation || "fade",
variant: variant || "default",
zIndex: parseInt(modal.style.zIndex) || this.zIndexCounter,
modal,
overlay,
focusableElements: this.getFocusableElements(modal)
});
this.log(`Initialized modal: ${modalId}`);
}
initializeLegacyModal(sheet) {
const modalId = sheet.getAttribute("data-modal-id");
if (!modalId) return;
let overlay = document.querySelector('[data-pb-modal="overlay"]');
if (!overlay) {
overlay = document.createElement("div");
overlay.setAttribute("data-pb-modal", "overlay");
document.body.appendChild(overlay);
}
sheet.setAttribute("role", "dialog");
sheet.setAttribute("aria-modal", "true");
sheet.setAttribute("aria-hidden", "true");
const virtualContainer = {
getAttribute: (attr) => attr === "data-modal-id" ? modalId : null,
dispatchEvent: (event) => sheet.dispatchEvent(event)
};
this.modalState.set(virtualContainer, {
id: modalId,
isOpen: false,
previousFocus: null,
animation: "fade",
variant: "default",
zIndex: parseInt(sheet.style.zIndex) || this.zIndexCounter,
modal: sheet,
overlay,
focusableElements: this.getFocusableElements(sheet),
isLegacy: true
});
this.log(`Initialized legacy modal: ${modalId}`);
}
handleDocumentClick(event) {
const trigger = event.target.closest("[data-pb-modal-trigger]");
if (trigger) {
event.preventDefault();
const modalId = trigger.getAttribute("data-pb-modal-trigger");
this.openModal(modalId, {
trigger
});
}
const closeButton = event.target.closest('[data-pb-modal="close"]');
if (closeButton) {
event.preventDefault();
const container = closeButton.closest('pageblock-modal, [data-pb-modal="container"]');
const modalId = container == null ? void 0 : container.getAttribute("data-modal-id");
const sheet = closeButton.closest('[data-pb-modal="sheet"]');
const legacyModalId = sheet == null ? void 0 : sheet.getAttribute("data-modal-id");
this.closeModal(modalId || legacyModalId);
}
const overlay = event.target.closest('[data-pb-modal="overlay"]');
if (overlay && overlay === event.target) {
event.preventDefault();
if (this.activeModals.length > 0) {
const topModalId = this.activeModals[this.activeModals.length - 1];
this.closeModal(topModalId);
}
}
}
handleKeyDown(event) {
if (event.key === "Escape" && this.activeModals.length > 0) {
const topModalId = this.activeModals[this.activeModals.length - 1];
this.closeModal(topModalId);
} else if (event.key === "Tab" && this.activeModals.length > 0) {
const topModalId = this.activeModals[this.activeModals.length - 1];
const container = this.findModalContainer(topModalId);
if (container) {
const state = this.modalState.get(container);
if (state && state.modal) {
this.trapFocus(event, state.modal);
}
}
}
}
handleCustomOpenEvent(event) {
const modalId = event.detail.modalId;
const options = event.detail.options || {};
this.openModal(modalId, options);
}
handleCustomCloseEvent(event) {
const modalId = event.detail.modalId;
const options = event.detail.options || {};
this.closeModal(modalId, options);
}
findModalContainer(modalId) {
let container = document.querySelector(`pageblock-modal[data-modal-id="${modalId}"], [data-pb-modal="container"][data-modal-id="${modalId}"]`);
if (!container) {
const legacySheet = document.querySelector(`[data-pb-modal="sheet"][data-modal-id="${modalId}"]`);
if (legacySheet) {
container = {
getAttribute: (attr) => attr === "data-modal-id" ? modalId : null,
dispatchEvent: (event) => legacySheet.dispatchEvent(event)
};
}
}
return container;
}
openModal(modalId, options = {}) {
if (!modalId) {
console.error("Modal ID is required to open a modal");
return;
}
const container = this.findModalContainer(modalId);
if (!container) {
console.error(`Modal container not found for ID: ${modalId}`);
return;
}
const state = this.modalState.get(container);
if (!state) {
console.error(`Modal state not found for ID: ${modalId}`);
return;
}
const modal = state.modal;
const overlay = state.overlay;
if (state.isOpen) return;
state.previousFocus = document.activeElement;
this.zIndexCounter += 2;
state.zIndex = this.zIndexCounter;
modal.style.zIndex = state.zIndex;
overlay.style.zIndex = state.zIndex - 1;
modal.setAttribute("aria-hidden", "false");
modal.classList.add("cc-active");
overlay.classList.add("cc-active");
if (options.animation && !state.isLegacy) {
container.dataset.animation = options.animation;
}
if (options.variant && !state.isLegacy) {
container.dataset.variant = options.variant;
}
document.body.style.overflow = "hidden";
this.activeModals.push(modalId);
state.isOpen = true;
this.modalState.set(container, state);
setTimeout(() => {
const focusTarget = modal.querySelector("[data-pb-modal-autofocus]") || state.focusableElements[0] || modal;
if (focusTarget) {
focusTarget.focus();
}
}, 50);
container.dispatchEvent(new CustomEvent("modal:opened", {
detail: {
modalId,
options
}
}));
if (typeof options.onOpen === "function") {
options.onOpen(modal, overlay, container);
}
this.log(`Opened modal: ${modalId}`);
}
closeModal(modalId, options = {}) {
if (!modalId) {
console.error("Modal ID is required to close a modal");
return;
}
const container = this.findModalContainer(modalId);
if (!container) {
console.error(`Modal container not found for ID: ${modalId}`);
return;
}
const state = this.modalState.get(container);
if (!state || !state.isOpen) return;
const modal = state.modal;
const overlay = state.overlay;
modal.setAttribute("aria-hidden", "true");
modal.classList.remove("cc-active");
let otherModalsUsingOverlay = false;
for (let i = 0; i < this.activeModals.length; i++) {
const activeId = this.activeModals[i];
if (activeId !== modalId) {
const activeContainer = this.findModalContainer(activeId);
if (activeContainer) {
const activeState = this.modalState.get(activeContainer);
if (activeState && activeState.overlay === overlay) {
otherModalsUsingOverlay = true;
break;
}
}
}
}
if (!otherModalsUsingOverlay) {
overlay.classList.remove("cc-active");
}
const index = this.activeModals.indexOf(modalId);
if (index > -1) {
this.activeModals.splice(index, 1);
}
if (this.activeModals.length === 0) {
document.body.style.overflow = "";
}
state.isOpen = false;
this.modalState.set(container, state);
const transitionEndHandler = () => {
if (state.previousFocus && state.previousFocus.focus) {
state.previousFocus.focus();
}
container.dispatchEvent(new CustomEvent("modal:closed", {
detail: {
modalId,
options
}
}));
if (typeof options.onClose === "function") {
options.onClose(modal, overlay, container);
}
modal.removeEventListener("transitionend", transitionEndHandler);
};
modal.addEventListener("transitionend", transitionEndHandler);
this.log(`Closed modal: ${modalId}`);
}
closeAllModals() {
const modalsToClose = [...this.activeModals];
modalsToClose.forEach((modalId) => {
this.closeModal(modalId);
});
this.log("Closed all modals");
}
// Utility function to get all focusable elements within a container
getFocusableElements(container) {
const focusableSelectors = [
"a[href]:not([disabled])",
"button:not([disabled])",
"textarea:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
'[tabindex]:not([tabindex="-1"])'
];
return Array.from(container.querySelectorAll(focusableSelectors.join(",")));
}
// Focus trap implementation
trapFocus(event, modal) {
const container = modal.closest('pageblock-modal, [data-pb-modal="container"]') || this.findModalContainer(modal.getAttribute("data-modal-id"));
if (!container) return;
const state = this.modalState.get(container);
if (!state) return;
const focusableElements = state.focusableElements;
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
}
}
}
// Public API methods
open(modalId, options = {}) {
this.openModal(modalId, options);
}
close(modalId, options = {}) {
this.closeModal(modalId, options);
}
closeAll() {
this.closeAllModals();
}
destroy() {
document.removeEventListener("click", this.handleDocumentClick);
document.removeEventListener("keydown", this.handleKeyDown);
document.removeEventListener("pb:modal:open", this.handleCustomOpenEvent);
document.removeEventListener("pb:modal:close", this.handleCustomCloseEvent);
this.closeAllModals();
this.modalState = /* @__PURE__ */ new WeakMap();
this.activeModals = [];
this.log("Modal system destroyed");
}
}
export {
Modal,
Modal as default
};
//# sourceMappingURL=modal.js.map