diff --git a/resources/css/components/datepicker.css b/resources/css/components/datepicker.css index 2b495410..e8d98b62 100644 --- a/resources/css/components/datepicker.css +++ b/resources/css/components/datepicker.css @@ -189,11 +189,16 @@ padding: 0 var(--md-sys-measurement-space300) var(--md-sys-measurement-space200); } - /* The calendar is the only part that scrolls, with the weekday header held at its top. */ + /* The calendar is the only part that scrolls, with the weekday header held at its top. It + opts out of the browser's scroll anchoring: when months are added above, extendMonths() + (resources/js/datepicker.js) puts the scroll back itself, in every engine alike, and an + engine that anchors as well moved it back a second time — six months on from where the + reader was. */ [data-md-datepicker-picker][data-md-presentation='full'] [data-md-datepicker-calendar] { flex: 1 1 auto; min-height: 0; overflow-y: auto; + overflow-anchor: none; overscroll-behavior: contain; } diff --git a/resources/dist/showcase.css b/resources/dist/showcase.css index 7bf74389..fa09b953 100644 --- a/resources/dist/showcase.css +++ b/resources/dist/showcase.css @@ -8060,6 +8060,7 @@ flex: 1 1 auto; min-height: 0; overflow-y: auto; + overflow-anchor: none; overscroll-behavior: contain; } diff --git a/resources/js/datepicker.js b/resources/js/datepicker.js index 59b03efa..2ba6be5d 100644 --- a/resources/js/datepicker.js +++ b/resources/js/datepicker.js @@ -625,7 +625,10 @@ document.addEventListener('alpine:init', () => { /** * Grows the window when the scroll comes near either end. Adding months above moves - * everything down, so the scroll is put back by however much the list grew. + * everything down, so the scroll is put back by however much the list grew — here, and + * only here: the list opts out of the browser's scroll anchoring (datepicker.css), which + * Chrome, Firefox and WebKit would otherwise apply on top of this, and a Safari without + * `overflow-anchor` would not apply at all. */ extendMonths(event) { if (this.presentation !== 'full' || this.monthsFrom === null) { diff --git a/tests/Browser/ActionsTest.php b/tests/Browser/ActionsTest.php index 8b1fafa6..09c4a71f 100644 --- a/tests/Browser/ActionsTest.php +++ b/tests/Browser/ActionsTest.php @@ -137,10 +137,12 @@ function focused(string $expression): string } it('opens a menu on the first item and walks it with the keyboard', function () { - $page = showcase('menus') - ->assertNoJavaScriptErrors() - ->click(MORE) - ->assertAttribute(MORE, 'aria-expanded', 'true') + $page = showcase('menus')->assertNoJavaScriptErrors(); + + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click(MORE); + + $page->assertAttribute(MORE, 'aria-expanded', 'true') ->assertScript(focused("textContent.trim().startsWith('Copy link')")); $page->keys(':focus', ['ArrowDown', 'ArrowDown']) @@ -310,8 +312,10 @@ it('hangs a menu on its menu button, even when the button is fixed to a corner o $page = ready(visit('/menu-anchor-probe')->resize(400, 800)); - $page->click('@fab') - ->assertAttribute('@fab', 'aria-expanded', 'true') + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click('@fab'); + + $page->assertAttribute('@fab', 'aria-expanded', 'true') ->assertScript($above); $page->keys(':focus', 'Escape')->assertAttribute('@fab', 'aria-expanded', 'false'); @@ -906,14 +910,14 @@ it('opens a sheet-at-compact menu below 600px, focused on its first item, and th ->assertScript(focused("textContent.trim() === 'Photo'")); // From 600px the trigger opens the popover instead, and a resize across it while one is open - // closes whichever was shown. `aria-haspopup` is how the trigger says what it opens now, and - // the menu writes it in the same handler as that close — so waiting for it is waiting for the - // component to have heard of the new width. A press that lands before the media query's change - // event is delivered, which a loaded runner leaves room for, is closed again by it a moment on. + // closes whichever was shown. The trigger says which it opens now before it is pressed — and + // pressed once, as it toggles: pressOnce() in tests/Pest.php. $page->resize(900, 800) - ->assertAttribute($trigger, 'aria-haspopup', 'menu') - ->click($trigger) - ->assertAttribute($trigger, 'aria-expanded', 'true'); + ->assertAttribute($trigger, 'aria-haspopup', 'menu'); + + pressOnce($page)->click($trigger); + + $page->assertAttribute($trigger, 'aria-expanded', 'true'); $page->resize(400, 800) ->assertAttribute($trigger, 'aria-expanded', 'false'); diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index bc06a5d0..44a5d7e9 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -446,7 +446,9 @@ it('cycles a bottom sheet\'s preset heights from its handle, announcing each, an $gripSelector = '#containment [data-md-bottom-sheet-panel][data-md-preset] [data-md-bottom-sheet-grip]'; - $page->click($gripSelector); + // Each press moves the sheet to its next stop, and from the last one closes it, so a press + // repeated by the plugin's retry would skip a stop: pressed once, pressOnce() in tests/Pest.php. + pressOnce($page)->click($gripSelector); $page->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '90dvh'") ->assertScript("{$announce}.textContent.trim() === 'Height 3 of 3'") @@ -456,7 +458,7 @@ it('cycles a bottom sheet\'s preset heights from its handle, announcing each, an ->assertScript(settled($sheet, subtree: true)); // From the last stop, activating the handle closes the sheet, as a handle with no stops does. - $page->click($gripSelector); + pressOnce($page)->click($gripSelector); $page->assertScript("getComputedStyle({$sheet}).display === 'none'"); }); diff --git a/tests/Browser/DatepickerTest.php b/tests/Browser/DatepickerTest.php index 901e1adc..02c91001 100644 --- a/tests/Browser/DatepickerTest.php +++ b/tests/Browser/DatepickerTest.php @@ -537,19 +537,20 @@ 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, and the press it costs the runner is the one press this test cannot - // afford to lose. The browser plugin gives each attempt a second and then presses again, - // and once the full-screen picker is up it covers the toggle it came from — the month - // grid is what the browser finds there — so no later attempt can ever land. That is the - // 90 seconds (45s of attempts, then one last one with the whole budget) this timed out in. + // Explicit, not implicit: the resize's own reflow can still be under way when the press + // that follows lands. ->assertScript('window.innerWidth === 390') ->assertScript(settled('document.documentElement', subtree: true)); expect($page->script(hitTarget('[aria-controls=\"trip-field-picker\"][data-md-datepicker-toggle]')))->toBe('the target'); - $page->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]') - ->assertScript("{$picker}.dataset.mdPresentation === 'full'") + // Every press into the full-screen picker goes through pressOnce(): it re-renders every day of + // every month it holds on each one, which on a loaded runner takes longer than the plugin's + // second for an attempt — and a retried press is a second press. The first one here opens the + // picker over its own toggle, so a second could never land. + pressOnce($page)->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]'); + + $page->assertScript("{$picker}.dataset.mdPresentation === 'full'") ->assertScript("{$picker}.matches(':modal')") ->assertScript("document.querySelector('#trip-field-picker [data-md-datepicker-app-bar]').offsetHeight > 0") // M3's 14-element anatomy: a close (×) icon button and Save in the app bar, not Cancel/OK. @@ -562,7 +563,7 @@ it('opens the range picker full screen below 600px and grows its month list both // Paging seven months forward crosses the window's forward edge, growing it to cover the month walked to. foreach (range(1, 7) as $ignored) { - $page->keys(':focus', 'PageDown'); + pressOnce($page)->keys(':focus', 'PageDown'); } $page->assertScript("{$scope}.monthsTo > '2027-03-01'") @@ -571,7 +572,7 @@ it('opens the range picker full screen below 600px and grows its month list both // Paging fourteen months back does the same the other way, past the original start. foreach (range(1, 14) as $ignored) { - $page->keys(':focus', 'PageUp'); + pressOnce($page)->keys(':focus', 'PageUp'); } $page->assertScript("{$scope}.monthsFrom < '2026-03-01'") @@ -582,18 +583,67 @@ it('opens the range picker full screen below 600px and grows its month list both // script() returns the script's result, not the page, so the chain starts again after it. $page->script("{$scope}.moveTo('2026-09-13', false)"); - $page->click(day('trip-field', '2026-09-20')) - ->click(day('trip-field', '2026-09-24')) - // What Save is about to commit, read from the picker before it is pressed: a press that - // landed on another day — the month list grows both ways as it scrolls, so a cell can move - // under one — otherwise shows up only as the value never reaching the server, which says - // nothing about which half went wrong. - ->assertScript("JSON.stringify({$scope}.draft) === '{\"start\":\"2026-09-20\",\"end\":\"2026-09-24\"}'") - ->click('#trip-field-picker [data-md-datepicker-save]') - ->assertSeeIn('#trip', '{"start":"2026-09-20","end":"2026-09-24"}') + pressOnce($page)->click(day('trip-field', '2026-09-20')) + ->click(day('trip-field', '2026-09-24')); + + // What Save is about to commit, read from the picker before it is pressed: a day pressed twice + // makes it the range's end as well as its start, and the next press then begins a new range — + // which otherwise shows up only as the value never reaching the server. + $page->assertScript("JSON.stringify({$scope}.draft) === '{\"start\":\"2026-09-20\",\"end\":\"2026-09-24\"}'"); + + pressOnce($page)->click('#trip-field-picker [data-md-datepicker-save]'); + + $page->assertSeeIn('#trip', '{"start":"2026-09-20","end":"2026-09-24"}') ->assertScript("! {$picker}.open"); }); +it('keeps the months in view where they were when the full-screen range picker adds months above them', function () { + $picker = "document.querySelector('#trip-field-picker')"; + + $page = dateProbe() + ->resize(390, 800) + // Settled before the press, as the test above says at length. + ->assertScript('window.innerWidth === 390') + ->assertScript(settled('document.documentElement', subtree: true)); + + pressOnce($page)->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]'); + + $page->assertScript("{$picker}.dataset.mdPresentation === 'full'") + ->assertScript(focusedDay('2026-09-13')); + + // Scrolled close to the top, the list grows by six months above what is on screen. What sits + // under a fixed point of the list stays what sat there, rather than the list being put back + // twice — once by the picker and once more by the browser's own scroll anchoring — and + // landing half a year on. + $result = $page->script(onceInPage(<<<'JS' + (async () => { + const list = document.querySelector('#trip-field-picker [data-md-datepicker-calendar]') + const scope = Alpine.$data(document.querySelector('[data-md-datepicker]:has(#trip-field)')) + const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)) + const under = () => { + const box = list.getBoundingClientRect() + const element = document.elementFromPoint(box.left + box.width / 2, box.top + 150) + + return element?.closest('[data-md-value]')?.dataset.mdValue ?? element?.textContent.trim() ?? null + } + + const from = scope.monthsFrom + list.scrollTop = 300 + const before = under() + + for (let i = 0; i < 4; i++) { + await frame() + } + + return { grew: scope.monthsFrom < from, before, after: under() } + })() + JS)); + + expect($result['grew'])->toBeTrue() + ->and($result['before'])->not->toBeNull() + ->and($result['after'])->toBe($result['before']); +}); + it('closes the full-screen range picker from its app bar close button without keeping the draft', function () { $picker = "document.querySelector('#trip-field-picker')"; @@ -605,10 +655,11 @@ it('closes the full-screen range picker from its app bar close button without ke expect($page->script(hitTarget('[aria-controls=\"trip-field-picker\"][data-md-datepicker-toggle]')))->toBe('the target'); - $page->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]') + pressOnce($page)->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]') - ->assertScript("! {$picker}.open") + ->click('#trip-field-picker [data-md-datepicker-close]'); + + $page->assertScript("! {$picker}.open") ->assertSeeIn('#trip', '{"start":"2026-09-13","end":"2026-09-15"}'); }); diff --git a/tests/Browser/TimepickerTest.php b/tests/Browser/TimepickerTest.php index 93ac2260..3e737231 100644 --- a/tests/Browser/TimepickerTest.php +++ b/tests/Browser/TimepickerTest.php @@ -411,7 +411,7 @@ it('keeps the dial and the boxes their fixed pixel size at a 20px root font size // After the dialog's entry, not during it: mid-scale the dial measures 0.95 of its own size, // which would make the resting width this test compares against a number the CSS never sets. - $page->assertScript("document.querySelector('#meeting-dialog').getAnimations({ subtree: true }).length === 0"); + $page->assertScript(settled("document.querySelector('#meeting-dialog')", subtree: true)); $restingWidth = (int) $page->script($dialWidth); $restingFont = $page->script($font); diff --git a/tests/Pest.php b/tests/Pest.php index 32b95073..db5aa831 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,6 +2,7 @@ use Illuminate\Support\Facades\File; use NoNameWeb\LivewireMaterial\Tests\TestCase; +use Pest\Browser\Api\Webpage; pest()->extend(TestCase::class)->in('Feature', 'Browser'); @@ -117,6 +118,26 @@ function onceInPage(string $expression): string return "(() => (window.{$memo} ??= {$expression}))()"; } +/** + * `$page` for an action that must happen exactly once: the plugin's own page object, without the + * retry every call on `$page` goes through (see onceInPage()). That retry does the whole call + * again after 1000ms, which costs an assertion nothing but repeats a press: a key or a click the + * page took longer than that to handle arrived twice — one PageDown paging two months, a day + * pressed twice making the range's start its end as well — or the second press found what the + * first one opened lying over its button, and never landed. Here the action runs once, with + * Playwright's own wait for the element and the whole timeout. + * + * For a press a second one would undo or overturn — a menu's trigger toggles it, a day pressed + * twice ends the range it began — into something that can take a loaded runner that long: opening + * a menu, or the full-screen range picker, which re-renders every day of every month it holds on + * each press. Assert on `$page` again. The plugin retrying every action is the cause, and every + * press in the suite is exposed to it; these are the ones slow and undoable enough to have shown it. + */ +function pressOnce(mixed $page): Webpage +{ + return new Webpage($page->page(), $page->url()); +} + /** * A JS expression that runs `$trigger` in the page and then samples `$condition` there once per * animation frame, answering whether it ever held: the whole exit and its sample in one round trip,