<x-search> renders data-md-search with data-md-trigger and binds data-md-open and data-md-full-screen; its parts become data-md-search-* and the empty row data-md-search-empty. search.css moves into material.components on px, spacing and state tokens (plan step 36). search.js and the showcase layout follow the renamed hooks. Browser tests cover the polite result count as it changes, the icon entry point expanding full screen and returning focus, and suggestions swapping to results on the first key. Co-Authored-By: Claude Opus 5 (1M context) <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-md-search-results], [data-md-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-md-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()
|
|
}
|
|
},
|
|
}))
|
|
})
|