Files
notytex/static/js/components/modal-manager.js

382 lines
12 KiB
JavaScript

/**
* 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 = `
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
`;
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;