// ============================================================================ // Stylescape | Modal // ============================================================================ // Accessible modal dialog with focus trapping and animations. // Supports data-ss-modal attributes for declarative configuration. // ============================================================================ /** * Configuration options for Modal */ export interface ModalOptions { /** Close on backdrop click */ closeOnBackdrop?: boolean; /** Close on Escape key */ closeOnEscape?: boolean; /** Animation duration in milliseconds */ animationDuration?: number; /** CSS class for open state */ openClass?: string; /** CSS class for backdrop */ backdropClass?: string; /** Trap focus within modal */ trapFocus?: boolean; /** Element to focus when opened */ focusElement?: string; /** Return focus to trigger element on close */ returnFocus?: boolean; /** Callback when modal opens */ onOpen?: (modal: HTMLElement) => void; /** Callback when modal closes */ onClose?: (modal: HTMLElement) => void; /** Callback before close (return false to prevent) */ onBeforeClose?: (modal: HTMLElement) => boolean | void; } /** * Accessible modal dialog component. * * @example JavaScript * ```typescript * const modal = new Modal("#myModal", { * closeOnBackdrop: true, * closeOnEscape: true, * trapFocus: true, * onOpen: () => console.log("Modal opened") * }) * * // Open programmatically * modal.open() * ``` * * @example HTML with data-ss * ```html * * * * ``` */ export class Modal { private element: HTMLElement | null; private options: Required; private triggerElement: HTMLElement | null = null; private focusableElements: HTMLElement[] = []; private isOpen: boolean = false; private backdropElement: HTMLElement | null = null; constructor( selectorOrElement: string | HTMLElement, options: ModalOptions = {}, ) { this.element = typeof selectorOrElement === "string" ? document.querySelector(selectorOrElement) : selectorOrElement; this.options = { closeOnBackdrop: options.closeOnBackdrop ?? true, closeOnEscape: options.closeOnEscape ?? true, animationDuration: options.animationDuration ?? 300, openClass: options.openClass ?? "modal--open", backdropClass: options.backdropClass ?? "modal-backdrop", trapFocus: options.trapFocus ?? true, focusElement: options.focusElement ?? "", returnFocus: options.returnFocus ?? true, onOpen: options.onOpen ?? (() => {}), onClose: options.onClose ?? (() => {}), onBeforeClose: options.onBeforeClose ?? (() => true), }; if (!this.element) { console.warn("[Stylescape] Modal element not found"); return; } this.init(); } // ======================================================================== // Public Properties // ======================================================================== /** * Check if modal is currently open */ public get opened(): boolean { return this.isOpen; } // ======================================================================== // Public Methods // ======================================================================== /** * Open the modal */ public open(trigger?: HTMLElement): void { if (!this.element || this.isOpen) return; this.triggerElement = trigger || (document.activeElement as HTMLElement); // Create backdrop this.createBackdrop(); // Show modal this.element.hidden = false; this.element.setAttribute("aria-hidden", "false"); document.body.classList.add("modal-open"); document.body.style.overflow = "hidden"; // Add open class with delay for animation requestAnimationFrame(() => { this.element?.classList.add(this.options.openClass); this.backdropElement?.classList.add( `${this.options.backdropClass}--visible`, ); }); // Focus management this.updateFocusableElements(); this.setInitialFocus(); // Add event listeners document.addEventListener("keydown", this.handleKeydown); this.isOpen = true; this.options.onOpen(this.element); } /** * Close the modal */ public close(): void { if (!this.element || !this.isOpen) return; // Check beforeClose callback if (this.options.onBeforeClose(this.element) === false) { return; } // Remove open class for animation this.element.classList.remove(this.options.openClass); this.backdropElement?.classList.remove( `${this.options.backdropClass}--visible`, ); // Hide after animation setTimeout(() => { if (!this.element) return; this.element.hidden = true; this.element.setAttribute("aria-hidden", "true"); document.body.classList.remove("modal-open"); document.body.style.overflow = ""; this.removeBackdrop(); // Return focus if (this.options.returnFocus && this.triggerElement) { this.triggerElement.focus(); } this.isOpen = false; this.options.onClose(this.element); }, this.options.animationDuration); // Remove event listeners document.removeEventListener("keydown", this.handleKeydown); } /** * Toggle the modal */ public toggle(trigger?: HTMLElement): void { if (this.isOpen) { this.close(); } else { this.open(trigger); } } /** * Update modal content */ public setContent(html: string): void { const content = this.element?.querySelector( "[data-ss-modal-content], .modal-content", ); if (content) { content.innerHTML = html; this.updateFocusableElements(); } } /** * Destroy the modal */ public destroy(): void { this.close(); document.removeEventListener("keydown", this.handleKeydown); this.element ?.querySelectorAll("[data-ss-modal-close]") .forEach((button) => { button.removeEventListener("click", this.handleCloseClick); }); this.element = null; } // ======================================================================== // Static Factory // ======================================================================== /** * Initialize all modals and triggers */ public static initModals(): Modal[] { const modals: Modal[] = []; const modalMap = new Map(); // Initialize modal elements document .querySelectorAll('[data-ss="modal"]') .forEach((el) => { const closeOnBackdrop = el.dataset.ssModalCloseBackdrop !== "false"; const closeOnEscape = el.dataset.ssModalCloseEscape !== "false"; const modal = new Modal(el, { closeOnBackdrop, closeOnEscape, }); modals.push(modal); if (el.id) { modalMap.set(`#${el.id}`, modal); } }); // Setup triggers document .querySelectorAll("[data-ss-modal-trigger]") .forEach((trigger) => { const targetSelector = trigger.dataset.ssModalTrigger; if (targetSelector) { const modal = modalMap.get(targetSelector); if (modal) { trigger.addEventListener("click", () => modal.open(trigger), ); } } }); return modals; } // ======================================================================== // Private Methods // ======================================================================== private init(): void { if (!this.element) return; // Set up ARIA attributes this.element.setAttribute("role", "dialog"); this.element.setAttribute("aria-modal", "true"); this.element.setAttribute("aria-hidden", "true"); this.element.hidden = true; // Setup close buttons this.element .querySelectorAll("[data-ss-modal-close]") .forEach((button) => { button.addEventListener("click", this.handleCloseClick); }); } private createBackdrop(): void { this.backdropElement = document.createElement("div"); this.backdropElement.className = this.options.backdropClass; if (this.options.closeOnBackdrop) { this.backdropElement.addEventListener("click", () => this.close()); } document.body.appendChild(this.backdropElement); } private removeBackdrop(): void { this.backdropElement?.remove(); this.backdropElement = null; } private updateFocusableElements(): void { if (!this.element) return; const focusableSelectors = [ "button:not([disabled])", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])", "a[href]", '[tabindex]:not([tabindex="-1"])', ].join(","); this.focusableElements = Array.from( this.element.querySelectorAll(focusableSelectors), ); } private setInitialFocus(): void { if (!this.element) return; // Focus specified element if (this.options.focusElement) { const focusEl = this.element.querySelector( this.options.focusElement, ); if (focusEl) { focusEl.focus(); return; } } // Focus first focusable element or the modal itself if (this.focusableElements.length > 0) { this.focusableElements[0].focus(); } else { this.element.setAttribute("tabindex", "-1"); this.element.focus(); } } private handleKeydown = (event: KeyboardEvent): void => { if (event.key === "Escape" && this.options.closeOnEscape) { event.preventDefault(); this.close(); return; } // Focus trap if (event.key === "Tab" && this.options.trapFocus) { this.handleTabKey(event); } }; private handleTabKey(event: KeyboardEvent): void { if (this.focusableElements.length === 0) return; const firstElement = this.focusableElements[0]; const lastElement = this.focusableElements[this.focusableElements.length - 1]; if (event.shiftKey) { // Shift + Tab if (document.activeElement === firstElement) { event.preventDefault(); lastElement.focus(); } } else { // Tab if (document.activeElement === lastElement) { event.preventDefault(); firstElement.focus(); } } } private handleCloseClick = (): void => { this.close(); }; } export default Modal;