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. `<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 ```blade
<x-list segmented label="Files"> <x-list segmented label="Files">
@foreach ($files as $file) @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, 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 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 `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([ @props([
'title' => null, 'title' => null,
@@ -33,14 +38,23 @@
'disabled' => false, '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 @php
$isLink = filled($link) && ! $disabled; $isLink = filled($link) && ! $disabled;
$lines = (filled($overline) ? 1 : 0) + (filled($description) ? 1 : 0); $lines = (filled($overline) ? 1 : 0) + (filled($description) ? 1 : 0);
$initials = filled($avatar) && ! str_contains((string) $avatar, '/') && ! str_contains((string) $avatar, '.'); $initials = filled($avatar) && ! str_contains((string) $avatar, '/') && ! str_contains((string) $avatar, '.');
$option = $selectable || $selection !== null;
$check = $option && $selected && blank($iconRight);
@endphp @endphp
<div <div
role="listitem" role="{{ $option ? 'option' : 'listitem' }}"
@if ($option) aria-selected="{{ $selected ? 'true' : 'false' }}" @elseif ($selected) aria-current="true" @endif
data-list-item data-list-item
@if ($isLink) data-list-row @endif @if ($isLink) data-list-row @endif
@if ($selected) data-selected @endif @if ($selected) data-selected @endif
@@ -98,5 +112,7 @@
@if ($iconRight) @if ($iconRight)
<x-livewire-material::icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" /> <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 @endif
</div> </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 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). 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 A plain list is a `role="list"` of `listitem`s. `selectable` (or `selection="single"` /
can use `data-list` (resources/js/list-rows.js marks rows; a list keyboard arrives with the `selection="multi"`) makes it the list a person chooses from: M3 maps single- and
list-detail pane). `label` names the list. --}} 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([ @props([
'segmented' => false, 'segmented' => false,
'dividers' => false, 'dividers' => false,
'label' => null, '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 <div
role="list" role="{{ $selection === null ? 'list' : 'listbox' }}"
@if ($selection === 'multi') aria-multiselectable="true" @endif
data-list="{{ $segmented ? 'segmented' : 'plain' }}" data-list="{{ $segmented ? 'segmented' : 'plain' }}"
@if ($label) aria-label="{{ $label }}" @endif @if ($label) aria-label="{{ $label }}" @endif
{{ $attributes->class([ {{ $attributes->class([
@@ -40,9 +40,17 @@
<x-list-item title="Chiara Rossi" avatar="CR"> <x-list-item title="Chiara Rossi" avatar="CR">
<x-slot:end><x-button icon="more_vert" tooltip="More" /></x-slot:end> <x-slot:end><x-button icon="more_vert" tooltip="More" /></x-slot:end>
</x-list-item> </x-list-item>
<x-list-item title="Dan Fischer" description="Invitation withdrawn" avatar="DF" link="#containment" disabled />
</x-list> </x-list>
</div> </div>
BLADE, 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' 'Dividers and collapse' => <<<'BLADE'
<div class="w-full space-y-4"> <div class="w-full space-y-4">
<x-collapse title="How long do links last?" icon="schedule" open> <x-collapse title="How long do links last?" icon="schedule" open>
+22
View File
@@ -38,6 +38,28 @@ it('makes a linked item a row that opens from anywhere', function () {
->toContain('wire:navigate'); ->toContain('wire:navigate');
}); });
it('is a list box of options when it is one a person chooses from', function () {
expect((string) $this->blade('<x-list selectable label="Recipients"><x-list-item title="Anna" :selected="true" /><x-list-item title="Ben" /></x-list>'))
->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('<x-list selection="multi"><x-list-item title="Anna" /></x-list>'))
->toContain('role="listbox"')
->toContain('aria-multiselectable="true"')
->and((string) $this->blade('<x-list><x-list-item title="Anna" :selected="true" /></x-list>'))
->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('<x-list selectable><x-list-item title="Anna" :selected="true" /></x-list>'), '<svg'))->toBe(1)
->and(substr_count((string) $this->blade('<x-list selectable><x-list-item title="Anna" :selected="true" icon-right="chevron_right" /></x-list>'), '<svg'))->toBe(1)
->and(substr_count((string) $this->blade('<x-list selectable><x-list-item title="Anna" /></x-list>'), '<svg'))->toBe(0)
->and(substr_count((string) $this->blade('<x-list><x-list-item title="Anna" :selected="true" /></x-list>'), '<svg'))->toBe(0);
});
it('leaves a disabled item out of the keyboard and announces it disabled', function () { it('leaves a disabled item out of the keyboard and announces it disabled', function () {
expect((string) $this->blade('<x-list-item title="Settings" link="/settings" icon="settings" disabled />')) expect((string) $this->blade('<x-list-item title="Settings" link="/settings" icon="settings" disabled />'))
->toContain('aria-disabled="true"') ->toContain('aria-disabled="true"')