From 07e008d9429c2196a4ce9823ff4bc8bcdbfc2ff5 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 17:57:46 +0200 Subject: [PATCH] Add sticky toasts, action events and toast hooks A sticky toast stays until it is dismissed or its action pressed, for a question that must be answered ("A new version is ready" with Reload). It is kept aside rather than queued, so it never holds up ordinary toasts: one that arrives while it shows takes its place, and the sticky toast comes back once the queue is empty. One is kept at a time; a newer sticky toast replaces it. Every other toast keeps today's queue. An action's `event` names a window event dispatched when it is pressed, beside `handler`, for toasts whose detail cannot carry a function. The snackbar now closes before either runs, so a toast they show is not the one dismissed. `data-toast` and `data-toast-action` give applications stable hooks for their tests. The queue now forgets a cleared timer in next(): a toast dismissed by a click that neither hovered nor focused it first, as a screen reader activates a button, left its timer running and cut the next toast short. The showcase's snackbar buttons had no Alpine scope and did nothing; they are wrapped in one, with a sticky example. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2 --- .../livewire-material-development/SKILL.md | 10 +++ resources/js/snackbar.js | 50 ++++++++++++--- resources/views/components/toast.blade.php | 18 ++++-- .../showcase/sections/communication.blade.php | 11 ++-- tests/Browser/CommunicationTest.php | 61 +++++++++++++++++++ tests/Feature/Components/ToastTest.php | 8 +++ 6 files changed, 143 insertions(+), 15 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 141e217f..e16e4965 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -313,6 +313,16 @@ materialToast('Share deleted', { type: 'success', description: null, timeout: 40 `type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer. +- `action`: `label`, plus `handler` (a function) and/or `event` (a name). Pressing it closes the snackbar, calls `handler`, then dispatches `new CustomEvent(event)` on `window`; give both and both run. Use `event` where a function cannot travel, such as a toast built from JSON. +- `sticky: true` keeps a toast until it is dismissed or its action is pressed (any `timeout` is ignored), without holding the queue up: a toast dispatched meanwhile shows in its place, and the sticky one comes back once the queue is empty. One sticky toast is kept at a time; a newer one replaces it. Use it for a question that must be answered, not for news: + +```js +window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: 'A new version is ready', sticky: true, action: { label: 'Reload', event: 'app:update' } } })) +window.addEventListener('app:update', () => location.reload()) +``` + +- Hooks: `data-toast` on the snackbar on screen, `data-toast-action` on its action button (`[data-toast]` is absent while nothing shows). Target these in tests, not classes. + ### `` M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless a `size-*` class is passed), flat or `wavy`, determinate with a `value` or indeterminate without one. diff --git a/resources/js/snackbar.js b/resources/js/snackbar.js index 8bd59349..8d3cf59f 100644 --- a/resources/js/snackbar.js +++ b/resources/js/snackbar.js @@ -4,6 +4,11 @@ * 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 @@ -25,6 +30,7 @@ document.addEventListener('alpine:init', () => { window.Alpine.data('materialSnackbar', () => ({ queue: [], current: null, + sticky: null, timer: null, remaining: 0, startedAt: 0, @@ -44,24 +50,43 @@ document.addEventListener('alpine:init', () => { // 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 - this.queue.push({ + const entry = { id: ++sequence, type: toast.type ?? null, title: toast.title ?? '', description: toast.description ?? null, - timeout: toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS), + timeout: sticky || toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS), action: toast.action ?? null, - }) + sticky, + } - if (!this.current) { + 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.current = this.queue.shift() ?? null + this.timer = null + this.current = this.queue.shift() ?? this.sticky if (this.current?.timeout) { this.remaining = this.current.timeout @@ -92,13 +117,24 @@ document.addEventListener('alpine:init', () => { }, dismiss() { - this.timer = null + 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() { - this.current?.action?.handler?.() + const action = this.current?.action + this.dismiss() + action?.handler?.() + + if (typeof action?.event === 'string' && action.event !== '') { + window.dispatchEvent(new CustomEvent(action.event)) + } }, icon(type) { diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php index 65ce4a27..bb5b4357 100644 --- a/resources/views/components/toast.blade.php +++ b/resources/views/components/toast.blade.php @@ -5,9 +5,18 @@ It shows every `toast` browser event — what `NoNameWeb\LivewireMaterial\Concerns\Toasts` dispatches from a Livewire component — and for `window.materialToast(title, options)` from - JavaScript (`{ type, description, timeout, action: { label, handler } }`). Toasts queue and - show in turn, each for its `timeout` (4s by default; M3 asks for 4–10s), paused while the - pointer or focus is on it. A toast with an action or no timeout gets a close button. + JavaScript (`{ type, description, timeout, sticky, action: { label, handler, event } }`). + Toasts queue and show in turn, each for its `timeout` (4s by default; M3 asks for 4–10s), + paused while the pointer or focus is on it. A toast with an action or no timeout gets a close + button. Pressing the action closes the snackbar, calls `handler` and dispatches `event` (a + name) on `window`; both may be given. + + `sticky: true` keeps a toast until it is dismissed or its action pressed, without holding up + the queue: a toast that arrives meanwhile shows in its place, and the sticky one comes back + once the queue is empty. One is kept at a time; a newer sticky toast replaces it. + + Hooks for tests and styling: `data-toast` on the snackbar on screen, `data-toast-action` on its + action button. `@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is still on screen when the next page arrives. @@ -32,6 +41,7 @@