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 @@