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:
co-authored by
Claude Opus 5
parent
9f46630352
commit
db6023bf80
@@ -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;
|
||||
|
||||
+50
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <<<JS
|
||||
(async () => {
|
||||
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()";
|
||||
|
||||
@@ -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';");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user