/** * Toast Queue Manager * * Manages multiple toast notifications with: * - Queue system for multiple toasts * - Position management (stacking toasts) * - Auto-dismiss with queue management * - Maximum number of simultaneous toasts */ export class ToastQueue { constructor(maxToasts = 5) { this.maxToasts = maxToasts; this.queue = []; this.activeToasts = new Map(); // componentId → toast element this.positionOffsets = new Map(); // position → current offset } /** * Add toast to queue * @param {string} componentId - Component ID * @param {Object} options - Toast options * @returns {HTMLElement|null} Toast element or null if queue full */ add(componentId, options = {}) { const { message = '', type = 'info', duration = 5000, position = 'top-right' } = options; // Check if we already have a toast for this component if (this.activeToasts.has(componentId)) { // Replace existing toast this.remove(componentId); } // Check if we've reached max toasts for this position const positionCount = this.getPositionCount(position); if (positionCount >= this.maxToasts) { // Remove oldest toast at this position this.removeOldestAtPosition(position); } // Create toast element const toast = this.createToastElement(message, type, position, componentId); // Calculate offset for stacking const offset = this.getNextOffset(position); this.setToastPosition(toast, position, offset); // Add to DOM document.body.appendChild(toast); // Animate in requestAnimationFrame(() => { toast.style.opacity = '1'; toast.style.transform = 'translateY(0)'; }); // Store reference this.activeToasts.set(componentId, toast); this.updatePositionOffset(position, offset); // Setup auto-dismiss if (duration > 0) { setTimeout(() => { this.remove(componentId); }, duration); } // Setup close button const closeBtn = toast.querySelector('.toast-close'); if (closeBtn) { closeBtn.addEventListener('click', () => { this.remove(componentId); }); } return toast; } /** * Remove toast * @param {string} componentId - Component ID */ remove(componentId) { const toast = this.activeToasts.get(componentId); if (!toast) { return; } // Animate out toast.style.opacity = '0'; const position = this.getToastPosition(toast); toast.style.transform = `translateY(${position.includes('top') ? '-20px' : '20px'})`; setTimeout(() => { if (toast.parentNode) { toast.parentNode.removeChild(toast); } this.activeToasts.delete(componentId); this.recalculateOffsets(position); }, 300); } /** * Remove all toasts */ clear() { for (const componentId of this.activeToasts.keys()) { this.remove(componentId); } } /** * Remove oldest toast at position * @param {string} position - Position */ removeOldestAtPosition(position) { let oldestToast = null; let oldestComponentId = null; let oldestTimestamp = Infinity; for (const [componentId, toast] of this.activeToasts.entries()) { if (this.getToastPosition(toast) === position) { const timestamp = parseInt(toast.dataset.timestamp || '0'); if (timestamp < oldestTimestamp) { oldestTimestamp = timestamp; oldestToast = toast; oldestComponentId = componentId; } } } if (oldestComponentId) { this.remove(oldestComponentId); } } /** * Get count of toasts at position * @param {string} position - Position * @returns {number} */ getPositionCount(position) { let count = 0; for (const toast of this.activeToasts.values()) { if (this.getToastPosition(toast) === position) { count++; } } return count; } /** * Get next offset for position * @param {string} position - Position * @returns {number} */ getNextOffset(position) { const currentOffset = this.positionOffsets.get(position) || 0; return currentOffset + 80; // 80px spacing between toasts } /** * Update position offset * @param {string} position - Position * @param {number} offset - Offset value */ updatePositionOffset(position, offset) { this.positionOffsets.set(position, offset); } /** * Recalculate offsets for position after removal * @param {string} position - Position */ recalculateOffsets(position) { const toastsAtPosition = []; for (const [componentId, toast] of this.activeToasts.entries()) { if (this.getToastPosition(toast) === position) { toastsAtPosition.push({ componentId, toast }); } } // Sort by DOM order toastsAtPosition.sort((a, b) => { const positionA = a.toast.compareDocumentPosition(b.toast); return positionA & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; }); // Recalculate offsets let offset = 0; for (const { toast } of toastsAtPosition) { this.setToastPosition(toast, position, offset); offset += 80; } this.positionOffsets.set(position, offset); } /** * Create toast element * @param {string} message - Toast message * @param {string} type - Toast type * @param {string} position - Position * @param {string} componentId - Component ID * @returns {HTMLElement} */ createToastElement(message, type, position, componentId) { const toast = document.createElement('div'); toast.className = `toast-queue-toast toast-queue-toast--${type} toast-queue-toast--${position}`; toast.setAttribute('role', 'alert'); toast.setAttribute('aria-live', 'polite'); toast.dataset.componentId = componentId; toast.dataset.timestamp = Date.now().toString(); const colors = { info: '#3b82f6', success: '#10b981', warning: '#f59e0b', error: '#ef4444' }; toast.innerHTML = `