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';"); });