Fix and extend the containment browser tests

Plan step 36 browser-test run. Five test bugs: a nested-dialog test
matched the wrong dialog by substring (the outer's textContent also
contains the inner's), a bottom-sheet test and a side-sheet test passed
Playwright-only `:has-text()`/a JS expression into APIs expecting a
CSS selector or ran page JS with `:has-text()`, a scroll-divider test
never actually scrolled (its content fit without it), and a full-screen
dialog test narrowed the viewport before an unrelated open side sheet
correctly became modal below it, leaving nothing else clickable.

The reviewer's review-checklist items without a covering test get one:
a selected segmented row keeps its fill under a real hover and focus
(pressed already has a Feature-level check); the side sheet mirrors
into a right-to-left page; a basic dialog nested inside a full-screen
one keeps its own title and padding at 400px; a dragged showcase card
shows the 16% state layer; the side and bottom sheet scrims are proven
to fade rather than vanish, sampled every few ms in the page itself
rather than after a fixed PHP-side wait (unreliable next to a 200ms
transition once round trips are counted); and the reduced-motion
dialog test now also proves a real, non-zero transition exists without
reduced motion, not only that none does with it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 22:57:56 +02:00
co-authored by Claude Sonnet 5
parent 54d88ebc3b
commit 17a575c753
+294 -21
View File
@@ -225,12 +225,20 @@ class NestedDialogProbe extends Component
<x-button label="Open outer" wire:click="$set('outer', true)" />
<x-modal wire:model="outer" title="Outer">
<p>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.</p>
{{-- 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. --}}
<p>Outer body, short enough to fit without scrolling.</p>
<x-button label="Open inner" wire:click="$set('inner', true)" />
<x-modal wire:model="inner" title="Inner">
<p>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.</p>
<p>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.</p>
<p>Inner body, line two, long enough on its own to take real vertical room in the scrolling body.</p>
<p>Inner body, line three, long enough on its own to take real vertical room in the scrolling body.</p>
<p>Inner body, line four, long enough on its own to take real vertical room in the scrolling body.</p>
<p>Inner body, line five, long enough on its own to take real vertical room in the scrolling body.</p>
<p>Inner body, line six, long enough on its own to take real vertical room in the scrolling body.</p>
</x-modal>
</x-modal>
</div>
@@ -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(<<<JS
(async () => {
{$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(<<<JS
(async () => {
{$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'
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<div x-data="{ open: false }" class="p-4">
<x-button label="Open" x-on:click="open = true" />
<x-drawer title="Filters">Narrow the list down.</x-drawer>
</div>
@livewireScripts
</body>
</html>
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'
<div>
<x-button label="Open settings" wire:click="$set('settings', true)" />
<x-modal wire:model="settings" title="Share settings" fullscreen>
A form that needs the room.
<x-button label="Delete the share" danger wire:click="$set('confirm', true)" />
<x-modal wire:model="confirm" title="Delete this share?">
Recipients lose access at once.
<x-slot:actions><x-button label="Delete" danger x-on:click="close()" /></x-slot:actions>
</x-modal>
</x-modal>
</div>
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'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:nested-fullscreen-dialog-probe />
@livewireScripts
</body>
</html>
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'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<div class="p-4" style="max-width: 400px">
<x-list segmented label="Rows">
<x-list-item title="Selected" link="#segmented-selected-row-probe" :selected="true" />
<x-list-item title="Plain" link="#segmented-selected-row-probe" />
</x-list>
</div>
@livewireScripts
</body>
</html>
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);
});