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
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 16:42:05 +02:00
co-authored by Claude Opus 5
parent 8cb8727f75
commit ce8ac4d316
9 changed files with 329 additions and 53 deletions
+1
View File
@@ -27,6 +27,7 @@
@import './components/fab-menu-item.css'; @import './components/fab-menu-item.css';
@import './components/fab-menu.css'; @import './components/fab-menu.css';
@import './components/rich-tooltip.css'; @import './components/rich-tooltip.css';
@import './components/toast.css';
/* Inputs, selection and data */ /* Inputs, selection and data */
@import './components/form.css'; @import './components/form.css';
+183
View File
@@ -0,0 +1,183 @@
/*
* <x-toast>: the snackbar host — shows every toast queued behind it, one at a time.
*
* SnackbarTokens (androidx Compose Material 3, Apache-2.0): inverse-surface container,
* inverse-on-surface text, an inverse-primary action, extra-small corners, elevation 3, 48px for
* one line and 68px for two (SnackbarTokens.TwoLinesContainerHeight — the more precise of M3's
* two published figures; the site's prose says 64dp). `[data-md-two-line]`, bound from whether
* the current toast carries a description, pins the container to 68px instead of leaving it to
* grow into it; below `medium` (600px), a two-line snackbar with an action wraps the action under
* the text through `[data-md-toast-wrap]`, bound the same way — M3's "two lines with longer
* action" configuration.
*
* It lifts above a bottom bar through `--material-bottom-bar`; `data-md-position="bottom-start"`
* moves it to the start edge, with M3's margin growing from `medium`. resources/js/snackbar.js
* publishes the snackbar's own height as `--material-snackbar-height`, so a FAB (fab.css) can
* lift clear of it — M3 puts a snackbar above a FAB, never in front of or behind one (ACT-17).
* Below `medium` it is full width; from there it hugs its line length, which an application's own
* class can still bound.
*
* The action and the close button are both drawn at 40px but reach M3's 48px target (ACT-18) with
* the same `::after` touch target the shared classes use (foundation/interaction.css); they draw
* their own state layer and focus ring instead of those classes because the action's ink is
* inverse-primary, not the button's inherited inverse-on-surface.
*
* It enters on a translate and a fade on the fast spatial spring, `@starting-style` giving both a
* start. No state icon: M3 says to avoid one in a snackbar (ACT-20). The live region is the host
* itself, in the page before any message is, never the snackbar that comes and goes (ACT-02);
* resources/js/snackbar.js queues, times and dismisses a toast, including on Escape while it
* holds the focus and on Alt+G, which moves the focus to one carrying an action (ACT-34).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-toast] {
position: fixed;
z-index: 50;
inset-inline: var(--md-sys-measurement-space200);
bottom: calc(var(--material-bottom-bar, 0px) + var(--md-sys-measurement-space200));
display: flex;
justify-content: center;
pointer-events: none;
}
[data-md-toast][data-md-position='bottom-start'] {
justify-content: flex-start;
}
@media (width >= 600px) {
[data-md-toast][data-md-position='bottom-start'] {
inset-inline-start: var(--md-sys-measurement-space300);
}
}
[data-md-toast-snackbar] {
pointer-events: auto;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
width: 100%;
max-width: min(100%, 576px);
min-height: var(--md-sys-measurement-space600);
border-radius: var(--md-sys-shape-corner-xs);
padding-block: 6px;
padding-inline: var(--md-sys-measurement-space200) var(--md-sys-measurement-space100);
background-color: var(--md-sys-color-inverse-surface);
color: var(--md-sys-color-inverse-on-surface);
box-shadow: var(--md-sys-elevation-3);
translate: 0;
opacity: 1;
transition-property: translate, opacity;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
@starting-style {
translate: 0 16px;
opacity: 0;
}
}
[data-md-toast-snackbar][data-md-two-line] {
min-height: 68px;
}
@media (width >= 600px) {
[data-md-toast-snackbar] {
width: auto;
min-width: 344px;
}
}
[data-md-toast-content] {
min-width: 0;
flex: 1 1 auto;
padding-block: 6px;
}
@media (width < 600px) {
[data-md-toast-content][data-md-toast-wrap] {
flex-basis: 100%;
}
}
[data-md-toast-title],
[data-md-toast-description] {
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
font-variation-settings: normal;
}
:is([data-md-toast-action], [data-md-toast-dismiss]) {
position: relative;
isolation: isolate;
flex-shrink: 0;
border-radius: var(--md-sys-shape-corner-full);
cursor: pointer;
outline: none;
&::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: currentColor;
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
min-width: var(--md-sys-measurement-space600);
min-height: var(--md-sys-measurement-space600);
translate: -50% -50%;
}
@media (hover: hover) {
&:hover::before {
opacity: var(--md-sys-state-hover-state-layer-opacity);
}
}
&:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
&:focus-visible::before {
opacity: var(--md-sys-state-focus-state-layer-opacity);
}
&:active::before {
opacity: var(--md-sys-state-pressed-state-layer-opacity);
}
}
[data-md-toast-action] {
margin-inline-start: auto;
height: var(--md-sys-measurement-space500);
padding-inline: 12px;
color: var(--md-sys-color-inverse-primary);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-toast-dismiss] {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--md-sys-measurement-space500);
height: var(--md-sys-measurement-space500);
}
}
+1 -1
View File
@@ -283,4 +283,4 @@ document.addEventListener('alpine:init', () => {
* snackbar (`<x-toast>`'s, which reads --material-bottom-bar), or a bottom sheet or drawer over the * snackbar (`<x-toast>`'s, which reads --material-bottom-bar), or a bottom sheet or drawer over the
* page. M3 lets those cover the bar; it is the bar leaving from under them that looks broken. * page. M3 lets those cover the bar; it is the bar leaving from under them that looks broken.
*/ */
const anchored = () => [...document.querySelectorAll('[data-toast], [role="dialog"]')].some((over) => over.getClientRects().length > 0) const anchored = () => [...document.querySelectorAll('[data-md-toast-snackbar], [role="dialog"]')].some((over) => over.getClientRects().length > 0)
+2 -2
View File
@@ -60,7 +60,7 @@ document.addEventListener('alpine:init', () => {
// itself, so without one the keyboard cannot reach the action at all. `event.code` // 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. // rather than `event.key`, which Alt rewrites to another character on some layouts.
if (event.altKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyG') { if (event.altKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyG') {
const action = this.$el.querySelector('[data-toast-action]') const action = this.$el.querySelector('[data-md-toast-action]')
if (action) { if (action) {
event.preventDefault() event.preventDefault()
@@ -142,7 +142,7 @@ document.addEventListener('alpine:init', () => {
*/ */
measure() { measure() {
this.$nextTick(() => { this.$nextTick(() => {
const snackbar = this.$el.querySelector('[data-toast]') const snackbar = this.$el.querySelector('[data-md-toast-snackbar]')
const root = document.documentElement.style const root = document.documentElement.style
if (snackbar) { if (snackbar) {
+33 -30
View File
@@ -18,26 +18,27 @@
The live region is the host itself, not the snackbar: a region must be in the page before its 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 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 goes (ACT-02). It is `aria-live="polite" aria-atomic="true"`, as M3 asks for a snackbar
assertive); a `type` picks the region's role — `alert` for an error or a warning, `status` (never assertive); a `type` picks the region's role — `alert` for an error or a warning,
otherwise — and the explicit `aria-live` keeps even those polite. `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 Hooks for tests and styling: `data-md-toast` on the host, `data-md-toast-snackbar` on the
action button. snackbar on screen, `data-md-toast-action` on its action button and `data-md-toast-dismiss`
on its close button. Drawn by resources/css/components/toast.css.
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is `@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
still on screen when the next page arrives. 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 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 feels necessary", ACT-20), and both lines of the message are plain inverse-on-surface — M3
supporting text no fourth colour and tells the two lines apart by position. The 40px action gives the supporting text no fourth colour and tells the two lines apart by position. The
and close buttons carry `touch-target`, which reaches M3's 48px without growing the container. 40px action and close buttons reach M3's 48px target without growing the container (ACT-18).
Escape dismisses a snackbar that holds the focus. Escape dismisses a snackbar that holds the focus (ACT-34).
Alt+G moves the focus to a snackbar that carries an action, from wherever the page had it 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 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 and a keyboard has no other way to reach one (ACT-34). It does nothing when the snackbar on
no action. screen has no action.
M3's snackbar (SnackbarTokens, androidx Compose Material 3, Apache-2.0): inverse surface, 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, body-medium text, a label-large action in inverse-primary, extra-small corners, elevation 3,
@@ -45,14 +46,19 @@
says 64dp, and the token is the more precise of the two). A description is that second line, 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 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 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 the third of M3's five snackbar configurations ("two lines with longer action"). `position`:
above a bottom bar through `--material-bottom-bar`, and publishes its own height as `bottom` (centred, the default) or `bottom-start`. It lifts above a bottom bar through
`--material-snackbar-height` so a FAB can lift clear of it — M3: a snackbar appears above a `--material-bottom-bar`, and publishes its own height as `--material-snackbar-height` so a
FAB, never in front of or behind one. A compact window (below `medium`, 600px) gets the FAB can lift clear of it — M3: a snackbar appears above a FAB, never in front of or behind
full-width snackbar; from `medium` it hugs its line length instead, as M3 asks. --}} one (ACT-17). 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']) @props(['position' => 'bottom'])
@php
$position = in_array($position, ['bottom', 'bottom-start'], true) ? $position : 'bottom';
@endphp
@persist('material-toast') @persist('material-toast')
<div <div
x-data="materialSnackbar" x-data="materialSnackbar"
@@ -60,35 +66,32 @@
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-atomic="true" aria-atomic="true"
{{ $attributes->class([ data-md-toast
'pointer-events-none fixed inset-x-4 z-50 flex bottom-[calc(var(--material-bottom-bar,0px)+1rem)]', data-md-position="{{ $position }}"
'justify-center' => $position !== 'bottom-start', {{ $attributes }}
'justify-start medium:start-6' => $position === 'bottom-start',
]) }}
> >
<template x-if="current"> <template x-if="current">
<div <div
x-bind:key="current.id" x-bind:key="current.id"
data-toast data-md-toast-snackbar
x-bind:data-md-two-line="current.description ? '' : null"
x-on:mouseenter="pause()" x-on:mouseenter="pause()"
x-on:mouseleave="resume()" x-on:mouseleave="resume()"
x-on:focusin="pause()" x-on:focusin="pause()"
x-on:focusout="resume()" 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"> <div data-md-toast-content x-bind:data-md-toast-wrap="current.description && current.action ? '' : null">
<p class="type-body-md" x-text="current.title"></p> <p data-md-toast-title x-text="current.title"></p>
<p class="type-body-md" x-show="current.description" x-text="current.description"></p> <p data-md-toast-description x-show="current.description" x-text="current.description"></p>
</div> </div>
<template x-if="current.action"> <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> <button type="button" data-md-toast-action x-text="current.action.label" x-on:click="act()"></button>
</template> </template>
<template x-if="current.action || ! current.timeout"> <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()"> <button type="button" data-md-toast-dismiss aria-label="{{ __('Dismiss') }}" x-on:click="dismiss()">
<x-livewire-material::icon name="close" optical="20" class="size-5" /> <x-livewire-material::icon name="close" size="20" />
</button> </button>
</template> </template>
</div> </div>
+58 -5
View File
@@ -42,7 +42,7 @@ class RichTooltipMorphProbe extends Component
// The live region is the host itself, in the page before any message is (actions.md ACT-02). // The live region is the host itself, in the page before any message is (actions.md ACT-02).
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"][aria-live]')"; const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"][aria-live]')";
const TOAST = "document.querySelector('[data-toast]')"; const TOAST = "document.querySelector('[data-md-toast-snackbar]')";
it('shows a toast as a snackbar, then the next in turn', function () { it('shows a toast as a snackbar, then the next in turn', function () {
$page = visit('/material/communication')->waitForEvent('networkidle') $page = visit('/material/communication')->waitForEvent('networkidle')
@@ -90,7 +90,7 @@ it('does not let a toast dismissed early cut the next one short', function () {
// A click with no hover or focus before it, as a screen reader activates a button: nothing has // A click with no hover or focus before it, as a screen reader activates a button: nothing has
// paused the first toast's timer. // paused the first toast's timer.
$page->script("document.querySelector('[data-toast] button[aria-label=\"Dismiss\"]').click()"); $page->script("document.querySelector('[data-md-toast-dismiss]').click()");
$page->assertScript(TOAST."?.textContent.includes('Link copied')") $page->assertScript(TOAST."?.textContent.includes('Link copied')")
->wait(1.2); ->wait(1.2);
@@ -117,7 +117,7 @@ it('keeps a sticky toast aside while a passing one shows, brings back the newest
$page->assertScript(TOAST."?.textContent.includes('Version 2 is ready')"); $page->assertScript(TOAST."?.textContent.includes('Version 2 is ready')");
$page->click('[data-toast] button[aria-label="Dismiss"]') $page->click('[data-md-toast-dismiss]')
->assertScript(TOAST.' === null'); ->assertScript(TOAST.' === null');
// Dismissed rather than left to time out: the pointer still rests where the snackbar appears, // Dismissed rather than left to time out: the pointer still rests where the snackbar appears,
@@ -125,7 +125,7 @@ it('keeps a sticky toast aside while a passing one shows, brings back the newest
$page->script("window.eval(\"materialToast('Link copied', { timeout: 0 })\")"); $page->script("window.eval(\"materialToast('Link copied', { timeout: 0 })\")");
$page->assertScript(TOAST."?.textContent.includes('Link copied')") $page->assertScript(TOAST."?.textContent.includes('Link copied')")
->click('[data-toast] button[aria-label="Dismiss"]') ->click('[data-md-toast-dismiss]')
->assertScript(TOAST.' === null'); ->assertScript(TOAST.' === null');
}); });
@@ -135,12 +135,65 @@ it('dispatches a toast action\'s window event alongside its handler, and closes'
$page->script("window.eval(\"window.reloads = 0; window.handled = 0; window.addEventListener('material:test-reload', () => window.reloads++); materialToast('A new version is ready', { sticky: true, action: { label: 'Reload', event: 'material:test-reload', handler: () => window.handled++ } })\")"); $page->script("window.eval(\"window.reloads = 0; window.handled = 0; window.addEventListener('material:test-reload', () => window.reloads++); materialToast('A new version is ready', { sticky: true, action: { label: 'Reload', event: 'material:test-reload', handler: () => window.handled++ } })\")");
$page->click('[data-toast-action]') $page->click('[data-md-toast-action]')
->assertScript(TOAST.' === null') ->assertScript(TOAST.' === null')
->assertScript("window.eval('window.reloads') === 1") ->assertScript("window.eval('window.reloads') === 1")
->assertScript("window.eval('window.handled') === 1"); ->assertScript("window.eval('window.handled') === 1");
}); });
it('keeps an actioned snackbar on screen past the default timeout, until it is acted on', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
// No timeout written out: M3 forbids an actioned snackbar from auto-dismissing (ACT-03).
$page->script("materialToast('Share deleted', { action: { label: 'Undo', handler: () => {} } })");
$page->assertScript(TOAST."?.textContent.includes('Share deleted')")
->wait(4.5);
$page->assertScript(TOAST."?.textContent.includes('Share deleted')")
->click('[data-md-toast-dismiss]')
->assertScript(TOAST.' === null');
});
it('dismisses a focused snackbar with Escape', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("materialToast('Share deleted', { action: { label: 'Undo', handler: () => {} } })");
$page->assertScript(TOAST."?.textContent.includes('Share deleted')")
->script("document.querySelector('[data-md-toast-dismiss]').focus()")
->keys(':focus', 'Escape')
->assertScript(TOAST.' === null');
});
it('keeps the snackbar\'s live region in the page, polite, before any message arrives', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->assertScript(SNACKBAR.' !== null')
->assertScript(SNACKBAR."?.getAttribute('aria-live') === 'polite'")
->assertScript(SNACKBAR."?.getAttribute('aria-atomic') === 'true'")
->assertScript(TOAST.' === null');
});
it('grows to M3\'s two-line height with a description, and Alt+G reaches its action from anywhere on the page', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("materialToast('Upload paused', { description: 'The connection dropped at 64%.', action: { label: 'Retry now', handler: () => {} } })");
$page->assertScript(TOAST."?.textContent.includes('Upload paused')")
->assertScript('Math.round('.TOAST.'.getBoundingClientRect().height) === 68');
// Focused somewhere else on the page first: the shortcut has to reach the snackbar from
// wherever the keyboard already was, since a snackbar never takes the focus on its own.
$page->script("document.body.setAttribute('tabindex', '-1'); document.body.focus()")
->keys(':focus', 'Alt+g')
->assertScript('document.activeElement === '.TOAST."?.querySelector('[data-md-toast-action]')");
});
it('opens a persistent rich tooltip on press', function () { it('opens a persistent rich tooltip on press', function () {
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')"; $bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
@@ -26,6 +26,7 @@ dataset('action components', [
'fab-menu-item', 'fab-menu-item',
'fab-menu', 'fab-menu',
'rich-tooltip', 'rich-tooltip',
'toast',
]); ]);
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
@@ -101,5 +101,5 @@ it('hides on a scroll down and springs back on a scroll up', function () {
expect(file_get_contents(__DIR__.'/../../../resources/js/navigation.js')) expect(file_get_contents(__DIR__.'/../../../resources/js/navigation.js'))
// Never out from under a snackbar, a bottom sheet or a drawer resting on its edge. // Never out from under a snackbar, a bottom sheet or a drawer resting on its edge.
->toContain("document.querySelectorAll('[data-toast], [role=\"dialog\"]')"); ->toContain("document.querySelectorAll('[data-md-toast-snackbar], [role=\"dialog\"]')");
}); });
+49 -14
View File
@@ -1,48 +1,77 @@
<?php <?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('hosts the snackbar queue, kept across wire:navigate', function () { it('hosts the snackbar queue, kept across wire:navigate', function () {
$html = (string) $this->blade('<x-toast />'); $html = (string) $this->blade('<x-toast />');
expect($html) expect($html)
->toContain('x-persist="material-toast"') ->toContain('x-persist="material-toast"')
->toContain('x-data="materialSnackbar"') ->toContain('x-data="materialSnackbar"')
->toContain('bg-inverse-surface') ->toContain('data-md-toast')
->toContain('justify-center'); ->toContain('data-md-position="bottom"')
->and(ComponentStylesheet::read('toast')->declarations('[data-md-toast-snackbar]'))->toMatchArray([
'background-color' => 'var(--md-sys-color-inverse-surface)',
'color' => 'var(--md-sys-color-inverse-on-surface)',
]);
}); });
it('draws no state icon, and reaches 48px from its 40px controls', function () { it('draws no state icon, and reaches 48px from its 40px controls', function () {
$html = (string) $this->blade('<x-toast />'); $html = (string) $this->blade('<x-toast />');
$css = ComponentStylesheet::read('toast');
// M3 tells you to avoid an icon in a snackbar; the type is left to pick the announcement role. // M3 tells you to avoid an icon in a snackbar; the type is left to pick the announcement role.
expect(substr_count($html, '<svg'))->toBe(1) expect(substr_count($html, '<svg'))->toBe(1)
->and($html)->not->toContain('text-inverse-success') ->and($html)->not->toContain('text-inverse-success')
->not->toContain('opacity-80') ->toContain('data-md-toast-action')
->toContain('data-toast-action class="state-layer focus-ring touch-target') ->toContain('data-md-toast-dismiss')
->toContain('state-layer focus-ring touch-target inline-flex size-10'); ->and($css->declarations(':is([data-md-toast-action], [data-md-toast-dismiss])'))
->toHaveKey('border-radius', 'var(--md-sys-shape-corner-full)')
->and($css->declarations(':is([data-md-toast-action], [data-md-toast-dismiss])::after'))->toMatchArray([
'min-width' => 'var(--md-sys-measurement-space600)',
'min-height' => 'var(--md-sys-measurement-space600)',
])
->and($css->declarations('[data-md-toast-dismiss]'))->toMatchArray([
'width' => 'var(--md-sys-measurement-space500)',
'height' => 'var(--md-sys-measurement-space500)',
]);
}); });
it('grows to M3\'s two-line height, and wraps the action below on a compact window', function () { it('grows to M3\'s two-line height, and wraps the action below on a compact window', function () {
$html = (string) $this->blade('<x-toast />'); $html = (string) $this->blade('<x-toast />');
$css = ComponentStylesheet::read('toast');
// SnackbarTokens: 48dp for one line, 68dp for two — a description is that second line. // SnackbarTokens: 48dp for one line, 68dp for two — a description is that second line.
expect($html) expect($html)
->toContain('x-bind:class="current.description ? \'min-h-17\' : \'min-h-12\'"') ->toContain('x-bind:data-md-two-line="current.description ? \'\' : null"')
->toContain('flex-wrap') ->toContain('x-bind:data-md-toast-wrap="current.description && current.action ? \'\' : null"')
// M3's "two lines with longer action": the action leaves the text's line below `medium`. ->and($css->declarations('[data-md-toast-snackbar]'))->toHaveKey('min-height', 'var(--md-sys-measurement-space600)')
->toContain('x-bind:class="current.description && current.action ? \'max-medium:basis-full\' : \'\'"') ->and($css->declarations('[data-md-toast-snackbar][data-md-two-line]'))->toBe(['min-height' => '68px'])
->toContain('touch-target ms-auto h-10'); ->and($css->declarations('[data-md-toast-content][data-md-toast-wrap]', ['@media (width < 600px)']))->toBe(['flex-basis' => '100%'])
->and($css->declarations('[data-md-toast-action]'))->toHaveKey('height', 'var(--md-sys-measurement-space500)');
}); });
it('can sit at the start', function () { it('can sit at the start, growing its margin from medium', function () {
expect((string) $this->blade('<x-toast position="bottom-start" />'))->toContain('justify-start'); $css = ComponentStylesheet::read('toast');
expect((string) $this->blade('<x-toast position="bottom-start" />'))->toContain('data-md-position="bottom-start"')
->and($css->declarations("[data-md-toast][data-md-position='bottom-start']"))->toBe(['justify-content' => 'flex-start'])
->and($css->declarations("[data-md-toast][data-md-position='bottom-start']", ['@media (width >= 600px)']))->toBe([
'inset-inline-start' => 'var(--md-sys-measurement-space300)',
]);
});
it('falls back to bottom for an unknown position', function () {
expect((string) $this->blade('<x-toast position="top" />'))->toContain('data-md-position="bottom"');
}); });
it('marks the snackbar and its action for tests and styling', function () { it('marks the snackbar and its action for tests and styling', function () {
$html = (string) $this->blade('<x-toast />'); $html = (string) $this->blade('<x-toast />');
expect($html) expect($html)
->toMatch('/<div\s[^>]*\bdata-toast\b/') ->toMatch('/<div\s[^>]*\bdata-md-toast-snackbar\b/')
->toMatch('/<button\s[^>]*\bdata-toast-action\b[^>]*x-on:click="act\(\)"/'); ->toMatch('/<button\s[^>]*\bdata-md-toast-action\b[^>]*x-on:click="act\(\)"/')
->toMatch('/<button\s[^>]*\bdata-md-toast-dismiss\b[^>]*x-on:click="dismiss\(\)"/');
}); });
it('keeps the live region in the page, polite, around the snackbar that comes and goes', function () { it('keeps the live region in the page, polite, around the snackbar that comes and goes', function () {
@@ -54,3 +83,9 @@ it('keeps the live region in the page, polite, around the snackbar that comes an
->toContain("x-bind:role=\"current && (current.type === 'error' || current.type === 'warning') ? 'alert' : 'status'\"") ->toContain("x-bind:role=\"current && (current.type === 'error' || current.type === 'warning') ? 'alert' : 'status'\"")
->and(substr_count($html, 'aria-live'))->toBe(1); ->and(substr_count($html, 'aria-live'))->toBe(1);
}); });
it('lifts above a bottom bar, publishing its own height for a FAB to clear', function () {
expect(ComponentStylesheet::read('toast')->declarations('[data-md-toast]'))->toMatchArray([
'bottom' => 'calc(var(--material-bottom-bar, 0px) + var(--md-sys-measurement-space200))',
]);
});