M3's accessibility page forbids it outright: an actioned snackbar has to wait for the person to read it and reach its action. An entry with an action and no timeout of its own is now untimed, the close button it already draws being the way out; a timeout written out still wins. Plan step 11, actions.md ACT-03. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
151 lines
5.1 KiB
JavaScript
151 lines
5.1 KiB
JavaScript
/**
|
|
* `materialSnackbar`: the queue behind `<x-toast>`, 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 snackbar carrying an action has no timeout at all —
|
|
* M3's accessibility page forbids one — unless the caller writes a timeout out.
|
|
*
|
|
* 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
|
|
|
|
// M3 forbids a snackbar with an action from auto-dismissing: it has to wait for the
|
|
// person to read it and reach it. A timeout written out still wins, for a caller who
|
|
// means it; a missing one no longer falls back to the default.
|
|
const untimed = sticky || toast.timeout === 0 || toast.timeout === null || (toast.action != null && toast.timeout === undefined)
|
|
|
|
const entry = {
|
|
id: ++sequence,
|
|
type: toast.type ?? null,
|
|
title: toast.title ?? '',
|
|
description: toast.description ?? null,
|
|
timeout: untimed ? 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)
|
|
},
|
|
}))
|
|
})
|