From ce8ac4d31681930dcd18cc43b36bf59f7480eae2 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 16:42:05 +0200 Subject: [PATCH] Draw the snackbar host without Tailwind Plan step 36 (actions). 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 1 + resources/css/components/toast.css | 183 ++++++++++++++++++ resources/js/navigation.js | 2 +- resources/js/snackbar.js | 4 +- resources/views/components/toast.blade.php | 63 +++--- tests/Browser/CommunicationTest.php | 63 +++++- .../Components/ActionStylesheetsTest.php | 1 + .../Feature/Components/NavigationBarTest.php | 2 +- tests/Feature/Components/ToastTest.php | 63 ++++-- 9 files changed, 329 insertions(+), 53 deletions(-) create mode 100644 resources/css/components/toast.css diff --git a/resources/css/components.css b/resources/css/components.css index 6b451f99..381e7708 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -27,6 +27,7 @@ @import './components/fab-menu-item.css'; @import './components/fab-menu.css'; @import './components/rich-tooltip.css'; +@import './components/toast.css'; /* Inputs, selection and data */ @import './components/form.css'; diff --git a/resources/css/components/toast.css b/resources/css/components/toast.css new file mode 100644 index 00000000..48033952 --- /dev/null +++ b/resources/css/components/toast.css @@ -0,0 +1,183 @@ +/* + * : 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); + } +} diff --git a/resources/js/navigation.js b/resources/js/navigation.js index f6076578..6b35e6db 100644 --- a/resources/js/navigation.js +++ b/resources/js/navigation.js @@ -283,4 +283,4 @@ document.addEventListener('alpine:init', () => { * snackbar (``'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. */ -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) diff --git a/resources/js/snackbar.js b/resources/js/snackbar.js index 2fff8626..6f461808 100644 --- a/resources/js/snackbar.js +++ b/resources/js/snackbar.js @@ -60,7 +60,7 @@ document.addEventListener('alpine:init', () => { // 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]') + const action = this.$el.querySelector('[data-md-toast-action]') if (action) { event.preventDefault() @@ -142,7 +142,7 @@ document.addEventListener('alpine:init', () => { */ measure() { this.$nextTick(() => { - const snackbar = this.$el.querySelector('[data-toast]') + const snackbar = this.$el.querySelector('[data-md-toast-snackbar]') const root = document.documentElement.style if (snackbar) { diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php index 3165db21..e1ea0dff 100644 --- a/resources/views/components/toast.blade.php +++ b/resources/views/components/toast.blade.php @@ -18,26 +18,27 @@ 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. + goes (ACT-02). 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. + Hooks for tests and styling: `data-md-toast` on the host, `data-md-toast-snackbar` on the + 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 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. + feels necessary", ACT-20), 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 reach M3's 48px target without growing the container (ACT-18). + 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 — 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. + and a keyboard has no other way to reach one (ACT-34). 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, @@ -45,14 +46,19 @@ 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. --}} + 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 (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']) +@php + $position = in_array($position, ['bottom', 'bottom-start'], true) ? $position : 'bottom'; +@endphp + @persist('material-toast')
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', - ]) }} + data-md-toast + data-md-position="{{ $position }}" + {{ $attributes }} >