Files
livewire-material/resources/js/search.js
T
Andreas Reinhold / reiniandClaude Fable 5.1 31663c48ba 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
2026-09-14 05:50:15 +02:00

145 lines
4.9 KiB
JavaScript

/**
* `materialSearch`: the behaviour of `<x-search>` — a search bar that opens into a search view.
*
* Focus or a press on the bar opens the view; Escape, the back arrow, a press outside, focus
* leaving the search, or choosing a result closes it. ArrowDown from the input moves into the
* results and the arrow keys walk them; ArrowUp past the first result returns to the input. On a
* 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'
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
const CHOOSES = 'a[href], button:not([disabled]), [data-list-open]'
// A close hands focus back to the input: Escape does, and so does the full-screen view's focus trap
// when it lets go, a moment later. Focus arriving this soon after a close is that, not someone
// 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, 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() {
return this.open && this.compact && !docked
},
show() {
this.open = true
},
focused() {
if (performance.now() - this.closedAt > RETURN_GUARD_MS) {
this.show()
}
},
close(refocus = false) {
this.open = false
this.closedAt = performance.now()
if (refocus) {
this.$refs.input.focus()
}
},
clear() {
const input = this.$refs.input
input.value = ''
input.dispatchEvent(new Event('input', { bubbles: true }))
input.focus()
},
results() {
return [...this.$refs.view.querySelectorAll(FOCUSABLE)].filter((element) => element.getClientRects().length > 0)
},
step(delta) {
const results = this.results()
const index = results.indexOf(document.activeElement)
if (results.length === 0) {
return
}
if (index === -1) {
results[delta > 0 ? 0 : results.length - 1].focus()
} else if (index + delta < 0) {
this.$refs.input.focus()
} else {
results[Math.min(index + delta, results.length - 1)].focus()
}
},
choose(event) {
if (event.target.closest(CHOOSES)) {
this.close()
}
},
leave(event) {
if (event.relatedTarget && !this.$root.contains(event.relatedTarget)) {
this.close()
}
},
}))
})