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. `<x-list
selectable>` (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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 05:57:00 +02:00
co-authored by Claude Opus 5
parent 7f67856916
commit d64fb91303
5 changed files with 74 additions and 6 deletions
@@ -433,6 +433,8 @@ A card or list item that opens something is a **row**: `data-list-row` on it and
`<x-list>`: `label`, `dividers`, `segmented` (M3 Expressive: separate tiles 2px apart). `<x-list-item>`: `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 `<x-list selectable>` (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
<x-list segmented label="Files">
@foreach ($files as $file)
+18 -2
View File
@@ -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 `<x-list>` 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 `<x-list>` 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
<div
role="listitem"
role="{{ $option ? 'option' : 'listitem' }}"
@if ($option) aria-selected="{{ $selected ? 'true' : 'false' }}" @elseif ($selected) aria-current="true" @endif
data-list-item
@if ($isLink) data-list-row @endif
@if ($selected) data-selected @endif
@@ -98,5 +112,7 @@
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
@elseif ($check)
<x-livewire-material::icon name="check" class="size-6" />
@endif
</div>
+24 -4
View File
@@ -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
<div
role="list"
role="{{ $selection === null ? 'list' : 'listbox' }}"
@if ($selection === 'multi') aria-multiselectable="true" @endif
data-list="{{ $segmented ? 'segmented' : 'plain' }}"
@if ($label) aria-label="{{ $label }}" @endif
{{ $attributes->class([
@@ -40,9 +40,17 @@
<x-list-item title="Chiara Rossi" avatar="CR">
<x-slot:end><x-button icon="more_vert" tooltip="More" /></x-slot:end>
</x-list-item>
<x-list-item title="Dan Fischer" description="Invitation withdrawn" avatar="DF" link="#containment" disabled />
</x-list>
</div>
BLADE,
'A list to choose from' => <<<'BLADE'
<x-list selectable label="Expiry" class="w-full max-w-sm">
<x-list-item title="One hour" :selected="false" />
<x-list-item title="Three days" :selected="true" />
<x-list-item title="Thirty days" :selected="false" />
</x-list>
BLADE,
'Dividers and collapse' => <<<'BLADE'
<div class="w-full space-y-4">
<x-collapse title="How long do links last?" icon="schedule" open>