Files
livewire-material/tests/Browser/LayoutTest.php
T
surtic86andClaude Opus 5 ebdc2ef2e1
tests / feature (8.4) (push) Successful in 1m50s
tests / feature (8.5) (push) Successful in 1m54s
tests / browser (chrome, chromium) (push) Successful in 7m52s
tests / browser (firefox, firefox) (push) Successful in 11m56s
tests / browser (safari, webkit) (push) Successful in 12m35s
Press once whatever a second press would open over or undo
The browser plugin runs every call on a page again when its first
attempt takes over a second, and on the runner a press can. For most
presses that costs nothing. For two kinds it breaks the test: a press
that opens a dialog, a sheet, a full-screen view or a modal rail over
its own trigger, whose second press can never land, and a press that
changes state a second one would change again — a menu trigger, a
toggle, a chip, a range picker's day, a paging key, a Save. The bottom
sheet with preset heights timed out on WebKit that way after the date
picker had on Firefox.

Every such press in the browser suite now goes through pressOnce(), 253
of them across sixteen files, not only the ones the runner happened to
catch; focus moves inside an open view, Escape, links and plain "set"
actions keep the retry, which is harmless for them.

Two samples that were still racing the machine:

- The standard side sheet's exit is caught half-way with its motion
  stretched, as every other mid-exit sample is, and fullSpeed() takes
  the stretch off again before the test times a reopen against the real
  exit. Under load on Linux WebKit it had failed two runs in five; it
  passes ten in ten.
- The switch-and-checkbox row measures its widths once, so it now waits
  for the resize to land and the brand face to load before it does.

Browser 300 passed on Firefox and WebKitGTK in a Linux container held to
two busy cores, and on Chrome, Firefox and WebKit on macOS. Feature 1159
passed.

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

597 lines
30 KiB
PHP

<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
/**
* The layout components at M3's breakpoints: each on a page of its own, measured at the pixel
* either side of the breakpoint it changes at. The window is resized after the page has loaded,
* which is what a person does; a media query answers at once, and a script listening to one
* (resources/js/layout.js) hears a change event.
*/
function layoutPage(string $body, int $width, int $height = 900, string $dir = 'ltr'): mixed
{
$path = '/layout-probe/'.md5($body.$dir);
Route::middleware('web')->get($path, fn () => Blade::render(<<<BLADE
<!DOCTYPE html>
<html dir="{$dir}" lang="en">
<head>
<link rel="icon" href="data:,">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body>
{$body}
@livewireScripts
</body>
</html>
BLADE));
return layoutReady(visit($path)->resize($width, $height));
}
function layoutReady(mixed $page): mixed
{
return ready($page);
}
/** A computed style of the element matching the selector. */
function layoutStyle(string $selector, string $property): string
{
return "getComputedStyle(document.querySelector('{$selector}')).{$property}";
}
/** A side of the element's box, rounded to the pixel. */
function layoutRect(string $selector, string $side): string
{
return "Math.round(document.querySelector('{$selector}').getBoundingClientRect().{$side})";
}
/** How many column tracks a grid has. */
function layoutColumns(string $selector): string
{
return layoutStyle($selector, 'gridTemplateColumns').".split(' ').length";
}
it('spaces a stack with its token, never with its parent\'s, and hides it from a breakpoint over its own display', function () {
$body = '<x-stack id="outer" gap="space300"><x-stack id="inner"><p>One</p><p>Two</p></x-stack><p>Three</p></x-stack>'
.'<x-stack id="hidden-from" hide-from="medium"><p>Compact only</p></x-stack>';
layoutPage($body, 599)
->assertScript(layoutStyle('#outer', 'flexDirection')." === 'column'")
->assertScript(layoutStyle('#outer', 'rowGap')." === '24px'")
->assertScript(layoutStyle('#inner', 'rowGap')." === '0px'")
->assertScript(layoutStyle('#hidden-from', 'display')." === 'flex'");
layoutPage($body, 600)
->assertScript(layoutStyle('#hidden-from', 'display')." === 'none'");
});
it('keeps a button at its label\'s width in a stretched stack and a form, where the rest stretch', function () {
// M3: keep a button's width "dynamic to fit label"; don't "stretch buttons into long flat shapes
// on large windows". An application's own width still wins, and a centred stack centres it.
$body = '<style>.probe-full { inline-size: 100%; }</style>'
.'<x-stack id="stack" gap="space200" style="inline-size: 600px"><p id="stack-field">Name</p><x-button label="Save" id="stack-save" /><x-button label="Wide" class="probe-full" id="stack-wide" /></x-stack>'
.'<x-stack id="centred" align="center" style="inline-size: 600px"><x-button label="Save" id="centred-save" /></x-stack>'
.'<x-form id="form" style="inline-size: 600px"><x-input label="Name" /><x-button label="Go" id="form-go" /></x-form>';
layoutPage($body, 1280)
->assertScript(layoutRect('#stack-field', 'width').' === 600')
->assertScript(layoutRect('#stack-save', 'width').' < 200')
->assertScript(layoutRect('#stack-save', 'left').' === '.layoutRect('#stack', 'left'))
->assertScript(layoutRect('#stack-wide', 'width').' === 600')
->assertScript('Math.abs(('.layoutRect('#centred-save', 'left').' + '.layoutRect('#centred-save', 'right').') / 2 - ('.layoutRect('#centred', 'left').' + '.layoutRect('#centred', 'right').') / 2) <= 1')
->assertScript(layoutRect('#form-go', 'width').' < 200')
->assertScript(layoutRect('#form-go', 'left').' === '.layoutRect('#form', 'left'));
// In a right-to-left document the start edge is the right.
layoutPage($body, 1280, 900, 'rtl')
->assertScript(layoutRect('#stack-save', 'right').' === '.layoutRect('#stack', 'right'))
->assertScript(layoutRect('#form-go', 'right').' === '.layoutRect('#form', 'right'));
});
it('stacks a row below its breakpoint and lays it side by side from it', function () {
$body = '<x-row id="row" gap="space200" stack-below="medium"><span>One</span><span>Two</span></x-row>'
.'<x-row id="aligned" align="start" stack-below="medium"><span>One</span></x-row>'
.'<x-row id="hidden" hide-below="medium"><span>Medium and up</span></x-row>';
layoutPage($body, 599)
->assertScript(layoutStyle('#row', 'flexDirection')." === 'column'")
->assertScript(layoutStyle('#row', 'alignItems')." === 'stretch'")
->assertScript(layoutStyle('#aligned', 'alignItems')." === 'flex-start'")
->assertScript(layoutStyle('#row', 'rowGap')." === '16px'")
->assertScript(layoutStyle('#hidden', 'display')." === 'none'");
layoutPage($body, 600)
->assertScript(layoutStyle('#row', 'flexDirection')." === 'row'")
->assertScript(layoutStyle('#row', 'alignItems')." === 'center'")
->assertScript(layoutStyle('#hidden', 'display')." === 'flex'");
});
it('mirrors a row in a right-to-left document', function () {
layoutPage('<x-row id="row"><span id="first">First</span><span id="second">Second</span></x-row>', 800, 600, 'rtl')
->assertScript(layoutRect('#first', 'left').' > '.layoutRect('#second', 'left'));
});
it('gives a grid its columns per breakpoint, and a nested grid its own', function () {
$body = <<<'BLADE'
<x-grid id="outer" :columns="['medium' => 3, 'large' => 4]" gap="space200">
<x-grid id="inner"><div>A</div><div>B</div></x-grid>
<div>2</div><div>3</div><div>4</div>
</x-grid>
BLADE;
layoutPage($body, 599)
->assertScript(layoutColumns('#outer').' === 1')
->assertScript(layoutColumns('#inner').' === 1');
layoutPage($body, 600)
->assertScript(layoutColumns('#outer').' === 3')
->assertScript(layoutStyle('#outer', 'columnGap')." === '16px'")
// The parent's three never reach the grid inside it.
->assertScript(layoutColumns('#inner').' === 1');
layoutPage($body, 1199)->assertScript(layoutColumns('#outer').' === 3');
layoutPage($body, 1200)->assertScript(layoutColumns('#outer').' === 4');
});
it('fills a grid with columns of a minimum width, capped by the count', function () {
$body = '<div style="width: 580px"><x-grid id="fill" min-item="180px"><div>1</div><div>2</div><div>3</div><div>4</div></x-grid></div>'
.'<div style="width: 580px"><x-grid id="capped" min-item="100px" :columns="[\'compact\' => 2]" gap="space200"><div>1</div><div>2</div><div>3</div><div>4</div></x-grid></div>';
layoutPage($body, 800)
->assertScript(layoutColumns('#fill').' === 3')
->assertScript(layoutColumns('#capped').' === 2');
});
it('draws a surface in its role, padding and corner, and hides it below a breakpoint', function () {
$body = '<x-surface id="surface" level="surface-container-high" padding="space300" corner="lg" outlined hide-below="medium">Surface</x-surface>'
.'<div id="role" style="background-color: var(--md-sys-color-surface-container-high)"></div>';
layoutPage($body, 599)
->assertScript(layoutStyle('#surface', 'display')." === 'none'");
layoutPage($body, 600)
->assertScript(layoutStyle('#surface', 'display')." === 'block'")
->assertScript(layoutStyle('#surface', 'backgroundColor').' === '.layoutStyle('#role', 'backgroundColor'))
->assertScript(layoutStyle('#surface', 'paddingTop')." === '24px'")
->assertScript(layoutStyle('#surface', 'borderTopLeftRadius')." === '16px'")
->assertScript(layoutStyle('#surface', 'borderTopWidth')." === '1px'")
->assertNoJavaScriptErrors();
});
it('keeps M3\'s margin in a pane, once, with its app bar across the whole pane', function () {
$body = <<<'BLADE'
<x-pane id="pane" title="Settings">
<p id="text">Body</p>
<x-pane id="inner"><p>Nested</p></x-pane>
<x-surface id="card"><x-pane id="on-surface"><p id="surface-text">On a surface</p></x-pane></x-surface>
</x-pane>
<x-pane id="narrow" width="narrow"><p>Narrow</p></x-pane>
BLADE;
layoutPage($body, 599)
->assertScript(layoutRect('#text', 'left').' === 16')
->assertScript(layoutRect('#pane [data-md-pane-bar]', 'width').' === '.layoutRect('#pane', 'width'))
->assertScript(layoutStyle('#inner [data-md-pane-body]', 'paddingLeft')." === '0px'");
layoutPage($body, 600)
->assertScript(layoutRect('#text', 'left').' === 24')
->assertScript(layoutRect('#pane [data-md-pane-bar]', 'left').' === 0')
->assertScript(layoutStyle('#inner [data-md-pane-body]', 'paddingLeft')." === '0px'")
// A surface is a new edge, so a pane on it keeps its margin again.
->assertScript(layoutRect('#surface-text', 'left').' - '.layoutRect('#card', 'left').' === 24');
layoutPage($body, 1200)
->assertScript(layoutRect('#narrow', 'width').' === 640')
->assertScript(layoutRect('#narrow', 'left').' === 280')
->assertNoJavaScriptErrors();
});
class LayoutListDetailProbe extends Component
{
public ?int $messageId = null;
public function render(): string
{
return <<<'BLADE'
<div>
<p>selected: <span id="selected">{{ var_export($messageId, true) }}</span></p>
<x-list-detail id="inbox" wire:model.live="messageId">
<x-slot:list>
<x-pane title="Inbox" :sticky="false">
<x-list label="Messages">
@foreach ([1 => 'Photos', 2 => 'Contract', 3 => 'Trains'] as $id => $subject)
<x-list-item :title="$subject" link="#message-{{ $id }}" no-wire-navigate wire:click.prevent="$set('messageId', {{ $id }})" :selected="$messageId === $id" wire:key="message-{{ $id }}" />
@endforeach
</x-list>
</x-pane>
</x-slot:list>
<x-slot:detail>
<x-pane :title="$messageId ? 'Message '.$messageId : 'Nothing selected'" heading="h2" back :sticky="false">
<p id="detail-text">{{ $messageId ? 'The message' : 'Select a message' }}</p>
</x-pane>
</x-slot:detail>
</x-list-detail>
</div>
BLADE;
}
}
function listDetailPage(int $width, string $dir = 'ltr'): mixed
{
Livewire::component('layout-list-detail-probe', LayoutListDetailProbe::class);
return layoutPage('<livewire:layout-list-detail-probe />', $width, 900, $dir);
}
const LIST_PANE = '[data-md-list-detail-pane="list"]';
const DETAIL_PANE = '[data-md-list-detail-pane="detail"]';
function listDetailVisible(string $pane): string
{
return "document.querySelector('{$pane}').checkVisibility()";
}
it('shows the list alone below expanded, and the detail with a back button once something is selected', function () {
foreach ([599, 600, 839] as $width) {
listDetailPage($width)
->assertScript(listDetailVisible(LIST_PANE))
->assertScript('! '.listDetailVisible(DETAIL_PANE));
}
$page = listDetailPage(599);
$page->script("document.querySelector('a[href=\"#message-2\"]').focus()");
// The selected item's link is hidden once the detail pane covers it: pressOnce(), tests/Pest.php.
pressOnce($page)->keys('a[href="#message-2"]', 'Enter');
$page->assertSeeIn('#selected', '2')
->assertSeeIn('#detail-text', 'The message')
->assertScript('! '.listDetailVisible(LIST_PANE))
->assertScript(listDetailVisible(DETAIL_PANE))
// Focus moves with the person to the pane that replaced the list.
->assertScript("document.activeElement === document.querySelector('".DETAIL_PANE."')")
->assertScript("document.querySelector('".DETAIL_PANE." [data-md-list-detail-back] button').checkVisibility()");
// The back button closes the detail pane: pressOnce(), tests/Pest.php.
pressOnce($page)->click(DETAIL_PANE.' [data-md-list-detail-back] button');
$page->assertSeeIn('#selected', 'NULL')
->assertScript(listDetailVisible(LIST_PANE))
->assertScript('! '.listDetailVisible(DETAIL_PANE))
// And back to the item it came from.
->assertScript("document.activeElement === document.querySelector('a[href=\"#message-2\"]')")
->assertNoJavaScriptErrors();
});
it('shows both panes from expanded at M3\'s fixed widths, with no back button and no focus move', function () {
foreach ([840 => 360, 1199 => 360, 1200 => 412] as $width => $list) {
listDetailPage($width)
->assertScript(listDetailVisible(LIST_PANE))
->assertScript(listDetailVisible(DETAIL_PANE))
->assertScript(layoutRect(LIST_PANE, 'width')." === {$list}")
// M3's 24px margin and 24px spacer.
->assertScript(layoutRect(LIST_PANE, 'left').' === 24')
->assertScript(layoutRect(DETAIL_PANE, 'left').' === '.(24 + $list + 24))
->assertScript("! document.querySelector('".DETAIL_PANE." [data-md-list-detail-back]').checkVisibility()");
}
$page = listDetailPage(1200);
$page->script("document.querySelector('a[href=\"#message-3\"]').focus()");
$page->keys('a[href="#message-3"]', 'Enter')
->assertSeeIn('#detail-text', 'The message')
->assertScript(listDetailVisible(LIST_PANE))
->assertScript("document.activeElement === document.querySelector('a[href=\"#message-3\"]')");
// Narrowed past 840px with focus in the list that is about to hide, focus goes to the detail.
$page->resize(839, 900)
->wait(0.2)
->assertScript('! '.listDetailVisible(LIST_PANE))
->assertScript("document.activeElement === document.querySelector('".DETAIL_PANE."')")
->assertNoJavaScriptErrors();
});
it('mirrors the list-detail in a right-to-left document', function () {
listDetailPage(1200, 'rtl')
->assertScript(layoutRect(LIST_PANE, 'left').' > '.layoutRect(DETAIL_PANE, 'left'))
->assertScript(layoutRect(LIST_PANE, 'right').' === window.innerWidth - 24');
$page = listDetailPage(599, 'rtl');
// The selected item's link is hidden once the detail pane covers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('a[href="#message-1"]');
$page->assertSeeIn('#selected', '1')
->assertScript("getComputedStyle(document.querySelector('".DETAIL_PANE." [data-md-list-detail-back] svg')).transform !== 'none'");
});
it('selects from Alpine through x-model, with the layout\'s own back row', function () {
$body = <<<'BLADE'
<div x-data="{ chosen: null }">
<p>chosen: <span id="chosen" x-text="String(chosen)"></span></p>
<x-list-detail x-model="chosen">
<x-slot:list><button type="button" id="pick" x-on:click="selected = 'b'">Pick b</button></x-slot:list>
<x-slot:detail><p id="shown" x-text="selected"></p></x-slot:detail>
</x-list-detail>
</div>
BLADE;
$page = layoutPage($body, 599);
// The list pane is hidden once the button that picks it is covered: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#pick');
$page->assertSeeIn('#chosen', 'b')
->assertSeeIn('#shown', 'b')
->assertScript('! '.listDetailVisible(LIST_PANE))
->assertScript("document.querySelector('[data-md-list-detail-back-row]').checkVisibility()");
// The back button closes the detail pane: pressOnce(), tests/Pest.php.
pressOnce($page)->click('[data-md-list-detail-back-row] button');
$page->assertSeeIn('#chosen', 'null')
->assertScript(listDetailVisible(LIST_PANE))
->assertScript("document.activeElement === document.querySelector('#pick')");
});
const SUPPORTING_MAIN = '[data-md-supporting-pane-main]';
const SUPPORTING_SIDE = '[data-md-supporting-pane-supporting]';
function supportingPanePage(int $width, string $attributes = '', string $dir = 'ltr'): mixed
{
$body = <<<BLADE
<x-supporting-pane label="Comments" {$attributes}>
<x-slot:main><article id="main" style="block-size: 1400px">The proposal</article></x-slot:main>
<x-slot:supporting><p id="comment">Friday works for me.</p><button type="button" id="reply">Reply</button></x-slot:supporting>
</x-supporting-pane>
BLADE;
return layoutPage($body, $width, 800, $dir);
}
it('puts the supporting pane under the focus pane below expanded', function () {
foreach ([599 => 16, 600 => 24, 839 => 24] as $width => $margin) {
supportingPanePage($width)
->assertScript(layoutRect(SUPPORTING_SIDE, 'top').' >= '.layoutRect(SUPPORTING_MAIN, 'bottom'))
->assertScript(layoutRect(SUPPORTING_SIDE, 'width').' === '.layoutRect(SUPPORTING_MAIN, 'width'))
->assertScript(layoutRect(SUPPORTING_MAIN, 'left')." === {$margin}")
->assertScript(layoutRect(SUPPORTING_SIDE, 'top').' - '.layoutRect(SUPPORTING_MAIN, 'bottom')." === {$margin}");
}
});
it('puts the supporting pane beside the focus pane from expanded, at M3\'s widths or the two-thirds split', function () {
foreach ([840 => 360, 1199 => 360, 1200 => 412] as $width => $side) {
supportingPanePage($width)
->assertScript(layoutRect(SUPPORTING_SIDE, 'top').' === '.layoutRect(SUPPORTING_MAIN, 'top'))
->assertScript(layoutRect(SUPPORTING_SIDE, 'width')." === {$side}")
->assertScript(layoutRect(SUPPORTING_SIDE, 'right')." === {$width} - 24")
->assertScript(layoutRect(SUPPORTING_SIDE, 'left').' - '.layoutRect(SUPPORTING_MAIN, 'right').' === 24');
}
supportingPanePage(1200, 'width="split"')
->assertScript('Math.abs('.layoutRect(SUPPORTING_MAIN, 'width').' - 2 * '.layoutRect(SUPPORTING_SIDE, 'width').') <= 1');
supportingPanePage(1200, '', 'rtl')
->assertScript(layoutRect(SUPPORTING_SIDE, 'left').' === 24')
->assertScript(layoutRect(SUPPORTING_SIDE, 'right').' < '.layoutRect(SUPPORTING_MAIN, 'left'));
});
it('docks the supporting pane as a bottom sheet below expanded, opened and closed by its handle', function () {
$handle = '[data-md-supporting-pane-handle]';
$page = supportingPanePage(599, 'compact="sheet"')
->assertScript("document.querySelector('{$handle}').checkVisibility()")
->assertAttribute($handle, 'aria-expanded', 'false')
->assertScript("! document.querySelector('#comment').checkVisibility()")
// Docked to the bottom of the window while the focus pane scrolls under it.
->assertScript(layoutRect(SUPPORTING_SIDE, 'bottom').' === window.innerHeight')
->assertScript(layoutStyle(SUPPORTING_SIDE, 'borderTopLeftRadius')." === '28px'");
// The handle toggles the sheet on every press: pressOnce(), tests/Pest.php.
pressOnce($page)->click($handle);
$page->assertAttribute($handle, 'aria-expanded', 'true')
->assertScript("document.querySelector('#comment').checkVisibility()")
->assertScript(layoutRect(SUPPORTING_SIDE, 'bottom').' === window.innerHeight');
$page->keys('#reply', 'Escape')
->assertAttribute($handle, 'aria-expanded', 'false')
->assertScript("! document.querySelector('#comment').checkVisibility()")
->assertScript("document.activeElement === document.querySelector('{$handle}')");
// Scrolled to the end, the sheet has its own room and covers nothing of the focus pane.
$page->script('window.scrollTo(0, document.documentElement.scrollHeight)');
$page->assertScript(layoutRect(SUPPORTING_SIDE, 'top').' >= '.layoutRect(SUPPORTING_MAIN, 'bottom'));
supportingPanePage(840, 'compact="sheet"')
->assertScript("! document.querySelector('{$handle}').checkVisibility()")
->assertScript("document.querySelector('#comment').checkVisibility()")
->assertScript(layoutRect(SUPPORTING_SIDE, 'width').' === 360')
->assertNoJavaScriptErrors();
});
it('lays a feed out in one column below medium and in columns of its minimum width from it, in order', function () {
$body = '<x-feed id="feed" min-item="240px">'
.collect(range(1, 6))->map(fn (int $item): string => "<article id=\"item-{$item}\">Item {$item}</article>")->implode('')
.'</x-feed>';
layoutPage($body, 599)
->assertScript(layoutColumns('#feed').' === 1')
->assertScript(layoutStyle('#feed', 'rowGap')." === '16px'");
// 600px, less no margins, fits two 240px columns with M3's 24px spacer between them.
layoutPage($body, 600)
->assertScript(layoutColumns('#feed').' === 2')
->assertScript(layoutStyle('#feed', 'columnGap')." === '24px'")
->assertScript(layoutRect('#item-2', 'left').' > '.layoutRect('#item-1', 'left'))
->assertScript(layoutRect('#item-3', 'top').' > '.layoutRect('#item-1', 'top'));
layoutPage($body, 1200)
->assertScript(layoutColumns('#feed').' === 4')
->assertScript(layoutRect('#item-5', 'left').' === '.layoutRect('#item-1', 'left'));
});
function scaffoldFabPage(int $width, string $dir = 'ltr'): mixed
{
$body = <<<'BLADE'
<x-scaffold :destinations="[
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'active' => true],
['title' => 'Sent', 'icon' => 'send', 'url' => '/sent'],
['title' => 'Drafts', 'icon' => 'draft', 'url' => '/drafts'],
]">
<x-slot:fab><x-fab icon="edit" tooltip="Compose" /></x-slot:fab>
<p style="block-size: 2000px">The page</p>
</x-scaffold>
BLADE;
return layoutPage($body, $width, 800, $dir);
}
const SCAFFOLD_FAB = '[data-md-scaffold-fab]';
/** The navigation bar the scaffold pins to the bottom: the one navigation landmark at the window's foot. */
const SCAFFOLD_BAR = "[...document.querySelectorAll('[data-md-scaffold] nav')].find((nav) => nav.checkVisibility() && Math.round(nav.getBoundingClientRect().bottom) >= window.innerHeight - 1)";
it('places the scaffold\'s FAB at the bottom-end corner, clear of the navigation bar and of a snackbar', function () {
$page = scaffoldFabPage(599)
->assertScript(layoutStyle(SCAFFOLD_FAB, 'position')." === 'fixed'")
->assertScript(SCAFFOLD_BAR.' !== undefined')
->assertScript('window.innerWidth - '.layoutRect(SCAFFOLD_FAB, 'right').' === 16')
// 16px above the bar's top edge.
->assertScript('Math.round('.SCAFFOLD_BAR.'.getBoundingClientRect().top) - '.layoutRect(SCAFFOLD_FAB, 'bottom').' === 16');
$page->script("window.eval(\"materialToast('Draft saved', { sticky: true })\")");
$page->wait(1)
->assertScript("document.documentElement.style.getPropertyValue('--material-snackbar-height') !== ''")
// Lifted above the snackbar, which sits 16px above the bar, by M3's 16px margin.
->assertScript("Math.round(document.querySelector('[x-data=\"materialSnackbar\"] > div').getBoundingClientRect().top) - ".layoutRect(SCAFFOLD_FAB, 'bottom').' === 16')
->assertScript(layoutRect(SCAFFOLD_FAB, 'bottom')." <= Math.round(document.querySelector('[x-data=\"materialSnackbar\"] > div').getBoundingClientRect().top)");
scaffoldFabPage(600)
->assertScript('window.innerWidth - '.layoutRect(SCAFFOLD_FAB, 'right').' === 24')
->assertScript('window.innerHeight - '.layoutRect(SCAFFOLD_FAB, 'bottom').' === 24')
->assertNoJavaScriptErrors();
});
it('drops the bar\'s own height from the FAB\'s offset once hide-bar-on-scroll slides it away', function () {
$body = <<<'BLADE'
<x-scaffold hide-bar-on-scroll :destinations="[
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'active' => true],
['title' => 'Sent', 'icon' => 'send', 'url' => '/sent'],
]">
<x-slot:fab><x-fab icon="edit" tooltip="Compose" /></x-slot:fab>
<p style="block-size: 2000px">The page</p>
</x-scaffold>
BLADE;
// The window opens wider than 599 before resize() narrows it (layoutPage()), so the FAB's own
// spatial-spring transition is still settling from the wider margin when the page first
// reports ready; wait it out before the baseline below or it captures a mid-flight value.
$page = layoutPage($body, 599, 800)->wait(0.6);
$shown = (int) $page->script(layoutRect(SCAFFOLD_FAB, 'bottom'));
$page->script('window.scrollTo(0, 400)');
$page->assertScript("document.querySelector('[data-md-navigation-bar]').hasAttribute('data-md-hidden')")
// The FAB's inset-block-end is a spatial spring (scaffold.css), so its move down is not
// done the instant the bar's data-md-hidden attribute flips; wait past it before measuring.
->wait(0.6)
// The bar's own 64px is what --material-bottom-bar drops once the bar has slid off, so the
// FAB moves down by exactly that much (navigation-bar.css, layout/scaffold.css).
->assertScript(layoutRect(SCAFFOLD_FAB, 'bottom')." === {$shown} + 64")
->assertNoJavaScriptErrors();
});
it('places the FAB at the trailing edge, the left in a right-to-left document', function () {
scaffoldFabPage(599, 'rtl')
->assertScript(layoutRect(SCAFFOLD_FAB, 'left').' === 16')
->assertScript(layoutRect(SCAFFOLD_FAB, 'right').' < window.innerWidth - 16')
->assertNoJavaScriptErrors();
});
it('clears a safe area an application sets on the FAB\'s inline-end and bottom edges', function () {
$body = <<<'BLADE'
<x-scaffold :destinations="[['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'active' => true, 'bar' => false]]">
<x-slot:fab><x-fab icon="edit" tooltip="Compose" /></x-slot:fab>
</x-scaffold>
BLADE;
// The window opens wider than 599 before resize() narrows it (layoutPage()), so the FAB's
// margin (and with it inset-block-end, a spatial spring) is still settling from the wider
// default when the page first reports ready; wait it out before either baseline below.
$page = layoutPage($body, 599, 800)->wait(0.6);
$right = (int) $page->script('window.innerWidth - '.layoutRect(SCAFFOLD_FAB, 'right'));
$bottom = (int) $page->script('window.innerHeight - '.layoutRect(SCAFFOLD_FAB, 'bottom'));
// inset-inline-end (right) has no transition of its own, so this one applies at once. It is
// max(margin, safe-right) — an alternative to the margin, not additive with it — unlike the
// bottom edge below, which always adds its margin on top of the greater of the two.
$page->script("document.documentElement.style.setProperty('--material-safe-right', '20px')");
$page->assertScript('window.innerWidth - '.layoutRect(SCAFFOLD_FAB, 'right')." === Math.max({$right}, 20)");
// inset-block-end (bottom) is the spatial spring scaffold.css transitions; wait past it.
$page->script("document.documentElement.style.setProperty('--material-safe-bottom', '30px')");
$page->wait(0.6)
->assertScript('window.innerHeight - '.layoutRect(SCAFFOLD_FAB, 'bottom')." === {$bottom} + 30")
->assertNoJavaScriptErrors();
});
it('draws no second margin for a pane or a canonical layout inside the scaffold\'s content region', function () {
$body = <<<'BLADE'
<x-scaffold :destinations="[['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'active' => true]]">
<x-pane id="pane"><p id="text">Body</p></x-pane>
</x-scaffold>
BLADE;
// The content region's own 24px beside the 96px collapsed rail, and nothing more.
layoutPage($body, 700)
->assertScript(layoutRect('#text', 'left').' - '.layoutRect('main', 'left').' === 24')
->assertScript(layoutStyle('#pane [data-md-pane-body]', 'paddingLeft')." === '0px'");
});
it('shows each canonical layout working on its own Layout page', function () {
$listDetail = layoutReady(visit('/material/layout/list-detail')->resize(1280, 900))
->assertScript("document.querySelectorAll('#example-list-detail [data-md-list-detail-pane]').length === 2")
->assertScript("[...document.querySelectorAll('#example-list-detail [data-md-list-detail-pane]')].every((pane) => pane.checkVisibility())");
$listDetail->click('#example-list-detail a:has-text("Ben Keller")')
->assertSeeIn('#example-list-detail [data-md-list-detail-pane="detail"]', 'Contract, second draft')
->assertNoJavaScriptErrors();
$compactListDetail = layoutReady(visit('/material/layout/list-detail')->resize(599, 900));
// The selected item's link is hidden once the detail pane covers it: pressOnce(), tests/Pest.php.
pressOnce($compactListDetail)->click('#example-list-detail a:has-text("Chiara Rossi")');
$compactListDetail->assertSeeIn('#example-list-detail [data-md-list-detail-pane="detail"]', 'Train times for Friday')
->assertScript("! document.querySelector('#example-list-detail [data-md-list-detail-pane=\"list\"]').checkVisibility()")
->assertNoJavaScriptErrors();
layoutReady(visit('/material/layout/supporting-pane')->resize(1280, 900))
->assertScript(layoutRect('#example-supporting-pane [data-md-supporting-pane-supporting]', 'top').' === '.layoutRect('#example-supporting-pane [data-md-supporting-pane-main]', 'top'));
layoutReady(visit('/material/layout/supporting-pane')->resize(599, 900))
->assertScript("document.querySelector('#example-supporting-pane [data-md-supporting-pane-handle]').checkVisibility()")
->assertNoJavaScriptErrors();
layoutReady(visit('/material/layout/feed')->resize(1280, 900))
->assertScript(layoutColumns('#example-feed [data-md-feed]').' > 1');
layoutReady(visit('/material/layout/feed')->resize(599, 900))
->assertScript(layoutColumns('#example-feed [data-md-feed]').' === 1')
->assertNoJavaScriptErrors();
});