Take the icon off the snackbar and lift the FAB clear of it

M3 says to avoid an icon in a snackbar and gives the supporting text no fourth
colour, so the state glyph and the 80% description are gone: a type now only
picks the announcement role. The 40px action and close buttons take the shared
touch-target, Escape dismisses a snackbar that holds the focus, and the host
publishes --material-snackbar-height on <html> so a `fab` button sits above the
snackbar instead of under it. Plan step 18, actions.md ACT-17, ACT-18, ACT-20,
ACT-34, ACT-35.

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
2026-09-14 06:09:47 +02:00
co-authored by Claude Opus 5
parent eac7f6a1de
commit 7bc4f740e7
7 changed files with 76 additions and 33 deletions
@@ -111,6 +111,8 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through `var(--material-safe-top|bottom|left|right, env(safe-area-inset-…))`. The layout needs `viewport-fit=cover` in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on `<html>` or any ancestor: a browser test fakes a notch with `document.documentElement.style.setProperty('--material-safe-top', '47px')`, and an app that draws its own status strip adds its height. Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through `var(--material-safe-top|bottom|left|right, env(safe-area-inset-…))`. The layout needs `viewport-fit=cover` in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on `<html>` or any ancestor: a browser test fakes a notch with `document.documentElement.style.setProperty('--material-safe-top', '47px')`, and an app that draws its own status strip adds its height.
`--material-snackbar-height` is the height of the snackbar on screen, written on `<html>` by `<x-toast>` while one shows and removed when it goes. `<x-button fab>` reads it, so the FAB sits above the snackbar rather than under it, as M3 requires; a placed `<x-fab>` does the same by wrapping it in `<div class="fixed end-4 bottom-[calc(1rem+var(--material-snackbar-height,0px))] large:end-6">`.
`--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `<x-app-shell>` (an offline banner): the shell adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at `bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`, on a compact window (below `medium`) only. `--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `<x-app-shell>` (an offline banner): the shell adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at `bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`, on a compact window (below `medium`) only.
## Toasts ## Toasts
@@ -326,7 +328,7 @@ The snackbar host. Once per layout, near the end of `<body>`: `<x-toast />` (`po
materialToast('Share deleted', { type: 'success', description: null, timeout: 4000, action: { label: 'Undo', handler: () => $wire.restore() } }) materialToast('Share deleted', { type: 'success', description: null, timeout: 4000, action: { label: 'Undo', handler: () => $wire.restore() } })
``` ```
`type` (`success`, `error`, `warning`, `info`) picks the announcement role; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer. A toast with an `action` never auto-dismisses (M3's rule) unless you write a `timeout` out. `type` (`success`, `error`, `warning`, `info`) picks the announcement role and draws no icon (M3 tells you to avoid one in a snackbar); `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer. A toast with an `action` never auto-dismisses (M3's rule) unless you write a `timeout` out.
- `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. - `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: - `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:
@@ -336,6 +338,7 @@ window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: '
window.addEventListener('app:update', () => location.reload()) window.addEventListener('app:update', () => location.reload())
``` ```
- Escape dismisses a snackbar that holds the focus.
- 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. - 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.
### `<x-progress>` ### `<x-progress>`
+7 -4
View File
@@ -11,7 +11,8 @@
* list and matching it position for position. * list and matching it position for position.
* *
* `state-transition-fast` is the press/hover/select transition of a control the finger lands on: * `state-transition-fast` is the press/hover/select transition of a control the finger lands on:
* shape and size on the fast spatial spring, colour and elevation on the fast effects spring. * shape, size and the place it sits on the fast spatial spring (a `fab` button nudges up when a
* snackbar arrives under it), colour and elevation on the fast effects spring.
* `state-transition-default` is the same pair one step slower, for the bigger morph of a FAB * `state-transition-default` is the same pair one step slower, for the bigger morph of a FAB
* turning into a close button. * turning into a close button.
* *
@@ -26,12 +27,14 @@
*/ */
@utility state-transition-fast { @utility state-transition-fast {
transition-property: border-radius, padding, margin, background-color, color, box-shadow; transition-property: border-radius, padding, margin, bottom, background-color, color, box-shadow;
transition-duration: transition-duration:
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration); var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration);
transition-timing-function: transition-timing-function:
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast); var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast);
} }
+33 -4
View File
@@ -35,13 +35,27 @@ document.addEventListener('alpine:init', () => {
timer: null, timer: null,
remaining: 0, remaining: 0,
startedAt: 0, startedAt: 0,
escape: null,
init() { init() {
host = this host = this
pending.splice(0).forEach((detail) => this.add(detail)) pending.splice(0).forEach((detail) => this.add(detail))
// M3: Esc dismisses the focused snackbar. Only the focused one — a key pressed
// anywhere else on the page belongs to whatever has the focus there.
this.escape = (event) => {
if (event.key === 'Escape' && this.current && this.$el.contains(document.activeElement)) {
this.dismiss()
}
}
document.addEventListener('keydown', this.escape)
}, },
destroy() { destroy() {
document.removeEventListener('keydown', this.escape)
document.documentElement.style.removeProperty('--material-snackbar-height')
if (host === this) { if (host === this) {
host = null host = null
} }
@@ -93,6 +107,7 @@ document.addEventListener('alpine:init', () => {
clearTimeout(this.timer) clearTimeout(this.timer)
this.timer = null this.timer = null
this.current = this.queue.shift() ?? this.sticky this.current = this.queue.shift() ?? this.sticky
this.measure()
if (this.current?.timeout) { if (this.current?.timeout) {
this.remaining = this.current.timeout this.remaining = this.current.timeout
@@ -100,6 +115,24 @@ document.addEventListener('alpine:init', () => {
} }
}, },
/**
* Publishes the snackbar's height on <html> as `--material-snackbar-height`, and clears it
* when nothing shows. M3 puts a snackbar above a FAB, never in front of or behind one, and
* the FAB is somewhere else in the page: a variable is the only thing the two share.
*/
measure() {
this.$nextTick(() => {
const snackbar = this.$el.querySelector('[data-toast]')
const root = document.documentElement.style
if (snackbar) {
root.setProperty('--material-snackbar-height', `${Math.round(snackbar.getBoundingClientRect().height)}px`)
} else {
root.removeProperty('--material-snackbar-height')
}
})
},
pause() { pause() {
if (!this.current?.timeout || !this.timer) { if (!this.current?.timeout || !this.timer) {
return return
@@ -142,9 +175,5 @@ document.addEventListener('alpine:init', () => {
window.dispatchEvent(new CustomEvent(action.event)) window.dispatchEvent(new CustomEvent(action.event))
} }
}, },
icon(type) {
return ['success', 'error', 'warning', 'info'].includes(type)
},
})) }))
}) })
+4 -2
View File
@@ -35,7 +35,9 @@
`tooltip`, `tooltip-left`, `tooltip-right` `tooltip`, `tooltip-left`, `tooltip-right`
and `tooltip-bottom` attach a plain tooltip. `fab` is a page's create action: an extended FAB and `tooltip-bottom` attach a plain tooltip. `fab` is a page's create action: an extended FAB
pinned in the thumb zone on a compact window (below `medium`, 600px), a filled button from pinned in the thumb zone on a compact window (below `medium`, 600px), a filled button from
there one element either way. there one element either way. It lifts clear of a bottom bar and of a snackbar on screen
(`--material-bottom-bar`, `--material-snackbar-height`), because M3 puts a snackbar above a
FAB and never over one.
Never pass `hidden`, a display or a position class: the button is `inline-flex` and Never pass `hidden`, a display or a position class: the button is `inline-flex` and
`relative`, and whichever Tailwind emits last wins. Wrap it instead. --}} `relative`, and whichever Tailwind emits last wins. Wrap it instead. --}}
@@ -187,7 +189,7 @@
'disabled:bg-on-surface/10 disabled:text-on-surface/38 aria-disabled:bg-on-surface/10 aria-disabled:text-on-surface/38' => $contained, 'disabled:bg-on-surface/10 disabled:text-on-surface/38 aria-disabled:bg-on-surface/10 aria-disabled:text-on-surface/38' => $contained,
'disabled:text-on-surface/38 aria-disabled:text-on-surface/38' => ! $contained, 'disabled:text-on-surface/38 aria-disabled:text-on-surface/38' => ! $contained,
// The FAB, on a compact window only: an extended FAB in the thumb zone, clear of a bottom bar the layout declares. // The FAB, on a compact window only: an extended FAB in the thumb zone, clear of a bottom bar the layout declares.
'max-medium:fixed max-medium:end-4 max-medium:bottom-[calc(var(--material-bottom-bar,0px)+1rem)] max-medium:z-30 max-medium:h-14 max-medium:gap-2 max-medium:px-4 max-medium:rounded-corner-lg max-medium:type-title-md max-medium:bg-primary-container max-medium:text-on-primary-container max-medium:shadow-elevation-3' => $fab, 'max-medium:fixed max-medium:end-4 max-medium:bottom-[calc(var(--material-bottom-bar,0px)+var(--material-snackbar-height,0px)+1rem)] max-medium:z-30 max-medium:h-14 max-medium:gap-2 max-medium:px-4 max-medium:rounded-corner-lg max-medium:type-title-md max-medium:bg-primary-container max-medium:text-on-primary-container max-medium:shadow-elevation-3' => $fab,
]; ];
$tag = $isLink ? 'a' : 'button'; $tag = $isLink ? 'a' : 'button';
+14 -21
View File
@@ -28,12 +28,19 @@
`@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
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.
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,
48px for one line. A type draws its state icon in the inverse state colour. `position`: 48px for one line. `position`: `bottom` (centred, the default) or `bottom-start`. It lifts
`bottom` (centred, the default) or `bottom-start`. It lifts above a bottom bar through above a bottom bar through `--material-bottom-bar`, and publishes its own height as
`--material-bottom-bar`. A compact window (below `medium`, 600px) gets the full-width snackbar; `--material-snackbar-height` so a FAB can lift clear of it — M3: a snackbar appears above a
from `medium` it hugs its line length instead, as M3 asks. --}} 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']) @props(['position' => 'bottom'])
@@ -60,31 +67,17 @@
x-on:focusout="resume()" 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" 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"> <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" x-text="current.title"></p>
<p class="type-body-md opacity-80" x-show="current.description" x-text="current.description"></p> <p class="type-body-md" 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 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-toast-action class="state-layer focus-ring touch-target 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>
<template x-if="current.action || ! current.timeout"> <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()"> <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" /> <x-livewire-material::icon name="close" optical="20" class="size-5" />
</button> </button>
</template> </template>
+3 -1
View File
@@ -168,7 +168,9 @@ it('is a FAB on a compact window and a filled button from medium, in one element
$html = (string) $this->blade('<x-button label="New share" icon="add" fab />'); $html = (string) $this->blade('<x-button label="New share" icon="add" fab />');
expect(substr_count($html, '<button'))->toBe(1) expect(substr_count($html, '<button'))->toBe(1)
->and($html)->toContain('max-medium:fixed')->toContain('max-medium:bg-primary-container')->toContain('bg-primary text-on-primary'); ->and($html)->toContain('max-medium:fixed')->toContain('max-medium:bg-primary-container')->toContain('bg-primary text-on-primary')
// M3 puts a snackbar above a FAB, never over one: the host publishes its height.
->toContain('max-medium:bottom-[calc(var(--material-bottom-bar,0px)+var(--material-snackbar-height,0px)+1rem)]');
}); });
it('submits a form when asked', function () { it('submits a form when asked', function () {
+11
View File
@@ -10,6 +10,17 @@ it('hosts the snackbar queue, kept across wire:navigate', function () {
->toContain('justify-center'); ->toContain('justify-center');
}); });
it('draws no state icon, and reaches 48px from its 40px controls', function () {
$html = (string) $this->blade('<x-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')
->not->toContain('opacity-80')
->toContain('data-toast-action class="state-layer focus-ring touch-target')
->toContain('state-layer focus-ring touch-target inline-flex size-10');
});
it('can sit at the start', function () { it('can sit at the start', function () {
expect((string) $this->blade('<x-toast position="bottom-start" />'))->toContain('justify-start'); expect((string) $this->blade('<x-toast position="bottom-start" />'))->toContain('justify-start');
}); });