/** * 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 = `
${message}
`; toast.style.cssText = ` position: fixed; ${position.includes('top') ? 'top' : 'bottom'}: 1rem; ${position.includes('left') ? 'left' : 'right'}: 1rem; max-width: 400px; padding: 1rem; background: ${colors[type] || colors.info}; color: white; border-radius: 0.5rem; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); z-index: 10000; opacity: 0; transform: translateY(${position.includes('top') ? '-20px' : '20px'}); transition: opacity 0.3s ease, transform 0.3s ease; `; return toast; } /** * Set toast position with offset * @param {HTMLElement} toast - Toast element * @param {string} position - Position * @param {number} offset - Offset in pixels */ setToastPosition(toast, position, offset) { if (position.includes('top')) { toast.style.top = `${1 + offset / 16}rem`; // Convert px to rem (assuming 16px base) } else { toast.style.bottom = `${1 + offset / 16}rem`; } } /** * Get toast position from element * @param {HTMLElement} toast - Toast element * @returns {string} */ getToastPosition(toast) { if (toast.classList.contains('toast-queue-toast--top-right')) { return 'top-right'; } if (toast.classList.contains('toast-queue-toast--top-left')) { return 'top-left'; } if (toast.classList.contains('toast-queue-toast--bottom-right')) { return 'bottom-right'; } if (toast.classList.contains('toast-queue-toast--bottom-left')) { return 'bottom-left'; } return 'top-right'; // default } }