UNPKG

modalitor

Version:

A modern, accessible modal system with focus management, URL hash support, and rich animations

426 lines (352 loc) 11 kB
/** * @name Modalitor * @author HamidReza Yazdani * @license MIT * @version 1.0.3 * @description A modern, accessible modal system with focus management, URL hash support, and rich animations */ const ANIMATION_DURATIONS = { DEFAULT: 300, BOUNCE: 800, DOOR: 600, ROTATE: 600, ROADRUNNER: 800, FLIP: 600, UNFOLD: 600 }; class Modalitor { static instances = new Map(); static zIndex = 1000; static activeModal = null; static scrollPosition = null; static scrollbarWidth = null; static modalCount = 0; constructor(options) { if (!options.id) { throw new Error('Modal ID is required'); } // Return existing instance if available const existingInstance = Modalitor.instances.get(options.id); if (existingInstance) { return existingInstance; } // Calculate scrollbar width once if (Modalitor.scrollbarWidth === null) { Modalitor.scrollbarWidth = this.calculateScrollbarWidth(); } // Default options this.defaultOptions = { size: 'md', animation: 'default', closeOnOverlay: true, closeOnEscape: true, preventScroll: true, role: 'dialog', labelledBy: null, describedBy: null, updateUrl: true, hashPrefix: 'modal-', onShow: () => {}, onHide: () => {} }; this.options = { ...this.defaultOptions, ...options }; this.focusedElementBeforeModal = null; this.isClosing = false; this.initializeModal(); Modalitor.instances.set(this.options.id, this); this.handleInitialHash(); } calculateScrollbarWidth() { const outer = document.createElement('div'); outer.style.visibility = 'hidden'; outer.style.overflow = 'scroll'; document.body.appendChild(outer); const inner = document.createElement('div'); outer.appendChild(inner); const scrollbarWidth = outer.offsetWidth - inner.offsetWidth; document.body.removeChild(outer); return scrollbarWidth; } initializeModal() { this.createOverlay(); this.setupModal(); this.setupEventListeners(); this.setupFocusTrap(); this.setupHashChange(); } createOverlay() { this.overlay = document.createElement('div'); this.overlay.className = 'modalitor-overlay'; this.overlay.setAttribute('tabindex', '-1'); this.overlay.id = `modalitor-overlay-${this.options.id}`; this.modal = document.getElementById(this.options.id); if (!this.modal) { throw new Error(`Modal with ID ${this.options.id} not found`); } this.modal.parentNode?.insertBefore(this.overlay, this.modal); } setupModal() { const { role, labelledBy, describedBy, size, animation } = this.options; this.modal.setAttribute('role', role); this.modal.setAttribute('aria-modal', 'true'); this.modal.setAttribute('tabindex', '-1'); if (labelledBy) { this.modal.setAttribute('aria-labelledby', labelledBy); } if (describedBy) { this.modal.setAttribute('aria-describedby', describedBy); } this.modal.classList.add('modalitor', `modalitor-${size}`); if (animation !== 'none') { this.modal.classList.add(`animation-${animation}`); } } setupEventListeners() { this.setupCloseButton(); this.setupOverlayClick(); this.setupEscapeKey(); this.setupPopState(); } setupCloseButton() { const closeBtn = this.modal.querySelector('.modalitor-close'); if (closeBtn) { closeBtn.setAttribute('aria-label', 'Close modal'); closeBtn.addEventListener('click', () => this.hide()); } } setupOverlayClick() { if (this.options.closeOnOverlay) { this.overlay.addEventListener('click', (e) => { if (e.target === this.overlay && !this.isClosing) { this.hide(); } }); } } setupEscapeKey() { if (this.options.closeOnEscape) { document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.isVisible() && this.isTopModal()) { this.hide(); } }); } } setupFocusTrap() { this.modal.addEventListener('keydown', (e) => { if (e.key !== 'Tab') return; const focusableElements = this.getFocusableElements(); if (!focusableElements.length) return; const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; const activeElement = document.activeElement; if (e.shiftKey && activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } else if (!e.shiftKey && activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } }); } setupHashChange() { window.addEventListener('hashchange', () => { this.handleHashChange(); }); } setupPopState() { window.addEventListener('popstate', () => { this.handleHashChange(); }); } handleInitialHash() { if (this.shouldShowBasedOnHash()) { this.show(); } } shouldShowBasedOnHash() { const hash = window.location.hash.slice(1); return hash === `${this.options.hashPrefix}${this.options.id}`; } handleHashChange() { const shouldShow = this.shouldShowBasedOnHash(); if (shouldShow && !this.isVisible()) { this.show(); } else if (!shouldShow && this.isVisible()) { this.hide(); } } updateUrlHash(show) { if (!this.options.updateUrl) return; const currentHash = window.location.hash; const modalHash = `#${this.options.hashPrefix}${this.options.id}`; if (show && currentHash !== modalHash) { window.history.pushState({}, '', modalHash); } else if (!show && currentHash === modalHash) { window.history.pushState({}, '', window.location.pathname + window.location.search); } } saveScrollPosition() { Modalitor.scrollPosition = window.scrollY; } handleScrollLock(restore = false) { if (!this.options.preventScroll) return; if (restore) { Modalitor.modalCount--; if (Modalitor.modalCount === 0) { document.body.style.overflow = ''; document.body.style.paddingRight = ''; if (Modalitor.scrollPosition !== null) { window.scrollTo(0, Modalitor.scrollPosition); Modalitor.scrollPosition = null; } } } else { if (Modalitor.modalCount === 0) { Modalitor.scrollPosition = window.scrollY; document.body.style.overflow = 'hidden'; document.body.style.paddingRight = `${Modalitor.scrollbarWidth}px`; } Modalitor.modalCount++; } } show() { if (this.isVisible()) return; this.focusedElementBeforeModal = document.activeElement; this.updateZIndex(); if (Modalitor.modalCount === 0) { this.saveScrollPosition(); } this.updateUrlHash(true); this.showModalWithAnimation(); this.handleScrollLock(); this.updateAriaAttributes(false); Modalitor.activeModal = this; this.options.onShow(); } hide() { if (!this.isVisible() || this.isClosing) return; this.isClosing = true; this.modal.classList.add('closing'); this.overlay.classList.remove('active'); this.updateUrlHash(false); setTimeout(() => { this.completeHiding(); this.isClosing = false; this.options.onHide(); }, this.getAnimationDuration()); } completeHiding() { this.modal.classList.remove('active', 'closing'); this.restoreFocus(); this.handleScrollLock(true); this.updateAriaAttributes(true); if (Modalitor.activeModal === this) { Modalitor.activeModal = null; } } updateZIndex() { Modalitor.zIndex += 2; this.overlay.style.zIndex = String(Modalitor.zIndex); this.modal.style.zIndex = String(Modalitor.zIndex + 1); } showModalWithAnimation() { requestAnimationFrame(() => { this.overlay.classList.add('active'); this.modal.classList.add('active'); this.modal.focus(); }); } getFocusableElements() { return Array.from( this.modal.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) ); } updateAriaAttributes(hidden) { this.modal.setAttribute('aria-hidden', String(hidden)); } restoreFocus() { if (this.focusedElementBeforeModal) { this.focusedElementBeforeModal.focus(); } } getAnimationDuration() { return ANIMATION_DURATIONS[this.options.animation.toUpperCase()] || ANIMATION_DURATIONS.DEFAULT; } isTopModal() { const activeModals = Array.from(Modalitor.instances.values()) .filter(modal => modal.isVisible()) .sort((a, b) => { return parseInt(b.modal.style.zIndex || '0') - parseInt(a.modal.style.zIndex || '0'); }); return activeModals[0] === this; } isVisible() { return this.modal.classList.contains('active'); } destroy() { if (this.isVisible()) { this.hide(); } this.overlay.remove(); Modalitor.instances.delete(this.options.id); if (this.isVisible()) { Modalitor.modalCount = Math.max(0, Modalitor.modalCount - 1); } } static getAllInstances() { return Modalitor.instances; } static handlePageLoad() { const hash = window.location.hash.slice(1); if (!hash) return; const prefix = 'modal-'; if (hash.startsWith(prefix)) { const modalId = hash.slice(prefix.length); const modal = Modalitor.instances.get(modalId); if (modal) { modal.show(); } else { const modalElement = document.getElementById(modalId); if (modalElement) { const options = { id: modalId }; const newModal = new Modalitor(options); newModal.show(); } } } } } // Global click handler for modal triggers document.addEventListener('click', (e) => { const trigger = e.target.closest('[data-modal-target]'); if (!trigger) return; const modalId = trigger.getAttribute('data-modal-target'); if (!modalId) return; const options = { id: modalId, size: trigger.getAttribute('data-modal-size') || 'md', animation: trigger.getAttribute('data-modal-animation') || 'default', labelledBy: trigger.getAttribute('data-modal-label'), describedBy: trigger.getAttribute('data-modal-description'), role: trigger.getAttribute('data-modal-role') || 'dialog', updateUrl: trigger.getAttribute('data-modal-update-url') !== 'false', hashPrefix: trigger.getAttribute('data-modal-hash-prefix') || 'modal-' }; const modal = new Modalitor(options); modal.show(); }); // Handle initial page load document.addEventListener('DOMContentLoaded', () => { Modalitor.handlePageLoad(); }); // For usage in module systems if (typeof module !== 'undefined' && module.exports) { module.exports = Modalitor; } else if (typeof define === 'function' && define.amd) { define([], function() { return Modalitor; }); } else { window.Modalitor = Modalitor; }