diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index 389e86c0..f9b472f5 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -225,12 +225,20 @@ class NestedDialogProbe extends Component -

Outer body, long enough to scroll on its own: line one, line two, line three, line four, line five, line six, line seven, line eight, line nine, line ten, line eleven, line twelve.

+ {{-- Short, and the trigger for the inner dialog right after it: both stay in + view without scrolling, so opening the inner dialog is not itself a + scroll of the outer one's body — the very thing this test rules out. --}} +

Outer body, short enough to fit without scrolling.

-

Inner body, long enough to scroll on its own, independent of the outer dialog it opened from: line one, line two, line three, line four, line five, line six, line seven, line eight, line nine, line ten, line eleven, line twelve.

+

Inner body, line one of several, independent of the outer dialog it opened from and its own paragraphs so the stack genuinely overflows the box regardless of the window's height.

+

Inner body, line two, long enough on its own to take real vertical room in the scrolling body.

+

Inner body, line three, long enough on its own to take real vertical room in the scrolling body.

+

Inner body, line four, long enough on its own to take real vertical room in the scrolling body.

+

Inner body, line five, long enough on its own to take real vertical room in the scrolling body.

+

Inner body, line six, long enough on its own to take real vertical room in the scrolling body.

@@ -262,11 +270,15 @@ function nestedDialogProbe() } it('marks a scrolling dialog\'s divider at the edge with more content, and a nested dialog keeps its own marks', function () { - $page = nestedDialogProbe(); + // Short and narrow so the probes' paragraphs genuinely overflow their body. + $page = nestedDialogProbe()->resize(320, 300); $page->click('button:has-text("Open outer")'); - $outer = "[...document.querySelectorAll('dialog[open]')].find((d) => d.textContent.includes('Outer'))"; + // Matched by each dialog's own title element, not its full (recursive) textContent: the + // outer dialog's textContent also contains the nested inner dialog's text, "Inner" included, + // so a substring match on the whole dialog would find the outer one for both. + $outer = "[...document.querySelectorAll('dialog[open]')].find((d) => d.querySelector('[data-md-modal-title]')?.textContent === 'Outer')"; $outerHead = "{$outer}.querySelector('[data-md-modal-head]')"; $outerBody = "{$outer}.querySelector('[data-md-modal-body]')"; @@ -275,7 +287,7 @@ it('marks a scrolling dialog\'s divider at the edge with more content, and a nes $page->click('button:has-text("Open inner")'); - $inner = "[...document.querySelectorAll('dialog[open]')].find((d) => d.textContent.includes('Inner'))"; + $inner = "[...document.querySelectorAll('dialog[open]')].find((d) => d.querySelector('[data-md-modal-title]')?.textContent === 'Inner')"; $innerHead = "{$inner}.querySelector('[data-md-modal-head]')"; $innerBody = "{$inner}.querySelector('[data-md-modal-body]')"; @@ -283,6 +295,10 @@ it('marks a scrolling dialog\'s divider at the edge with more content, and a nes $page->script("{$innerBody}.scrollTop = {$innerBody}.scrollHeight"); + // The scroll listener that marks the dialog runs off the native `scroll` event, which fires + // asynchronously after the assignment above. + $page->wait(0.2); + // The inner dialog's own body scrolled to its end marks its own head; the outer dialog, whose // body was never touched, keeps its own mark off — each dialog watches only its own scroll. $page->assertScript("getComputedStyle({$innerHead}, '::after').opacity === '1'") @@ -290,10 +306,16 @@ it('marks a scrolling dialog\'s divider at the edge with more content, and a nes }); it('shows a full-screen dialog\'s 56px phone bar below 600px, and the basic dialog above it', function () { - $page = containment()->resize(400, 800); + // Opened wide, before narrowing: the standard side sheet example further down the page opens + // by default too, and below 840px its scrim (correctly) covers the whole viewport and traps + // the page — narrowing first would leave nothing else clickable. The bar's visibility is pure + // CSS on the fullscreen flag, so which comes first makes no difference to what is asserted. + $page = containment(); $page->click('#containment button:has-text("Full screen on a phone")'); + $page->resize(400, 800); + $dialog = "document.querySelector('#containment dialog[open]')"; $bar = "{$dialog}.querySelector('[data-md-modal-bar]')"; @@ -320,17 +342,28 @@ it('draws separator dividers whatever the scroll, on a dialog that fits without ->assertScript("getComputedStyle({$actions}, '::before').opacity === '1'"); }); -it('opens a dialog with no fade under reduced motion', function () { - $page = visit('/material/containment', ['reducedMotion' => 'reduce'])->waitForEvent('networkidle') - ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); - - $page->click('#containment button:has-text("Basic dialog")'); - +it('opens a dialog with no fade under reduced motion, but a real one without it', function () { $dialog = "document.querySelector('#containment dialog[open]')"; - // Reduced motion takes the spring's duration to zero, so the dialog is at its final opacity - // at once rather than mid-fade. - $page->assertScript("getComputedStyle({$dialog}).opacity === '1'"); + // Without reduced motion: the spring is a real, non-zero transition. + $normal = containment() + ->assertScript("! window.matchMedia('(prefers-reduced-motion: reduce)').matches"); + + $normal->click('#containment button:has-text("Basic dialog")'); + + $normal->assertScript("parseFloat(getComputedStyle({$dialog}).transitionDuration) > 0"); + + // With reduced motion (the plugin's own emulation, `visit(..., ['reducedMotion' => 'reduce'])`): + // tokens/motion.css takes the same spring's duration to zero, so there is no transition to run, + // and the dialog is at its final opacity at once rather than mid-fade. + $reduced = visit('/material/containment', ['reducedMotion' => 'reduce'])->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'") + ->assertScript("window.matchMedia('(prefers-reduced-motion: reduce)').matches"); + + $reduced->click('#containment button:has-text("Basic dialog")'); + + $reduced->assertScript("parseFloat(getComputedStyle({$dialog}).transitionDuration) === 0") + ->assertScript("getComputedStyle({$dialog}).opacity === '1'"); }); class TextOnlyDialogProbe extends Component @@ -375,7 +408,9 @@ function textOnlyDialogProbe() } it('gives the scrolling body an inset focus ring when a text-only dialog opens from the keyboard', function () { - $page = textOnlyDialogProbe(); + // A short, narrow window so the paragraph genuinely overflows the body before the dialog + // opens: showModal() picks its initial focus once, at open, from what is focusable then. + $page = textOnlyDialogProbe()->resize(320, 300); $page->script("document.querySelector('button').focus()"); @@ -401,13 +436,15 @@ it('cycles a bottom sheet\'s preset heights from its handle, announcing each, an $page->assertScript("getComputedStyle({$sheet}).display !== 'none'") ->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '50dvh'"); - $page->click($handle); + $gripSelector = '#containment [data-md-bottom-sheet-panel][data-md-preset] [data-md-bottom-sheet-grip]'; + + $page->click($gripSelector); $page->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '90dvh'") ->assertScript("{$announce}.textContent.trim() === 'Height 3 of 3'"); // From the last stop, activating the handle closes the sheet, as a handle with no stops does. - $page->click($handle); + $page->click($gripSelector); $page->assertScript("getComputedStyle({$sheet}).display === 'none'"); }); @@ -443,7 +480,7 @@ it('is a standard side sheet from 840px, without a scrim or a focus trap, and th $sheet = "document.querySelector('#containment aside[data-md-side]')"; $scrim = "{$sheet}.parentElement.querySelector(':scope > [data-md-drawer-scrim]')"; - $toggle = "document.querySelector('#containment button:has-text(\"Show or hide the filters\")')"; + $toggle = '#containment button:has-text("Show or hide the filters")'; // From 840px it opens co-planar (the example starts `open: true`): no scrim shown, and the // rest of the page stays interactive. @@ -452,13 +489,13 @@ it('is a standard side sheet from 840px, without a scrim or a focus trap, and th ->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') === null") ->assertScript("! {$sheet}.hasAttribute('inert')"); - $page->script("{$toggle}.click()"); + $page->click($toggle); $page->assertScript("getComputedStyle({$sheet}).opacity === '0'"); // Below 840px the same sheet becomes the modal one: fixed, with a scrim, and the page inert. $page->resize(700, 800) - ->script("{$toggle}.click()"); + ->click($toggle); $page->assertScript("getComputedStyle({$sheet}).position === 'fixed'") ->assertScript("getComputedStyle({$scrim}).display !== 'none'") @@ -530,3 +567,239 @@ it('binds a collapse to an Alpine property both ways', function () { ->assertSeeIn('#advanced', 'false') ->assertScript("{$collapse}.open === false"); }); + +it('fades the side sheet\'s scrim out on close, rather than making it vanish', function () { + $sheet = "document.querySelector('#containment aside[role=\"dialog\"]')"; + $scrim = "{$sheet}.parentElement.querySelector(':scope > [data-md-drawer-scrim]')"; + + $page = containment() + ->click('#containment button:has-text("Side sheet")') + ->assertScript("getComputedStyle({$sheet}).display !== 'none'") + ->assertScript("getComputedStyle({$scrim}).opacity === '1'"); + + // 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 + // land before the fade starts or after it ends. This samples every few ms, in the page itself, + // for whether it was ever caught strictly between fully shown and fully hidden, rather than + // simply gone at once. + $midFade = $page->script(<< { + {$scrim}.click() + + for (let i = 0; i < 60; i++) { + const opacity = parseFloat(getComputedStyle({$scrim}).opacity) + if (opacity > 0.02 && opacity < 0.98) return true + await new Promise((resolve) => setTimeout(resolve, 5)) + } + return false + })() + JS); + + expect($midFade)->toBeTrue(); + + $page->assertScript("getComputedStyle({$scrim}).display === 'none'"); +}); + +it('fades the bottom sheet\'s scrim out on close, rather than making it vanish', function () { + $sheet = "document.querySelector('#containment section[role=\"dialog\"]')"; + $scrim = "{$sheet}.parentElement.querySelector(':scope > [data-md-bottom-sheet-scrim]')"; + + $page = containment() + ->click('#containment button:has(> span:text-is("Bottom sheet"))') + ->assertScript("getComputedStyle({$sheet}).display !== 'none'") + ->assertScript("getComputedStyle({$scrim}).opacity === '1'"); + + $midFade = $page->script(<< { + {$scrim}.click() + + for (let i = 0; i < 60; i++) { + const opacity = parseFloat(getComputedStyle({$scrim}).opacity) + if (opacity > 0.02 && opacity < 0.98) return true + await new Promise((resolve) => setTimeout(resolve, 5)) + } + return false + })() + JS); + + expect($midFade)->toBeTrue(); + + $page->assertScript("getComputedStyle({$scrim}).display === 'none'"); +}); + +it('slides the side sheet in from its own edge in a right-to-left page', function () { + Route::middleware('web')->get('/drawer-rtl-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + +
+ + Narrow the list down. +
+ @livewireScripts + + + BLADE)); + + $page = visit('/drawer-rtl-probe')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'"); + + $sheet = "document.querySelector('aside[data-md-side]')"; + + $page->click('button:has-text("Open")'); + + // `side="end"` (the default) is the trailing edge — the *left* of a right-to-left page — so it + // slides in from off the left, not the right (the CSS `:dir(rtl)` mirror the Tailwind build + // rewrites to a `:lang()` list, hence `lang="ar"` here as well as `dir="rtl"`): caught well + // before the 500ms spatial transition ends, it is still off-screen on the left. + $page->wait(0.05); + + $page->assertScript("getComputedStyle({$sheet}).display !== 'none'") + ->assertScript("{$sheet}.getBoundingClientRect().left < 0"); + + // And settles flush against that same left edge, not the right. + $page->wait(0.6) + ->assertScript("Math.round({$sheet}.getBoundingClientRect().left) === 0"); +}); + +class NestedFullscreenDialogProbe extends Component +{ + public bool $settings = false; + + public bool $confirm = false; + + public function render(): string + { + return <<<'BLADE' +
+ + + + A form that needs the room. + + + + + Recipients lose access at once. + + + +
+ BLADE; + } +} + +it('keeps a basic dialog\'s own title and padding when it is nested inside a full-screen dialog at 400px', function () { + Livewire::component('nested-fullscreen-dialog-probe', NestedFullscreenDialogProbe::class); + + Route::middleware('web')->get('/nested-fullscreen-dialog-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + BLADE)); + + $page = visit('/nested-fullscreen-dialog-probe')->resize(400, 800)->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); + + $page->click('button:has-text("Open settings")'); + + $outerBar = "document.querySelector('dialog[data-md-fullscreen][open] [data-md-modal-bar]')"; + + // The rule hiding a full-screen dialog's own head below 600px, so the phone bar alone names + // it, is scoped to that dialog's own box — confirmed here, since the nested basic dialog below + // must keep its own head regardless. + $page->assertScript("getComputedStyle({$outerBar}).display !== 'none'"); + + $page->click('button:has-text("Delete the share")'); + + $inner = "[...document.querySelectorAll('dialog[open]')].find((d) => d.querySelector('[data-md-modal-title]')?.textContent === 'Delete this share?')"; + $innerHead = "{$inner}.querySelector('[data-md-modal-head]')"; + $innerTitle = "{$inner}.querySelector('[data-md-modal-title]')"; + + $page->assertScript("getComputedStyle({$innerHead}).display !== 'none'") + ->assertScript("getComputedStyle({$innerTitle}).display !== 'none'") + ->assertScript("parseFloat(getComputedStyle({$innerHead}).paddingTop) > 0") + ->assertScript("parseFloat(getComputedStyle({$innerHead}).paddingInlineStart) > 0"); +}); + +it('shows the 16% dragged state layer on the showcase card being dragged', function () { + $page = containment(); + + $card = "[...document.querySelectorAll('#containment [data-md-card]')].find((c) => c.textContent.includes('Elevation 4 and the 16% state layer'))"; + + $page->assertScript("getComputedStyle({$card}, '::before').opacity === '0'"); + + $page->click('#containment button:has-text("Pick it up or put it down")'); + + $page->assertScript("{$card}.hasAttribute('data-md-dragged')") + ->assertScript("getComputedStyle({$card}, '::before').opacity === '0.16'"); + + $page->click('#containment button:has-text("Pick it up or put it down")'); + + $page->assertScript("! {$card}.hasAttribute('data-md-dragged')") + ->assertScript("getComputedStyle({$card}, '::before').opacity === '0'"); +}); + +it('keeps a selected segmented row\'s fill under the hover and focus tint', function () { + Route::middleware('web')->get('/segmented-selected-row-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + +
+ + + + +
+ @livewireScripts + + + BLADE)); + + $page = visit('/segmented-selected-row-probe')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'"); + + $selected = "document.querySelector('[data-md-selected]')"; + $plain = "document.querySelector('[data-md-list-item]:not([data-md-selected])')"; + + $restBackground = $page->script("getComputedStyle({$selected}).backgroundColor"); + + $page->hover('[data-md-selected]'); + $selectedHoverBackground = $page->script("getComputedStyle({$selected}).backgroundColor"); + + $page->hover('[data-md-list-item]:not([data-md-selected])'); + $plainHoverBackground = $page->script("getComputedStyle({$plain}).backgroundColor"); + + // The hover tint is mixed *over* the row's own fill (`--md-list-row-fill`, list-item.css): + // the selected row's secondary-container stays under it, so its hover colour differs from + // both its own resting colour and a plain row's hover, which has no fill to keep. + expect($selectedHoverBackground)->not->toBe($restBackground) + ->and($selectedHoverBackground)->not->toBe($plainHoverBackground); + + $page->script("document.querySelector('[data-md-selected] [data-md-list-open]').focus()"); + + $selectedFocusBackground = $page->script("getComputedStyle({$selected}).backgroundColor"); + + expect($selectedFocusBackground)->not->toBe($restBackground) + ->and($selectedFocusBackground)->not->toBe($selectedHoverBackground); +});