From 91f49be6d7f4771d24602f217ac6bccfb05386c5 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold Date: Mon, 14 Sep 2026 05:52:26 +0200 Subject: [PATCH 01/18] Draw a button's spinner in the button's own ink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loading indicator inside a filled or tonal button was painted in text-primary on a primary container — 1:1 contrast. M3 asks an indicator embedded in another component to take that component's label colour, so the button now passes text-current, which loading.blade.php reads as a caller colour and leaves alone. Plan step 11, actions.md ACT-01. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/views/components/button.blade.php | 6 ++++-- tests/Feature/Components/ButtonTest.php | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/resources/views/components/button.blade.php b/resources/views/components/button.blade.php index dd8d75c5..0a35e9c0 100644 --- a/resources/views/components/button.blade.php +++ b/resources/views/components/button.blade.php @@ -24,7 +24,9 @@ Behaviour kept from maryUI: `link` renders an anchor with `wire:navigate` (unless `external` or `no-wire-navigate`); `disabled` works on a link too, as `aria-disabled`; `spinner` shows the loading indicator while the button's own `wire:click` runs (or the action named by a - string); `responsive` hides the label below `expanded` (840px, where M3 lets buttons reposition); + string), drawn in the button's own ink — M3 asks an indicator embedded in another component + to take that component's label colour, so it stays visible on a filled or tonal container; + `responsive` hides the label below `expanded` (840px, where M3 lets buttons reposition); `tooltip`, `tooltip-left`, `tooltip-right` 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 @@ -206,7 +208,7 @@ <{{ $tag }} {{ $attributes }}> @if ($spinnerTarget) - + @endif diff --git a/tests/Feature/Components/ButtonTest.php b/tests/Feature/Components/ButtonTest.php index 4de5bb2b..9a7543d5 100644 --- a/tests/Feature/Components/ButtonTest.php +++ b/tests/Feature/Components/ButtonTest.php @@ -120,10 +120,12 @@ it('disables a button, and a link as far as a link can be', function () { ->assertSee('tabindex="-1"', false); }); -it('shows the loading indicator while its own action runs', function () { +it('shows the loading indicator while its own action runs, in the button\'s own ink', function () { $this->blade('') ->assertSee('wire:loading.attr="disabled"', false) - ->assertSee('wire:target="save"', false); + ->assertSee('wire:target="save"', false) + ->assertSee('size-5 text-current', false) + ->assertDontSee('text-primary"', false); $this->blade('') ->assertSee('wire:target="upload"', false); From 04b655fa9bf98605f37f1b0ce08dd3d2e5df3495 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold Date: Mon, 14 Sep 2026 05:53:51 +0200 Subject: [PATCH 02/18] Keep the snackbar's live region in the page The region carrying aria-live was created together with its message, inside the x-if that draws the snackbar, so a screen reader had nothing to notice changing. It moves to the permanent host, which is polite and atomic as M3 asks; a type now only picks the region's role, and the explicit aria-live keeps an error polite too. Plan step 11, actions.md ACT-02. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/views/components/toast.blade.php | 12 ++++++++++-- tests/Feature/Components/ToastTest.php | 12 +++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php index f82ff6d9..fb09d3c7 100644 --- a/resources/views/components/toast.blade.php +++ b/resources/views/components/toast.blade.php @@ -15,6 +15,12 @@ the queue: a toast that arrives meanwhile shows in its place, and the sticky one comes back once the queue is empty. One is kept at a time; a newer sticky toast replaces it. + 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. + Hooks for tests and styling: `data-toast` on the snackbar on screen, `data-toast-action` on its action button. @@ -33,6 +39,10 @@ @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', @@ -43,8 +53,6 @@
blade(''); expect($html) - ->toMatch('/]*\bdata-toast\b[^>]*aria-live="polite"/') + ->toMatch('/]*\bdata-toast\b/') ->toMatch('/]*\bdata-toast-action\b[^>]*x-on:click="act\(\)"/'); }); + +it('keeps the live region in the page, polite, around the snackbar that comes and goes', function () { + $html = (string) $this->blade(''); + + expect($html) + ->toMatch('/]*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); +}); From 6e24ff2cf80b980ef502e3370c71600470ee87d4 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold Date: Mon, 14 Sep 2026 05:54:42 +0200 Subject: [PATCH 03/18] Never time out a snackbar that carries an action M3's accessibility page forbids it outright: an actioned snackbar has to wait for the person to read it and reach its action. An entry with an action and no timeout of its own is now untimed, the close button it already draws being the way out; a timeout written out still wins. Plan step 11, actions.md ACT-03. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../skills/livewire-material-development/SKILL.md | 2 +- resources/js/snackbar.js | 10 ++++++++-- resources/views/components/toast.blade.php | 7 ++++--- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 38ecc56c..696f0a57 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -326,7 +326,7 @@ The snackbar host. Once per layout, near the end of ``: `` (`po materialToast('Share deleted', { type: 'success', description: null, timeout: 4000, action: { label: 'Undo', handler: () => $wire.restore() } }) ``` -`type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer. +`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. - `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: diff --git a/resources/js/snackbar.js b/resources/js/snackbar.js index 8d3cf59f..87ee4515 100644 --- a/resources/js/snackbar.js +++ b/resources/js/snackbar.js @@ -3,7 +3,8 @@ * * One snackbar at a time, as M3 shows them. Each waits its turn, stays for its timeout (paused * while hovered or focused, so it is never pulled away from someone reading or reaching for its - * action) and is replaced by the next. + * action) and is replaced by the next. A snackbar carrying an action has no timeout at all — + * M3's accessibility page forbids one — unless the caller writes a timeout out. * * A `sticky` toast ("A new version is ready" with a Reload action) stays until it is answered, but * never holds the queue up: it is kept aside rather than queued, a toast that arrives while it shows @@ -52,12 +53,17 @@ document.addEventListener('alpine:init', () => { const toast = Array.isArray(detail) ? detail[0] : detail const sticky = toast.sticky === true + // M3 forbids a snackbar with an action from auto-dismissing: it has to wait for the + // person to read it and reach it. A timeout written out still wins, for a caller who + // means it; a missing one no longer falls back to the default. + const untimed = sticky || toast.timeout === 0 || toast.timeout === null || (toast.action != null && toast.timeout === undefined) + const entry = { id: ++sequence, type: toast.type ?? null, title: toast.title ?? '', description: toast.description ?? null, - timeout: sticky || toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS), + timeout: untimed ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS), action: toast.action ?? null, sticky, } diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php index fb09d3c7..1227beeb 100644 --- a/resources/views/components/toast.blade.php +++ b/resources/views/components/toast.blade.php @@ -7,9 +7,10 @@ dispatches from a Livewire component — and for `window.materialToast(title, options)` from JavaScript (`{ type, description, timeout, sticky, action: { label, handler, event } }`). Toasts queue and show in turn, each for its `timeout` (4s by default; M3 asks for 4–10s), - paused while the pointer or focus is on it. A toast with an action or no timeout gets a close - button. Pressing the action closes the snackbar, calls `handler` and dispatches `event` (a - name) on `window`; both may be given. + paused while the pointer or focus is on it. A toast with an `action` has no timeout at all, + as M3 requires — it waits to be read and acted on — unless the caller writes a `timeout` out. + A toast with an action or no timeout gets a close button. Pressing the action closes the + snackbar, calls `handler` and dispatches `event` (a name) on `window`; both may be given. `sticky: true` keeps a toast until it is dismissed or its action pressed, without holding up the queue: a toast that arrives meanwhile shows in its place, and the sticky one comes back From c1ca1573414071667a015d76ee1b474f02ca4d81 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold Date: Mon, 14 Sep 2026 05:55:38 +0200 Subject: [PATCH 04/18] Scroll a menu that is too long for the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popover was fit-content with overflow visible, so a long menu ran past the edge of the top layer, where the page's own scrolling cannot reach it. It now caps at 18rem — less on a short window — and scrolls, as M3's menu behaviour asks, and the keyboard brings the item it moves to into view. Plan step 11, actions.md ACT-04. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/js/menu.js | 12 +++++++++--- resources/views/components/menu.blade.php | 6 +++++- tests/Feature/Components/MenuTest.php | 6 ++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/resources/js/menu.js b/resources/js/menu.js index 1140c641..4e8df277 100644 --- a/resources/js/menu.js +++ b/resources/js/menu.js @@ -183,10 +183,16 @@ document.addEventListener('alpine:init', () => { return [...this.$refs.menu.querySelectorAll(ITEMS)].filter((item) => item.getAttribute('aria-disabled') !== 'true') }, + /** The menu scrolls when it is too long for the window, so the item taken has to be shown. */ focusItem(which) { const items = this.items() - ;(which === 'last' ? items.at(-1) : items[0])?.focus() + this.reach(which === 'last' ? items.at(-1) : items[0]) + }, + + reach(item) { + item?.focus() + item?.scrollIntoView({ block: 'nearest' }) }, navigate(event) { @@ -195,7 +201,7 @@ document.addEventListener('alpine:init', () => { const move = (index) => { event.preventDefault() - items[(index + items.length) % items.length]?.focus() + this.reach(items[(index + items.length) % items.length]) } switch (event.key) { @@ -226,7 +232,7 @@ document.addEventListener('alpine:init', () => { if (match) { event.preventDefault() - match.focus() + this.reach(match) } } }, diff --git a/resources/views/components/menu.blade.php b/resources/views/components/menu.blade.php index a7ed03c6..a228ce73 100644 --- a/resources/views/components/menu.blade.php +++ b/resources/views/components/menu.blade.php @@ -32,6 +32,10 @@ of the loop iteration around it, which would give every child component after the menu the same key. + A menu too long for the window scrolls, as M3 asks, rather than running off the edge of the + top layer where nothing can reach it: 18rem at most, and less on a short window. The arrow + keys, Home, End and typeahead bring the item they move to into view. + The container is Expressive's standard menu (surface-container-low, 16px corner, elevation 2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from androidx Compose Material 3 (Apache-2.0). --}} @@ -67,7 +71,7 @@ x-on:keydown="navigate($event)" x-on:click="activate($event)" @class([ - 'm-0 min-w-28 max-w-70 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]', + 'm-0 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]', 'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]', 'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0', 'bg-surface-container-low text-on-surface' => ! $vibrant, diff --git a/tests/Feature/Components/MenuTest.php b/tests/Feature/Components/MenuTest.php index cf58b0f8..a75ec3ea 100644 --- a/tests/Feature/Components/MenuTest.php +++ b/tests/Feature/Components/MenuTest.php @@ -23,6 +23,12 @@ it('opens a popover menu from its trigger', function () { ->toContain('[position-area:bottom_span-right]'); }); +it('scrolls a menu too long for the window instead of running off it', function () { + expect((string) $this->blade('')) + ->toContain('max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto') + ->not->toContain('overflow-visible'); +}); + it('opens at the position asked for, in the vibrant colours on request', function () { $html = (string) $this->blade(''); From e4de0673503be086afa57c01a46f4a13b4d941e6 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold Date: Mon, 14 Sep 2026 05:56:52 +0200 Subject: [PATCH 05/18] Give a connected segment M3's 48px target and width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An segment is a plain label, 32px or 40px tall, with nothing reaching past it, and nothing gave a connected its 48dp minimum width either — both numbers M3 names and tells you not to reduce. The segment now carries the shared `touch-target` utility and a 48px floor, and groups.css puts the floor on the buttons a caller nests in a connected group. drops its hand-rolled ::after for the same utility. Plan step 11, actions.md ACT-05. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components/groups.css | 12 ++++++++++++ resources/views/components/button.blade.php | 4 ++-- resources/views/components/group.blade.php | 10 ++++++++-- tests/Feature/Components/ButtonGroupTest.php | 10 ++++++++++ tests/Feature/Components/ButtonTest.php | 4 ++-- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/resources/css/components/groups.css b/resources/css/components/groups.css index ce05d2be..b556acd3 100644 --- a/resources/css/components/groups.css +++ b/resources/css/components/groups.css @@ -47,6 +47,18 @@ padding-inline-start: calc(var(--group-pad) - var(--group-grow)); } +/* + * A connected segment at the two smallest sizes is 32px or 40px tall, and an icon-only one is as + * narrow as it is tall. M3 names both numbers and tells you not to reduce them: "XS and S + * connected button groups have a 48dp target area and a 48dp minimum width". The target is the + * `touch-target` utility on the segment itself; the width is here, where a group can reach the + * ``s a caller put inside it. + */ +[data-button-group='connected'][data-size='xs'] > *, +[data-button-group='connected'][data-size='sm'] > * { + min-inline-size: 3rem; +} + /* * Connected segments and split halves take their inner corner from `--group-corner`, so pressing * or selecting changes one variable and the rounded outer corners stay put. diff --git a/resources/views/components/button.blade.php b/resources/views/components/button.blade.php index 0a35e9c0..c010c534 100644 --- a/resources/views/components/button.blade.php +++ b/resources/views/components/button.blade.php @@ -172,8 +172,8 @@ $outline => $variant === 'outlined', 'hover:shadow-elevation-1' => in_array($variant, ['filled', 'tonal'], true), 'shadow-elevation-1 hover:shadow-elevation-2' => $variant === 'elevated', - // Below 48px the touch target reaches past the button, as M3 requires. - 'after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12 after:min-w-12 after:-translate-x-1/2 after:-translate-y-1/2' => in_array($size, ['xs', 'sm'], true), + // Below 48px the touch target reaches past the button, as M3 requires (tokens/state.css). + 'touch-target' => in_array($size, ['xs', 'sm'], true), 'disabled:cursor-not-allowed disabled:shadow-none aria-disabled:pointer-events-none aria-disabled:shadow-none', '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, diff --git a/resources/views/components/group.blade.php b/resources/views/components/group.blade.php index d5a19826..1cff7b5a 100644 --- a/resources/views/components/group.blade.php +++ b/resources/views/components/group.blade.php @@ -10,7 +10,9 @@ bind as on any input, the arrow keys move the choice, and a screen reader announces a group. The chosen segment rounds fully and takes the selected colour; `variant` is `tonal` (the default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless - `inline`. An option with `'disabled' => true` greys its own segment. + `inline`. An option with `'disabled' => true` greys its own segment. At `xs` and `sm` a + segment carries `touch-target` and a 48px minimum width, which M3 asks for by name and tells + you never to reduce. ReStride's props, kept: `label`, `hint`, `hint-class`, `name` (needed with `x-model`, which names no property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`, @@ -43,6 +45,9 @@ $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm'; + // M3: an xs or sm connected segment keeps a 48px target and a 48px minimum width, whatever + // its 32px/40px container measures. From md the segment is wider than that on its own. + $small = in_array($size, ['xs', 'sm'], true); $segment = [ 'xs' => 'h-8 gap-2 px-3 type-label-lg', @@ -75,7 +80,8 @@