Some checks failed
Deploy Application / deploy (push) Has been cancelled
289 lines
8.6 KiB
JavaScript
289 lines
8.6 KiB
JavaScript
/**
|
||
* 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
|
||
}
|
||
}
|
||
|