Say how many search results there are, in a combobox
M3's search accessibility page asks that results be announced when they appear and read as a list. The bar now wraps its input in a role="combobox" that carries aria-expanded and aria-controls — ARIA gives a bare textbox neither — the results container is a role="list", and a polite live region counts the results whenever the view's DOM settles. Plan step 13, finding IN-01. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
0adfb6fff4
commit
31663c48ba
@@ -11,9 +11,11 @@
|
|||||||
*
|
*
|
||||||
* [data-search] the root; data-open, data-full-screen
|
* [data-search] the root; data-open, data-full-screen
|
||||||
* [data-search-bar] the pill, above the view
|
* [data-search-bar] the pill, above the view
|
||||||
* [data-search-leading], [data-search-input], [data-search-clear], [data-search-trailing]
|
* [data-search-leading], [data-search-field] (the combobox around [data-search-input]),
|
||||||
|
* [data-search-clear], [data-search-trailing]
|
||||||
* [data-search-view] the container behind the bar
|
* [data-search-view] the container behind the bar
|
||||||
* [data-search-results]
|
* [data-search-results]
|
||||||
|
* [data-search-status] the polite live region that counts the results
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
@@ -86,6 +88,14 @@
|
|||||||
outline-offset: -3px;
|
outline-offset: -3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The combobox wrapper is only a role holder; it has to lay out as the input used to. */
|
||||||
|
[data-search-field] {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 0%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
[data-search-input] {
|
[data-search-input] {
|
||||||
flex: 1 1 0%;
|
flex: 1 1 0%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
+46
-1
@@ -7,6 +7,11 @@
|
|||||||
* compact window (below `medium`, 600px) the view is full screen and modal: focus stays in it and
|
* compact window (below `medium`, 600px) the view is full screen and modal: focus stays in it and
|
||||||
* the page behind does not scroll — M3 docks the view from medium upwards. The results themselves
|
* the page behind does not scroll — M3 docks the view from medium upwards. The results themselves
|
||||||
* are the caller's, rendered by Livewire into the view as the query changes.
|
* are the caller's, rendered by Livewire into the view as the query changes.
|
||||||
|
*
|
||||||
|
* Because the results arrive from the server, nothing in the page tells a screen reader they are
|
||||||
|
* there; M3 asks that it be told. A MutationObserver counts the list items whenever the view's DOM
|
||||||
|
* settles and writes "N results" into the polite live region the view renders, which is the one
|
||||||
|
* announcement M3's search accessibility page names.
|
||||||
*/
|
*/
|
||||||
import { upTo } from './breakpoints.js'
|
import { upTo } from './breakpoints.js'
|
||||||
|
|
||||||
@@ -19,17 +24,57 @@ const CHOOSES = 'a[href], button:not([disabled]), [data-list-open]'
|
|||||||
// coming to search.
|
// coming to search.
|
||||||
const RETURN_GUARD_MS = 250
|
const RETURN_GUARD_MS = 250
|
||||||
|
|
||||||
|
// A Livewire morph replaces the results in several mutations; wait for the batch to end before
|
||||||
|
// counting, so the live region speaks once.
|
||||||
|
const SETTLE_MS = 120
|
||||||
|
|
||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
window.Alpine.data('materialSearch', (docked = false) => ({
|
window.Alpine.data('materialSearch', (docked = false, announce = {}) => ({
|
||||||
open: false,
|
open: false,
|
||||||
compact: false,
|
compact: false,
|
||||||
closedAt: -Infinity,
|
closedAt: -Infinity,
|
||||||
|
announcement: '',
|
||||||
|
observer: null,
|
||||||
|
settle: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
const query = upTo('medium')
|
const query = upTo('medium')
|
||||||
|
|
||||||
this.compact = query.matches
|
this.compact = query.matches
|
||||||
query.addEventListener('change', (event) => (this.compact = event.matches))
|
query.addEventListener('change', (event) => (this.compact = event.matches))
|
||||||
|
|
||||||
|
this.observer = new MutationObserver(() => this.countLater())
|
||||||
|
this.observer.observe(this.$refs.view, { childList: true, subtree: true, characterData: true })
|
||||||
|
|
||||||
|
this.$watch('open', () => this.countLater())
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.observer?.disconnect()
|
||||||
|
clearTimeout(this.settle)
|
||||||
|
},
|
||||||
|
|
||||||
|
countLater() {
|
||||||
|
clearTimeout(this.settle)
|
||||||
|
this.settle = setTimeout(() => this.count(), SETTLE_MS)
|
||||||
|
},
|
||||||
|
|
||||||
|
count() {
|
||||||
|
if (! this.open) {
|
||||||
|
this.announcement = ''
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = this.$refs.view.querySelector('[data-search-results]')
|
||||||
|
const items = results ? results.querySelectorAll('[role="listitem"], li') : []
|
||||||
|
const total = items.length || (results ? this.results().length : 0)
|
||||||
|
|
||||||
|
this.announcement = total === 0
|
||||||
|
? (announce.none ?? '')
|
||||||
|
: total === 1
|
||||||
|
? (announce.one ?? '')
|
||||||
|
: (announce.many ?? '').replace(':count', total)
|
||||||
},
|
},
|
||||||
|
|
||||||
get fullScreen() {
|
get fullScreen() {
|
||||||
|
|||||||
@@ -5,11 +5,16 @@
|
|||||||
`empty` is shown instead when the slot renders nothing (say, "No shares match"). Results are
|
`empty` is shown instead when the slot renders nothing (say, "No shares match"). Results are
|
||||||
usually `<x-list-item>`s with a `link`, or buttons: choosing one closes the view.
|
usually `<x-list-item>`s with a `link`, or buttons: choosing one closes the view.
|
||||||
|
|
||||||
Docked under the bar from `sm`, full screen below it with a back arrow (resources/css/
|
Docked under the bar from `medium`, full screen below it with a back arrow (resources/css/
|
||||||
components/search.css); `docked` keeps it docked at every width. `placeholder` ("Search"),
|
components/search.css); `docked` keeps it docked at every width. `placeholder` ("Search"),
|
||||||
`label` (the input's name when it differs from the placeholder), leading `icon` (`search`), and
|
`label` (the input's name when it differs from the placeholder), leading `icon` (`search`), and
|
||||||
a `trailing` slot for an avatar or icon buttons in the bar. Every other attribute reaches the
|
a `trailing` slot for an avatar or icon buttons in the bar. Every other attribute reaches the
|
||||||
`<input type="search">`. --}}
|
`<input type="search">`.
|
||||||
|
|
||||||
|
The bar is a combobox: the wrapper around the input carries `role="combobox"` with
|
||||||
|
`aria-expanded` and `aria-controls`, because ARIA gives a bare textbox neither. The results are
|
||||||
|
a list, and a polite live region says how many of them there are whenever they change — M3 asks
|
||||||
|
for both (docs/reference/m3/components-navigation-selection-inputs.md § Search). --}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
'placeholder' => null,
|
'placeholder' => null,
|
||||||
@@ -22,10 +27,16 @@
|
|||||||
$model = $attributes->wire('model')->value() ?: null;
|
$model = $attributes->wire('model')->value() ?: null;
|
||||||
$placeholder ??= __('Search');
|
$placeholder ??= __('Search');
|
||||||
$id = $attributes->get('id') ?? 'material-search-'.substr(md5($model.'|'.$placeholder), 0, 10);
|
$id = $attributes->get('id') ?? 'material-search-'.substr(md5($model.'|'.$placeholder), 0, 10);
|
||||||
|
|
||||||
|
$announce = [
|
||||||
|
'none' => __('No results'),
|
||||||
|
'one' => __('1 result'),
|
||||||
|
'many' => __(':count results'),
|
||||||
|
];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div
|
<div
|
||||||
x-data="materialSearch({{ $docked ? 'true' : 'false' }})"
|
x-data="materialSearch({{ $docked ? 'true' : 'false' }}, @js($announce))"
|
||||||
x-on:keydown.escape="if (open) { $event.stopPropagation(); close(true); }"
|
x-on:keydown.escape="if (open) { $event.stopPropagation(); close(true); }"
|
||||||
x-on:focusout="leave($event)"
|
x-on:focusout="leave($event)"
|
||||||
x-on:pointerdown.outside="close()"
|
x-on:pointerdown.outside="close()"
|
||||||
@@ -44,24 +55,30 @@
|
|||||||
<x-livewire-material::icon name="arrow_back" />
|
<x-livewire-material::icon name="arrow_back" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<input
|
<span
|
||||||
{{ $attributes->except(['class', 'wire:key', 'id', 'placeholder', 'type']) }}
|
data-search-field
|
||||||
x-ref="input"
|
role="combobox"
|
||||||
id="{{ $id }}"
|
aria-haspopup="dialog"
|
||||||
type="search"
|
|
||||||
autocomplete="off"
|
|
||||||
enterkeyhint="search"
|
|
||||||
placeholder="{{ $placeholder }}"
|
|
||||||
aria-label="{{ $label ?? $placeholder }}"
|
|
||||||
aria-controls="{{ $id }}-view"
|
aria-controls="{{ $id }}-view"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
x-bind:aria-expanded="open.toString()"
|
x-bind:aria-expanded="open.toString()"
|
||||||
x-on:focus="focused()"
|
>
|
||||||
x-on:click="show()"
|
<input
|
||||||
x-on:input="show()"
|
{{ $attributes->except(['class', 'wire:key', 'id', 'placeholder', 'type']) }}
|
||||||
x-on:keydown.arrow-down.prevent="show(); $nextTick(() => step(1))"
|
x-ref="input"
|
||||||
data-search-input
|
id="{{ $id }}"
|
||||||
/>
|
type="search"
|
||||||
|
autocomplete="off"
|
||||||
|
enterkeyhint="search"
|
||||||
|
placeholder="{{ $placeholder }}"
|
||||||
|
aria-label="{{ $label ?? $placeholder }}"
|
||||||
|
x-on:focus="focused()"
|
||||||
|
x-on:click="show()"
|
||||||
|
x-on:input="show()"
|
||||||
|
x-on:keydown.arrow-down.prevent="show(); $nextTick(() => step(1))"
|
||||||
|
data-search-input
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
<button type="button" data-search-clear x-on:click="clear()" aria-label="{{ __('Clear') }}">
|
<button type="button" data-search-clear x-on:click="clear()" aria-label="{{ __('Clear') }}">
|
||||||
<x-livewire-material::icon name="close" />
|
<x-livewire-material::icon name="close" />
|
||||||
@@ -83,9 +100,11 @@
|
|||||||
x-on:click="choose($event)"
|
x-on:click="choose($event)"
|
||||||
>
|
>
|
||||||
@if ($slot->hasActualContent())
|
@if ($slot->hasActualContent())
|
||||||
<div data-search-results>{{ $slot }}</div>
|
<div data-search-results role="list">{{ $slot }}</div>
|
||||||
@elseif (isset($empty))
|
@elseif (isset($empty))
|
||||||
<div data-search-results><p class="px-4 py-3 type-body-md text-on-surface-variant">{{ $empty }}</p></div>
|
<div data-search-results><p class="px-4 py-3 type-body-md text-on-surface-variant">{{ $empty }}</p></div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<span data-search-status class="sr-only" aria-live="polite" aria-atomic="true" x-text="announcement"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
it('draws a search bar whose input controls the view', function () {
|
it('draws a search bar whose combobox controls the view', function () {
|
||||||
$html = (string) $this->blade('<x-search id="find" placeholder="Search shares" wire:model.live.debounce.300ms="query" />');
|
$html = (string) $this->blade('<x-search id="find" placeholder="Search shares" wire:model.live.debounce.300ms="query" />');
|
||||||
|
|
||||||
expect($html)
|
expect($html)
|
||||||
@@ -9,22 +9,41 @@ it('draws a search bar whose input controls the view', function () {
|
|||||||
->toContain('type="search"')
|
->toContain('type="search"')
|
||||||
->toContain('placeholder="Search shares"')
|
->toContain('placeholder="Search shares"')
|
||||||
->toContain('aria-label="Search shares"')
|
->toContain('aria-label="Search shares"')
|
||||||
|
->toContain('role="combobox"')
|
||||||
|
->toContain('aria-haspopup="dialog"')
|
||||||
->toContain('aria-controls="find-view"')
|
->toContain('aria-controls="find-view"')
|
||||||
->toContain('aria-expanded="false"')
|
->toContain('aria-expanded="false"')
|
||||||
->toContain('wire:model.live.debounce.300ms="query"')
|
->toContain('wire:model.live.debounce.300ms="query"')
|
||||||
->toContain('id="find-view"')
|
->toContain('id="find-view"')
|
||||||
->toContain('data-search-clear')
|
->toContain('data-search-clear')
|
||||||
->toContain('aria-label="Back"')
|
->toContain('aria-label="Back"')
|
||||||
->toContain('materialSearch(false)')
|
->toContain('materialSearch(false,')
|
||||||
->not->toContain('data-search-results');
|
->not->toContain('data-search-results');
|
||||||
|
|
||||||
|
// The state belongs to the combobox, never to the bare textbox ARIA gives it to.
|
||||||
|
expect($html)->toMatch('/<span\s+data-search-field[^>]*aria-expanded/s');
|
||||||
|
expect($html)->not->toMatch('/<input[^>]*aria-expanded/s');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the results, or what to say when there are none', function () {
|
it('announces how many results there are, politely', function () {
|
||||||
|
$html = (string) $this->blade('<x-search />');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('data-search-status')
|
||||||
|
->toContain('aria-live="polite"')
|
||||||
|
->toContain('aria-atomic="true"')
|
||||||
|
->toContain('No results')
|
||||||
|
->toContain('1 result')
|
||||||
|
->toContain(':count results');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the results as a list, or what to say when there are none', function () {
|
||||||
expect((string) $this->blade('<x-search><a href="/shares/1">holiday.zip</a></x-search>'))
|
expect((string) $this->blade('<x-search><a href="/shares/1">holiday.zip</a></x-search>'))
|
||||||
->toContain('data-search-results')
|
->toContain('data-search-results role="list"')
|
||||||
->toContain('<a href="/shares/1">holiday.zip</a>')
|
->toContain('<a href="/shares/1">holiday.zip</a>')
|
||||||
->and((string) $this->blade('<x-search><x-slot:empty>No shares match.</x-slot:empty></x-search>'))
|
->and((string) $this->blade('<x-search><x-slot:empty>No shares match.</x-slot:empty></x-search>'))
|
||||||
->toContain('No shares match.');
|
->toContain('No shares match.')
|
||||||
|
->not->toContain('data-search-results role="list"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('names the input, trails the bar, and stays docked on request', function () {
|
it('names the input, trails the bar, and stays docked on request', function () {
|
||||||
@@ -35,5 +54,5 @@ it('names the input, trails the bar, and stays docked on request', function () {
|
|||||||
->toContain('aria-label="Search your shares"')
|
->toContain('aria-label="Search your shares"')
|
||||||
->toContain('data-search-trailing')
|
->toContain('data-search-trailing')
|
||||||
->toContain('<button>AR</button>')
|
->toContain('<button>AR</button>')
|
||||||
->toContain('materialSearch(true)');
|
->toContain('materialSearch(true,');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user