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
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>
232 lines
9.3 KiB
JavaScript
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()
|
|
}
|
|
},
|
|
}))
|
|
})
|