7 changed files with 340 additions and 24 deletions
@ -0,0 +1,162 @@ |
|||||||
|
import { Controller } from '@hotwired/stimulus'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Central toast notification controller |
||||||
|
* |
||||||
|
* Usage from other controllers: |
||||||
|
* |
||||||
|
* // Get the toast controller instance
|
||||||
|
* const toastController = this.application.getControllerForElementAndIdentifier( |
||||||
|
* document.querySelector('[data-controller~="utility--toast"]'), |
||||||
|
* 'utility--toast' |
||||||
|
* ); |
||||||
|
* |
||||||
|
* // Show a toast
|
||||||
|
* toastController.show('Success!', 'success'); |
||||||
|
* toastController.show('Error occurred', 'danger'); |
||||||
|
* toastController.show('Processing...', 'info'); |
||||||
|
* toastController.show('Warning!', 'warning'); |
||||||
|
* |
||||||
|
* // Or use the global helper (recommended)
|
||||||
|
* window.showToast('Success!', 'success'); |
||||||
|
*/ |
||||||
|
export default class extends Controller { |
||||||
|
static targets = ['container']; |
||||||
|
|
||||||
|
connect() { |
||||||
|
console.log('Toast controller connected'); |
||||||
|
this.queue = []; |
||||||
|
this.currentToast = null; |
||||||
|
this.isProcessing = false; |
||||||
|
|
||||||
|
// Expose globally for easy access from any controller
|
||||||
|
window.showToast = (message, type = 'info', duration = 4000) => { |
||||||
|
this.show(message, type, duration); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
disconnect() { |
||||||
|
// Clean up global reference
|
||||||
|
if (window.showToast) { |
||||||
|
delete window.showToast; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Show a toast notification |
||||||
|
* @param {string} message - The message to display |
||||||
|
* @param {string} type - Type of toast: 'success', 'danger', 'warning', 'info' |
||||||
|
* @param {number} duration - How long to show the toast in milliseconds (default: 4000) |
||||||
|
*/ |
||||||
|
show(message, type = 'info', duration = 4000) { |
||||||
|
this.queue.push({ message, type, duration }); |
||||||
|
this.processQueue(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process the toast queue one at a time |
||||||
|
*/ |
||||||
|
processQueue() { |
||||||
|
// If already showing a toast, wait
|
||||||
|
if (this.isProcessing) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// If queue is empty, nothing to do
|
||||||
|
if (this.queue.length === 0) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
this.isProcessing = true; |
||||||
|
const { message, type, duration } = this.queue.shift(); |
||||||
|
|
||||||
|
// Create toast element
|
||||||
|
const toast = this.createToastElement(message, type); |
||||||
|
this.containerTarget.appendChild(toast); |
||||||
|
this.currentToast = toast; |
||||||
|
|
||||||
|
// Trigger animation after a small delay (for CSS transition)
|
||||||
|
requestAnimationFrame(() => { |
||||||
|
requestAnimationFrame(() => { |
||||||
|
toast.classList.add('toast--show'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// Auto-dismiss after duration
|
||||||
|
setTimeout(() => { |
||||||
|
this.dismissToast(toast); |
||||||
|
}, duration); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a toast DOM element |
||||||
|
* @param {string} message |
||||||
|
* @param {string} type |
||||||
|
* @returns {HTMLElement} |
||||||
|
*/ |
||||||
|
createToastElement(message, type) { |
||||||
|
const toast = document.createElement('div'); |
||||||
|
toast.className = `toast toast--${type}`; |
||||||
|
toast.setAttribute('role', 'alert'); |
||||||
|
toast.setAttribute('aria-live', 'polite'); |
||||||
|
toast.setAttribute('aria-atomic', 'true'); |
||||||
|
|
||||||
|
const content = document.createElement('div'); |
||||||
|
content.className = 'toast__content'; |
||||||
|
content.textContent = message; |
||||||
|
|
||||||
|
const closeButton = document.createElement('button'); |
||||||
|
closeButton.className = 'toast__close'; |
||||||
|
closeButton.setAttribute('type', 'button'); |
||||||
|
closeButton.setAttribute('aria-label', 'Close'); |
||||||
|
closeButton.innerHTML = '×'; |
||||||
|
closeButton.addEventListener('click', () => this.dismissToast(toast)); |
||||||
|
|
||||||
|
toast.appendChild(content); |
||||||
|
toast.appendChild(closeButton); |
||||||
|
|
||||||
|
return toast; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Dismiss a toast with animation |
||||||
|
* @param {HTMLElement} toast |
||||||
|
*/ |
||||||
|
dismissToast(toast) { |
||||||
|
if (!toast || !toast.parentNode) { |
||||||
|
this.isProcessing = false; |
||||||
|
this.processQueue(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Start fade out animation
|
||||||
|
toast.classList.remove('toast--show'); |
||||||
|
toast.classList.add('toast--hide'); |
||||||
|
|
||||||
|
// Remove from DOM after animation completes
|
||||||
|
setTimeout(() => { |
||||||
|
if (toast.parentNode) { |
||||||
|
toast.remove(); |
||||||
|
} |
||||||
|
this.currentToast = null; |
||||||
|
this.isProcessing = false; |
||||||
|
|
||||||
|
// Process next toast in queue
|
||||||
|
this.processQueue(); |
||||||
|
}, 300); // Match CSS transition duration
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear all toasts immediately |
||||||
|
*/ |
||||||
|
clearAll() { |
||||||
|
this.queue = []; |
||||||
|
const toasts = this.containerTarget.querySelectorAll('.toast'); |
||||||
|
toasts.forEach(toast => { |
||||||
|
toast.remove(); |
||||||
|
}); |
||||||
|
this.currentToast = null; |
||||||
|
this.isProcessing = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,116 @@ |
|||||||
|
/* Toast Notification Styles */ |
||||||
|
|
||||||
|
.toast-container { |
||||||
|
position: fixed; |
||||||
|
top: 80px; /* Below the header */ |
||||||
|
right: 20px; |
||||||
|
z-index: 9999; |
||||||
|
pointer-events: none; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 10px; |
||||||
|
max-width: 400px; |
||||||
|
} |
||||||
|
|
||||||
|
.toast { |
||||||
|
pointer-events: auto; |
||||||
|
background: var(--color-bg-light); |
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
padding: 12px 16px; |
||||||
|
min-height: 48px; |
||||||
|
border: 1px solid; |
||||||
|
border-left: 4px solid; |
||||||
|
opacity: 0; |
||||||
|
transform: translateX(400px); |
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
||||||
|
} |
||||||
|
|
||||||
|
.toast--show { |
||||||
|
opacity: 1; |
||||||
|
transform: translateX(0); |
||||||
|
} |
||||||
|
|
||||||
|
.toast--hide { |
||||||
|
opacity: 0; |
||||||
|
transform: translateX(400px); |
||||||
|
} |
||||||
|
|
||||||
|
/* Toast types - using theme colors */ |
||||||
|
.toast--success { |
||||||
|
border-color: var(--color-accent-600); |
||||||
|
background-color: var(--color-bg-light); |
||||||
|
} |
||||||
|
|
||||||
|
.toast--danger, |
||||||
|
.toast--error { |
||||||
|
border-color: #dc3545; |
||||||
|
background-color: var(--color-bg-light); |
||||||
|
} |
||||||
|
|
||||||
|
.toast--warning { |
||||||
|
border-color: var(--color-accent-strong); |
||||||
|
background-color: var(--color-bg-light); |
||||||
|
} |
||||||
|
|
||||||
|
.toast--info { |
||||||
|
border-color: var(--color-teal-500); |
||||||
|
background-color: var(--color-bg-light); |
||||||
|
} |
||||||
|
|
||||||
|
.toast__content { |
||||||
|
flex: 1; |
||||||
|
font-size: 14px; |
||||||
|
line-height: 1.4; |
||||||
|
color: var(--color-text); |
||||||
|
} |
||||||
|
|
||||||
|
.toast__close { |
||||||
|
background: none; |
||||||
|
border: none; |
||||||
|
font-size: 24px; |
||||||
|
line-height: 1; |
||||||
|
color: currentColor; |
||||||
|
opacity: 0.5; |
||||||
|
cursor: pointer; |
||||||
|
padding: 0; |
||||||
|
margin-left: 12px; |
||||||
|
width: 24px; |
||||||
|
height: 24px; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
transition: opacity 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.toast__close:hover { |
||||||
|
opacity: 0.8; |
||||||
|
} |
||||||
|
|
||||||
|
.toast__close:focus { |
||||||
|
outline: 2px solid currentColor; |
||||||
|
outline-offset: 2px; |
||||||
|
opacity: 1; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/* Mobile responsive */ |
||||||
|
@media (max-width: 768px) { |
||||||
|
.toast-container { |
||||||
|
top: 60px; |
||||||
|
right: 10px; |
||||||
|
left: 10px; |
||||||
|
max-width: none; |
||||||
|
} |
||||||
|
|
||||||
|
.toast { |
||||||
|
width: 100%; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/* Animation for stacking */ |
||||||
|
.toast:not(:last-child) { |
||||||
|
margin-bottom: 0; |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,18 @@ |
|||||||
|
{# |
||||||
|
Toast Notification Component |
||||||
|
|
||||||
|
Include this once in your layout to enable toast notifications throughout your app. |
||||||
|
|
||||||
|
Usage: |
||||||
|
{% include 'components/Toast.html.twig' %} |
||||||
|
|
||||||
|
Then from JavaScript: |
||||||
|
window.showToast('Success!', 'success'); |
||||||
|
window.showToast('Error occurred', 'danger'); |
||||||
|
window.showToast('Processing...', 'info'); |
||||||
|
window.showToast('Warning!', 'warning'); |
||||||
|
#} |
||||||
|
<div {{ stimulus_controller('utility--toast') }} class="toast-container" data-utility--toast-target="container"> |
||||||
|
{# Toast messages will be dynamically inserted here #} |
||||||
|
</div> |
||||||
|
|
||||||
Loading…
Reference in new issue