/** * `materialSearch`: the behaviour of `` — 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. * * A full-screen view closes back into the bar or the icon it came from (M3's search view), so the * full-screen layout outlives `open` by the view's own exit: `leaving` holds `fullScreen` — and with * it `data-md-full-screen`, the fixed header bar and the back arrow — until the view's closing * transition has run, while the header bar fades out with it (search.css). Without it the bar went * back to its resting form, or to nothing behind the icon, on the first frame of the exit, and the * fading view dropped to the docked layout. The focus trap lets go at the close itself, not at the * end of the exit, so its return of focus still lands inside RETURN_GUARD_MS. */ 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-md-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 /** The longest `transition-duration` + `transition-delay` on an element, in ms: zero under reduced motion. */ const longestTransition = (element) => { const style = getComputedStyle(element) const ms = (value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) const delays = style.transitionDelay.split(',').map(ms) return Math.max(0, ...style.transitionDuration.split(',').map((duration, index) => ms(duration) + (delays[index % delays.length] || 0))) } document.addEventListener('alpine:init', () => { window.Alpine.data('materialSearch', (docked = false, announce = {}, trigger = 'bar') => ({ open: false, // A full-screen view on its way out; see the file's header. leaving: false, leavings: 0, 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 || this.leaving) && (trigger === 'icon' || (this.compact && !docked)) }, show() { this.leavings++ this.leaving = false 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) { const fromFullScreen = this.open && this.fullScreen this.open = false this.closedAt = performance.now() if (fromFullScreen) { this.hold() } if (refocus) { // Back to whatever opened the view: the icon button, or the field itself. A frame // after the tick, as `expand()` waits: the icon button is `x-show`n, and Alpine only // shows it on that frame, so a focus in the tick reaches a hidden button, which // Firefox and WebKit refuse (in Chrome the trap's own return had covered for it). const back = this.$refs.trigger ?? this.$refs.input this.$nextTick(() => requestAnimationFrame(() => back.focus())) } }, /** * Keeps the full-screen layout for the length of the view's exit. The closed state's * durations are read a frame on, once they are the ones computed; reopening, which counts * `leavings` up, lets a pending end go by. */ hold() { const leaving = ++this.leavings this.leaving = true requestAnimationFrame(() => { const duration = this.$refs.view ? longestTransition(this.$refs.view) : 0 setTimeout(() => { if (leaving === this.leavings) { this.leaving = false } }, duration) }) }, 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() } }, })) })