/** * `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 `returning` (close()) covers its return of focus like the field's own. */ import { upTo } from './breakpoints.js' import { ms } from './util.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 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, // A full-screen view on its way out; see the file's header. leaving: false, leavings: 0, compact: false, // A close is handing focus back; see close(). returning: false, 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 (!this.returning) { this.show() } }, /** * `refocus` puts focus back where the view came from: the icon button, or the field itself. * * Either way a close hands focus back — the full-screen view's focus trap returns it as it * lets go, whether this asked for it or not — and that focus must not read as someone * coming to search, or the view would open again on its way out. `returning` covers it * until the hand-back has run, rather than for a fixed stretch of wall-clock time after * the close: both returns are scheduled on frames, and a runner painting a handful of * frames a second takes longer over one than any such guard would allow, which left the * view open for good. */ close(refocus = false) { const fromFullScreen = this.open && this.fullScreen const back = refocus ? (this.$refs.trigger ?? this.$refs.input) : null this.open = false this.returning = true if (fromFullScreen) { this.hold() } // 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). this.$nextTick(() => requestAnimationFrame(() => { back?.focus() this.returning = false })) }, /** * Keeps the full-screen layout for the length of the view's exit — the duration search.css * gives it, which is also the one Alpine's `x-transition` holds its `display` for, so the * two end together. Reopening, which counts `leavings` up, lets a pending end go by, and * reduced motion zeroes the token, which ends the hold on the next task, as it should. * * The duration rather than the exit's own animations: `getAnimations()` is empty both * before an engine has created the transitions and after they have finished, and nothing * on the element tells the two apart. Waiting a frame or two for them to appear only moves * the guess — a loaded engine can leave a second between two frames, and holding for what * it had not started yet ended the full-screen layout at once, mid-exit. */ hold() { const leaving = ++this.leavings this.leaving = true setTimeout(() => { if (leaving === this.leavings) { this.leaving = false } }, ms(this.$refs.view, '--md-sys-motion-spatial-fast-duration') ?? 0) }, 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() } }, })) })