Files
livewire-material/resources/views/components/toast.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 44ae9ecea4 Pin the two-line snackbar, and reach it with Alt+G
Plan step 22, actions.md § Missing (two-line snackbar height, snackbar
keyboard shortcut): the container grew organically from `min-h-12` rather
than sitting at SnackbarTokens.TwoLinesContainerHeight, and there was no
way for a keyboard to reach an actioned snackbar at all.

A `description` is M3's second line, so the snackbar is pinned to 68px
whenever one is there, and below `medium` a two-line snackbar with an
action wraps the action under the text — M3's "two lines with longer
action" configuration. Alt+G, the shortcut M3 suggests for the web, moves
the focus to a snackbar that carries an action from wherever the page had
it; `event.code`, because Alt rewrites `event.key` on some layouts.

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

98 lines
6.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{-- 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 410s),
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.
There is no state icon: M3 says to avoid one in a snackbar ("use a dialog instead if an icon
feels necessary"), and both lines of the message are plain inverse-on-surface — M3 gives the
supporting text no fourth colour and tells the two lines apart by position. The 40px action
and close buttons carry `touch-target`, which reaches M3's 48px without growing the container.
Escape dismisses a snackbar that holds the focus.
Alt+G moves the focus to a snackbar that carries an action, from wherever the page had it
M3 asks for a documented shortcut on the web, since a snackbar never takes the focus itself
and a keyboard has no other way to reach one. It does nothing when the snackbar on screen has
no action.
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 and 68px for two (SnackbarTokens.TwoLinesContainerHeight; the site's prose
says 64dp, and the token is the more precise of the two). A description is that second line,
so the container is pinned to 68px whenever one is there rather than left to grow into it. On
a compact window a two-line snackbar with an action wraps the action below the text, which is
the third of M3's five snackbar configurations ("two lines with longer action"). `position`: `bottom` (centred, the default) or `bottom-start`. It lifts
above a bottom bar through `--material-bottom-bar`, and publishes its own height as
`--material-snackbar-height` so a FAB can lift clear of it — M3: a snackbar appears above a
FAB, never in front of or behind one. 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()"
x-bind:class="current.description ? 'min-h-17' : 'min-h-12'"
class="pointer-events-auto flex w-full max-w-[min(100%,36rem)] flex-wrap 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"
>
<div x-bind:class="current.description && current.action ? 'max-medium:basis-full' : ''" class="min-w-0 flex-1 py-1.5">
<p class="type-body-md" x-text="current.title"></p>
<p class="type-body-md" 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 touch-target ms-auto 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 touch-target 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" optical="20" class="size-5" />
</button>
</template>
</div>
</template>
</div>
@endpersist