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>
This commit is contained in:
Andreas Reinhold / reini
2026-09-16 21:00:23 +02:00
co-authored by Claude Opus 5
parent 9f46630352
commit db6023bf80
5 changed files with 135 additions and 6 deletions
+50 -1
View File
@@ -17,6 +17,14 @@
* `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'
@@ -33,9 +41,21 @@ const RETURN_GUARD_MS = 250
// 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: '',
@@ -93,10 +113,12 @@ document.addEventListener('alpine:init', () => {
get fullScreen() {
// An icon button has nothing to dock under, so M3's icon entry point always expands.
return this.open && (trigger === 'icon' || (this.compact && !docked))
return (this.open || this.leaving) && (trigger === 'icon' || (this.compact && !docked))
},
show() {
this.leavings++
this.leaving = false
this.open = true
},
@@ -119,9 +141,15 @@ document.addEventListener('alpine:init', () => {
},
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
@@ -133,6 +161,27 @@ document.addEventListener('alpine:init', () => {
}
},
/**
* 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