Files
livewire-material/resources/js/search.js
T
Andreas Reinhold / reiniandClaude Opus 5 e7885ce1eb
tests / feature (8.4) (push) Successful in 1m49s
tests / feature (8.5) (push) Successful in 1m51s
tests / browser (chrome, chromium) (push) Successful in 7m34s
tests / browser (firefox, firefox) (push) Failing after 14m19s
tests / browser (safari, webkit) (push) Failing after 13m16s
Keep the search view closed when its focus comes back late
On a slow machine Escape could close the full-screen search and have it
open again for good. A close hands focus back to the input — Escape does,
and so does the view's focus trap as it lets go — and `focused()` told
that return from someone coming to search by a 250ms wall-clock window.
Both returns run on frames, and a runner painting a few frames a second
took longer than that, so the returning focus opened the view again. A
`returning` flag now covers the close until the hand-back has actually
run, on the same frame, and the constant is gone.

`hold()` times the full-screen layout from the exit's own duration token,
the one Alpine's x-transition holds `display` for, instead of waiting a
frame or two for the exit's transitions to appear: `getAnimations()` is
empty both before an engine creates them and after they end, and a loaded
engine can leave a second between frames.

The browser tests were racing the same slow machine, reproduced in a
Linux container like the runner, with its Playwright Firefox and
WebKitGTK and two cores kept busy:

- The browser plugin retries every script and action with a one-second
  attempt until the budget runs out. An in-page sleep longer than that
  only ever passed on the last attempt, which is where the 47-50s tests
  came from, and a script that clicks was run again against a page that
  had moved on. `onceInPage()` runs such a script once however often it
  is retried; the long sleeps are plain retried conditions now.
- Under load WebKitGTK paints no frame while a tight setTimeout loop
  runs, so a sample loop saw the start value and then nothing.
  `caughtMidExit()` samples on animation frames, for 2.5s.
- "No animations running" is also true before an opening transition
  exists, so a close could be sampled from a scrim at 4% opacity.
  `settled()` waits two frames before it asks.

The workflow no longer uploads failure screenshots: Gitea's artifact
service timed out on every attempt, two minutes per red run, and the job
logs are readable without it.

Feature 1159 passed. Browser 299 passed on Chrome, Firefox and WebKit on
macOS, and on Firefox and WebKitGTK in the Linux container under load,
twice each.

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

229 lines
9.6 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 `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()
}
},
}))
})