/** * MODAL MANAGER - Système de modales unifié * Fait partie du design system Notytex */ class ModalManager { constructor(core, config = {}) { this.core = core; this.config = { backdrop: true, keyboard: true, focus: true, ...config }; this.activeModals = new Set(); this.modalStack = []; } init() { this.core.components.set('modals', this); this.setupEventListeners(); // Auto-détecter les modales existantes this.core.utils.queryAll('.modal').forEach(modal => { this.registerModal(modal); }); } /** * Configuration des événements */ setupEventListeners() { // Gestion du clavier if (this.config.keyboard) { document.addEventListener('keydown', this.handleKeydown.bind(this)); } // Gestion des clics sur backdrop document.addEventListener('click', this.handleBackdropClick.bind(this)); // Détection automatique des triggers document.addEventListener('click', this.handleTriggerClick.bind(this)); } /** * Enregistrer une modale existante */ registerModal(modalElement) { const modalId = modalElement.id; if (!modalId) { console.warn('Modal sans ID détectée, ignorée'); return; } // Marquer comme gérée par le système modalElement.setAttribute('data-modal-managed', 'true'); // Configuration ARIA modalElement.setAttribute('role', 'dialog'); modalElement.setAttribute('aria-modal', 'true'); modalElement.setAttribute('aria-hidden', 'true'); return modalId; } /** * Créer une modale programmatiquement */ create(options = {}) { const modalId = options.id || `modal-${Date.now()}`; const modal = this.core.utils.createElement('div', { id: modalId, className: 'modal fixed inset-0 bg-gray-600 bg-opacity-50 hidden z-50', role: 'dialog', 'aria-modal': 'true', 'aria-hidden': 'true', 'aria-labelledby': options.titleId || `${modalId}-title` }); const modalDialog = this.core.utils.createElement('div', { className: 'flex items-center justify-center min-h-screen p-4' }); const modalContent = this.core.utils.createElement('div', { className: `modal-content bg-white rounded-xl shadow-xl transform transition-all duration-300 scale-95 ${options.size === 'large' ? 'max-w-4xl' : options.size === 'small' ? 'max-w-md' : 'max-w-2xl'} w-full` }); // Header si fourni if (options.title || options.header) { const header = this.core.utils.createElement('div', { className: 'modal-header px-6 py-4 border-b border-gray-200 flex items-center justify-between' }); const title = this.core.utils.createElement('h3', { id: options.titleId || `${modalId}-title`, className: 'text-lg font-semibold text-gray-900' }); if (options.title) { title.textContent = options.title; } else if (options.header) { title.innerHTML = options.header; } const closeButton = this.core.utils.createElement('button', { className: 'text-gray-400 hover:text-gray-600 transition-colors', onclick: () => this.close(modalId) }); closeButton.innerHTML = ` `; header.appendChild(title); header.appendChild(closeButton); modalContent.appendChild(header); } // Body const body = this.core.utils.createElement('div', { className: 'modal-body p-6' }); if (options.content) { if (typeof options.content === 'string') { body.innerHTML = options.content; } else { body.appendChild(options.content); } } modalContent.appendChild(body); // Footer si fourni if (options.footer || options.actions) { const footer = this.core.utils.createElement('div', { className: 'modal-footer px-6 py-4 border-t border-gray-200 flex justify-end space-x-3' }); if (options.actions) { options.actions.forEach(action => { const button = this.core.utils.createElement('button', { className: `px-4 py-2 rounded-lg font-medium transition-colors ${this.getActionButtonClasses(action.type)}`, onclick: action.handler }); button.textContent = action.text; footer.appendChild(button); }); } else if (options.footer) { footer.innerHTML = options.footer; } modalContent.appendChild(footer); } modalDialog.appendChild(modalContent); modal.appendChild(modalDialog); document.body.appendChild(modal); this.registerModal(modal); return modalId; } /** * Ouvrir une modale */ open(modalId, options = {}) { const modal = document.getElementById(modalId); if (!modal) { console.error(`Modal with ID "${modalId}" not found`); return false; } // Fermer autres modales si non-stackable if (!options.stackable && this.activeModals.size > 0) { this.closeAll(); } // Ajouter à la pile active this.activeModals.add(modalId); this.modalStack.push(modalId); // Affichage modal.classList.remove('hidden'); modal.classList.add('flex'); modal.setAttribute('aria-hidden', 'false'); // Animation d'entrée const content = modal.querySelector('.modal-content'); if (content) { requestAnimationFrame(() => { content.classList.remove('scale-95'); content.classList.add('scale-100'); }); } // Gestion du focus if (this.config.focus) { this.trapFocus(modal); } // Bloquer le scroll du body document.body.classList.add('overflow-hidden'); // Émettre événement this.core.emit('modal:opened', { modalId, modal }); return true; } /** * Fermer une modale */ close(modalId) { const modal = document.getElementById(modalId); if (!modal || !this.activeModals.has(modalId)) { return false; } // Animation de sortie const content = modal.querySelector('.modal-content'); if (content) { content.classList.remove('scale-100'); content.classList.add('scale-95'); } // Masquage après animation setTimeout(() => { modal.classList.add('hidden'); modal.classList.remove('flex'); modal.setAttribute('aria-hidden', 'true'); }, 150); // Retirer de la pile active this.activeModals.delete(modalId); const stackIndex = this.modalStack.indexOf(modalId); if (stackIndex > -1) { this.modalStack.splice(stackIndex, 1); } // Restaurer le scroll si aucune modale active if (this.activeModals.size === 0) { document.body.classList.remove('overflow-hidden'); } // Émettre événement this.core.emit('modal:closed', { modalId, modal }); return true; } /** * Fermer toutes les modales */ closeAll() { Array.from(this.activeModals).forEach(modalId => { this.close(modalId); }); } /** * Piégeage du focus dans la modale */ trapFocus(modal) { const focusableElements = modal.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; // Focus initial if (firstElement) { firstElement.focus(); } // Gestionnaire de tabulation const handleTab = (event) => { if (event.key !== 'Tab') return; if (event.shiftKey) { // Shift + Tab if (document.activeElement === firstElement) { event.preventDefault(); lastElement.focus(); } } else { // Tab if (document.activeElement === lastElement) { event.preventDefault(); firstElement.focus(); } } }; modal.addEventListener('keydown', handleTab); // Nettoyer à la fermeture const cleanup = () => { modal.removeEventListener('keydown', handleTab); this.core.off('modal:closed', cleanup); }; this.core.on('modal:closed', cleanup); } /** * Gestionnaire de clics sur backdrop */ handleBackdropClick(event) { if (!this.config.backdrop) return; const modal = event.target.closest('.modal'); if (!modal || !this.activeModals.has(modal.id)) return; // Vérifier si le clic est sur le backdrop (pas le contenu) if (event.target === modal || event.target.classList.contains('modal-backdrop')) { this.close(modal.id); } } /** * Gestionnaire de touches clavier */ handleKeydown(event) { if (event.key === 'Escape' && this.modalStack.length > 0) { const topModalId = this.modalStack[this.modalStack.length - 1]; this.close(topModalId); } } /** * Gestionnaire de clics sur triggers */ handleTriggerClick(event) { const trigger = event.target.closest('[data-modal-target]'); if (!trigger) return; event.preventDefault(); const targetId = trigger.getAttribute('data-modal-target'); const options = { stackable: trigger.hasAttribute('data-modal-stackable') }; this.open(targetId, options); } /** * Classes CSS pour les boutons d'action */ getActionButtonClasses(type) { const classes = { primary: 'bg-blue-600 text-white hover:bg-blue-700', secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300', success: 'bg-green-600 text-white hover:bg-green-700', danger: 'bg-red-600 text-white hover:bg-red-700', warning: 'bg-orange-600 text-white hover:bg-orange-700' }; return classes[type] || classes.secondary; } /** * Utilitaires publics */ isOpen(modalId) { return this.activeModals.has(modalId); } getActiveModals() { return Array.from(this.activeModals); } hasActiveModals() { return this.activeModals.size > 0; } } export default ModalManager;