1377 lines
41 KiB
JavaScript
1377 lines
41 KiB
JavaScript
/**
|
|
* UX Manager pour Portail Inter Santé
|
|
* Gestion avancée de l'expérience utilisateur
|
|
* Version: 2025.12.20.01
|
|
*/
|
|
|
|
// Configuration globale
|
|
const UXConfig = {
|
|
debug: false,
|
|
transitionSpeed: 300,
|
|
proximityRadius: 150,
|
|
notificationPollInterval: 30000,
|
|
saveState: true
|
|
};
|
|
|
|
// Module principal UX Manager
|
|
class UXManager {
|
|
constructor() {
|
|
this.contextPanelOpen = false;
|
|
this.sidebarOpen = false;
|
|
this.activeMenuId = window.appConfig?.activeParentId || null;
|
|
this.proximityDetectionActive = true;
|
|
|
|
this.modules = {
|
|
navigation: null,
|
|
context: null,
|
|
notifications: null,
|
|
accessibility: null,
|
|
performance: null
|
|
};
|
|
|
|
this.init();
|
|
}
|
|
|
|
/**
|
|
* Initialisation du système UX
|
|
*/
|
|
init() {
|
|
try {
|
|
console.log('🚀 UX Manager initialisation...');
|
|
|
|
// Initialiser les modules
|
|
this.modules.navigation = new NavigationManager();
|
|
this.modules.context = new ContextPanelManager();
|
|
this.modules.notifications = new NotificationManager();
|
|
this.modules.accessibility = new AccessibilityManager();
|
|
this.modules.performance = new PerformanceManager();
|
|
|
|
// Restaurer l'état précédent
|
|
this.restoreState();
|
|
|
|
// Configurer les événements globaux
|
|
this.setupGlobalEvents();
|
|
|
|
// Lancer les pollings
|
|
this.startPollings();
|
|
|
|
console.log('✅ UX Manager prêt');
|
|
|
|
} catch (error) {
|
|
console.error('❌ Erreur initialisation UX Manager:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restaurer l'état depuis localStorage
|
|
*/
|
|
restoreState() {
|
|
if (!UXConfig.saveState) return;
|
|
|
|
try {
|
|
const savedPanelState = localStorage.getItem('contextPanelOpen');
|
|
if (savedPanelState === 'true') {
|
|
// Ouvrir avec délai pour meilleure UX
|
|
setTimeout(() => {
|
|
this.modules.context.togglePanel();
|
|
}, 1000);
|
|
}
|
|
|
|
const savedMenuState = localStorage.getItem('expandedMenus');
|
|
if (savedMenuState) {
|
|
this.modules.navigation.restoreMenuState(JSON.parse(savedMenuState));
|
|
}
|
|
|
|
} catch (e) {
|
|
if (UXConfig.debug) console.warn('Impossible de restaurer l\'état:', e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configurer les événements globaux
|
|
*/
|
|
setupGlobalEvents() {
|
|
// Gestionnaire de redimensionnement avec debounce
|
|
let resizeTimeout;
|
|
window.addEventListener('resize', () => {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(() => {
|
|
this.handleResize();
|
|
}, 250);
|
|
});
|
|
|
|
// Navigation au clavier
|
|
document.addEventListener('keydown', (e) => {
|
|
this.handleKeyboardNavigation(e);
|
|
});
|
|
|
|
// Prévenir la fermeture accidentelle avec données non sauvegardées
|
|
window.addEventListener('beforeunload', (e) => {
|
|
// Ici vous pouvez ajouter une logique pour vérifier les données non sauvegardées
|
|
// if (this.hasUnsavedChanges()) {
|
|
// e.preventDefault();
|
|
// e.returnValue = '';
|
|
// }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gérer le redimensionnement de la fenêtre
|
|
*/
|
|
handleResize() {
|
|
const isMobile = window.innerWidth < 768;
|
|
document.body.classList.toggle('mobile-view', isMobile);
|
|
|
|
// Ajuster le comportement du sidebar sur mobile
|
|
if (!isMobile && this.sidebarOpen) {
|
|
document.getElementById('sidebar')?.classList.remove('open');
|
|
this.sidebarOpen = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gérer la navigation au clavier
|
|
*/
|
|
handleKeyboardNavigation(e) {
|
|
// Marquer la navigation clavier pour le CSS
|
|
if (e.key === 'Tab') {
|
|
document.body.classList.add('keyboard-navigation');
|
|
}
|
|
|
|
// Échap pour fermer les modales
|
|
if (e.key === 'Escape') {
|
|
if (this.contextPanelOpen) {
|
|
this.modules.context.togglePanel();
|
|
}
|
|
|
|
// Fermer toutes les modales Bootstrap ouvertes
|
|
const openModals = document.querySelectorAll('.modal.show');
|
|
openModals.forEach(modal => {
|
|
const bsModal = bootstrap.Modal.getInstance(modal);
|
|
if (bsModal) bsModal.hide();
|
|
});
|
|
}
|
|
|
|
// Navigation dans les menus avec flèches
|
|
if (e.target.closest('.sidebar-nav')) {
|
|
this.modules.navigation.handleKeyboardMenuNavigation(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Démarrer les pollings automatiques
|
|
*/
|
|
startPollings() {
|
|
// Polling des notifications
|
|
setInterval(() => {
|
|
this.modules.notifications.pollNotifications();
|
|
}, UXConfig.notificationPollInterval);
|
|
|
|
// Premier poll immédiat
|
|
setTimeout(() => {
|
|
this.modules.notifications.pollNotifications();
|
|
}, 2000);
|
|
}
|
|
|
|
/**
|
|
* Basculer le panneau contexte
|
|
*/
|
|
toggleContextPanel() {
|
|
return this.modules.context.togglePanel();
|
|
}
|
|
|
|
/**
|
|
* Afficher la modale photo
|
|
*/
|
|
openPhotoModal() {
|
|
return this.modules.context.openPhotoModal();
|
|
}
|
|
|
|
/**
|
|
* Basculer le sidebar mobile
|
|
*/
|
|
toggleSidebar() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
if (!sidebar) return;
|
|
|
|
this.sidebarOpen = !this.sidebarOpen;
|
|
sidebar.classList.toggle('open');
|
|
|
|
const toggleBtn = document.querySelector('.sidebar-toggle');
|
|
if (toggleBtn) {
|
|
toggleBtn.setAttribute('aria-expanded', this.sidebarOpen);
|
|
}
|
|
|
|
// Fermer le sidebar si on clique à l'extérieur (mobile uniquement)
|
|
if (this.sidebarOpen && window.innerWidth < 768) {
|
|
setTimeout(() => {
|
|
document.addEventListener('click', this.closeSidebarOnClickOutside.bind(this), { once: true });
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fermer le sidebar en cliquant à l'extérieur
|
|
*/
|
|
closeSidebarOnClickOutside(e) {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const toggleBtn = document.querySelector('.sidebar-toggle');
|
|
|
|
if (!sidebar?.contains(e.target) && !toggleBtn?.contains(e.target)) {
|
|
sidebar.classList.remove('open');
|
|
this.sidebarOpen = false;
|
|
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'false');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// MODULE: Gestionnaire de Navigation
|
|
// ============================================
|
|
|
|
class NavigationManager {
|
|
constructor() {
|
|
this.expandedMenus = new Set();
|
|
this.activeMenuId = window.appConfig?.activeParentId || null;
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.setupMenuAutoClose();
|
|
this.highlightActiveMenu();
|
|
this.setupMenuInteractions();
|
|
}
|
|
|
|
/**
|
|
* Fermer automatiquement les menus inactifs
|
|
*/
|
|
setupMenuAutoClose() {
|
|
// Fermer tous les sous-menus sauf celui actif
|
|
document.querySelectorAll('.nav-submenu.show').forEach(submenu => {
|
|
const menuId = submenu.id;
|
|
const parentLink = document.querySelector(`[href="#${menuId}"]`);
|
|
|
|
if (!parentLink?.classList.contains('active')) {
|
|
this.collapseMenu(submenu.id);
|
|
}
|
|
});
|
|
|
|
// Fermer les menus quand on clique ailleurs
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('.nav-item')) {
|
|
this.collapseAllExceptActive();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mettre en évidence le menu actif
|
|
*/
|
|
highlightActiveMenu() {
|
|
if (!this.activeMenuId) return;
|
|
|
|
const activeLink = document.querySelector(`[href="#submenu${this.activeMenuId}"]`);
|
|
if (activeLink) {
|
|
activeLink.classList.add('active');
|
|
this.expandMenu(`submenu${this.activeMenuId}`);
|
|
}
|
|
|
|
// Mettre en évidence les enfants actifs
|
|
document.querySelectorAll('.nav-submenu .nav-link').forEach(link => {
|
|
const href = link.getAttribute('href');
|
|
if (href && href.includes(window.appConfig.activeLink)) {
|
|
link.classList.add('active');
|
|
link.setAttribute('aria-current', 'page');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Configurer les interactions des menus
|
|
*/
|
|
setupMenuInteractions() {
|
|
document.querySelectorAll('.nav-link[data-bs-toggle="collapse"]').forEach(link => {
|
|
link.addEventListener('click', (e) => {
|
|
const menuId = link.getAttribute('href').substring(1);
|
|
|
|
// Si on clique sur un menu déjà actif, ne rien faire
|
|
if (link.classList.contains('active')) return;
|
|
|
|
// Fermer tous les autres menus
|
|
this.collapseAllExcept(menuId);
|
|
|
|
// Sauvegarder l'état
|
|
this.saveMenuState();
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Développer un menu
|
|
*/
|
|
expandMenu(menuId) {
|
|
const submenu = document.getElementById(menuId);
|
|
const parentLink = document.querySelector(`[href="#${menuId}"]`);
|
|
|
|
if (submenu && parentLink) {
|
|
submenu.classList.add('show');
|
|
parentLink.setAttribute('aria-expanded', 'true');
|
|
parentLink.classList.add('active');
|
|
|
|
const arrow = parentLink.querySelector('.nav-arrow');
|
|
if (arrow) arrow.style.transform = 'rotate(90deg)';
|
|
|
|
this.expandedMenus.add(menuId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Réduire un menu
|
|
*/
|
|
collapseMenu(menuId) {
|
|
const submenu = document.getElementById(menuId);
|
|
const parentLink = document.querySelector(`[href="#${menuId}"]`);
|
|
|
|
if (submenu && parentLink) {
|
|
submenu.classList.remove('show');
|
|
parentLink.setAttribute('aria-expanded', 'false');
|
|
parentLink.classList.remove('active');
|
|
|
|
const arrow = parentLink.querySelector('.nav-arrow');
|
|
if (arrow) arrow.style.transform = 'rotate(0deg)';
|
|
|
|
this.expandedMenus.delete(menuId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Réduire tous les menus sauf un
|
|
*/
|
|
collapseAllExcept(exceptMenuId) {
|
|
document.querySelectorAll('.nav-submenu.show').forEach(submenu => {
|
|
if (submenu.id !== exceptMenuId) {
|
|
this.collapseMenu(submenu.id);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Réduire tous les menus sauf l'actif
|
|
*/
|
|
collapseAllExceptActive() {
|
|
const activeMenuId = `submenu${this.activeMenuId}`;
|
|
this.collapseAllExcept(activeMenuId);
|
|
}
|
|
|
|
/**
|
|
* Gérer la navigation clavier dans les menus
|
|
*/
|
|
handleKeyboardMenuNavigation(e) {
|
|
const currentItem = e.target.closest('.nav-link');
|
|
if (!currentItem) return;
|
|
|
|
const items = Array.from(document.querySelectorAll('.nav-link'));
|
|
const currentIndex = items.indexOf(currentItem);
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
if (currentIndex < items.length - 1) {
|
|
items[currentIndex + 1].focus();
|
|
}
|
|
break;
|
|
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
if (currentIndex > 0) {
|
|
items[currentIndex - 1].focus();
|
|
}
|
|
break;
|
|
|
|
case 'ArrowRight':
|
|
e.preventDefault();
|
|
if (currentItem.hasAttribute('data-bs-toggle')) {
|
|
const menuId = currentItem.getAttribute('href').substring(1);
|
|
this.expandMenu(menuId);
|
|
}
|
|
break;
|
|
|
|
case 'ArrowLeft':
|
|
e.preventDefault();
|
|
if (currentItem.hasAttribute('data-bs-toggle')) {
|
|
const menuId = currentItem.getAttribute('href').substring(1);
|
|
this.collapseMenu(menuId);
|
|
}
|
|
break;
|
|
|
|
case 'Enter':
|
|
case ' ':
|
|
e.preventDefault();
|
|
if (currentItem.hasAttribute('data-bs-toggle')) {
|
|
const menuId = currentItem.getAttribute('href').substring(1);
|
|
if (this.expandedMenus.has(menuId)) {
|
|
this.collapseMenu(menuId);
|
|
} else {
|
|
this.expandMenu(menuId);
|
|
}
|
|
} else if (currentItem.href) {
|
|
window.location.href = currentItem.href;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sauvegarder l'état des menus
|
|
*/
|
|
saveMenuState() {
|
|
if (!UXConfig.saveState) return;
|
|
|
|
try {
|
|
const state = Array.from(this.expandedMenus);
|
|
localStorage.setItem('expandedMenus', JSON.stringify(state));
|
|
} catch (e) {
|
|
if (UXConfig.debug) console.warn('Impossible de sauvegarder l\'état des menus:', e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restaurer l'état des menus
|
|
*/
|
|
restoreMenuState(state) {
|
|
if (!Array.isArray(state)) return;
|
|
|
|
state.forEach(menuId => {
|
|
this.expandMenu(menuId);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// MODULE: Gestionnaire du Panneau Contexte
|
|
// ============================================
|
|
|
|
class ContextPanelManager {
|
|
constructor() {
|
|
this.panel = document.getElementById('contextPanel');
|
|
this.toggleButton = document.querySelector('.context-toggle');
|
|
this.proximityArea = document.querySelector('.proximity-hover-area');
|
|
this.isOpen = false;
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
if (!this.panel || !this.toggleButton) {
|
|
console.warn('❌ Éléments du panneau contexte non trouvés');
|
|
return;
|
|
}
|
|
|
|
this.setupProximityDetection();
|
|
this.setupAnimations();
|
|
this.setupCloseBehavior();
|
|
}
|
|
|
|
/**
|
|
* Détection de proximité (style QuillBot)
|
|
*/
|
|
setupProximityDetection() {
|
|
if (!this.proximityArea) return;
|
|
|
|
let proximityTimeout;
|
|
|
|
// Détection de la souris
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (this.isOpen) return;
|
|
|
|
const rect = this.proximityArea.getBoundingClientRect();
|
|
const centerX = rect.left + rect.width / 2;
|
|
const centerY = rect.top + rect.height / 2;
|
|
|
|
const distance = Math.sqrt(
|
|
Math.pow(e.clientX - centerX, 2) +
|
|
Math.pow(e.clientY - centerY, 2)
|
|
);
|
|
|
|
clearTimeout(proximityTimeout);
|
|
|
|
if (distance < UXConfig.proximityRadius) {
|
|
// Souris proche - montrer le bouton
|
|
this.showButton();
|
|
} else {
|
|
// Souris éloignée - cacher progressivement
|
|
proximityTimeout = setTimeout(() => {
|
|
this.hideButton();
|
|
}, 1000);
|
|
}
|
|
});
|
|
|
|
// Toujours montrer le bouton quand le panel est ouvert
|
|
this.panel.addEventListener('transitionend', () => {
|
|
if (this.isOpen) {
|
|
this.showButton();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Afficher le bouton contexte
|
|
*/
|
|
showButton() {
|
|
if (this.toggleButton) {
|
|
this.toggleButton.style.opacity = '0.8';
|
|
this.toggleButton.classList.add('active');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cacher le bouton contexte
|
|
*/
|
|
hideButton() {
|
|
if (this.toggleButton && !this.isOpen) {
|
|
this.toggleButton.style.opacity = '0.2';
|
|
this.toggleButton.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configurer les animations
|
|
*/
|
|
setupAnimations() {
|
|
// Observer pour animations d'entrée
|
|
const observer = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
entry.target.classList.add('animate__animated', 'animate__fadeInUp');
|
|
}
|
|
});
|
|
}, { threshold: 0.1 });
|
|
|
|
// Observer les sections du panneau
|
|
document.querySelectorAll('.context-section').forEach(section => {
|
|
observer.observe(section);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Configurer le comportement de fermeture
|
|
*/
|
|
setupCloseBehavior() {
|
|
// Fermer avec Échap
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && this.isOpen) {
|
|
this.togglePanel();
|
|
}
|
|
});
|
|
|
|
// Fermer en cliquant à l'extérieur
|
|
document.addEventListener('click', (e) => {
|
|
if (this.isOpen &&
|
|
!this.panel.contains(e.target) &&
|
|
!this.toggleButton.contains(e.target)) {
|
|
this.togglePanel();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Basculer l'état du panneau
|
|
*/
|
|
togglePanel() {
|
|
this.isOpen = !this.isOpen;
|
|
|
|
if (this.isOpen) {
|
|
this.openPanel();
|
|
} else {
|
|
this.closePanel();
|
|
}
|
|
|
|
// Mettre à jour les attributs ARIA
|
|
this.toggleButton.setAttribute('aria-expanded', this.isOpen);
|
|
|
|
// Sauvegarder l'état
|
|
if (UXConfig.saveState) {
|
|
localStorage.setItem('contextPanelOpen', this.isOpen.toString());
|
|
}
|
|
|
|
return this.isOpen;
|
|
}
|
|
|
|
/**
|
|
* Ouvrir le panneau
|
|
*/
|
|
openPanel() {
|
|
this.panel.classList.add('open');
|
|
this.toggleButton.classList.add('panel-open');
|
|
|
|
// Focus sur le premier élément focusable
|
|
setTimeout(() => {
|
|
const firstFocusable = this.panel.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
|
if (firstFocusable) firstFocusable.focus();
|
|
}, UXConfig.transitionSpeed);
|
|
|
|
// Émettre un événement personnalisé
|
|
this.dispatchEvent('contextpanel:open');
|
|
}
|
|
|
|
/**
|
|
* Fermer le panneau
|
|
*/
|
|
closePanel() {
|
|
this.panel.classList.remove('open');
|
|
this.toggleButton.classList.remove('panel-open');
|
|
|
|
// Retourner le focus au bouton toggle
|
|
this.toggleButton.focus();
|
|
|
|
// Cacher progressivement le bouton
|
|
setTimeout(() => {
|
|
if (!this.isOpen) this.hideButton();
|
|
}, 1000);
|
|
|
|
// Émettre un événement personnalisé
|
|
this.dispatchEvent('contextpanel:close');
|
|
}
|
|
|
|
/**
|
|
* Ouvrir la modale photo
|
|
*/
|
|
openPhotoModal() {
|
|
const modalElement = document.getElementById('photoModal');
|
|
if (modalElement) {
|
|
const modal = new bootstrap.Modal(modalElement);
|
|
modal.show();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Émettre un événement personnalisé
|
|
*/
|
|
dispatchEvent(eventName, detail = {}) {
|
|
const event = new CustomEvent(eventName, {
|
|
detail: { ...detail, timestamp: Date.now() }
|
|
});
|
|
document.dispatchEvent(event);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// MODULE: Gestionnaire de Notifications
|
|
// ============================================
|
|
|
|
class NotificationManager {
|
|
constructor() {
|
|
this.badge = document.getElementById('notificationCount');
|
|
this.lastCount = 0;
|
|
this.unreadMessages = [];
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.setupBadgeAnimation();
|
|
this.setupMessagePolling();
|
|
}
|
|
|
|
/**
|
|
* Configurer l'animation du badge
|
|
*/
|
|
setupBadgeAnimation() {
|
|
if (!this.badge) return;
|
|
|
|
// Animation de pulse pour nouveaux messages
|
|
this.badge.addEventListener('animationend', () => {
|
|
this.badge.classList.remove('pulse');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Configurer le polling des messages
|
|
*/
|
|
setupMessagePolling() {
|
|
// Poll initial
|
|
this.pollNotifications();
|
|
|
|
// Écouter les événements de nouvelle notification
|
|
document.addEventListener('newnotification', (e) => {
|
|
this.handleNewNotification(e.detail);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Poller les notifications
|
|
*/
|
|
async pollNotifications() {
|
|
try {
|
|
// Remplacer par votre appel API réel
|
|
const response = await this.fetchNotifications();
|
|
const data = await response.json();
|
|
|
|
this.updateBadge(data.count);
|
|
|
|
if (data.newMessages > 0) {
|
|
this.showNewNotificationAlert(data.newMessages);
|
|
this.unreadMessages = data.messages || [];
|
|
}
|
|
|
|
} catch (error) {
|
|
if (UXConfig.debug) console.warn('Erreur polling notifications:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Récupérer les notifications
|
|
*/
|
|
async fetchNotifications() {
|
|
// Simuler une API - remplacer par votre endpoint réel
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
count: Math.floor(Math.random() * 10),
|
|
newMessages: Math.random() > 0.7 ? 1 : 0,
|
|
messages: []
|
|
})
|
|
};
|
|
|
|
// En production, utiliser :
|
|
// return fetch('api/notifications/count', {
|
|
// headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
// });
|
|
}
|
|
|
|
/**
|
|
* Mettre à jour le badge
|
|
*/
|
|
updateBadge(count) {
|
|
if (!this.badge) return;
|
|
|
|
const hasNew = count > this.lastCount;
|
|
this.lastCount = count;
|
|
|
|
this.badge.textContent = count;
|
|
this.badge.style.display = count > 0 ? 'flex' : 'none';
|
|
|
|
if (hasNew && count > 0) {
|
|
this.badge.classList.add('pulse');
|
|
|
|
// Notification système si autorisée
|
|
if (Notification.permission === 'granted' && document.hidden) {
|
|
this.showSystemNotification(count);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Afficher une alerte pour nouvelles notifications
|
|
*/
|
|
showNewNotificationAlert(count) {
|
|
// Créer un toast Bootstrap
|
|
const toastHTML = `
|
|
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1060">
|
|
<div id="notificationToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
|
<div class="toast-header bg-primary text-white">
|
|
<i class="bi bi-bell me-2"></i>
|
|
<strong class="me-auto">Nouvelle notification</strong>
|
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Fermer"></button>
|
|
</div>
|
|
<div class="toast-body">
|
|
${count} nouvelle${count > 1 ? 's' : ''} notification${count > 1 ? 's' : ''} reçue${count > 1 ? 's' : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const container = document.createElement('div');
|
|
container.innerHTML = toastHTML;
|
|
document.body.appendChild(container.firstElementChild);
|
|
|
|
const toastElement = document.getElementById('notificationToast');
|
|
if (toastElement) {
|
|
const toast = new bootstrap.Toast(toastElement, { autohide: true, delay: 5000 });
|
|
toast.show();
|
|
|
|
toastElement.addEventListener('hidden.bs.toast', () => {
|
|
toastElement.remove();
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Afficher une notification système
|
|
*/
|
|
showSystemNotification(count) {
|
|
const notification = new Notification('Inter Santé', {
|
|
body: `${count} nouvelle${count > 1 ? 's' : ''} notification${count > 1 ? 's' : ''}`,
|
|
icon: 'Bootstrap_new/images/new/favicon.png',
|
|
tag: 'notification'
|
|
});
|
|
|
|
notification.onclick = () => {
|
|
window.focus();
|
|
this.showMessagesModal();
|
|
notification.close();
|
|
};
|
|
|
|
setTimeout(() => notification.close(), 5000);
|
|
}
|
|
|
|
/**
|
|
* Demander la permission pour les notifications
|
|
*/
|
|
async requestNotificationPermission() {
|
|
if (!('Notification' in window)) {
|
|
console.log('Notifications non supportées');
|
|
return false;
|
|
}
|
|
|
|
if (Notification.permission === 'granted') {
|
|
return true;
|
|
}
|
|
|
|
if (Notification.permission !== 'denied') {
|
|
const permission = await Notification.requestPermission();
|
|
return permission === 'granted';
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Afficher la modale des messages
|
|
*/
|
|
showMessagesModal() {
|
|
const modalElement = document.getElementById('messagesModal');
|
|
if (!modalElement) return;
|
|
|
|
const modal = new bootstrap.Modal(modalElement);
|
|
|
|
// Charger les messages
|
|
this.loadMessages().then(messages => {
|
|
const container = document.getElementById('div_messagerie');
|
|
if (container) {
|
|
container.innerHTML = this.renderMessages(messages);
|
|
}
|
|
});
|
|
|
|
modal.show();
|
|
|
|
// Réinitialiser le compteur
|
|
this.updateBadge(0);
|
|
}
|
|
|
|
/**
|
|
* Charger les messages
|
|
*/
|
|
async loadMessages() {
|
|
try {
|
|
// Remplacer par votre appel API réel
|
|
const response = await fetch('api/messages/unread');
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('Erreur chargement messages:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rendre les messages en HTML
|
|
*/
|
|
renderMessages(messages) {
|
|
if (!messages.length) {
|
|
return '<div class="text-center text-muted py-5">Aucun message non lu</div>';
|
|
}
|
|
|
|
return `
|
|
<div class="message-list">
|
|
${messages.map(msg => `
|
|
<div class="message-item p-3 border-bottom">
|
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
|
<strong>${msg.sender}</strong>
|
|
<small class="text-muted">${this.formatDate(msg.date)}</small>
|
|
</div>
|
|
<div class="message-content">${msg.content}</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Formater une date
|
|
*/
|
|
formatDate(dateString) {
|
|
const date = new Date(dateString);
|
|
const options = {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
};
|
|
|
|
return date.toLocaleDateString(window.appConfig.isAnglophone ? 'en-US' : 'fr-FR', options);
|
|
}
|
|
|
|
/**
|
|
* Gérer une nouvelle notification
|
|
*/
|
|
handleNewNotification(detail) {
|
|
this.updateBadge(this.lastCount + 1);
|
|
this.showNewNotificationAlert(1);
|
|
|
|
// Ajouter au tableau des messages non lus
|
|
if (detail.message) {
|
|
this.unreadMessages.unshift(detail.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// MODULE: Gestionnaire d'Accessibilité
|
|
// ============================================
|
|
|
|
class AccessibilityManager {
|
|
constructor() {
|
|
this.keyboardNavigation = false;
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.setupKeyboardNavigationDetection();
|
|
this.setupFocusManagement();
|
|
this.setupSkipLinks();
|
|
this.setupAriaLiveRegions();
|
|
}
|
|
|
|
/**
|
|
* Détecter la navigation clavier
|
|
*/
|
|
setupKeyboardNavigationDetection() {
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Tab') {
|
|
this.keyboardNavigation = true;
|
|
document.body.classList.add('keyboard-navigation');
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mousedown', () => {
|
|
this.keyboardNavigation = false;
|
|
document.body.classList.remove('keyboard-navigation');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gérer le focus
|
|
*/
|
|
setupFocusManagement() {
|
|
// Garder le focus dans les modales
|
|
document.addEventListener('focusin', (e) => {
|
|
const modal = e.target.closest('.modal');
|
|
if (modal && modal.classList.contains('show')) {
|
|
this.trapFocus(modal);
|
|
}
|
|
});
|
|
|
|
// Focus sur le contenu principal après navigation
|
|
document.addEventListener('pjax:success', () => {
|
|
this.focusMainContent();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Piéger le focus dans un élément
|
|
*/
|
|
trapFocus(element) {
|
|
const focusableElements = 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];
|
|
|
|
element.addEventListener('keydown', (e) => {
|
|
if (e.key !== 'Tab') return;
|
|
|
|
if (e.shiftKey) {
|
|
if (document.activeElement === firstElement) {
|
|
e.preventDefault();
|
|
lastElement.focus();
|
|
}
|
|
} else {
|
|
if (document.activeElement === lastElement) {
|
|
e.preventDefault();
|
|
firstElement.focus();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Focus sur le contenu principal
|
|
*/
|
|
focusMainContent() {
|
|
const mainContent = document.getElementById('mainContent');
|
|
if (mainContent) {
|
|
mainContent.setAttribute('tabindex', '-1');
|
|
mainContent.focus();
|
|
setTimeout(() => {
|
|
mainContent.removeAttribute('tabindex');
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configurer les liens de saut
|
|
*/
|
|
setupSkipLinks() {
|
|
// Ajouter un lien pour sauter au contenu principal
|
|
const skipLink = document.createElement('a');
|
|
skipLink.href = '#mainContent';
|
|
skipLink.className = 'skip-link visually-hidden';
|
|
skipLink.textContent = 'Aller au contenu principal';
|
|
|
|
skipLink.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
this.focusMainContent();
|
|
});
|
|
|
|
document.body.insertBefore(skipLink, document.body.firstChild);
|
|
}
|
|
|
|
/**
|
|
* Configurer les régions ARIA Live
|
|
*/
|
|
setupAriaLiveRegions() {
|
|
// Région pour les notifications
|
|
const notificationRegion = document.createElement('div');
|
|
notificationRegion.setAttribute('aria-live', 'polite');
|
|
notificationRegion.setAttribute('aria-atomic', 'true');
|
|
notificationRegion.className = 'visually-hidden';
|
|
notificationRegion.id = 'aria-live-notifications';
|
|
|
|
document.body.appendChild(notificationRegion);
|
|
}
|
|
|
|
/**
|
|
* Annoncer un message pour les lecteurs d'écran
|
|
*/
|
|
announce(message, priority = 'polite') {
|
|
const region = document.getElementById('aria-live-notifications');
|
|
if (region) {
|
|
region.textContent = '';
|
|
setTimeout(() => {
|
|
region.textContent = message;
|
|
}, 100);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// MODULE: Gestionnaire de Performance
|
|
// ============================================
|
|
|
|
class PerformanceManager {
|
|
constructor() {
|
|
this.metrics = {
|
|
fcp: null,
|
|
lcp: null,
|
|
fid: null,
|
|
cls: 0
|
|
};
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.setupPerformanceMonitoring();
|
|
this.setupLazyLoading();
|
|
this.setupConnectionAwareLoading();
|
|
this.setupServiceWorker();
|
|
}
|
|
|
|
/**
|
|
* Surveiller les métriques de performance
|
|
*/
|
|
setupPerformanceMonitoring() {
|
|
if ('PerformanceObserver' in window) {
|
|
// Core Web Vitals
|
|
this.observeLCP();
|
|
this.observeFID();
|
|
this.observeCLS();
|
|
}
|
|
|
|
// First Contentful Paint
|
|
if ('PerformancePaintTiming' in performance) {
|
|
performance.getEntriesByType('paint').forEach(entry => {
|
|
if (entry.name === 'first-contentful-paint') {
|
|
this.metrics.fcp = entry.startTime;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Observer Largest Contentful Paint
|
|
*/
|
|
observeLCP() {
|
|
const observer = new PerformanceObserver((list) => {
|
|
const entries = list.getEntries();
|
|
const lastEntry = entries[entries.length - 1];
|
|
this.metrics.lcp = lastEntry.renderTime || lastEntry.loadTime;
|
|
|
|
if (UXConfig.debug) {
|
|
console.log('LCP:', this.metrics.lcp);
|
|
}
|
|
});
|
|
|
|
observer.observe({ entryTypes: ['largest-contentful-paint'] });
|
|
}
|
|
|
|
/**
|
|
* Observer First Input Delay
|
|
*/
|
|
observeFID() {
|
|
const observer = new PerformanceObserver((list) => {
|
|
const entries = list.getEntries();
|
|
entries.forEach(entry => {
|
|
this.metrics.fid = entry.processingStart - entry.startTime;
|
|
|
|
if (UXConfig.debug) {
|
|
console.log('FID:', this.metrics.fid);
|
|
}
|
|
});
|
|
});
|
|
|
|
observer.observe({ entryTypes: ['first-input'] });
|
|
}
|
|
|
|
/**
|
|
* Observer Cumulative Layout Shift
|
|
*/
|
|
observeCLS() {
|
|
const observer = new PerformanceObserver((list) => {
|
|
list.getEntries().forEach(entry => {
|
|
if (!entry.hadRecentInput) {
|
|
this.metrics.cls += entry.value;
|
|
}
|
|
|
|
if (UXConfig.debug) {
|
|
console.log('CLS:', this.metrics.cls);
|
|
}
|
|
});
|
|
});
|
|
|
|
observer.observe({ entryTypes: ['layout-shift'] });
|
|
}
|
|
|
|
/**
|
|
* Configurer le lazy loading
|
|
*/
|
|
setupLazyLoading() {
|
|
const images = document.querySelectorAll('img[data-src]');
|
|
const imageObserver = new IntersectionObserver((entries, observer) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const img = entry.target;
|
|
img.src = img.dataset.src;
|
|
img.classList.add('loaded');
|
|
observer.unobserve(img);
|
|
}
|
|
});
|
|
});
|
|
|
|
images.forEach(img => imageObserver.observe(img));
|
|
}
|
|
|
|
/**
|
|
* Chargement adaptatif selon la connexion
|
|
*/
|
|
setupConnectionAwareLoading() {
|
|
if ('connection' in navigator) {
|
|
const connection = navigator.connection;
|
|
|
|
// Adapter la qualité des images
|
|
if (connection.saveData === true || connection.effectiveType.includes('2g')) {
|
|
this.enableDataSavingMode();
|
|
}
|
|
|
|
// Prévenir l'utilisateur si connexion lente
|
|
connection.addEventListener('change', () => {
|
|
if (connection.effectiveType.includes('2g') || connection.effectiveType.includes('3g')) {
|
|
this.showConnectionWarning();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Activer le mode économie de données
|
|
*/
|
|
enableDataSavingMode() {
|
|
// Réduire la qualité des images
|
|
document.querySelectorAll('img[data-src-low]').forEach(img => {
|
|
img.dataset.src = img.dataset.srcLow;
|
|
});
|
|
|
|
// Désactiver les animations non essentielles
|
|
document.body.classList.add('data-saving-mode');
|
|
}
|
|
|
|
/**
|
|
* Afficher un avertissement de connexion lente
|
|
*/
|
|
showConnectionWarning() {
|
|
const warning = document.createElement('div');
|
|
warning.className = 'connection-warning alert alert-warning alert-dismissible fade show';
|
|
warning.innerHTML = `
|
|
<i class="bi bi-wifi-off me-2"></i>
|
|
<span>Connexion lente détectée. Certaines fonctionnalités peuvent être limitées.</span>
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Fermer"></button>
|
|
`;
|
|
|
|
document.body.prepend(warning);
|
|
|
|
// Auto-remove after 10 seconds
|
|
setTimeout(() => {
|
|
warning.remove();
|
|
}, 10000);
|
|
}
|
|
|
|
/**
|
|
* Configurer le Service Worker
|
|
*/
|
|
setupServiceWorker() {
|
|
if ('serviceWorker' in navigator && window.location.protocol === 'https:') {
|
|
window.addEventListener('load', () => {
|
|
navigator.serviceWorker.register('/sw.js')
|
|
.then(registration => {
|
|
if (UXConfig.debug) {
|
|
console.log('Service Worker enregistré:', registration.scope);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
if (UXConfig.debug) {
|
|
console.log('Échec enregistrement Service Worker:', error);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Optimiser les animations pour la performance
|
|
*/
|
|
optimizeAnimations() {
|
|
// Utiliser will-change pour les éléments animés
|
|
document.querySelectorAll('.nav-link, .action-btn, .context-toggle').forEach(el => {
|
|
el.style.willChange = 'transform, opacity';
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// EXPORT ET INITIALISATION GLOBALE
|
|
// ============================================
|
|
|
|
// Exposer les fonctions globales
|
|
window.appUX = {
|
|
toggleContextPanel: () => window.uxManager?.toggleContextPanel(),
|
|
openPhotoModal: () => window.uxManager?.openPhotoModal(),
|
|
toggleSidebar: () => window.uxManager?.toggleSidebar()
|
|
};
|
|
|
|
window.appNotifications = {
|
|
showMessagesModal: () => window.uxManager?.modules.notifications?.showMessagesModal(),
|
|
requestNotificationPermission: () => window.uxManager?.modules.notifications?.requestNotificationPermission()
|
|
};
|
|
|
|
window.appLanguage = {
|
|
changeLanguage: () => {
|
|
Swal.fire({
|
|
title: 'Changer de langue',
|
|
text: 'Sélectionnez la langue de l\'interface :',
|
|
icon: 'question',
|
|
showCancelButton: true,
|
|
confirmButtonText: 'Français',
|
|
cancelButtonText: 'English',
|
|
reverseButtons: true,
|
|
customClass: { popup: 'animate__animated animate__fadeIn' }
|
|
}).then((result) => {
|
|
if (result.isConfirmed) {
|
|
window.location.href = '?lang=fr_FR';
|
|
} else if (result.dismiss === Swal.DismissReason.cancel) {
|
|
window.location.href = '?lang=en_US';
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// Initialiser au chargement du DOM
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Attendre que les ressources critiques soient chargées
|
|
if (document.readyState === 'complete') {
|
|
window.uxManager = new UXManager();
|
|
} else {
|
|
window.addEventListener('load', () => {
|
|
window.uxManager = new UXManager();
|
|
});
|
|
}
|
|
|
|
// Initialiser les composants Bootstrap
|
|
initializeBootstrapComponents();
|
|
});
|
|
|
|
/**
|
|
* Initialiser les composants Bootstrap
|
|
*/
|
|
function initializeBootstrapComponents() {
|
|
// Tooltips
|
|
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
|
tooltipTriggerList.map(tooltipTriggerEl => {
|
|
return new bootstrap.Tooltip(tooltipTriggerEl, {
|
|
trigger: 'hover focus',
|
|
animation: true
|
|
});
|
|
});
|
|
|
|
// Select2
|
|
if (typeof $.fn.select2 === 'function') {
|
|
$('.select2').select2({
|
|
theme: 'bootstrap-5',
|
|
width: '100%',
|
|
placeholder: 'Sélectionnez...',
|
|
allowClear: true,
|
|
dropdownParent: $('body')
|
|
});
|
|
}
|
|
|
|
// DataTables
|
|
if (typeof $.fn.DataTable === 'function') {
|
|
$('.datatable').DataTable({
|
|
responsive: true,
|
|
language: {
|
|
url: '//cdn.datatables.net/plug-ins/1.13.6/i18n/fr-FR.json'
|
|
},
|
|
dom: '<"row"<"col-sm-12 col-md-6"l><"col-sm-12 col-md-6"f>>' +
|
|
'<"row"<"col-sm-12"tr>>' +
|
|
'<"row"<"col-sm-12 col-md-5"i><"col-sm-12 col-md-7"p>>',
|
|
pageLength: 25,
|
|
stateSave: true,
|
|
stateDuration: 60 * 60 * 24 // 24 heures
|
|
});
|
|
}
|
|
}
|
|
|
|
// Exporter pour les tests
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = {
|
|
UXManager,
|
|
NavigationManager,
|
|
ContextPanelManager,
|
|
NotificationManager,
|
|
AccessibilityManager,
|
|
PerformanceManager
|
|
};
|
|
} |