Files
michaelschiemer/resources/js/modules/livecomponent/ToastQueue.js
2025-11-24 21:28:25 +01:00

289 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = `
<div class="toast-content">
<span class="toast-message">${message}</span>
<button class="toast-close" aria-label="Close">×</button>
</div>
`;
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
}
}