From f02fa1a78d6cda2eeb6249d96295941089dd1c8c Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 05:53:46 +0200 Subject: [PATCH 01/16] Give the bottom sheet's drag handle a 48dp target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step 12, containment.md C-01. The handle button was 32x4px: the padding that makes M3's 48dp hit target sat on the wrapper, which is not the control. `touch-target` on the button and 22px above and below it — SheetDefaults.kt's DragHandleVerticalPadding — make the pressed area 48x48 while the handle is still drawn 32x4. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/views/components/bottom-sheet.blade.php | 10 +++++++--- tests/Feature/Components/OverlayTest.php | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/resources/views/components/bottom-sheet.blade.php b/resources/views/components/bottom-sheet.blade.php index c0847572..428e8510 100644 --- a/resources/views/components/bottom-sheet.blade.php +++ b/resources/views/components/bottom-sheet.blade.php @@ -9,7 +9,11 @@ SheetBottomTokens (androidx Compose Material 3, Apache-2.0): surface-container-low, extra-large top corners, elevation 1, a 32×4px drag handle in on-surface-variant; 640px wide at most, centred on a wide screen; it rises on emphasized decelerate. `title` and `actions` as on a dialog. - `height` caps it (default: 90% of the screen); content scrolls inside. --}} + `height` caps it (default: 90% of the screen); content scrolls inside. + + The handle is drawn 32×4px and pressed 48×48: `touch-target` on the button and 22px above and + below it, which is M3's "drag handle has an accessible 48dp hit target" and SheetDefaults.kt's + `DragHandleVerticalPadding = 22.dp` (docs/reference/m3/components-actions-communication-containment.md § Bottom sheets → Specs). --}} @props([ 'title' => null, @@ -56,8 +60,8 @@ $attributes->get('class'), ]) }} > -
- +
+
diff --git a/tests/Feature/Components/OverlayTest.php b/tests/Feature/Components/OverlayTest.php index 6e20420b..325f477d 100644 --- a/tests/Feature/Components/OverlayTest.php +++ b/tests/Feature/Components/OverlayTest.php @@ -86,6 +86,8 @@ it('draws a modal bottom sheet with a drag handle, or a standard one without a s ->toContain('rounded-t-corner-xl bg-surface-container-low') ->toContain('data-drag-handle') ->toContain('aria-modal="true"') + ->toContain('py-5.5') + ->toContain('touch-target h-1 w-8') ->and((string) $this->blade('Body')) ->toContain('...materialBottomSheet(true)') ->not->toContain('bg-scrim/32') From 7f678569167bd54cf7cf45a4a0e72dec94c9fda5 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 05:54:25 +0200 Subject: [PATCH 02/16] Stop a disabled list item answering the keyboard Plan step 12, containment.md C-02. `pointer-events-none` blocked the pointer but not Tab and Enter: the item's `` stayed in the tab order and navigated, and nothing announced it disabled. A disabled item now renders its title as text, is no longer a `data-list-row`, and carries `aria-disabled="true"`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/views/components/list-item.blade.php | 7 +++++-- tests/Feature/Components/ListTest.php | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/resources/views/components/list-item.blade.php b/resources/views/components/list-item.blade.php index f9791dbe..c87a38ad 100644 --- a/resources/views/components/list-item.blade.php +++ b/resources/views/components/list-item.blade.php @@ -13,7 +13,9 @@ `link` makes the whole item the link. Otherwise, to make it open something while its trailing controls keep their own presses, give it `data-list-row` and put `data-list-open` on the one control that opens — see resources/js/list-rows.js. `selected` (true) is M3's selected item, - in secondary-container; `disabled` greys it. --}} + in secondary-container; `disabled` greys it to 38%, drops its link and announces it + `aria-disabled` — M3's states model treats disabled as not interactive, so a disabled item + answers neither the pointer nor the keyboard. --}} @props([ 'title' => null, @@ -32,7 +34,7 @@ ]) @php - $isLink = filled($link); + $isLink = filled($link) && ! $disabled; $lines = (filled($overline) ? 1 : 0) + (filled($description) ? 1 : 0); $initials = filled($avatar) && ! str_contains((string) $avatar, '/') && ! str_contains((string) $avatar, '.'); @endphp @@ -42,6 +44,7 @@ data-list-item @if ($isLink) data-list-row @endif @if ($selected) data-selected @endif + @if ($disabled) aria-disabled="true" @endif {{ $attributes->class([ 'relative flex items-center gap-3 px-4 text-on-surface', 'min-h-14 py-2' => $lines === 0, diff --git a/tests/Feature/Components/ListTest.php b/tests/Feature/Components/ListTest.php index 7da5a031..c01436ef 100644 --- a/tests/Feature/Components/ListTest.php +++ b/tests/Feature/Components/ListTest.php @@ -38,6 +38,15 @@ it('makes a linked item a row that opens from anywhere', function () { ->toContain('wire:navigate'); }); +it('leaves a disabled item out of the keyboard and announces it disabled', function () { + expect((string) $this->blade('')) + ->toContain('aria-disabled="true"') + ->toContain('pointer-events-none text-on-surface/38') + ->not->toContain('href="/settings"') + ->not->toContain('data-list-open') + ->not->toContain('data-list-row'); +}); + it('marks a selected item and takes controls in its slots', function () { expect((string) $this->blade('')) ->toContain('data-selected') From d64fb9130314f705b0031052318f9d52ca945123 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 05:57:00 +0200 Subject: [PATCH 03/16] Announce a selectable list as M3's list box of options Plan step 12, containment.md C-03. `selected` was colour alone on a `listitem`, which cannot carry a selected state at all. `` (or `selection="single|multi"`) now makes the container a `role="listbox"` and each item an `option` with `aria-selected`; a plain list keeps `role="list"` and marks a selected item `aria-current`. A selected option draws a trailing check as M3's second cue, which `icon-right` replaces. The item reads its list's mode with `@aware`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 2 ++ .../views/components/list-item.blade.php | 20 +++++++++++-- resources/views/components/list.blade.php | 28 ++++++++++++++++--- .../showcase/sections/containment.blade.php | 8 ++++++ tests/Feature/Components/ListTest.php | 22 +++++++++++++++ 5 files changed, 74 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..ba47d3bb 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -433,6 +433,8 @@ A card or list item that opens something is a **row**: `data-list-row` on it and ``: `label`, `dividers`, `segmented` (M3 Expressive: separate tiles 2px apart). ``: `title` (or slot), `overline`, `description`, leading `icon` / `avatar` (image URL or initials) / `image` / `leading` slot, trailing `trailing` text / `icon-right` / `end` slot, `link` (the whole item becomes a row that opens it), `selected`, `disabled`. One-, two- and three-line heights follow from the content. +A list a person chooses from is `` (or `selection="single"` / `selection="multi"`): M3 maps those to a **list box** of **options**, so the container becomes `role="listbox"` (`aria-multiselectable` when multi) and each item an `option` announcing `aria-selected`. A selected option also draws a trailing check — M3 never allows colour as the only cue — which `icon-right` or a leading checkbox replaces. A plain list stays `role="list"`, where `selected` is `aria-current`. `disabled` renders no link and announces `aria-disabled`. + ```blade @foreach ($files as $file) diff --git a/resources/views/components/list-item.blade.php b/resources/views/components/list-item.blade.php index c87a38ad..04c720f4 100644 --- a/resources/views/components/list-item.blade.php +++ b/resources/views/components/list-item.blade.php @@ -15,7 +15,12 @@ control that opens — see resources/js/list-rows.js. `selected` (true) is M3's selected item, in secondary-container; `disabled` greys it to 38%, drops its link and announces it `aria-disabled` — M3's states model treats disabled as not interactive, so a disabled item - answers neither the pointer nor the keyboard. --}} + answers neither the pointer nor the keyboard. + + It takes its role from the `` around it: a `listitem` in a plain list, where + `selected` is `aria-current`, and an `option` announcing `aria-selected` in a `selectable` + one. M3 asks for two cues on a selected item, never colour alone, so a selected option also + draws a trailing check unless `icon-right` says otherwise. --}} @props([ 'title' => null, @@ -33,14 +38,23 @@ 'disabled' => false, ]) +{{-- Whether the `` around it is one a person chooses from, and how many it takes. --}} +@aware([ + 'selectable' => false, + 'selection' => null, +]) + @php $isLink = filled($link) && ! $disabled; $lines = (filled($overline) ? 1 : 0) + (filled($description) ? 1 : 0); $initials = filled($avatar) && ! str_contains((string) $avatar, '/') && ! str_contains((string) $avatar, '.'); + $option = $selectable || $selection !== null; + $check = $option && $selected && blank($iconRight); @endphp
+ @elseif ($check) + @endif
diff --git a/resources/views/components/list.blade.php b/resources/views/components/list.blade.php index 527e0db4..582eaeaa 100644 --- a/resources/views/components/list.blade.php +++ b/resources/views/components/list.blade.php @@ -5,18 +5,38 @@ apart, with small corners that open to large at the ends and while hovered, pressed or selected (ListTokens, androidx Compose Material 3, Apache-2.0). - It is a `role="list"`; for keyboard walking between rows, `j`/`k` or arrows, the application - can use `data-list` (resources/js/list-rows.js marks rows; a list keyboard arrives with the - list-detail pane). `label` names the list. --}} + A plain list is a `role="list"` of `listitem`s. `selectable` (or `selection="single"` / + `selection="multi"`) makes it the list a person chooses from: M3 maps single- and + multi-select lists on the web to a **list box** of **options** with a selected state + (docs/reference/m3/components-actions-communication-containment.md § Lists → Accessibility), + so the container becomes `role="listbox"` (`aria-multiselectable` when multi) and each item + an `option` that announces `aria-selected`. A selected option also draws a trailing check, so + selection is never colour alone; give the items a leading checkbox or radio instead and pass + `icon-right` to keep the check off. + + For keyboard walking between rows, `j`/`k` or arrows, the application can use `data-list` + (resources/js/list-rows.js marks rows; a list keyboard arrives with the list-detail pane). + `label` names the list. --}} @props([ 'segmented' => false, 'dividers' => false, 'label' => null, + 'selectable' => false, + 'selection' => null, ]) +@php + $selection = match (true) { + in_array($selection, ['single', 'multi'], true) => $selection, + $selectable || $selection !== null => 'single', + default => null, + }; +@endphp +
class([ diff --git a/resources/views/showcase/sections/containment.blade.php b/resources/views/showcase/sections/containment.blade.php index 169fe1bb..45080614 100644 --- a/resources/views/showcase/sections/containment.blade.php +++ b/resources/views/showcase/sections/containment.blade.php @@ -40,9 +40,17 @@ +
BLADE, + 'A list to choose from' => <<<'BLADE' + + + + + + BLADE, 'Dividers and collapse' => <<<'BLADE'
diff --git a/tests/Feature/Components/ListTest.php b/tests/Feature/Components/ListTest.php index c01436ef..09b87260 100644 --- a/tests/Feature/Components/ListTest.php +++ b/tests/Feature/Components/ListTest.php @@ -38,6 +38,28 @@ it('makes a linked item a row that opens from anywhere', function () { ->toContain('wire:navigate'); }); +it('is a list box of options when it is one a person chooses from', function () { + expect((string) $this->blade('')) + ->toContain('role="listbox"') + ->toMatch('/role="option"\s+aria-selected="true"/') + ->toMatch('/role="option"\s+aria-selected="false"/') + ->not->toContain('aria-multiselectable') + ->and((string) $this->blade('')) + ->toContain('role="listbox"') + ->toContain('aria-multiselectable="true"') + ->and((string) $this->blade('')) + ->toContain('role="list"') + ->toMatch('/role="listitem"\s+aria-current="true"/') + ->not->toContain('aria-selected'); +}); + +it('draws a second cue on a selected option, which icon-right replaces', function () { + expect(substr_count((string) $this->blade(''), 'toBe(1) + ->and(substr_count((string) $this->blade(''), 'toBe(1) + ->and(substr_count((string) $this->blade(''), 'toBe(0) + ->and(substr_count((string) $this->blade(''), 'toBe(0); +}); + it('leaves a disabled item out of the keyboard and announces it disabled', function () { expect((string) $this->blade('')) ->toContain('aria-disabled="true"') From 335ea4f4f1d0b5706ef22f16199f13be1d9a1b86 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 05:57:55 +0200 Subject: [PATCH 04/16] Give every side sheet the close affordance M3 requires Plan step 12, containment.md C-04. `with-close-button` defaulted to false, so a drawer could render with no exit at all once `close-on-escape` and the scrim were off. It now defaults to true, and `:with-close-button="false"` is ignored where nothing else closes the sheet: Escape off, the scrim off, or a pane, which has neither. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 2 +- resources/views/components/drawer.blade.php | 17 ++++++++++++++--- tests/Feature/Components/OverlayTest.php | 9 +++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index ba47d3bb..7cd9c5d9 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -479,7 +479,7 @@ Props: `title`, `subtitle`, `icon` (centred hero icon), `separator`, `persistent ### `` -An M3 side sheet, bound like ``; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`, `22.5rem` — M3's 360dp fixed pane) turns it into a second pane from `expanded`, where M3 shows two panes: render it after the list inside `
`. Escape leaves a pane open unless `pane-close-on-escape`. Its body is a size container — lay out inside with `@md:` (a *container* query), never a window class. +An M3 side sheet, bound like ``; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button` (**default true** — M3 requires a close affordance; `:with-close-button="false"` is ignored when Escape or the scrim is off, and on a pane), `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`, `22.5rem` — M3's 360dp fixed pane) turns it into a second pane from `expanded`, where M3 shows two panes: render it after the list inside `
`. Escape leaves a pane open unless `pane-close-on-escape`. Its body is a size container — lay out inside with `@md:` (a *container* query), never a window class. ### `` diff --git a/resources/views/components/drawer.blade.php b/resources/views/components/drawer.blade.php index e7847ba4..33d67b2b 100644 --- a/resources/views/components/drawer.blade.php +++ b/resources/views/components/drawer.blade.php @@ -25,6 +25,13 @@ a size container, so its contents lay out by the room the sheet or pane actually has (`@md:`), never by the viewport. + M3 **requires** a close affordance on a side sheet — without one nobody can predict the + sheet's open/close flow or tell whether it is transient or permanent + (docs/reference/m3/components-actions-communication-containment.md § Side sheets → + Accessibility) — so `with-close-button` is on by default, and `:with-close-button="false"` is + ignored where nothing else closes the sheet: Escape off, the scrim off, or a pane, which has + neither. + maryUI's API, kept: `title`, `subtitle`, `separator`, `with-close-button`, `close-on-escape`, `without-backdrop-close`, `right` (ignored; use `side`), and an `actions` slot. --}} @@ -34,7 +41,7 @@ 'separator' => false, 'side' => 'end', 'right' => true, - 'withCloseButton' => false, + 'withCloseButton' => true, 'closeOnEscape' => true, 'withoutBackdropClose' => false, 'width' => '25rem', @@ -47,6 +54,10 @@ $model = $attributes->wire('model')->value() ?: null; $id = $attributes->get('id') ?? 'material-sheet-'.substr(md5($model.'|'.$title), 0, 10); $start = $side === 'start'; + + // M3 requires a close affordance; the prop can only ever add one, never take away the last + // way out of the sheet. + $closeButton = $withCloseButton || ! $closeOnEscape || $withoutBackdropClose || ($pane && ! $paneCloseOnEscape); @endphp
get('class'), ]) }} > - @if (filled($title) || $withCloseButton) + @if (filled($title) || $closeButton)
@@ -118,7 +129,7 @@ @endif
- @if ($withCloseButton) + @if ($closeButton) diff --git a/tests/Feature/Components/OverlayTest.php b/tests/Feature/Components/OverlayTest.php index 325f477d..932bbe1c 100644 --- a/tests/Feature/Components/OverlayTest.php +++ b/tests/Feature/Components/OverlayTest.php @@ -64,6 +64,15 @@ it('leaves a pane open on Escape unless it is asked to close then too', function ->not->toContain('keydown.window.escape'); }); +it('always offers a way out of a side sheet, as M3 requires', function () { + expect((string) $this->blade('Body'))->toContain('aria-label="Close"') + ->and((string) $this->blade('Body'))->not->toContain('aria-label="Close"') + ->and((string) $this->blade('Body'))->toContain('aria-label="Close"') + ->and((string) $this->blade('Body'))->toContain('aria-label="Close"') + ->and((string) $this->blade('Body'))->toContain('aria-label="Close"') + ->and((string) $this->blade('Body'))->not->toContain('aria-label="Close"'); +}); + it('slides a side sheet in from either edge, and is a pane from expanded when asked', function () { expect((string) $this->blade('Body')) ->toContain('x-trap.inert.noscroll="open && ! wide"') From f895b553b65891f4d7fbad0d76f8b7ceac1edb8a Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 05:58:50 +0200 Subject: [PATCH 05/16] Leave carousel items unmasked under reduced motion Plan step 12, containment.md C-05. Only the parallax half of M3's rule was honoured: the mask was still written every frame, so items kept growing and shrinking between the keylines. Under reduced motion the inset, the shift and the label fade are now all zero, so every item stays at the strategy's large size and the keylines only decide where the row snaps. `--material-carousel-pin` went with it: with no mask there is nothing to pin the content to. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/js/carousel.js | 26 +++++++++++-------- .../views/components/carousel-item.blade.php | 2 +- resources/views/components/carousel.blade.php | 6 +++-- tests/Browser/CarouselTest.php | 7 +++-- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/resources/js/carousel.js b/resources/js/carousel.js index f02e0fb2..734fdf37 100644 --- a/resources/js/carousel.js +++ b/resources/js/carousel.js @@ -23,9 +23,10 @@ * records. A ResizeObserver does the same for a new width. RTL is read when measuring: * scroll offsets are negative there and shifts mirror, as Compose's `translationX` does. * - * Under reduced motion the buttons and keys scroll instantly, and the content stays pinned to - * the mask's leading edge instead of sliding inside it: the frame still opens and closes with - * the scroll the user makes, but nothing moves on its own. + * Under reduced motion the buttons and keys scroll instantly and nothing is masked at all: M3 + * says the parallax goes and items "should no longer expand as they come into view — all items + * are the same size", so every item stays at the strategy's large size and the keylines only + * decide where the row snaps. * * --------------------------------------------------------------------------------------- * Keyline maths ported from androidx (https://github.com/androidx/androidx), commit @@ -872,7 +873,6 @@ document.addEventListener('alpine:init', () => { .map((element) => ({ element, surface: element.querySelector('[data-material-carousel-surface]'), - content: element.querySelector('[data-material-carousel-content]'), label: element.querySelector('[data-material-carousel-label]'), labelWidth: 0, written: '', @@ -963,7 +963,13 @@ document.addEventListener('alpine:init', () => { const scroll = this.scrollOffset() const keylines = keylinesForScrollOffset(strategy, scroll, state.maxScroll) const size = strategy.itemSize - const pinned = state.reducedMotion.matches + + // M3: "When reduced motion settings are turned on, the parallax effect should be + // removed and carousel items should no longer expand as they come into view. All + // items are the same size" (docs/reference/m3/styles.md § Motion → Accessibility + // requirements). The keylines still decide where the row snaps; nothing is masked, + // moved or faded, so every item stays at strategy.itemSize. + const still = state.reducedMotion.matches state.items.forEach((item, index) => { const center = index * (size + strategy.itemSpacing) + size / 2 - scroll @@ -978,11 +984,10 @@ document.addEventListener('alpine:init', () => { translation += (center - keyline.unadjustedOffset) / keyline.size } - const inset = clamp((size - keyline.size) / 2, 0, size / 2) - const shift = state.rtl ? -translation : translation - const pin = pinned ? (state.rtl ? -inset : inset) : 0 - const opacity = item.labelWidth > 0 ? clamp((item.labelWidth - size + keyline.size) / item.labelWidth, 0, 1) : 1 - const written = `${inset.toFixed(2)}|${shift.toFixed(2)}|${pin.toFixed(2)}|${opacity.toFixed(3)}` + const inset = still ? 0 : clamp((size - keyline.size) / 2, 0, size / 2) + const shift = still ? 0 : state.rtl ? -translation : translation + const opacity = still || item.labelWidth === 0 ? 1 : clamp((item.labelWidth - size + keyline.size) / item.labelWidth, 0, 1) + const written = `${inset.toFixed(2)}|${shift.toFixed(2)}|${opacity.toFixed(3)}` if (written === item.written) { return @@ -993,7 +998,6 @@ document.addEventListener('alpine:init', () => { item.written = written item.surface.style.setProperty('--material-carousel-inset', `${inset.toFixed(2)}px`) item.surface.style.setProperty('--material-carousel-shift', `${shift.toFixed(2)}px`) - item.surface.style.setProperty('--material-carousel-pin', `${pin.toFixed(2)}px`) item.surface.style.setProperty('--material-carousel-label-shift', `${(state.rtl ? -inset : inset).toFixed(2)}px`) item.surface.style.setProperty('--material-carousel-label', opacity.toFixed(3)) }) diff --git a/resources/views/components/carousel-item.blade.php b/resources/views/components/carousel-item.blade.php index 81b2f05d..3d5eb0b8 100644 --- a/resources/views/components/carousel-item.blade.php +++ b/resources/views/components/carousel-item.blade.php @@ -34,7 +34,7 @@ data-material-carousel-surface class="relative size-full overflow-hidden rounded-corner-xl bg-surface-container-highest text-on-surface translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]" > -