/** * Modal Component * Create/Edit modals, confirmation dialogs with form submission handling * WCAG 3.0+ compliant with focus trap, ARIA attributes, and screen reader support */ class Modal { constructor(options = {}) { this.options = { title: 'Modal', size: 'md', // sm, md, lg, xl closable: true, onClose: null, onConfirm: null, showFooter: true, confirmText: 'Confirm', cancelText: 'Cancel', confirmClass: 'btn-primary', cancelClass: 'btn-secondary', ariaDescribedBy: null, ariaLabelledBy: null, ...options }; this.element = null; this.backdrop = null; this.focusableElements = []; this.firstFocusable = null; this.lastFocusable = null; this.previouslyFocused = null; } /** * Open a modal with custom content */ open(content) { // Store previously focused element for restoration this.previouslyFocused = document.activeElement; this.createBackdrop(); this.createModal(content); this.attachEvents(); this.animateIn(); // Focus first focusable element setTimeout(() => { const focusable = this.element.querySelector('input:not([type=hidden]), textarea, select, button:not(.modal-close), [tabindex]:not([tabindex="-1"])'); if (focusable) focusable.focus(); }, 100); return this; } /** * Create modal backdrop */ createBackdrop() { this.backdrop = document.createElement('div'); this.backdrop.className = 'modal-backdrop'; // Check for reduced motion preference const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion)').matches; const transitionValue = prefersReducedMotion ? '' : 'transition: opacity 0.2s ease;'; this.backdrop.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; z-index: ${getComputedStyle(document.documentElement).getPropertyValue('--z-modal-backdrop') || 300}; padding: 1rem; opacity: 0; ${transitionValue} `; document.body.appendChild(this.backdrop); } /** * Create modal element with ARIA attributes */ createModal(content) { const sizeClass = `modal-${this.options.size}`; const ariaLabelledBy = this.options.ariaLabelledBy || 'modal-title'; const ariaDescribedBy = this.options.ariaDescribedBy || 'modal-desc'; this.element = document.createElement('div'); this.element.className = `modal ${sizeClass}`; this.element.setAttribute('role', 'dialog'); this.element.setAttribute('aria-modal', 'true'); this.element.setAttribute('aria-labelledby', ariaLabelledBy); this.element.setAttribute('aria-describedby', ariaDescribedBy); // Check for reduced motion preference const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion)').matches; const transitionValue = prefersReducedMotion ? '' : 'transition: transform 0.2s ease, opacity 0.2s ease;'; this.element.style.cssText = ` background-color: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-xl); width: 100%; max-width: ${this.getMaxWidth()}; max-height: 90vh; display: flex; flex-direction: column; box-shadow: var(--shadow-xl); transform: scale(0.95); opacity: 0; ${transitionValue} `; // Generate unique IDs if not provided const titleId = ariaLabelledBy === 'modal-title' ? 'modal-title-' + Date.now() : ariaLabelledBy; const descId = ariaDescribedBy === 'modal-desc' ? 'modal-desc-' + Date.now() : ariaDescribedBy; this.element.innerHTML = ` ${this.options.showFooter ? ` ` : ''} `; this.backdrop.appendChild(this.element); } /** * Get max width based on size */ getMaxWidth() { switch (this.options.size) { case 'sm': return '400px'; case 'lg': return '800px'; case 'xl': return '1000px'; default: return '600px'; } } /** * Attach event listeners including focus trap */ attachEvents() { // Build focusable elements list for focus trap this.buildFocusableElements(); // Close button const closeBtn = this.element.querySelector('.modal-close'); if (closeBtn) { closeBtn.addEventListener('click', () => this.close()); } // Cancel button const cancelBtn = this.element.querySelector('.modal-cancel'); if (cancelBtn) { cancelBtn.addEventListener('click', () => this.close()); } // Confirm button const confirmBtn = this.element.querySelector('.modal-confirm'); if (confirmBtn) { confirmBtn.addEventListener('click', () => { if (this.options.onConfirm) { this.options.onConfirm(this); } else { this.close(); } }); } // Backdrop click this.backdrop.addEventListener('click', (e) => { if (e.target === this.backdrop && this.options.closable) { this.close(); } }); // Escape key handling (verify existing behavior) this.handleEscape = (e) => { if (e.key === 'Escape' && this.options.closable) { this.close(); } }; document.addEventListener('keydown', this.handleEscape); // Focus trap: Tab key cycles through focusable elements this.handleTabKey = (e) => { if (e.key !== 'Tab') return; if (this.focusableElements.length === 0) { e.preventDefault(); return; } if (e.shiftKey) { // Shift+Tab: move backwards if (document.activeElement === this.firstFocusable) { e.preventDefault(); this.lastFocusable.focus(); } } else { // Tab: move forwards if (document.activeElement === this.lastFocusable) { e.preventDefault(); this.firstFocusable.focus(); } } }; document.addEventListener('keydown', this.handleTabKey); } /** * Build list of focusable elements within modal for focus trap */ buildFocusableElements() { const focusableSelector = 'button:not([disabled]):not(.modal-close), input:not([type=hidden]):not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'; this.focusableElements = Array.from(this.element.querySelectorAll(focusableSelector)); if (this.focusableElements.length > 0) { this.firstFocusable = this.focusableElements[0]; this.lastFocusable = this.focusableElements[this.focusableElements.length - 1]; } } /** * Animate modal in */ animateIn() { requestAnimationFrame(() => { this.backdrop.style.opacity = '1'; this.element.style.transform = 'scale(1)'; this.element.style.opacity = '1'; }); } /** * Animate modal out and remove with focus restoration */ close() { if (this.options.onClose) { this.options.onClose(); } // Check for reduced motion preference const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion)').matches; const animationDuration = prefersReducedMotion ? 0 : 200; this.backdrop.style.opacity = '0'; this.element.style.transform = 'scale(0.95)'; this.element.style.opacity = '0'; setTimeout(() => { document.removeEventListener('keydown', this.handleEscape); document.removeEventListener('keydown', this.handleTabKey); this.backdrop.remove(); // Restore focus to previously focused element if (this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') { this.previouslyFocused.focus(); } }, animationDuration); } /** * Get form data from modal */ getFormData() { const form = this.element.querySelector('form'); if (form) { return Forms.getData(form); } return {}; } /** * Set loading state on confirm button */ setLoading(loading) { const confirmBtn = this.element.querySelector('.modal-confirm'); if (confirmBtn) { confirmBtn.disabled = loading; confirmBtn.innerHTML = loading ? ` Loading... ` : this.options.confirmText; } } /** * Show validation errors */ showErrors(errors) { // Clear existing errors this.element.querySelectorAll('.form-error').forEach(el => el.remove()); this.element.querySelectorAll('.is-error').forEach(el => el.classList.remove('is-error')); // Show new errors for (const [field, message] of Object.entries(errors)) { const input = this.element.querySelector(`[name="${field}"]`); if (input) { input.classList.add('is-error'); const errorEl = document.createElement('p'); errorEl.className = 'form-error'; errorEl.textContent = message; input.parentElement.appendChild(errorEl); } } } // ==================== STATIC METHODS ==================== /** * Show a confirmation dialog */ static confirm(options = {}) { const { title = 'Confirm', message = 'Are you sure?', confirmText = 'Confirm', cancelText = 'Cancel', confirmClass = 'btn-danger', onConfirm = () => {}, onCancel = () => {} } = options; const modal = new Modal({ title, size: 'sm', confirmText, cancelText, confirmClass, onConfirm: (m) => { onConfirm(); m.close(); }, onClose: onCancel }); modal.open(`

${message}

`); return modal; } /** * Show an alert dialog */ static alert(options = {}) { const { title = 'Alert', message = '', confirmText = 'OK', onConfirm = () => {} } = options; const modal = new Modal({ title, size: 'sm', confirmText, cancelText: null, onConfirm: (m) => { onConfirm(); m.close(); } }); modal.open(`

${message}

`); return modal; } /** * Show a form modal */ static form(options = {}) { const { title = 'Form', fields = '', validate = null, onSubmit = () => {}, ...modalOptions } = options; const formContent = ` `; const modal = new Modal({ title, ...modalOptions, onConfirm: async (m) => { const data = m.getFormData(); // Validate if validator provided if (validate) { const validation = validate(data); if (!validation.valid) { m.showErrors(validation.errors); return; } } m.setLoading(true); try { await onSubmit(data, m); } catch (error) { m.setLoading(false); Toast.error(error.message || 'An error occurred'); } } }); modal.open(formContent); return modal; } } window.Modal = Modal;