Files
livewire-material/resources/js/search.js
T
Andreas Reinhold / reiniandClaude Opus 5 db6023bf80 Close a full-screen search back into its bar or icon, bar and view together
`fullScreen` followed `open`, so the moment a full-screen search closed,
`data-md-full-screen` went with it: the fixed header bar went back to its
resting pill — or to `display: none`, behind the search icon — and the
view, still fading out under Alpine's hold, dropped to the docked layout
for its exit. In every engine the bar vanished on the first frame while
the view went on fading somewhere else.

search.js: a close from full screen now `hold()`s the layout — `leaving`
keeps `fullScreen`, and with it `data-md-full-screen`, the back arrow and
the fixed bar, for the view's own closing duration, read from its
computed style a frame on (zero under reduced motion); reopening lets a
pending end go by. search.css fades the header bar out with the view on
the view's spring and keeps the root above the page while it leaves,
and the icon trigger's bar stays displayed until the layout settles. The
focus trap now binds to `open && fullScreen`, so it still lets go at the
close and its return of focus lands inside RETURN_GUARD_MS rather than
reopening the view at the end of the exit.

PickingTest samples both exits in the page — the icon trigger, and the
bar trigger on a compact window — for a moment where the root is still
full screen with a fixed bar and both bar and view part-way through their
fade, then checks the settled layout and focus back on the icon or the
field; both fail on the previous code in Chrome, Firefox and Safari. The
tests wait for the entry's own transitions first: Firefox reads a
transition's end value until its next refresh tick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:00:23 +02:00

228 lines
9.1 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.
*
* 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()
}
},
}))
})