Files
livewire-material/tests/Browser/ContainmentTest.php
T
surtic86andClaude Opus 5 1b38b39a47
tests / feature (8.4) (push) Successful in 1m51s
tests / feature (8.5) (push) Successful in 1m57s
tests / browser (chrome, chromium) (push) Successful in 9m7s
tests / browser (firefox, firefox) (push) Failing after 15m45s
tests / browser (safari, webkit) (push) Failing after 16m52s
Stop every test page asking for a favicon nothing serves
`ready()` waits for the network to go idle, and each probe page asked for
/favicon.ico, which its route does not answer: the server threw
NotFoundHttpException on the way, and on the runner the two full-screen
date picker tests spent their whole budget inside that wait rather than
in the click Pest named. Every probe page now carries `<link rel="icon"
href="data:,">`, so the browser asks for nothing.

The showcase's own head carries it too. It ships no icon, and the 404 was
the application's to answer.

The runner takes two and a half to three times as long as a workstation,
so BROWSER_TIMEOUT there goes from 15 to 45 seconds: a page that loads
the application's Vite entries, Alpine and Livewire had no headroom left.

Feature 1159 passed; Browser 299 passed on Chrome, Firefox and WebKit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 05:30:29 +02:00

1674 lines
76 KiB
PHP

<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
class OverlayProbe extends Component
{
public bool $confirming = false;
public ?int $deletingId = null;
public int $renders = 0;
public function touch(): void
{
$this->renders++;
}
public function render(): string
{
return <<<'BLADE'
<div style="display: flex; flex-direction: column; gap: var(--md-sys-measurement-space200); padding: var(--md-sys-measurement-space200);">
<p>confirming: <span id="confirming">{{ var_export($confirming, true) }}</span></p>
<p>deleting: <span id="deleting">{{ var_export($deletingId, true) }}</span></p>
<p>renders: <span id="renders">{{ $renders }}</span></p>
<x-button label="Confirm" wire:click="$set('confirming', true)" />
<x-button label="Delete 7" wire:click="$set('deletingId', 7)" />
<x-modal wire:model="confirming" title="Are you sure?">
<x-slot:actions>
<x-button label="Re-render" wire:click="touch" />
</x-slot:actions>
</x-modal>
<x-modal wire:model="deletingId" title="Delete share?" />
</div>
BLADE;
}
}
class CollapseProbe extends Component
{
public bool $fineTuning = false;
public int $renders = 0;
public function touch(): void
{
$this->renders++;
}
public function expand(): void
{
$this->fineTuning = true;
}
public function collapse(): void
{
$this->fineTuning = false;
}
public function render(): string
{
return <<<'BLADE'
<div style="display: flex; flex-direction: column; gap: var(--md-sys-measurement-space200); padding: var(--md-sys-measurement-space200);">
<p>fine tuning: <span id="fine-tuning">{{ var_export($fineTuning, true) }}</span></p>
<p>renders: <span id="renders">{{ $renders }}</span></p>
<x-button label="Re-render" wire:click="touch" />
<x-button label="Open from the server" wire:click="expand" />
<x-button label="Close from the server" wire:click="collapse" />
<x-collapse id="fine-tuning-collapse" title="Fine-tuning" wire:model="fineTuning">Zones and paces.</x-collapse>
<div x-data="{ advanced: false }">
<p>advanced: <span id="advanced" x-text="advanced"></span></p>
<x-button label="Close from Alpine" x-on:click="advanced = false" />
<x-collapse id="advanced-collapse" title="Advanced" x-model="advanced">Everything else.</x-collapse>
</div>
</div>
BLADE;
}
}
function collapseProbe()
{
Livewire::component('collapse-probe', CollapseProbe::class);
Route::middleware('web')->get('/collapse-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<livewire:collapse-probe />
@livewireScripts
</body>
</html>
BLADE));
return ready(visit('/collapse-probe'));
}
function overlayProbe()
{
Livewire::component('overlay-probe', OverlayProbe::class);
Route::middleware('web')->get('/overlay-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<livewire:overlay-probe />
@livewireScripts
</body>
</html>
BLADE));
return ready(visit('/overlay-probe'));
}
function containment()
{
return ready(visit('/material/containment'));
}
it('writes back what closed means: false for a flag, null for an id', function () {
$page = overlayProbe();
$page->click('button:has-text("Confirm")')
->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Are you sure?'))");
$page->click('dialog[open] button:has-text("Re-render")')
->assertSeeIn('#renders', '1')
->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Are you sure?'))");
$page->keys('dialog[open] button:has-text("Re-render")', 'Escape')
->assertScript("! [...document.querySelectorAll('dialog')].some((d) => d.open)")
->assertSeeIn('#confirming', 'false');
$page->click('button:has-text("Delete 7")')
->assertSeeIn('#deleting', '7')
->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Delete share?'))");
$page->script("document.querySelector('dialog[open]').dispatchEvent(new Event('cancel', { cancelable: true }))");
$page->assertSeeIn('#deleting', 'NULL');
});
it('closes a showcase dialog on Escape and gives focus back to its trigger', function () {
// Opened from the keyboard: Safari does not focus a button on click, so after a click there
// is nothing for the dialog to hand focus back to.
$page = containment();
$page->script("[...document.querySelectorAll('#containment button')].find((b) => b.textContent.trim() === 'Basic dialog').focus()");
$page->keys(':focus', 'Enter')
->assertScript("document.querySelector('#containment dialog').open");
$page->keys('#containment dialog[open] button:has-text("Cancel")', 'Escape')
->assertScript("! document.querySelector('#containment dialog').open")
->assertScript("document.activeElement.textContent.trim() === 'Basic dialog'");
});
it('slides a side sheet in, traps the page, and closes on the scrim', function () {
$sheet = "document.querySelector('#containment aside[role=\"dialog\"]')";
$page = containment()
->click('#containment button:has-text("Side sheet")')
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') !== null");
// This sheet's own scrim: the standard side sheet above it on the page is a `[data-md-drawer]` too.
$page->script("{$sheet}.parentElement.querySelector(':scope > [aria-hidden=\"true\"]').click()");
$page->assertScript("getComputedStyle({$sheet}).display === 'none'")
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') === null");
});
it('dismisses a bottom sheet dragged down past a quarter of its height', function () {
$sheet = "document.querySelector('#containment section[role=\"dialog\"]')";
$page = containment()
->click('#containment button:has(> span:text-is("Bottom sheet"))')
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->wait(0.5);
$page->script(<<<JS
(() => {
const handle = {$sheet}.querySelector('[data-md-bottom-sheet-handle]');
const box = handle.getBoundingClientRect();
const x = box.x + box.width / 2, y = box.y + box.height / 2;
handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: x, clientY: y, button: 0 }));
window.dispatchEvent(new PointerEvent('pointermove', { clientX: x, clientY: y + 400 }));
window.dispatchEvent(new PointerEvent('pointerup', { clientX: x, clientY: y + 400 }));
})()
JS);
$page->assertScript("getComputedStyle({$sheet}).display === 'none'");
});
class NestedDialogProbe extends Component
{
public bool $outer = false;
public bool $inner = false;
public function render(): string
{
return <<<'BLADE'
<div>
<x-button label="Open outer" wire:click="$set('outer', true)" />
<x-modal wire:model="outer" title="Outer">
{{-- 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, 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>
BLADE;
}
}
function nestedDialogProbe()
{
Livewire::component('nested-dialog-probe', NestedDialogProbe::class);
Route::middleware('web')->get('/nested-dialog-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<livewire:nested-dialog-probe />
@livewireScripts
</body>
</html>
BLADE));
return ready(visit('/nested-dialog-probe'));
}
it('marks a scrolling dialog\'s divider at the edge with more content, and a nested dialog keeps its own marks', function () {
// Short and narrow so the probes' paragraphs genuinely overflow their body.
$page = nestedDialogProbe()->resize(320, 300);
$page->click('button:has-text("Open 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]')";
// Scrolled to the top: nothing hidden above, so the head's rule stays off.
$page->assertScript("getComputedStyle({$outerHead}, '::after').opacity === '0'");
$page->click('button:has-text("Open 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]')";
$page->assertScript("getComputedStyle({$innerHead}, '::after').opacity === '0'");
$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'")
->assertScript("getComputedStyle({$outerHead}, '::after').opacity === '0'");
});
it('shows a full-screen dialog\'s 56px phone bar below 600px, and the basic dialog above it', function () {
// 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]')";
$page->assertScript("getComputedStyle({$bar}).display !== 'none'")
->assertScript("Math.round({$bar}.getBoundingClientRect().height) === 56");
$page->resize(900, 800)
->assertScript("getComputedStyle({$bar}).display === 'none'");
});
it('draws separator dividers whatever the scroll, on a dialog that fits without scrolling', function () {
$page = containment();
$page->click('#containment button:has-text("Always divided (separator)")');
$dialog = "document.querySelector('#containment dialog[open]')";
$head = "{$dialog}.querySelector('[data-md-modal-head]')";
$actions = "{$dialog}.querySelector('[data-md-modal-actions]')";
$body = "{$dialog}.querySelector('[data-md-modal-body]')";
// The body fits without scrolling, so an unmarked dialog would show neither rule.
$page->assertScript("{$body}.scrollHeight <= {$body}.clientHeight + 1")
->assertScript("getComputedStyle({$head}, '::after').opacity === '1'")
->assertScript("getComputedStyle({$actions}, '::before').opacity === '1'");
});
it('opens a dialog with no fade under reduced motion, but a real one without it', function () {
$dialog = "document.querySelector('#containment dialog[open]')";
// 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 = ready(visit('/material/containment', ['reducedMotion' => 'reduce']))
->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
{
public bool $open = false;
public function render(): string
{
return <<<'BLADE'
<div>
<x-button label="Open" wire:click="$set('open', true)" />
<x-modal wire:model="open" title="Read this">
<p>Line one of a body with nothing else focusable inside it, so the dialog hands its first focus to the scrolling body itself: line two, line three, line four, line five, line six, line seven, line eight, line nine, line ten, line eleven, line twelve, line thirteen, line fourteen, line fifteen.</p>
</x-modal>
</div>
BLADE;
}
}
function textOnlyDialogProbe()
{
Livewire::component('text-only-dialog-probe', TextOnlyDialogProbe::class);
Route::middleware('web')->get('/text-only-dialog-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<livewire:text-only-dialog-probe />
@livewireScripts
</body>
</html>
BLADE));
return ready(visit('/text-only-dialog-probe'));
}
it('gives the scrolling body an inset focus ring when a text-only dialog opens from the keyboard', function () {
// 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()");
$page->keys(':focus', 'Enter');
$body = "document.querySelector('dialog[open] [data-md-modal-body]')";
$page->assertScript("document.activeElement.matches('[data-md-modal-body]')")
->assertScript("getComputedStyle({$body}).outlineStyle === 'solid'")
->assertScript("getComputedStyle({$body}).outlineWidth === '3px'")
->assertScript("getComputedStyle({$body}).outlineOffset === '-3px'");
// Outside Chrome the body holds the focus through a tabindex materialShowModal() gives it; a
// Livewire render keeps it (the body is wire:ignore.self), so the focus stays in the dialog.
$page->script('window.eval("Livewire.first().$refresh()")');
$page->assertScript("document.querySelector('dialog[open]') !== null")
->assertScript("document.activeElement.matches('[data-md-modal-body]')");
});
it('cycles a bottom sheet\'s preset heights from its handle, announcing each, and closes from the last', function () {
$page = containment();
$page->click('#containment button:has-text("Bottom sheet with preset heights")');
$sheet = "document.querySelector('#containment [data-md-bottom-sheet-panel][data-md-preset]')";
$handle = "{$sheet}.querySelector('[data-md-bottom-sheet-grip]')";
$announce = "{$sheet}.querySelector('[data-md-bottom-sheet-announce]')";
$page->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->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]';
$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($gripSelector);
$page->assertScript("getComputedStyle({$sheet}).display === 'none'");
});
it('settles a dragged bottom sheet on the nearest preset height', function () {
$page = containment();
$page->click('#containment button:has-text("Bottom sheet with preset heights")');
$sheet = "document.querySelector('#containment [data-md-bottom-sheet-panel][data-md-preset]')";
$page->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->wait(0.5);
// Drag up from the 50dvh stop a little way — not as far as 90dvh — and release: it settles
// back on 50dvh, the nearest stop, rather than creep on to the next one.
$page->script(<<<JS
(() => {
const handle = {$sheet}.querySelector('[data-md-bottom-sheet-handle]');
const box = handle.getBoundingClientRect();
const x = box.x + box.width / 2, y = box.y + box.height / 2;
handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: x, clientY: y, button: 0 }));
window.dispatchEvent(new PointerEvent('pointermove', { clientX: x, clientY: y - 40 }));
window.dispatchEvent(new PointerEvent('pointerup', { clientX: x, clientY: y - 40 }));
})()
JS);
$page->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '50dvh'");
});
it('is a standard side sheet from 840px, without a scrim or a focus trap, and the modal one below', function () {
$page = containment()->resize(1000, 800);
$sheet = "document.querySelector('#containment aside[data-md-side]')";
$scrim = "{$sheet}.parentElement.querySelector(':scope > [data-md-drawer-scrim]')";
$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.
$page->assertScript("getComputedStyle({$sheet}).position === 'relative'")
->assertScript("getComputedStyle({$scrim}).display === 'none'")
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') === null")
->assertScript("! {$sheet}.hasAttribute('inert')");
$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)
->click($toggle);
$page->assertScript("getComputedStyle({$sheet}).position === 'fixed'")
->assertScript("getComputedStyle({$scrim}).display !== 'none'")
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') !== null");
});
it('gives a closing standard side sheet\'s room back to the content beside it as it leaves, from 840px', function () {
$sheet = "document.querySelector('#containment aside[data-md-side]')";
$root = "{$sheet}.parentElement";
$column = "{$root}.previousElementSibling";
$toggle = "Array.from(document.querySelectorAll('#containment button')).find((button) => button.textContent.trim() === 'Show or hide the filters')";
// The example starts open, and stands open from the first frame Alpine draws: full 400px, fully
// shown, nothing on it animating.
$page = containment()->resize(1000, 800)
->assertScript("{$root}.hasAttribute('data-md-open') && Math.round({$root}.getBoundingClientRect().width) === 400 && getComputedStyle({$sheet}).opacity === '1' && {$root}.getAnimations({ subtree: true }).length === 0");
// Closed and sampled in one round trip: the sheet's root caught part-way between its width
// and none, still in the layout, while the column beside it has grown part of the way — not
// the whole sheet and its gap handed back in one jump at the end. Only once the view has
// settled, two frames after Alpine starts, which WebKit can still be waiting for here: until
// then the sheet has no transitions, on purpose, and closes at once.
$midExit = $page->script(<<<JS
(async () => {
const root = {$root}
const column = {$column}
for (let i = 0; i < 100 && ! root.hasAttribute('data-md-drawer-settled'); i++) {
await new Promise((resolve) => setTimeout(resolve, 10))
}
const open = { root: root.getBoundingClientRect().width, column: column.getBoundingClientRect().width, row: root.parentElement.getBoundingClientRect().width }
{$toggle}.click()
for (let i = 0; i < 80; i++) {
const width = root.getBoundingClientRect().width
const columnWidth = column.getBoundingClientRect().width
if (! root.hasAttribute('data-md-drawer-collapsed') && getComputedStyle(root).display !== 'none' && width > 1 && width < open.root - 1 && columnWidth > open.column + 1 && columnWidth < open.row - 1) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
JS);
expect($midExit)->toBeTrue();
// Settled: out of the layout, and the column is the whole row, gap included.
$page->assertScript("{$root}.hasAttribute('data-md-drawer-collapsed') && getComputedStyle({$root}).display === 'none'")
->assertScript("Math.abs({$column}.getBoundingClientRect().width - {$root}.parentElement.getBoundingClientRect().width) < 1");
// Reopened part-way through its exit, it ends open, not collapsed. Each script stays well under
// the browser plugin's one-second call timeout, past which it runs the script a second time.
$page->script("{$toggle}.click()");
$page->wait(0.6)
->assertScript("{$root}.hasAttribute('data-md-open') && {$root}.getAnimations({ subtree: true }).length === 0");
$page->script(<<<JS
(async () => {
{$toggle}.click()
await new Promise((resolve) => setTimeout(resolve, 60))
{$toggle}.click()
})()
JS);
$page->wait(0.9)
->assertScript("{$root}.hasAttribute('data-md-open') && ! {$root}.hasAttribute('data-md-drawer-collapsed') && Math.abs({$root}.getBoundingClientRect().width - 400) < 1 && getComputedStyle({$sheet}).opacity === '1'");
});
/**
* Layers stacked every way a page stacks them: a modal sheet holding a menu, a select, a searchable
* choice and a sheet of its own, which opens a dialog and a second sheet rendered elsewhere on the
* page; the dialog holding a menu, a select, a searchable choice and a sheet.
*/
function layersProbe(): mixed
{
Route::middleware('web')->get('/layers-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<main>
<div x-data="{ open: false }">
<button type="button" id="open-sheet" x-on:click="open = true">Open the session</button>
<x-drawer id="sheet" title="Session">
<button type="button" id="open-dialog" x-on:click="$dispatch('open-probe-dialog')">Set back</button>
<button type="button" id="open-beside" x-on:click="$dispatch('open-probe-beside')">Swap</button>
<x-menu label="More">
<x-slot:trigger><button type="button" id="open-menu">More</button></x-slot:trigger>
<x-menu-item label="Copy" />
<x-menu-item label="Move" />
</x-menu>
<x-select id="sheet-select" label="Expires" :options="[['id' => '1', 'name' => '1 hour'], ['id' => '24', 'name' => '1 day']]" />
<x-choices id="sheet-choices" label="Sport" searchable :options="[['id' => 'run', 'name' => 'Run'], ['id' => 'ride', 'name' => 'Ride']]" />
<div x-data="{ open: false }">
<button type="button" id="open-inner" x-on:click="open = true">Notes</button>
<x-drawer id="inner-sheet" title="Notes"><input id="inner-field" aria-label="Note" /></x-drawer>
</div>
</x-drawer>
</div>
<div x-data="{ open: false }" x-on:open-probe-beside.window="open = true">
<x-drawer id="beside-sheet" title="Swap"><input id="beside-field" aria-label="Swap with" /></x-drawer>
</div>
</main>
<div x-data="{ open: false }" x-on:open-probe-dialog.window="open = true">
<x-modal id="dialog" title="Set back the plan">
<input id="dialog-first" aria-label="Weeks" />
<input id="dialog-second" aria-label="Reason" />
<x-menu label="Dialog menu">
<x-slot:trigger><button type="button" id="open-dialog-menu">Options</button></x-slot:trigger>
<x-menu-item label="Keep" />
</x-menu>
<x-select id="dialog-select" label="Expires" :options="[['id' => '1', 'name' => '1 hour'], ['id' => '24', 'name' => '1 day']]" />
<x-choices id="dialog-choices" label="Sport" searchable :options="[['id' => 'run', 'name' => 'Run'], ['id' => 'ride', 'name' => 'Ride']]" />
<div x-data="{ open: false }">
<button type="button" id="open-dialog-sheet" x-on:click="open = true">Details</button>
<x-drawer id="dialog-sheet" title="Details"><input id="dialog-sheet-field" aria-label="Detail" /></x-drawer>
</div>
</x-modal>
</div>
@livewireScripts
</body>
</html>
BLADE));
return ready(visit('/layers-probe')->resize(1000, 800), livewire: false);
}
function sheetOpen(string $id): string
{
return "document.getElementById('{$id}').closest('[data-md-drawer]').hasAttribute('data-md-open')";
}
function hiddenFromAssistiveTech(string $id): string
{
return "(document.getElementById('{$id}').closest('[aria-hidden=\"true\"]') !== null)";
}
it('closes only a dialog opened over a modal sheet on Escape, readable and tabbable while it is open', function () {
$page = layersProbe();
$page->click('#open-sheet')->assertScript(sheetOpen('sheet'));
$page->click('#open-dialog')->assertScript("document.getElementById('dialog').open");
// Rendered outside the sheet, the dialog is not inside what the sheet hides, and the sheet's
// focus trap does not take the Tab back to the sheet.
$page->assertScript('! '.hiddenFromAssistiveTech('dialog'));
$page->click('#dialog-first');
$page->keys('#dialog-first', 'Tab')->assertScript("document.activeElement.id === 'dialog-second'");
$page->keys('#dialog-second', 'Escape')
->assertScript("! document.getElementById('dialog').open")
->assertScript(sheetOpen('sheet'))
// Back under the sheet, the dialog's part of the page is hidden again, and shown once the
// sheet closes too.
->assertScript(hiddenFromAssistiveTech('dialog'));
$page->script("document.getElementById('open-dialog').focus()");
$page->keys('#open-dialog', 'Escape')
->assertScript('! '.sheetOpen('sheet'))
->assertScript('! '.hiddenFromAssistiveTech('dialog'))
->assertNoJavaScriptErrors();
});
it('closes only the menu, select list or searchable choice open inside a modal sheet on Escape', function () {
$page = layersProbe();
$page->click('#open-sheet')->assertScript(sheetOpen('sheet'));
$page->click('#open-menu')->assertScript("document.querySelector('#sheet [data-md-menu-popover]').matches(':popover-open')");
$page->keys(':focus', 'Escape')
->assertScript("! document.querySelector('#sheet [data-md-menu-popover]').matches(':popover-open')")
->assertScript(sheetOpen('sheet'));
$page->click('#sheet-choices')->assertScript("document.querySelector('#sheet [data-md-field-menu]').matches(':popover-open')");
$page->keys('#sheet-choices', 'Escape')
->assertScript("! document.querySelector('#sheet [data-md-field-menu]').matches(':popover-open')")
->assertScript(sheetOpen('sheet'));
// A customizable select's list, where the browser has one: elsewhere there is no list of the
// page's own open over the sheet.
if ($page->script("CSS.supports('appearance', 'base-select')") === true) {
$page->click('#sheet-select')->assertScript("document.getElementById('sheet-select').matches(':open')");
$page->keys(':focus', 'Escape')
->assertScript("! document.getElementById('sheet-select').matches(':open')")
->assertScript(sheetOpen('sheet'));
}
$page->assertNoJavaScriptErrors();
});
it('closes only the sheet on top on Escape, inside the first or beside it, and keeps the one beside it readable', function () {
$page = layersProbe();
$page->click('#open-sheet')->assertScript(sheetOpen('sheet'));
$page->click('#open-inner')->assertScript(sheetOpen('inner-sheet'));
$page->click('#inner-field');
$page->keys('#inner-field', 'Escape')
->assertScript('! '.sheetOpen('inner-sheet'))
->assertScript(sheetOpen('sheet'));
$page->click('#open-beside')->assertScript(sheetOpen('beside-sheet'))
->assertScript('! '.hiddenFromAssistiveTech('beside-sheet'));
$page->click('#beside-field');
$page->keys('#beside-field', 'Escape')
->assertScript('! '.sheetOpen('beside-sheet'))
->assertScript(sheetOpen('sheet'))
->assertScript(hiddenFromAssistiveTech('beside-sheet'))
->assertNoJavaScriptErrors();
});
it('closes only the sheet, searchable choice, menu or select list open inside a dialog on Escape', function () {
$page = layersProbe();
$page->script("window.dispatchEvent(new CustomEvent('open-probe-dialog'))");
$page->assertScript("document.getElementById('dialog').open");
$page->click('#open-dialog-sheet')->assertScript(sheetOpen('dialog-sheet'));
$page->click('#dialog-sheet-field');
$page->keys('#dialog-sheet-field', 'Escape')
->assertScript('! '.sheetOpen('dialog-sheet'))
->assertScript("document.getElementById('dialog').open");
$page->click('#dialog-choices')->assertScript("document.querySelector('#dialog [data-md-field-menu]').matches(':popover-open')");
$page->keys('#dialog-choices', 'Escape')
->assertScript("! document.querySelector('#dialog [data-md-field-menu]').matches(':popover-open')")
->assertScript("document.getElementById('dialog').open");
$page->click('#open-dialog-menu')->assertScript("document.querySelector('#dialog [data-md-menu-popover]').matches(':popover-open')");
$page->keys(':focus', 'Escape')
->assertScript("! document.querySelector('#dialog [data-md-menu-popover]').matches(':popover-open')")
->assertScript("document.getElementById('dialog').open");
if ($page->script("CSS.supports('appearance', 'base-select')") === true) {
$page->click('#dialog-select')->assertScript("document.getElementById('dialog-select').matches(':open')");
$page->keys(':focus', 'Escape')
->assertScript("! document.getElementById('dialog-select').matches(':open')")
->assertScript("document.getElementById('dialog').open");
}
$page->assertNoJavaScriptErrors();
});
it('draws a standard side sheet that starts open standing open, without growing it in on load', function () {
// Slow tokens, so a load-time entry would still be running when the page is first read.
Route::middleware('web')->get('/standard-sheet-load-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
<style>:root { --md-sys-motion-spatial-default-duration: 3000ms; --md-sys-motion-effects-default-duration: 3000ms; }</style>
</head>
<body>
<x-row align="stretch" gap="space300" x-data="{ open: true }">
<x-stack style="flex: 1 1 0%; min-width: 0;">
<button type="button" id="toggle-sheet" x-on:click="open = ! open">Show or hide the filters</button>
</x-stack>
<x-drawer standard title="Filters">Filter the list</x-drawer>
</x-row>
@livewireScripts
</body>
</html>
BLADE));
$root = "document.querySelector('[data-md-drawer]')";
$page = visit('/standard-sheet-load-probe')->resize(1000, 800)->waitForEvent('networkidle')
->assertScript("typeof window.Alpine !== 'undefined' && {$root}.hasAttribute('data-md-drawer-settled')")
->assertScript("{$root}.hasAttribute('data-md-open') && Math.abs({$root}.getBoundingClientRect().width - 400) < 1")
->assertScript("{$root}.getAnimations({ subtree: true }).length === 0")
->assertScript("getComputedStyle({$root}.querySelector('[data-md-drawer-sheet]')).opacity === '1'");
// Settled, it still moves when someone closes it.
$page->script("document.getElementById('toggle-sheet').click()");
$page->assertScript("{$root}.getAnimations({ subtree: true }).length > 0");
});
it('sticks a standard side sheet under the top safe area and a scaffold\'s sticky app bar, clear of the bottom one', function () {
Route::middleware('web')->get('/standard-sheet-safe-probe/{bar}', fn (string $bar) => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
<style>:root { --material-safe-top: 47px; --material-safe-bottom: 34px; }</style>
</head>
<body>
<x-scaffold :destinations="[['title' => 'Plans', 'icon' => 'event', 'url' => '/standard-sheet-safe-probe/none', 'active' => true]]">
@if ($bar !== 'none')
<x-slot:top><x-app-bar title="Plan" :variant="$bar" /></x-slot:top>
@endif
<x-row align="stretch" gap="space300" x-data="{ open: true }">
<x-stack style="flex: 1 1 0%; min-width: 0;"><p style="block-size: 3000px">The plan</p></x-stack>
<x-drawer standard title="Filters" id="safe-sheet">Filter the list</x-drawer>
</x-row>
</x-scaffold>
@livewireScripts
</body>
</html>
BLADE, ['bar' => $bar]));
$sheet = "document.getElementById('safe-sheet')";
$top = fn (string $element): string => "Math.round({$element}.getBoundingClientRect().top)";
// Where the sheet sticks once the page has scrolled: below the status bar alone, below a small
// bar's 64px row and the safe area it pads itself with, and below the 64px a large bar
// collapses to; its foot at the window's, with the bottom inset inside its padding.
foreach (['none' => 47, 'small' => 111, 'large' => 64] as $bar => $expected) {
$page = visit("/standard-sheet-safe-probe/{$bar}")->resize(1000, 800)->waitForEvent('networkidle')
->assertScript("typeof window.Alpine !== 'undefined' && {$sheet}.parentElement.hasAttribute('data-md-drawer-settled')");
$page->script('window.scrollTo(0, 600)');
$page->assertScript("{$top($sheet.'.parentElement')} === {$expected}")
->assertScript("Math.round({$sheet}.getBoundingClientRect().bottom) === 800")
->assertScript("{$top($sheet.".querySelector('[data-md-drawer-close]')")} >= {$expected}")
->assertScript("getComputedStyle({$sheet}).paddingBottom === '58px'");
if ($bar !== 'none') {
$page->assertScript("Math.round(document.querySelector('[data-md-app-bar-row]').getBoundingClientRect().bottom) <= {$expected}");
}
}
});
class StandardSheetsProbe extends Component
{
public bool $filters = false;
public ?int $session = 7;
public function render(): string
{
return <<<'BLADE'
<div>
<x-row id="sheets-row" align="stretch" gap="space300" x-data="{ open: false }">
<x-stack id="sheets-column" style="flex: 1 1 0%; min-width: 0;"><p>The session list</p></x-stack>
<x-drawer standard wire:model="filters" title="Filters">Narrow the list down</x-drawer>
<x-drawer standard wire:model="session" title="Session">The open session</x-drawer>
<x-drawer standard title="Notes">Nobody opened these</x-drawer>
</x-row>
<script>
window.firstPaint = {
column: document.getElementById('sheets-column').getBoundingClientRect().width,
alpine: typeof window.Alpine,
};
</script>
</div>
BLADE;
}
}
it('draws standard side sheets as the server renders them from the first paint, closed ones out of the row and an open one standing', function () {
Livewire::component('standard-sheets-probe', StandardSheetsProbe::class);
Route::middleware('web')->get('/standard-sheets-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body>
<livewire:standard-sheets-probe />
@livewireScripts
</body>
</html>
BLADE));
// Playwright's window is 1280px wide from the first paint, in the band where the sheets stand
// in the row. The column measured before Alpine starts is the column once it has settled: two
// closed sheets (one bound to Livewire, one to Alpine) take no gap, and the open one its width.
$page = visit('/standard-sheets-probe')->waitForEvent('networkidle')
->assertScript("window.eval('window.firstPaint.alpine') === 'undefined'")
->assertScript("[...document.querySelectorAll('[data-md-drawer]')].every((root) => root.hasAttribute('data-md-drawer-settled'))");
$settled = $page->script("document.getElementById('sheets-column').getBoundingClientRect().width");
expect(abs($page->script('window.firstPaint.column') - $settled))->toBeLessThan(1)
->and($page->script("Math.round([...document.querySelectorAll('[data-md-drawer]')][1].getBoundingClientRect().width)"))->toBe(400);
});
it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () {
$page = containment();
$row = '#containment [data-md-card][data-md-list-row]:not([data-md-list-actionable])';
$page->script("window.__opened = 0; document.querySelector('{$row} [data-md-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
$page->click("{$row} p")
->assertScript('window.__opened === 1');
$page->click("{$row} button:has-text(\"Copy link\")")
->assertScript('window.__opened === 1');
});
it('makes a directly actionable card the one tab stop, answering Enter', function () {
$card = '#containment [data-md-card][data-md-list-actionable]';
$page = containment();
$page->script("window.__opened = 0; document.querySelector('{$card} [data-md-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
$page->script("document.querySelector('{$card}').focus()");
$page->assertScript("document.activeElement.matches('{$card}')");
$page->keys($card, 'Enter')
->assertScript('window.__opened === 1');
});
it('binds a collapse to a Livewire property both ways', function () {
$collapse = "document.querySelector('#fine-tuning-collapse')";
$page = collapseProbe()
->assertScript("{$collapse}.open === false");
$page->click('#fine-tuning-collapse summary')
->assertScript("{$collapse}.open === true")
->click('button:has-text("Re-render")')
->assertSeeIn('#renders', '1')
->assertSeeIn('#fine-tuning', 'true')
->assertScript("{$collapse}.open === true");
$page->click('button:has-text("Close from the server")')
->assertSeeIn('#fine-tuning', 'false')
->assertScript("{$collapse}.open === false");
$page->click('button:has-text("Open from the server")')
->assertSeeIn('#fine-tuning', 'true')
->assertScript("{$collapse}.open === true");
});
it('binds a collapse to an Alpine property both ways', function () {
$collapse = "document.querySelector('#advanced-collapse')";
$page = collapseProbe()
->assertScript("{$collapse}.open === false");
$page->click('#advanced-collapse summary')
->assertScript("{$collapse}.open === true")
->assertSeeIn('#advanced', 'true');
$page->click('button:has-text("Close from Alpine")')
->assertSeeIn('#advanced', 'false')
->assertScript("{$collapse}.open === false");
});
/**
* The side sheet and the bottom sheet close the same way — a fade on the scrim, a slide on the
* sheet itself — differing only in the selector each renders under, the click that opens it, the
* scrim's own hook attribute, the dimension it slides across, the `translate` component that
* carries the offset (a single value for the side sheet's horizontal slide, the second of a
* `translate: x y` pair for the bottom sheet's vertical one) and whether that offset is read
* signed: the side sheet's can slide either way (a right-to-left page), so its mid-slide sample
* takes `Math.abs()`; the bottom sheet only ever slides down, so its own stays signed.
*/
dataset('containment sheets', [
'side sheet' => [
"document.querySelector('#containment aside[role=\"dialog\"]')",
'#containment button:has-text("Side sheet")',
'data-md-drawer-scrim',
'width',
'',
true,
],
'bottom sheet' => [
"document.querySelector('#containment section[role=\"dialog\"]')",
'#containment button:has(> span:text-is("Bottom sheet"))',
'data-md-bottom-sheet-scrim',
'height',
".split(' ')[1]",
false,
],
]);
it('fades the sheet\'s scrim out on close, rather than making it vanish', function (string $sheet, string $trigger, string $scrimAttr) {
$scrim = "{$sheet}.parentElement.querySelector(':scope > [{$scrimAttr}]')";
$page = containment()
->click($trigger)
->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
// 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'");
})->with('containment sheets');
it('slides the sheet out on close, rather than making it vanish', function (string $sheet, string $trigger, string $scrimAttr, string $dimension, string $axis, bool $absolute) {
$scrim = "{$sheet}.parentElement.querySelector(':scope > [{$scrimAttr}]')";
$offset = "(parseFloat(getComputedStyle({$sheet}).translate{$axis}) || 0)";
$sample = $absolute ? "Math.abs(parseFloat(style.translate{$axis}) || 0)" : "(parseFloat(style.translate{$axis}) || 0)";
$page = containment()
->click($trigger)
->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`. 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(<<<JS
(async () => {
const size = {$sheet}.getBoundingClientRect().{$dimension}
{$scrim}.click()
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
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
JS);
expect($midSlide)->toBeTrue();
$page->assertScript("getComputedStyle({$sheet}).display === 'none'");
})->with('containment sheets');
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 dir="rtl">
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<div x-data="{ open: false }" style="padding: var(--md-sys-measurement-space200);">
<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 = ready(visit('/drawer-rtl-probe'), livewire: false);
$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 mirror reads `[dir='rtl']` rather than
// `:dir(rtl)`, because Vite's own minifier rewrites that to a `:lang()` list): 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>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<livewire:nested-fullscreen-dialog-probe />
@livewireScripts
</body>
</html>
BLADE));
$page = ready(visit('/nested-fullscreen-dialog-probe')->resize(400, 800));
$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>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<div style="padding: var(--md-sys-measurement-space200); 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 = ready(visit('/segmented-selected-row-probe'), livewire: false);
$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);
});
/**
* An element's background and ink, "background / color", once the transitions a change started on
* it have run: a row moves its background on the effects springs.
*/
function settledPaint(string $element): string
{
return "(async () => { const element = {$element}; await Promise.allSettled(element.getAnimations().map((animation) => animation.finished)); const style = getComputedStyle(element); return style.backgroundColor + ' / ' + style.color })()";
}
it('fills a selected row written by hand in secondary-container in both themes, but not a card', function () {
Route::middleware('web')->get('/hand-made-selected-row-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<div style="padding: var(--md-sys-measurement-space200); max-width: 400px;">
<p id="swatch" style="background-color: var(--md-sys-color-secondary-container); color: var(--md-sys-color-on-secondary-container);">The role pair</p>
<ul>
<li id="selected-row" data-md-list-row data-md-selected><a href="#selected" data-md-list-open>Selected</a></li>
<li id="plain-row" data-md-list-row><a href="#plain" data-md-list-open>Plain</a></li>
</ul>
<x-table>
<tbody>
<tr id="selected-table-row" data-md-selected><td>Selected, opening nothing</td></tr>
</tbody>
</x-table>
<x-card id="selected-card" data-md-list-row data-md-selected>
<a href="#card" data-md-list-open>A card</a>
</x-card>
</div>
@livewireScripts
</body>
</html>
BLADE));
$page = ready(visit('/hand-made-selected-row-probe'), livewire: false);
$swatch = settledPaint("document.getElementById('swatch')");
$row = settledPaint("document.getElementById('selected-row')");
$tableRow = settledPaint("document.getElementById('selected-table-row')");
$light = $page->script($swatch);
// The role pair itself, on an `<li>` and on a plain `<tr>` in a table; a card keeps its own
// container, and a row that is not selected has no fill.
expect($page->script($row))->toBe($light)
->and($page->script($tableRow))->toBe($light)
->and($page->script("getComputedStyle(document.getElementById('plain-row')).backgroundColor"))->toBe('rgba(0, 0, 0, 0)')
->and($page->script("getComputedStyle(document.getElementById('selected-card')).backgroundColor"))->not->toBe(explode(' / ', $light)[0]);
$page->script("document.documentElement.setAttribute('data-theme', 'dark')");
$dark = $page->script($swatch);
expect($dark)->not->toBe($light)
->and($page->script($row))->toBe($dark)
->and($page->script($tableRow))->toBe($dark);
// The hover tint is laid over the fill, so it is neither the fill nor a plain row's hover.
$page->hover('#selected-row');
$selectedHover = explode(' / ', $page->script($row))[0];
$page->hover('#plain-row');
$plainHover = explode(' / ', $page->script(settledPaint("document.getElementById('plain-row')")))[0];
expect($selectedHover)->not->toBe(explode(' / ', $dark)[0])
->and($selectedHover)->not->toBe($plainHover)
->and($page->script($row))->toBe($dark);
});
/**
* Collapses to watch move: one on its own with a paragraph under it, one bound to Alpine, and a
* `name` group of two with the first open. Each body is tall enough for a height caught part-way
* to be told from both ends.
*/
function collapseMotionProbe(string $durations = ''): mixed
{
Route::middleware('web')->get('/collapse-motion-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<x-collapse id="plain" title="How long do links last?">
<p style="block-size: 240px">Until the expiry the sender chose, at most thirty days.</p>
</x-collapse>
<p id="below">Under the collapse.</p>
<div x-data="{ advanced: false }">
<x-collapse id="bound" title="Advanced" x-model="advanced">
<p style="block-size: 240px">Everything else.</p>
</x-collapse>
<button type="button" id="open-bound" x-on:click="advanced = true">Open from Alpine</button>
<button type="button" id="close-bound" x-on:click="advanced = false">Close from Alpine</button>
<output id="advanced" x-text="advanced"></output>
</div>
<x-collapse id="first" title="First" name="faq" open>
<p style="block-size: 240px">The first answer.</p>
</x-collapse>
<x-collapse id="second" title="Second" name="faq">
<p style="block-size: 240px">The second answer.</p>
</x-collapse>
@livewireScripts
</body>
</html>
BLADE));
$page = ready(visit('/collapse-motion-probe'), livewire: false);
if ($durations !== '') {
$page->script("document.head.insertAdjacentHTML('beforeend', '<style>:root { --md-sys-motion-spatial-fast-duration: {$durations}; }</style>')");
}
return $page;
}
/**
* Runs `$act` in the page and samples `$details` every frame until its animation has run: whether
* its height was ever caught strictly between where it started and where it ended, what `open` and
* the closing mark said as it began, whether the chevron was caught turning, and how it ended. In one round trip, because a separate read
* costs about as long as the 350ms spring.
*/
function collapseMotion(mixed $page, string $act, string $details): array
{
return $page->script(<<<JS
(async () => {
const details = document.querySelector('{$details}')
const chevron = details.querySelector('[data-md-collapse-chevron]')
const below = details.nextElementSibling
const start = details.getBoundingClientRect().height
const belowStart = below?.getBoundingClientRect().top ?? 0
const heights = []
const belows = []
{$act}
// A binding reaches the collapse from Alpine's own flush, a microtask on; a task later
// both routes have begun, and the close's attribute and `open` hold for its whole run.
await new Promise((resolve) => setTimeout(resolve, 0))
const first = { open: details.open, closing: details.hasAttribute('data-md-collapse-closing') }
let chevronTurning = false
for (let i = 0; i < 120; i++) {
await new Promise((resolve) => requestAnimationFrame(resolve))
heights.push(details.getBoundingClientRect().height)
belows.push(below?.getBoundingClientRect().top ?? 0)
const rotate = getComputedStyle(chevron).rotate
chevronTurning ||= rotate !== 'none' && rotate !== '180deg' && rotate !== '0deg'
if (i > 5 && details.getAnimations().length === 0) break
}
const end = details.getBoundingClientRect().height
const low = Math.min(start, end)
const high = Math.max(start, end)
return {
between: heights.some((height) => height > low + 2 && height < high - 2),
belowMoved: belows.some((top) => top > Math.min(belowStart, belows.at(-1)) + 2 && top < Math.max(belowStart, belows.at(-1)) - 2),
first,
chevronTurning,
start,
end,
open: details.open,
closing: details.hasAttribute('data-md-collapse-closing'),
style: details.getAttribute('style'),
animations: details.getAnimations().length,
}
})()
JS);
}
it('eases a collapse open and shut in every engine, moving what is under it', function () {
$page = collapseMotionProbe();
$opened = collapseMotion($page, "details.querySelector('summary').click()", '#plain');
expect($opened['first']['open'])->toBeTrue()
->and($opened['between'])->toBeTrue()
->and($opened['belowMoved'])->toBeTrue()
->and($opened['open'])->toBeTrue()
->and($opened['end'])->toBeGreaterThan($opened['start'] + 200)
->and($opened['style'])->toBeNull()
->and($opened['animations'])->toBe(0);
$closed = collapseMotion($page, "details.querySelector('summary').click()", '#plain');
// Still open while the height goes, so there is content to clip; the chevron turns back at once.
expect($closed['first'])->toBe(['open' => true, 'closing' => true])
->and($closed['chevronTurning'])->toBeTrue()
->and($closed['between'])->toBeTrue()
->and($closed['belowMoved'])->toBeTrue()
->and($closed['open'])->toBeFalse()
->and($closed['closing'])->toBeFalse()
->and($closed['end'])->toBe($opened['start'])
->and($closed['style'])->toBeNull()
->and($closed['animations'])->toBe(0);
// Clipped while it moves, so the content never shows past the edge that is moving.
$clipped = $page->script(<<<'JS'
(async () => {
const details = document.querySelector('#plain')
details.querySelector('summary').click()
await new Promise((resolve) => requestAnimationFrame(resolve))
const overflow = getComputedStyle(details).overflowY
await Promise.all(details.getAnimations().map((animation) => animation.finished))
return overflow
})()
JS);
expect($clipped)->toBe('clip');
// From the keyboard: Enter on the summary is the same press, taken over the same way.
$page->script("document.querySelector('#plain summary').focus()");
$page->keys('#plain summary', 'Enter')
->assertScript("document.querySelector('#plain').open === false && document.querySelector('#plain').getAnimations().length === 0");
});
it('eases a collapse bound to Alpine open and shut, keeping the binding in step', function () {
$page = collapseMotionProbe();
$opened = collapseMotion($page, "document.querySelector('#open-bound').click()", '#bound');
expect($opened['between'])->toBeTrue()
->and($opened['open'])->toBeTrue();
$page->assertSeeIn('#advanced', 'true');
$closed = collapseMotion($page, "document.querySelector('#close-bound').click()", '#bound');
expect($closed['first']['closing'])->toBeTrue()
->and($closed['between'])->toBeTrue()
->and($closed['open'])->toBeFalse()
->and($closed['end'])->toBe($opened['start']);
$page->assertSeeIn('#advanced', 'false');
// A press on the summary tells the binding when the section has closed, and never loops.
$page->click('#bound summary')
->assertSeeIn('#advanced', 'true')
->assertScript("document.querySelector('#bound').open === true");
});
it('turns a collapse round from the height it has reached, ending as the last press asked', function () {
$page = collapseMotionProbe('1500ms');
$result = $page->script(<<<'JS'
(async () => {
const details = document.querySelector('#plain')
const summary = details.querySelector('summary')
const closed = details.getBoundingClientRect().height
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
summary.click()
await wait(300)
const midway = details.getBoundingClientRect().height
summary.click()
await new Promise((resolve) => requestAnimationFrame(resolve))
const turned = details.getBoundingClientRect().height
await wait(1700)
return { closed, midway, turned, end: details.getBoundingClientRect().height, open: details.open, style: details.getAttribute('style') }
})()
JS);
expect($result['midway'])->toBeGreaterThan($result['closed'] + 2)
// Turned round from where it was, not from either end.
->and(abs($result['turned'] - $result['midway']))->toBeLessThan(40)
->and($result['open'])->toBeFalse()
->and($result['end'])->toBe($result['closed'])
->and($result['style'])->toBeNull();
});
it('closes the open member of a name group on the same spring as the one opening', function () {
$page = collapseMotionProbe();
$first = "document.querySelector('#first')";
$result = $page->script(<<<'JS'
(async () => {
const first = document.querySelector('#first')
const second = document.querySelector('#second')
const start = first.getBoundingClientRect().height
let caught = false
second.querySelector('summary').click()
const stillOpen = first.open
for (let i = 0; i < 120; i++) {
await new Promise((resolve) => requestAnimationFrame(resolve))
const height = first.getBoundingClientRect().height
caught ||= first.open && height > 60 && height < start - 2
if (i > 5 && first.getAnimations().length === 0 && second.getAnimations().length === 0) break
}
return { stillOpen, caught, first: first.open, second: second.open, name: first.getAttribute('name') }
})()
JS);
expect($result)->toBe(['stillOpen' => true, 'caught' => true, 'first' => false, 'second' => true, 'name' => 'faq']);
// The group still keeps one open: the browser's own exclusivity, with the name back.
$page->script("document.querySelector('#first').open = true");
$page->assertScript("{$first}.open === true && document.querySelector('#second').open === false");
});
it('opens and closes a collapse at once under reduced motion, leaving nothing behind', function () {
$page = collapseMotionProbe('0ms');
$result = $page->script(<<<'JS'
(() => {
const details = document.querySelector('#plain')
details.querySelector('summary').click()
const opened = { open: details.open, animations: details.getAnimations().length }
details.querySelector('summary').click()
return { opened, open: details.open, animations: details.getAnimations().length, closing: details.hasAttribute('data-md-collapse-closing'), style: details.getAttribute('style') }
})()
JS);
expect($result)->toBe(['opened' => ['open' => true, 'animations' => 0], 'open' => false, 'animations' => 0, 'closing' => false, 'style' => null]);
});
class OpenedLayersProbe extends Component
{
public ?int $workout = null;
public bool $setback = false;
public bool $about = false;
public function mount(): void
{
$this->workout = request()->has('workout') ? (int) request('workout') : null;
}
public function render(): string
{
return <<<'BLADE'
<div>
<p>The week's sessions</p>
<button type="button" id="open-setback" wire:click="$set('setback', true)">Set back the plan</button>
<button type="button" id="open-about" wire:click="$set('about', true)">About expiry</button>
<x-drawer wire:model="workout" title="Long run">
<input id="sheet-note" aria-label="Note" />
</x-drawer>
<x-modal wire:model="setback" title="Set back the plan" fullscreen>
<input id="dialog-weeks" aria-label="Weeks" />
</x-modal>
<x-modal wire:model="about" title="Expiry">
<x-rich-tooltip text="Recipients lose access after this time.">
<x-button id="about-help" icon="help" aria-label="What expiry means" />
</x-rich-tooltip>
</x-modal>
</div>
BLADE;
}
}
/** Whether the plain tooltip of the ✕ in the sheet or the full-screen dialog's bar is up. */
function closeTooltipOpen(string $layer): string
{
return "document.querySelector('{$layer} button[aria-label=\"Close\"] [data-md-tooltip]').matches(':popover-open')";
}
it('shows no tooltip on the control a sheet or a dialog focuses as it opens, but does once Tab reaches it', function () {
Livewire::component('opened-layers-probe', OpenedLayersProbe::class);
Route::middleware('web')->get('/opened-layers-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<livewire:opened-layers-probe />
@livewireScripts
</body>
</html>
BLADE));
$sheet = '[data-md-drawer-sheet]';
$dialog = 'dialog[data-md-modal]';
// A phone, where the sheet is modal and traps the focus, which it moves to its ✕ on open: here
// on a page loaded with the sheet open, as a link to a session opens it.
$page = ready(visit('/opened-layers-probe?workout=7', ['viewport' => ['width' => 393, 'height' => 800]]), livewire: false)
->assertScript("document.activeElement.matches('{$sheet} button[aria-label=\"Close\"]')")
->wait(0.3)
->assertScript('! '.closeTooltipOpen($sheet));
// Tab from the sheet's last field wraps round to its ✕, and a keyboard's focus shows its label.
$page->keys('#sheet-note', 'Tab')
->assertScript("document.activeElement.matches('{$sheet} button[aria-label=\"Close\"]')")
->assertScript(closeTooltipOpen($sheet));
// A full-screen dialog opened from the keyboard moves the focus to its bar's ✕ in showModal().
$page = ready(visit('/opened-layers-probe', ['viewport' => ['width' => 393, 'height' => 800]]), alpine: false);
$page->keys('#open-setback', 'Enter')
->assertScript("document.querySelector('{$dialog}').open && document.activeElement.matches('{$dialog} button[aria-label=\"Close\"]')")
->wait(0.3)
->assertScript('! '.closeTooltipOpen($dialog));
$page->keys('#dialog-weeks', 'Tab')
->assertScript("document.activeElement.matches('{$dialog} button[aria-label=\"Close\"]')")
->assertScript(closeTooltipOpen($dialog));
// A rich tooltip's trigger as the first control of a dialog opened from the keyboard.
$page = ready(visit('/opened-layers-probe', ['viewport' => ['width' => 393, 'height' => 800]]), alpine: false);
$page->keys('#open-about', 'Enter')
->assertScript("document.activeElement.id === 'about-help'")
->wait(0.3)
->assertScript("! document.querySelector('[data-md-rich-tooltip-bubble]').matches(':popover-open')");
});