382 lines
12 KiB
JavaScript
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; |