Files
Andreas Reinhold / reiniandClaude Opus 5 d0bed0da69 Lift the snackbar above a toolbar placed at the bottom
SealShare's only navigation is a floating toolbar at place="bottom". In
1.x it set --material-bottom-bar to lift the snackbar over it; in 2.0.0
the toolbar reads that variable to place itself, so an application can
no longer set it, and the snackbar landed on the toolbar. M3 nudges a
snackbar "upward to avoid overlapping FABs/docked toolbars" and never
puts one in front of navigation.

A bottom-placed toolbar now publishes --material-bottom-toolbar, the
distance from the window's bottom edge to its top: floating 64px (as
tall as an md or lg FAB beside it), docked 64px plus the safe area it
pads, rounded docked floating from 840px. It is declared on :root and
again on the scaffold's root, the only place its formula sees the bar.
The snackbar clears whichever of the bar and the toolbar reaches higher,
and a page pads its end with it. Two Chromium tests, both failing on the
old formula: a docked toolbar on a scaffold's navigation bar, and a
floating toolbar without a scaffold (plan step 46).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 10:47:50 +02:00

97 lines
5.2 KiB
PHP

<?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('hosts the snackbar queue, kept across wire:navigate', function () {
$html = (string) $this->blade('<x-toast />');
expect($html)
->toContain('x-persist="material-toast"')
->toContain('x-data="materialSnackbar"')
->toContain('data-md-toast')
->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 () {
$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.
expect(substr_count($html, '<svg'))->toBe(1)
->and($html)->not->toContain('text-inverse-success')
->toContain('data-md-toast-action')
->toContain('data-md-toast-dismiss')
// 48px, from the foundation's md-touch-target class, not a copy of its own.
->toMatch('/class="md-state-layer md-focus-ring md-touch-target" data-md-toast-action/')
->toMatch('/class="md-state-layer md-focus-ring md-touch-target" data-md-toast-dismiss/')
->and($css->declarations(':is([data-md-toast-action], [data-md-toast-dismiss])'))
->toHaveKey('border-radius', 'var(--md-sys-shape-corner-full)')
->and($css->has(':is([data-md-toast-action], [data-md-toast-dismiss])::after'))->toBeFalse()
->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 () {
$html = (string) $this->blade('<x-toast />');
$css = ComponentStylesheet::read('toast');
// SnackbarTokens: 48dp for one line, 68dp for two — a description is that second line.
expect($html)
->toContain('x-bind:data-md-two-line="current.description ? \'\' : null"')
->toContain('x-bind:data-md-toast-wrap="current.description && current.action ? \'\' : null"')
->and($css->declarations('[data-md-toast-snackbar]'))->toHaveKey('min-height', 'var(--md-sys-measurement-space600)')
->and($css->declarations('[data-md-toast-snackbar][data-md-two-line]'))->toBe(['min-height' => '68px'])
// The text grows from a zero basis, so a long title shrinks beside the action rather than
// wrapping it onto a line of its own where there is room for both.
->and($css->declarations('[data-md-toast-content]'))->toHaveKey('flex', '1')
->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, growing its margin from medium', function () {
$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 () {
$html = (string) $this->blade('<x-toast />');
expect($html)
->toMatch('/<div\s[^>]*\bdata-md-toast-snackbar\b/')
->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 () {
$html = (string) $this->blade('<x-toast />');
expect($html)
->toMatch('/<div\s[^>]*x-data="materialSnackbar"[^>]*aria-live="polite"/')
->toContain('aria-atomic="true"')
->toContain("x-bind:role=\"current && (current.type === 'error' || current.type === 'warning') ? 'alert' : 'status'\"")
->and(substr_count($html, 'aria-live'))->toBe(1);
});
it('lifts above a bottom bar or a toolbar placed at the bottom, publishing its own height for a FAB to clear', function () {
// Whichever reaches higher: M3 nudges a snackbar clear of a docked toolbar and never puts one
// in front of navigation (toolbar.css publishes --material-bottom-toolbar).
expect(ComponentStylesheet::read('toast')->declarations('[data-md-toast]'))->toMatchArray([
'bottom' => 'calc(max(var(--material-bottom-bar, 0px), var(--material-bottom-toolbar, 0px)) + var(--md-sys-measurement-space200))',
]);
});