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:
co-authored by
Claude Opus 5
parent
7f67856916
commit
d64fb91303
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"')
|
||||||
|
|||||||
Reference in New Issue
Block a user