/** * `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 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. * * Two keys are watched on the whole document: Escape dismisses the snackbar while the focus is in * it, and Alt+G moves the focus to a snackbar that carries an action from wherever the page had it * — M3 asks the web for a documented shortcut of that kind, since a snackbar never takes the focus * on its own and a keyboard would otherwise have no way to reach the action. */ 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, keys: null, init() { host = this pending.splice(0).forEach((detail) => this.add(detail)) this.keys = (event) => { // M3: Esc dismisses the focused snackbar. Only the focused one — a key pressed // anywhere else on the page belongs to whatever has the focus there. if (event.key === 'Escape' && this.current && this.$el.contains(document.activeElement)) { this.dismiss() return } // M3 asks the web for a documented shortcut that moves the focus to a snackbar // carrying an action, and suggests Alt+G: a snackbar never takes the focus by // itself, so without one the keyboard cannot reach the action at all. `event.code` // rather than `event.key`, which Alt rewrites to another character on some layouts. if (event.altKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyG') { const action = this.$el.querySelector('[data-md-toast-action]') if (action) { event.preventDefault() action.focus() } } } document.addEventListener('keydown', this.keys) }, destroy() { document.removeEventListener('keydown', this.keys) document.documentElement.style.removeProperty('--material-snackbar-height') 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 this.measure() if (this.current?.timeout) { this.remaining = this.current.timeout this.resume() } }, /** * Publishes the snackbar's height on as `--material-snackbar-height`, and clears it * when nothing shows. M3 puts a snackbar above a FAB, never in front of or behind one, and * the FAB is somewhere else in the page: a variable is the only thing the two share. */ measure() { this.$nextTick(() => { const snackbar = this.$el.querySelector('[data-md-toast-snackbar]') const root = document.documentElement.style if (snackbar) { root.setProperty('--material-snackbar-height', `${Math.round(snackbar.getBoundingClientRect().height)}px`) } else { root.removeProperty('--material-snackbar-height') } }) }, 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)) } }, })) })