From d64fb9130314f705b0031052318f9d52ca945123 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 05:57:00 +0200 Subject: [PATCH] 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"')