M3 § Search names three entry points and only the bar existed; suggestions before the first keystroke were missing too (plan step 24, audit docs/audits/m3-alignment/inputs.md § Missing). `trigger="icon"` is the search icon button — search as a secondary action, one 48px button that expands into the full-screen view at any width, since an icon button has nothing to dock under, and takes its focus back on close. The `suggestions` slot stands where the results do until something is typed; the live region counts whichever of the two is on screen and names suggestions as suggestions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
177 lines
6.7 KiB
JavaScript
177 lines
6.7 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. With a `suggestions` slot, whichever of the
|
|
* two lists is on screen is the one counted, and the suggestions are named as such.
|
|
*
|
|
* `trigger` is M3's entry point: the bar itself, or `icon` — a single search icon button that
|
|
* expands into the full-screen view wherever the window is wide enough to dock, because an icon
|
|
* button has nowhere to dock under.
|
|
*/
|
|
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 = {}, trigger = 'bar') => ({
|
|
open: false,
|
|
compact: false,
|
|
closedAt: -Infinity,
|
|
announcement: '',
|
|
// What is in the field, which is what tells suggestions from results.
|
|
query: '',
|
|
observer: null,
|
|
settle: null,
|
|
|
|
init() {
|
|
const media = upTo('medium')
|
|
|
|
this.compact = media.matches
|
|
media.addEventListener('change', (event) => (this.compact = event.matches))
|
|
|
|
// Alpine registers x-ref as it walks the children, which is after this runs.
|
|
this.$nextTick(() => {
|
|
this.query = this.$refs.input?.value ?? ''
|
|
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.$refs.view) {
|
|
this.announcement = ''
|
|
|
|
return
|
|
}
|
|
|
|
// Suggestions and results never show together; count whichever one is on screen.
|
|
const list = [...this.$refs.view.querySelectorAll('[data-search-results], [data-search-suggestions]')]
|
|
.find((element) => element.getClientRects().length > 0)
|
|
const items = list ? list.querySelectorAll('[role="listitem"], li') : []
|
|
const total = items.length || (list ? this.results().length : 0)
|
|
const suggesting = Boolean(list?.hasAttribute('data-search-suggestions'))
|
|
|
|
this.announcement = total === 0
|
|
? (announce.none ?? '')
|
|
: total === 1
|
|
? ((suggesting ? announce.suggestionOne : announce.one) ?? '')
|
|
: ((suggesting ? announce.suggestionMany : announce.many) ?? '').replace(':count', total)
|
|
},
|
|
|
|
get fullScreen() {
|
|
// An icon button has nothing to dock under, so M3's icon entry point always expands.
|
|
return this.open && (trigger === 'icon' || (this.compact && !docked))
|
|
},
|
|
|
|
show() {
|
|
this.open = true
|
|
},
|
|
|
|
/** M3's search-icon entry point: the button opens the view and hands over focus. */
|
|
expand() {
|
|
this.show()
|
|
this.$nextTick(() => requestAnimationFrame(() => this.$refs.input?.focus()))
|
|
},
|
|
|
|
/** Every keystroke: the view opens, and the query decides suggestions or results. */
|
|
typed(event) {
|
|
this.query = event.target.value
|
|
this.show()
|
|
},
|
|
|
|
focused() {
|
|
if (performance.now() - this.closedAt > RETURN_GUARD_MS) {
|
|
this.show()
|
|
}
|
|
},
|
|
|
|
close(refocus = false) {
|
|
this.open = false
|
|
this.closedAt = performance.now()
|
|
|
|
if (refocus) {
|
|
// Back to whatever opened the view: the icon button, or the field itself. A tick
|
|
// later, because the icon button is only on screen again once the view has closed.
|
|
const back = this.$refs.trigger ?? this.$refs.input
|
|
|
|
this.$nextTick(() => back.focus())
|
|
}
|
|
},
|
|
|
|
clear() {
|
|
const input = this.$refs.input
|
|
|
|
input.value = ''
|
|
this.query = ''
|
|
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()
|
|
}
|
|
},
|
|
}))
|
|
})
|