Files
livewire-material/resources/js/search.js
T
Andreas Reinhold / reiniandClaude Opus 5 94d6e31432
tests / feature (8.4) (push) Successful in 1m45s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Successful in 8m6s
tests / browser (firefox, firefox) (push) Failing after 12m52s
tests / browser (safari, webkit) (push) Failing after 13m1s
Catch an exit half-way on a slow machine, not only on a fast one
The browser suite passes on every engine here and fails on the runner,
which takes three times as long: each failing test triggers a close and
then samples for an in-between state — a rail part-way out, a scrim
part-way faded, a menu's exit copy part-way sunk — and a starved runner
takes its one sample after the 150-650ms exit has already finished.

So the tests that assert *that* something animates now stretch every
motion duration token to three seconds first (`slowMotion()`, beside
`ready()` in tests/Pest.php; ActionsTest's own copy of it goes). The two
polling helpers grew their budget to match: the rail panel and the sheet
slide on emphasized-accelerate, which is under 1% of its travel at a
quarter of the way through, so a 300ms window no longer reached the
threshold once the exit itself was three seconds long.

The two full-screen date picker tests waited for a resize through
click()'s own retry, which ate the whole 15s budget on Firefox; they now
wait for the new width and a settled document first. The bottom sheet's
preset test waits for its entry to finish before pressing the grip.

`hold()` in search.js reads the view's animations a frame after the
closed state, as the rail's settle() does, but one frame is not always
enough: an engine that starts them on its next tick shows none, and the
full-screen layout would end at once, mid-exit. An empty list is now
asked again on the following frame.

Feature 1159 passed. Browser 299 passed on Chrome, Firefox and WebKit,
and again on WebKit under ten spinning cores.

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

232 lines
9.3 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
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: until the animations the
* closed state starts, a frame on, have finished (at once when none run); reopening, which
* counts `leavings` up, lets a pending end go by.
*/
hold() {
const leaving = ++this.leavings
this.leaving = true
// A frame on, the closed state has met the style and the exit's transitions exist, as
// the rail's own settle() reads them. One frame is not always enough: an engine that
// starts them on its next refresh tick would show none here, and holding for nothing
// would end the full-screen layout at once, mid-exit. So an empty list is asked again
// on the following frame before it counts as "nothing to wait for".
const hold = (frame) => requestAnimationFrame(() => {
const animations = this.$refs.view?.getAnimations() ?? []
if (animations.length === 0 && frame === 0) {
hold(1)
return
}
Promise.allSettled(animations.map((animation) => animation.finished)).then(() => {
if (leaving === this.leavings) {
this.leaving = false
}
})
})
hold(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()
}
},
}))
})