From db6023bf806531739c700bf5d0d3af0b5dfcf2b5 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Wed, 16 Sep 2026 20:49:04 +0200 Subject: [PATCH] Close a full-screen search back into its bar or icon, bar and view together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- resources/css/components/search.css | 16 ++++- resources/js/search.js | 51 +++++++++++++++- resources/views/components/search.blade.php | 5 +- tests/Browser/PickingTest.php | 65 +++++++++++++++++++++ tests/Feature/Components/SearchTest.php | 4 +- 5 files changed, 135 insertions(+), 6 deletions(-) diff --git a/resources/css/components/search.css b/resources/css/components/search.css index 92f010b3..51c8678d 100644 --- a/resources/css/components/search.css +++ b/resources/css/components/search.css @@ -45,7 +45,8 @@ position: relative; } - [data-md-search][data-md-open] { + /* Full screen, through its exit too: the fixed view and bar stay above the page while they leave. */ + [data-md-search]:is([data-md-open], [data-md-full-screen]) { z-index: 50; } @@ -279,7 +280,7 @@ background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), transparent); } - [data-md-search][data-md-trigger='icon']:not([data-md-open]) [data-md-search-bar] { + [data-md-search][data-md-trigger='icon']:not([data-md-open], [data-md-full-screen]) [data-md-search-bar] { display: none; } @@ -294,6 +295,17 @@ border-radius: 0; } + /* Leaving full screen (`data-md-full-screen` outlives `data-md-open` by the view's exit, + resources/js/search.js): the header bar fades with the view, on the view's own spring, rather + than going back to its resting pill — or to nothing, behind the icon — on the first frame. */ + [data-md-search][data-md-full-screen]:not([data-md-open]) [data-md-search-bar] { + opacity: 0; + background-color: transparent; + transition-property: opacity; + transition-duration: var(--md-sys-motion-spatial-fast-duration); + transition-timing-function: var(--md-sys-motion-spatial-fast); + } + [data-md-search][data-md-full-screen] [data-md-search-view] { position: fixed; inset: 0; diff --git a/resources/js/search.js b/resources/js/search.js index 3a97723c..86e1d781 100644 --- a/resources/js/search.js +++ b/resources/js/search.js @@ -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 diff --git a/resources/views/components/search.blade.php b/resources/views/components/search.blade.php index c8dd0839..16c6cd9d 100644 --- a/resources/views/components/search.blade.php +++ b/resources/views/components/search.blade.php @@ -28,7 +28,8 @@ for both (docs/reference/m3/components-navigation-selection-inputs.md § Search). The root renders `data-md-search` with `data-md-trigger`, and `data-md-open` and - `data-md-full-screen` while they hold; the parts are `data-md-search-*`, drawn by + `data-md-full-screen` while they hold — `data-md-full-screen` a little longer, through a + full-screen view's exit back into the bar or the icon (resources/js/search.js); the parts are `data-md-search-*`, drawn by resources/css/components/search.css. `class`, `style` and `wire:key` land on the root. --}} @props([ @@ -59,7 +60,7 @@ x-on:keydown.escape="if (open) { $event.stopPropagation(); close(true); }" x-on:focusout="leave($event)" x-on:pointerdown.outside="close()" - x-trap.noscroll="fullScreen" + x-trap.noscroll="open && fullScreen" x-bind:data-md-open="open ? '' : null" x-bind:data-md-full-screen="fullScreen ? '' : null" data-md-search diff --git a/tests/Browser/PickingTest.php b/tests/Browser/PickingTest.php index 6be28e88..780ce7f5 100644 --- a/tests/Browser/PickingTest.php +++ b/tests/Browser/PickingTest.php @@ -284,6 +284,71 @@ it('expands the search icon into the full-screen view, and hands focus back to t ->assertScript("getComputedStyle({$trigger}).display !== 'none'"); }); +/** + * Closes a full-screen search from its back arrow and samples, in the page, for a moment in its + * exit where the layout is still full screen — the root marked, the header bar fixed at the top — + * with both the bar and the view part-way through their fade, rather than the bar gone back to + * its resting form (or to nothing, behind the icon) on the first frame. + */ +function fullScreenCloseSample(string $input): string +{ + return << { + const root = document.querySelector('[data-md-search]:has(#{$input})') + root.querySelector('[data-md-search-back]').click() + + for (let i = 0; i < 80; i++) { + const bar = getComputedStyle(root.querySelector('[data-md-search-bar]')) + const view = getComputedStyle(root.querySelector('[data-md-search-view]')) + const fading = (style) => style.display !== 'none' && parseFloat(style.opacity) > 0.02 && parseFloat(style.opacity) < 0.98 + + if (! root.hasAttribute('data-md-open') && root.hasAttribute('data-md-full-screen') && bar.position === 'fixed' && fading(bar) && fading(view)) return true + await new Promise((resolve) => setTimeout(resolve, 5)) + } + return false + })() + JS; +} + +it('closes the full-screen view and its bar together, back into the search icon', function () { + $root = "document.querySelector('[data-md-search]:has(#find-icon)')"; + $trigger = "{$root}.querySelector('[data-md-search-trigger]')"; + + $page = pickProbe() + ->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]') + ->assertScript("{$root}.hasAttribute('data-md-full-screen')") + // Its entry run: Firefox reads a transition's end value until its next refresh tick, so an + // opacity of 1 alone does not say the view has finished fading in. + ->assertScript("{$root}.getAnimations({ subtree: true }).length === 0"); + + expect($page->script(fullScreenCloseSample('find-icon')))->toBeTrue(); + + $page->assertScript("! {$root}.hasAttribute('data-md-full-screen')") + ->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-bar]')).display === 'none'") + ->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-view]')).display === 'none'") + ->assertScript("document.activeElement === {$trigger}") + // The view does not open again when the trap's return of focus lands on the icon. + ->assertScript("! {$root}.hasAttribute('data-md-open')"); +}); + +it('closes a compact window\'s full-screen view and its bar together, back into the bar', function () { + $root = "document.querySelector('[data-md-search]:has(#find)')"; + + $page = pickProbe()->resize(400, 800); + + $page->click('#find') + ->assertScript("{$root}.hasAttribute('data-md-full-screen')") + ->assertScript("{$root}.getAnimations({ subtree: true }).length === 0"); + + expect($page->script(fullScreenCloseSample('find')))->toBeTrue(); + + $page->assertScript("! {$root}.hasAttribute('data-md-full-screen')") + ->assertScript("(({ position, display, opacity }) => position === 'relative' && display !== 'none' && opacity === '1')(getComputedStyle({$root}.querySelector('[data-md-search-bar]')))") + ->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-view]')).display === 'none'") + ->assertScript("document.activeElement.id === 'find'") + ->assertScript("! {$root}.hasAttribute('data-md-open')"); +}); + it('shows the suggestions until the first key, then the results, and counts whichever is on screen', function () { $root = "document.querySelector('[data-md-search]:has(#find-icon)')"; $shown = fn (string $list): string => "{$root}.querySelector('[data-md-search-{$list}]').checkVisibility()"; diff --git a/tests/Feature/Components/SearchTest.php b/tests/Feature/Components/SearchTest.php index 3f8c84e3..8dc09e60 100644 --- a/tests/Feature/Components/SearchTest.php +++ b/tests/Feature/Components/SearchTest.php @@ -114,7 +114,9 @@ it('draws the bar and the view from its stylesheet, at M3\'s widths', function ( expect($css)->toContain('@layer material.components') ->toMatch('/\\[data-md-search\\] \\{\\s*--search-height: 56px;[^}]*--search-open-width: 720px;/') ->toContain('[data-md-search][data-md-full-screen] [data-md-search-view] {') - ->toContain("[data-md-search][data-md-trigger='icon']:not([data-md-open]) [data-md-search-bar] {") + ->toContain("[data-md-search][data-md-trigger='icon']:not([data-md-open], [data-md-full-screen]) [data-md-search-bar] {") + // Leaving full screen, the header bar fades with the view instead of going on the first frame. + ->toContain('[data-md-search][data-md-full-screen]:not([data-md-open]) [data-md-search-bar] {') ->not->toMatch('/\\d(?:\\.\\d+)?rem/') ->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/search.css';"); });