UNPKG

@gleb.askerko/componentkit-js

Version:

Lightweight, framework-agnostic JavaScript component library with progress gift components

284 lines (231 loc) 7.04 kB
export class Modal { constructor(options = {}) { this.options = { title: '', content: '', size: 'medium', closable: true, closeOnBackdrop: true, closeOnEscape: true, actions: [], className: '', onOpen: null, onClose: null, ...options }; this.element = null; this.overlay = null; this.isOpen = false; this.actionButtons = []; this.previousFocus = null; } open() { if (this.isOpen) return this; this.previousFocus = document.activeElement; this.createElement(); document.body.appendChild(this.overlay); // Force reflow for smooth animation this.overlay.offsetHeight; this.overlay.classList.add('ck-modal--open'); this.isOpen = true; this.bindEvents(); this.trapFocus(); if (this.options.onOpen) { this.options.onOpen(); } return this; } close() { if (!this.isOpen) return this; this.overlay.classList.remove('ck-modal--open'); // Wait for animation to complete setTimeout(() => { this.destroy(); this.isOpen = false; // Restore focus if (this.previousFocus) { this.previousFocus.focus(); } if (this.options.onClose) { this.options.onClose(); } }, 300); return this; } createElement() { // Create overlay this.overlay = document.createElement('div'); this.overlay.className = 'ck-modal-overlay'; // Create modal this.element = document.createElement('div'); this.element.className = this.getModalClasses(); this.element.setAttribute('role', 'dialog'); this.element.setAttribute('aria-modal', 'true'); if (this.options.title) { this.element.setAttribute('aria-labelledby', 'ck-modal-title'); } // Create header if (this.options.title) { const header = this.createHeader(); this.element.appendChild(header); } // Create body if (this.options.content) { const body = this.createBody(); this.element.appendChild(body); } // Create footer if (this.options.actions && this.options.actions.length > 0) { const footer = this.createFooter(); this.element.appendChild(footer); } this.overlay.appendChild(this.element); } createHeader() { const header = document.createElement('div'); header.className = 'ck-modal-header'; const title = document.createElement('h2'); title.id = 'ck-modal-title'; title.className = 'ck-modal-title'; title.textContent = this.options.title; header.appendChild(title); if (this.options.closable) { const closeButton = document.createElement('button'); closeButton.className = 'ck-modal-close'; closeButton.innerHTML = '×'; closeButton.setAttribute('aria-label', 'Close modal'); closeButton.addEventListener('click', () => this.close()); header.appendChild(closeButton); } return header; } createBody() { const body = document.createElement('div'); body.className = 'ck-modal-body'; if (typeof this.options.content === 'string') { const content = document.createElement('div'); content.className = 'ck-modal-content'; content.textContent = this.options.content; body.appendChild(content); } else if (this.options.content instanceof HTMLElement) { body.appendChild(this.options.content); } return body; } createFooter() { const footer = document.createElement('div'); footer.className = 'ck-modal-footer'; this.options.actions.forEach((action, index) => { const button = this.createActionButton(action, index); footer.appendChild(button); this.actionButtons.push(button); }); return footer; } createActionButton(action, index) { const button = document.createElement('button'); button.className = `ck-button ${action.variant ? `ck-button--${action.variant}` : 'ck-button--primary'}`; button.textContent = action.text || 'Action'; button.disabled = action.disabled || false; button.addEventListener('click', (e) => { if (action.onClick) { const result = action.onClick(e, index); // Auto-close modal unless action returns false if (result !== false && action.autoClose !== false) { this.close(); } } else { this.close(); } }); return button; } getModalClasses() { const baseClasses = 'ck-modal'; const sizeClasses = { small: 'ck-modal--small', medium: 'ck-modal--medium', large: 'ck-modal--large' }; return [ baseClasses, sizeClasses[this.options.size] || sizeClasses.medium, this.options.className ].filter(Boolean).join(' '); } bindEvents() { // Close on backdrop click if (this.options.closeOnBackdrop) { this.overlay.addEventListener('click', (e) => { if (e.target === this.overlay) { this.close(); } }); } // Close on escape key if (this.options.closeOnEscape) { this.escapeHandler = (e) => { if (e.key === 'Escape') { this.close(); } }; document.addEventListener('keydown', this.escapeHandler); } } trapFocus() { const focusableElements = this.element.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); if (focusableElements.length === 0) return; const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; this.tabHandler = (e) => { if (e.key === 'Tab') { if (e.shiftKey) { if (document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } } else { if (document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } } } }; document.addEventListener('keydown', this.tabHandler); firstElement.focus(); } update(newOptions) { this.options = { ...this.options, ...newOptions }; if (this.isOpen) { // Re-render the modal this.close(); setTimeout(() => this.open(), 300); } return this; } destroy() { // Clean up event listeners if (this.escapeHandler) { document.removeEventListener('keydown', this.escapeHandler); } if (this.tabHandler) { document.removeEventListener('keydown', this.tabHandler); } // Clean up action buttons this.actionButtons.forEach(button => { if (button.parentNode) { button.parentNode.removeChild(button); } }); // Remove from DOM if (this.overlay && this.overlay.parentNode) { this.overlay.parentNode.removeChild(this.overlay); } this.element = null; this.overlay = null; this.actionButtons = []; } }