/** * `materialSnackbar`: the queue behind ``, and `window.materialToast()`. * * One snackbar at a time, as M3 shows them. Each waits its turn, stays for its timeout (paused * while hovered or focused, so it is never pulled away from someone reading or reaching for its * action) and is replaced by the next. * * A `sticky` toast ("A new version is ready" with a Reload action) stays until it is answered, but * never holds the queue up: it is kept aside rather than queued, a toast that arrives while it shows * takes its place, and it comes back once the queue is empty. Only one is kept — a newer sticky * toast replaces it. Dismissing it, or pressing its action, lets it go. */ const DEFAULT_TIMEOUT_MS = 4000 let sequence = 0 // The listener lives here, not on the Alpine component: a toast dispatched before Alpine has // started (on page load, straight after a redirect) would otherwise be lost. Until a host // registers, toasts wait in `pending`. let host = null const pending = [] window.addEventListener('toast', (event) => (host ? host.add(event.detail) : pending.push(event.detail))) window.materialToast = (title, options = {}) => { window.dispatchEvent(new CustomEvent('toast', { detail: { title, ...options } })) } document.addEventListener('alpine:init', () => { window.Alpine.data('materialSnackbar', () => ({ queue: [], current: null, sticky: null, timer: null, remaining: 0, startedAt: 0, init() { host = this pending.splice(0).forEach((detail) => this.add(detail)) }, destroy() { if (host === this) { host = null } }, add(detail) { // Livewire dispatches named arguments as the detail object; a positional dispatch // arrives as an array whose first entry is that object. const toast = Array.isArray(detail) ? detail[0] : detail const sticky = toast.sticky === true const entry = { id: ++sequence, type: toast.type ?? null, title: toast.title ?? '', description: toast.description ?? null, timeout: sticky || toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS), action: toast.action ?? null, sticky, } if (sticky) { const showing = !this.current || this.current === this.sticky this.sticky = entry if (showing) { this.next() } return } this.queue.push(entry) // A sticky toast steps aside for it, and comes back from next() once the queue is empty. if (!this.current || this.current === this.sticky) { this.next() } }, next() { // Cleared and forgotten here, so a toast dismissed early never leaves its timer running // to cut the next one short. clearTimeout(this.timer) this.timer = null this.current = this.queue.shift() ?? this.sticky if (this.current?.timeout) { this.remaining = this.current.timeout this.resume() } }, pause() { if (!this.current?.timeout || !this.timer) { return } clearTimeout(this.timer) this.timer = null this.remaining -= performance.now() - this.startedAt }, resume() { if (!this.current?.timeout || this.timer) { return } this.startedAt = performance.now() this.timer = setTimeout(() => { this.timer = null this.next() }, Math.max(this.remaining, 0)) }, dismiss() { if (this.current && this.current === this.sticky) { this.sticky = null } this.next() }, // Closed before the handler and the event run, so a toast either of them shows is not the // one dismissed. act() { const action = this.current?.action this.dismiss() action?.handler?.() if (typeof action?.event === 'string' && action.event !== '') { window.dispatchEvent(new CustomEvent(action.event)) } }, icon(type) { return ['success', 'error', 'warning', 'info'].includes(type) }, })) })