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:
Andreas Reinhold / reini
2026-09-14 05:50:15 +02:00
co-authored by Claude Fable 5.1
parent 0adfb6fff4
commit 31663c48ba
4 changed files with 120 additions and 27 deletions
+46 -1
View File
@@ -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() {