From 44ae9ecea465dffb09a3270a802ff79a8472effc Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:45:06 +0200 Subject: [PATCH] Pin the two-line snackbar, and reach it with Alt+G MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 3 +- resources/js/snackbar.js | 32 +++++++++++++++---- resources/views/components/toast.blade.php | 18 ++++++++--- .../showcase/sections/communication.blade.php | 13 ++++++++ tests/Feature/Components/ToastTest.php | 12 +++++++ 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index da11b30c..3dbe379f 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -364,7 +364,8 @@ window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: ' window.addEventListener('app:update', () => location.reload()) ``` -- Escape dismisses a snackbar that holds the focus. +- `description` is M3's second line: the container goes from 48px to 68px (`SnackbarTokens.TwoLinesContainerHeight`), and below `medium` a two-line snackbar with an action wraps the action under the text. +- Escape dismisses a snackbar that holds the focus, and **Alt+G** moves the focus to a snackbar that carries an action from wherever the page had it — M3 asks the web for a documented shortcut of that kind, since a snackbar never takes the focus itself. Say so where your users read about keyboard shortcuts. - 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. ### `` diff --git a/resources/js/snackbar.js b/resources/js/snackbar.js index ffca2a70..2fff8626 100644 --- a/resources/js/snackbar.js +++ b/resources/js/snackbar.js @@ -10,6 +10,11 @@ * never holds the queue up: it is kept aside rather than queued, a toast that arrives while it shows * takes its place, and it comes back once the queue is empty. Only one is kept — a newer sticky * toast replaces it. Dismissing it, or pressing its action, lets it go. + * + * Two keys are watched on the whole document: Escape dismisses the snackbar while the focus is in + * it, and Alt+G moves the focus to a snackbar that carries an action from wherever the page had it + * — M3 asks the web for a documented shortcut of that kind, since a snackbar never takes the focus + * on its own and a keyboard would otherwise have no way to reach the action. */ const DEFAULT_TIMEOUT_MS = 4000 @@ -35,25 +40,40 @@ document.addEventListener('alpine:init', () => { timer: null, remaining: 0, startedAt: 0, - escape: null, + keys: null, init() { host = this 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) => { + this.keys = (event) => { + // 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. if (event.key === 'Escape' && this.current && this.$el.contains(document.activeElement)) { this.dismiss() + + return + } + + // M3 asks the web for a documented shortcut that moves the focus to a snackbar + // carrying an action, and suggests Alt+G: a snackbar never takes the focus by + // 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. + if (event.altKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyG') { + const action = this.$el.querySelector('[data-toast-action]') + + if (action) { + event.preventDefault() + action.focus() + } } } - document.addEventListener('keydown', this.escape) + document.addEventListener('keydown', this.keys) }, destroy() { - document.removeEventListener('keydown', this.escape) + document.removeEventListener('keydown', this.keys) document.documentElement.style.removeProperty('--material-snackbar-height') if (host === this) { diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php index 9190b770..3165db21 100644 --- a/resources/views/components/toast.blade.php +++ b/resources/views/components/toast.blade.php @@ -34,9 +34,18 @@ 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. `position`: `bottom` (centred, the default) or `bottom-start`. It lifts + 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 @@ -65,15 +74,16 @@ 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" + 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" > -
+