7 changed files with 340 additions and 24 deletions
@ -0,0 +1,162 @@
@@ -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 @@
@@ -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 @@
@@ -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