Files
livewire-material/resources/js/snackbar.js
T
Andreas Reinhold / reiniandClaude Opus 5 ce8ac4d316 Draw the snackbar host without Tailwind
Plan step 36 (actions). <x-toast> renders data-md-toast with
data-md-position, its snackbar data-md-toast-snackbar with a bound
data-md-two-line, data-md-toast-content with a bound
data-md-toast-wrap, and data-md-toast-action/-dismiss; no class list.
toast.css draws SnackbarTokens' inverse surface, 48/68px container,
the two 40px controls reaching 48px targets with their own state
layer and touch target (ACT-18), the enter transition and the
position-start margin that grows from medium.

data-toast becomes data-md-toast-snackbar and data-toast-action
becomes data-md-toast-action (snackbar.js, navigation.js's
hide-on-scroll guard, NavigationBarTest, CommunicationTest); a new
data-md-toast-dismiss hook names the close button. ToastTest is
rewritten on the hooks and ComponentStylesheet. CommunicationTest
gains the owed tests: an actioned snackbar past the default timeout,
Escape on a focused one, the live region before any message, and the
two-line height with Alt+G reaching the action from elsewhere on the
page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 16:42:05 +02:00

200 lines
7.5 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.
*
* 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 <html> 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))
}
},
}))
})