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
95 lines
5.7 KiB
PHP
95 lines
5.7 KiB
PHP
{{-- The snackbar host: shows every toast, one at a time. Put it once in each layout, near the end
|
||
of <body>:
|
||
|
||
<x-toast />
|
||
|
||
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, 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` has no timeout at all,
|
||
as M3 requires — it waits to be read and acted on — unless the caller writes a `timeout` out.
|
||
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.
|
||
|
||
The live region is the host itself, not the snackbar: a region must be in the page before its
|
||
contents change for a screen reader to announce them reliably, and the snackbar comes and
|
||
goes. It is `aria-live="polite" aria-atomic="true"`, as M3 asks for a snackbar (never
|
||
assertive); a `type` picks the region's role — `alert` for an error or a warning, `status`
|
||
otherwise — and the explicit `aria-live` keeps even those polite.
|
||
|
||
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.
|
||
|
||
M3's snackbar (SnackbarTokens, androidx Compose Material 3, Apache-2.0): inverse surface,
|
||
body-medium text, a label-large action in inverse-primary, extra-small corners, elevation 3,
|
||
48px for one line. A type draws its state icon in the inverse state colour. `position`:
|
||
`bottom` (centred, the default) or `bottom-start`. It lifts above a bottom bar through
|
||
`--material-bottom-bar`. A compact window (below `medium`, 600px) gets the full-width snackbar;
|
||
from `medium` it hugs its line length instead, as M3 asks. --}}
|
||
|
||
@props(['position' => 'bottom'])
|
||
|
||
@persist('material-toast')
|
||
<div
|
||
x-data="materialSnackbar"
|
||
x-bind:role="current && (current.type === 'error' || current.type === 'warning') ? 'alert' : 'status'"
|
||
role="status"
|
||
aria-live="polite"
|
||
aria-atomic="true"
|
||
{{ $attributes->class([
|
||
'pointer-events-none fixed inset-x-4 z-50 flex bottom-[calc(var(--material-bottom-bar,0px)+1rem)]',
|
||
'justify-center' => $position !== 'bottom-start',
|
||
'justify-start medium:start-6' => $position === 'bottom-start',
|
||
]) }}
|
||
>
|
||
<template x-if="current">
|
||
<div
|
||
x-bind:key="current.id"
|
||
data-toast
|
||
x-on:mouseenter="pause()"
|
||
x-on:mouseleave="resume()"
|
||
x-on:focusin="pause()"
|
||
x-on:focusout="resume()"
|
||
class="pointer-events-auto flex min-h-12 w-full max-w-[min(100%,36rem)] items-center gap-3 rounded-corner-xs bg-inverse-surface py-1.5 ps-4 pe-2 text-inverse-on-surface shadow-elevation-3 transition-[translate,opacity] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast starting:translate-y-4 starting:opacity-0 medium:w-auto medium:min-w-86"
|
||
>
|
||
<template x-if="icon(current.type)">
|
||
<span class="flex shrink-0" x-bind:class="{
|
||
'text-inverse-success': current.type === 'success',
|
||
'text-inverse-error': current.type === 'error',
|
||
'text-inverse-warning': current.type === 'warning',
|
||
'text-inverse-info': current.type === 'info',
|
||
}">
|
||
<span x-show="current.type === 'success'"><x-livewire-material::icon name="check_circle" filled class="size-6" /></span>
|
||
<span x-show="current.type === 'error'"><x-livewire-material::icon name="error" filled class="size-6" /></span>
|
||
<span x-show="current.type === 'warning'"><x-livewire-material::icon name="warning" filled class="size-6" /></span>
|
||
<span x-show="current.type === 'info'"><x-livewire-material::icon name="info" filled class="size-6" /></span>
|
||
</span>
|
||
</template>
|
||
|
||
<div class="min-w-0 flex-1 py-1.5">
|
||
<p class="type-body-md" x-text="current.title"></p>
|
||
<p class="type-body-md opacity-80" x-show="current.description" x-text="current.description"></p>
|
||
</div>
|
||
|
||
<template x-if="current.action">
|
||
<button type="button" data-toast-action class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
|
||
</template>
|
||
|
||
<template x-if="current.action || ! current.timeout">
|
||
<button type="button" class="state-layer focus-ring inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="dismiss()">
|
||
<x-livewire-material::icon name="close" class="size-5" />
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
@endpersist
|