diff --git a/resources/js/search.js b/resources/js/search.js index 8ab24ff7..fb877172 100644 --- a/resources/js/search.js +++ b/resources/js/search.js @@ -162,15 +162,28 @@ document.addEventListener('alpine:init', () => { this.leaving = true - requestAnimationFrame(() => { + // 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() { diff --git a/tests/Browser/ActionsTest.php b/tests/Browser/ActionsTest.php index f9c4fb88..a3a94f5f 100644 --- a/tests/Browser/ActionsTest.php +++ b/tests/Browser/ActionsTest.php @@ -993,20 +993,6 @@ it('scrolls the FAB menu\'s items on a short window, behind the close button, wh $page->assertScript("{$trigger}.getBoundingClientRect().top === window.__fabTop"); }); -/** - * Slows the page's motion tokens, so a popover's exit copy (resources/js/popover-exit.js) is - * still on screen after a separate round trip to the browser: a key press or a click already - * costs about as long as a 150–350ms exit, and the copy moves on the tokens as the popover would. - */ -function slowMotion(mixed $page, string $duration = '1500ms'): mixed -{ - $page->script(<<:root { --md-sys-motion-effects-fast-duration: {$duration}; --md-sys-motion-effects-default-duration: {$duration}; --md-sys-motion-spatial-fast-duration: {$duration}; --md-sys-motion-spatial-default-duration: {$duration}; }') - JS); - - return $page; -} - /** The exit copy on screen: shown, inert, hidden from assistive technology, with no ids of its own. */ const EXIT_COPY = "(() => { const copy = document.querySelector('[data-md-popover-ghost]'); return copy !== null && copy.matches(':popover-open') && copy.inert && copy.getAttribute('aria-hidden') === 'true' && ! copy.hasAttribute('id') && copy.querySelector('[id], [popover]:not([data-md-popover-ghost])') === null; })()"; @@ -1030,7 +1016,7 @@ it('fades a menu out after the browser has closed it, on Escape or a press outsi // The copy is decoration: Alpine starts nothing inside it. ->assertScript("window.eval(\"[...document.querySelectorAll('[data-md-popover-ghost], [data-md-popover-ghost] *')].every((element) => element._x_dataStack === undefined)\")") // Gone once the slowest of its transitions has run. - ->assertScript('new Promise((resolve) => setTimeout(() => resolve(document.querySelector("[data-md-popover-ghost]") === null), 1800))'); + ->assertScript('new Promise((resolve) => setTimeout(() => resolve(document.querySelector("[data-md-popover-ghost]") === null), 3300))'); // A press outside it. $page->click(MORE) diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index a9efdf2a..4f1ec4e9 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -435,7 +435,10 @@ it('cycles a bottom sheet\'s preset heights from its handle, announcing each, an $announce = "{$sheet}.querySelector('[data-md-bottom-sheet-announce]')"; $page->assertScript("getComputedStyle({$sheet}).display !== 'none'") - ->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '50dvh'"); + ->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '50dvh'") + // Settled, not still sliding in: a grip pressed while the entry is still under way would + // read as unstable on a loaded runner, the way opening it does elsewhere in this file. + ->assertScript("{$sheet}.getAnimations({ subtree: true }).length === 0"); $gripSelector = '#containment [data-md-bottom-sheet-panel][data-md-preset] [data-md-bottom-sheet-grip]'; @@ -992,6 +995,8 @@ it('fades the sheet\'s scrim out on close, rather than making it vanish', functi ->assertScript("getComputedStyle({$sheet}).display !== 'none'") ->assertScript("getComputedStyle({$scrim}).opacity === '1'"); + slowMotion($page); + // Triggering the close and sampling for a mid-fade opacity in the same round trip: a round // trip apiece for a separate `script` and `assertScript` already costs real time, easily as // much as the 200ms fade itself, so a click, then a later separate read, can just as easily @@ -1026,16 +1031,21 @@ it('slides the sheet out on close, rather than making it vanish', function (stri ->assertScript("getComputedStyle({$sheet}).display !== 'none'") ->assertScript("Math.abs({$offset}) < 0.5"); + slowMotion($page); + // Sampled in the page, in the same round trip as the close, as the scrim's fade is above: the // sheet must be caught still displayed and part of the way to its closed offset (one sheet's // own size past its edge), not gone at once — which is what Firefox showed while the exit - // leaned on `allow-discrete` holding `display`. + // leaned on `allow-discrete` holding `display`. 400 iterations (2000ms): the sheet closes on + // the emphasized-accelerate easing, which stays close to its start for a good part of its run, + // so slowMotion()'s stretched exit needs longer than a scrim's fade (a spring, front-loaded) + // before the offset has moved past a pixel. $midSlide = $page->script(<< { const size = {$sheet}.getBoundingClientRect().{$dimension} {$scrim}.click() - for (let i = 0; i < 60; i++) { + for (let i = 0; i < 400; i++) { const style = getComputedStyle({$sheet}) const offset = {$sample} if (style.display !== 'none' && offset > 1 && offset < size - 1) return true diff --git a/tests/Browser/DatepickerTest.php b/tests/Browser/DatepickerTest.php index 0e25a69a..ede2c04f 100644 --- a/tests/Browser/DatepickerTest.php +++ b/tests/Browser/DatepickerTest.php @@ -506,6 +506,10 @@ it('opens the range picker full screen below 600px and grows its month list both $page = dateProbe() ->resize(390, 800) + // Explicit, not implicit: the resize's own reflow can still be under way when the click + // that follows fires, which is what made this time out on a loaded runner — the toggle's + // box kept moving under the click rather than the click itself being slow. + ->assertScript('window.innerWidth === 390 && document.documentElement.getAnimations({ subtree: true }).length === 0') ->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]') ->assertScript("{$picker}.dataset.mdPresentation === 'full'") ->assertScript("{$picker}.matches(':modal')") @@ -552,6 +556,7 @@ it('closes the full-screen range picker from its app bar close button without ke dateProbe() ->resize(390, 800) + ->assertScript('window.innerWidth === 390 && document.documentElement.getAnimations({ subtree: true }).length === 0') ->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]') ->click(day('trip-field', '2026-09-20')) ->click('#trip-field-picker [data-md-datepicker-close]') diff --git a/tests/Browser/NavigationTest.php b/tests/Browser/NavigationTest.php index c2b1d153..a9b8cd62 100644 --- a/tests/Browser/NavigationTest.php +++ b/tests/Browser/NavigationTest.php @@ -647,7 +647,10 @@ it('reads expanded while a modal rail is open over the page, and collapsed once * Closes a rail with the given script and reports whether, sampling every few ms in the page * itself, it was ever caught part-way: the whole close in one round trip, since a separate * `script` and `assertScript` apiece already take as long as the 200ms exit (see ContainmentTest's - * scrim fades). + * scrim fades). Called after slowMotion(), so the panel's own close — on the emphasized-accelerate + * easing, which stays close to its start for a good part of its run — still needs a couple of + * seconds of that stretched exit before its position has moved the couple of pixels `$partWay` + * looks for; 2000ms (400 × 5ms) covers that with room to spare, short of the full exit ending. */ function caughtMidClose(mixed $page, string $close, string $partWay): bool { @@ -655,7 +658,7 @@ function caughtMidClose(mixed $page, string $close, string $partWay): bool (async () => { {$close} - for (let i = 0; i < 60; i++) { + for (let i = 0; i < 400; i++) { if ({$partWay}) return true await new Promise((resolve) => setTimeout(resolve, 5)) } @@ -686,6 +689,8 @@ it('slides the compact rail out on close, rather than making it vanish', functio ->assertScript(RAIL.".hasAttribute('data-md-open')") ->assertScript(settled(RAIL_PANEL).' && Math.round('.RAIL_PANEL.'.getBoundingClientRect().left) === 0'); + slowMotion($page); + expect(caughtMidClose($page, "document.querySelector('[data-md-navigation-rail-scrim]').click()", slidingOut(RAIL_PANEL)))->toBeTrue(); $page->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'") @@ -704,6 +709,8 @@ it('slides a rail that hides when collapsed out on close, rather than making it ->assertScript("{$rail}.hasAttribute('data-md-open')") ->assertScript(settled($panel)." && Math.round({$panel}.getBoundingClientRect().left) === 0 && Math.round({$panel}.getBoundingClientRect().width) === 256"); + slowMotion($page); + expect(caughtMidClose($page, "{$rail}.querySelector(':scope > [data-md-navigation-rail-scrim]').click()", slidingOut($panel)))->toBeTrue(); $page->assertScript("getComputedStyle({$panel}).display === 'none'") @@ -721,6 +728,8 @@ it('fades a modal rail\'s scrim out on close while the rail stands collapsed in $fading = "(getComputedStyle({$scrim}).display !== 'none' && parseFloat(getComputedStyle({$scrim}).opacity) > 0.02 && parseFloat(getComputedStyle({$scrim}).opacity) < 0.98)"; + slowMotion($page); + expect(caughtMidClose($page, "{$scrim}.click()", $fading))->toBeTrue(); // The panel goes straight back into the layout: only the scrim was over the page. diff --git a/tests/Browser/PickingTest.php b/tests/Browser/PickingTest.php index d7246c43..8972466c 100644 --- a/tests/Browser/PickingTest.php +++ b/tests/Browser/PickingTest.php @@ -246,6 +246,8 @@ it('fades the docked search\'s scrim out on close, rather than making it vanish' // `display: none` at the open state's full opacity. ->assertScript("(({ display, opacity }) => display !== 'none' && opacity === '1')(getComputedStyle({$root}.querySelector('[data-md-search-scrim]')))"); + slowMotion($page); + expect($page->script(searchCloseSample('[data-md-search-scrim]', 'parseFloat(getComputedStyle(element).opacity)')))->toBeTrue(); $page->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-scrim]')).display === 'none'"); @@ -258,6 +260,8 @@ it('keeps the docked search above the page while its view closes', function () { ->click('#find') ->assertScript("getComputedStyle({$view}).display !== 'none' && getComputedStyle({$view}).opacity === '1'"); + slowMotion($page); + // The view caught part-way through its fade only counts while the root is still at z-index 50. expect($page->script(searchCloseSample('[data-md-search-view]', "getComputedStyle(root).zIndex === '50' ? parseFloat(getComputedStyle(element).opacity) : 0")))->toBeTrue(); diff --git a/tests/Pest.php b/tests/Pest.php index 44aa1943..a88208a8 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -33,6 +33,40 @@ function layoutRoot(string $html, ?string $tag = null): array return $root + collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all(); } +/** + * Stretches every motion duration token (resources/css/tokens/motion.css) on :root to $duration, + * so a test that has to catch an exit half-way is not racing the machine: a round trip for a + * separate script() and assertScript() already costs real time, easily as much as a real exit + * (150–650ms), so a single sample taken after triggering one can just as easily land before it + * starts or after it has already finished on a loaded runner. Chain it, after the open has + * settled, immediately before the action that triggers the exit being sampled. + * + * Use this only in a test asserting *that* something animates — a part-way opacity, offset or + * transform. Never in a test asserting a duration value, which this would make wrong. + */ +function slowMotion(mixed $page, string $duration = '3s'): mixed +{ + $tokens = [ + '--md-sys-motion-spatial-fast-duration', + '--md-sys-motion-spatial-default-duration', + '--md-sys-motion-spatial-slow-duration', + '--md-sys-motion-effects-fast-duration', + '--md-sys-motion-effects-default-duration', + '--md-sys-motion-effects-slow-duration', + '--md-sys-motion-duration-short', + '--md-sys-motion-duration-medium', + '--md-sys-motion-duration-long', + ]; + + $css = collect($tokens)->map(fn (string $token): string => "{$token}: {$duration} !important;")->implode(' '); + + $page->script(<<:root { {$css} }') + JS); + + return $page; +} + /** * The page is done loading, and Alpine and/or Livewire (whichever the page renders) have booted — * chained after visit(), before a Browser test probe reads anything either one wires up. Most