From 31663c48ba0a096a9a0359ec9749993fca0e6a5e Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 05:50:15 +0200 Subject: [PATCH] Say how many search results there are, in a combobox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components/search.css | 12 ++++- resources/js/search.js | 47 ++++++++++++++++- resources/views/components/search.blade.php | 57 ++++++++++++++------- tests/Feature/Components/SearchTest.php | 31 ++++++++--- 4 files changed, 120 insertions(+), 27 deletions(-) diff --git a/resources/css/components/search.css b/resources/css/components/search.css index 167c36f3..04e31a2b 100644 --- a/resources/css/components/search.css +++ b/resources/css/components/search.css @@ -11,9 +11,11 @@ * * [data-search] the root; data-open, data-full-screen * [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-results] + * [data-search-status] the polite live region that counts the results */ @layer components { @@ -86,6 +88,14 @@ 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] { flex: 1 1 0%; min-width: 0; diff --git a/resources/js/search.js b/resources/js/search.js index 56fe0603..97267e40 100644 --- a/resources/js/search.js +++ b/resources/js/search.js @@ -7,6 +7,11 @@ * 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 * 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' @@ -19,17 +24,57 @@ const CHOOSES = 'a[href], button:not([disabled]), [data-list-open]' // coming to search. 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', () => { - window.Alpine.data('materialSearch', (docked = false) => ({ + window.Alpine.data('materialSearch', (docked = false, announce = {}) => ({ open: false, compact: false, closedAt: -Infinity, + announcement: '', + observer: null, + settle: null, init() { const query = upTo('medium') this.compact = query.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() { diff --git a/resources/views/components/search.blade.php b/resources/views/components/search.blade.php index ea7f902d..fc7ca864 100644 --- a/resources/views/components/search.blade.php +++ b/resources/views/components/search.blade.php @@ -5,11 +5,16 @@ `empty` is shown instead when the slot renders nothing (say, "No shares match"). Results are usually ``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"), `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 - ``. --}} + ``. + + 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([ 'placeholder' => null, @@ -22,10 +27,16 @@ $model = $attributes->wire('model')->value() ?: null; $placeholder ??= __('Search'); $id = $attributes->get('id') ?? 'material-search-'.substr(md5($model.'|'.$placeholder), 0, 10); + + $announce = [ + 'none' => __('No results'), + 'one' => __('1 result'), + 'many' => __(':count results'), + ]; @endphp
- except(['class', 'wire:key', 'id', 'placeholder', 'type']) }} - x-ref="input" - id="{{ $id }}" - type="search" - autocomplete="off" - enterkeyhint="search" - placeholder="{{ $placeholder }}" - aria-label="{{ $label ?? $placeholder }}" +
+ + diff --git a/tests/Feature/Components/SearchTest.php b/tests/Feature/Components/SearchTest.php index b46d4eab..db5aad10 100644 --- a/tests/Feature/Components/SearchTest.php +++ b/tests/Feature/Components/SearchTest.php @@ -1,6 +1,6 @@ blade(''); expect($html) @@ -9,22 +9,41 @@ it('draws a search bar whose input controls the view', function () { ->toContain('type="search"') ->toContain('placeholder="Search shares"') ->toContain('aria-label="Search shares"') + ->toContain('role="combobox"') + ->toContain('aria-haspopup="dialog"') ->toContain('aria-controls="find-view"') ->toContain('aria-expanded="false"') ->toContain('wire:model.live.debounce.300ms="query"') ->toContain('id="find-view"') ->toContain('data-search-clear') ->toContain('aria-label="Back"') - ->toContain('materialSearch(false)') + ->toContain('materialSearch(false,') ->not->toContain('data-search-results'); + + // The state belongs to the combobox, never to the bare textbox ARIA gives it to. + expect($html)->toMatch('/]*aria-expanded/s'); + expect($html)->not->toMatch('/]*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(''); + + 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('holiday.zip')) - ->toContain('data-search-results') + ->toContain('data-search-results role="list"') ->toContain('holiday.zip') ->and((string) $this->blade('No shares match.')) - ->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 () { @@ -35,5 +54,5 @@ it('names the input, trails the bar, and stays docked on request', function () { ->toContain('aria-label="Search your shares"') ->toContain('data-search-trailing') ->toContain('') - ->toContain('materialSearch(true)'); + ->toContain('materialSearch(true,'); });