Files
livewire-material/tests/Browser/LayoutTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 fb7007c976
tests / feature (8.4) (push) Successful in 2m0s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Failing after 8m3s
tests / browser (firefox, firefox) (push) Failing after 12m58s
tests / browser (safari, webkit) (push) Failing after 13m8s
Take Tailwind out of the package, and its detection out of the guard
Tailwind left the stack in 2.0.0, but the package still carried about 330
mentions of it. What the guard's Tailwind detection protected — a class
that compiles to nothing — is now protected by a check that does not care
where a dead class came from.

DesignGuard: about 500 lines of Tailwind tables, scales, palettes and
"2.0.0 replacement" hints give way to one check — a class a view or PHP
file writes that neither the application's stylesheets nor the package's
own declare. It catches a utility of any framework, a typo and a class
whose rules were deleted alike, so it also found two classes ReStride
draws nothing with. A stylesheet has to be in reach for it: the `.css`
files among the scanned paths, or what the `missingStylesheets()` entry
imports. The class reader no longer mistakes an array index for a class
list (`$block['base']`), and it reads the array a class helper is given,
where it read nothing before.

The package's own three Tailwind self-guards go with it. Only their one
unique check stays, as a test of its own: every `matchMedia` width in
resources/js is an M3 breakpoint.

The pagination views are `material.blade.php` and
`simple-material.blade.php`; only Laravel's and Livewire's default theme
names ever made them `tailwind`. The provider sets `Paginator`'s default
views and switches `livewire.pagination_theme` to `material` when it is
still Livewire's own default, so no application can forget the config; a
theme an application chose, and a component's own `$paginationTheme` or
`paginationView()`, still win.

The rest is prose: the layer-order guidance for an application that still
builds Tailwind, the Tailwind wording in the README, the Boost guidelines
and the development skill, and about 25 "this used to be a Tailwind
utility" comments, along with every "plan step NN" pointer into a
gitignored folder. The reset keeps its credit, and NOTICE now carries it
too.

Feature suite 1159 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:07:39 +02:00

581 lines
29 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>
<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()");
$page->keys('a[href="#message-2"]', 'Enter')
->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()");
$page->click(DETAIL_PANE.' [data-md-list-detail-back] button')
->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');
$page->click('a[href="#message-1"]')
->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);
$page->click('#pick')
->assertSeeIn('#chosen', 'b')
->assertSeeIn('#shown', 'b')
->assertScript('! '.listDetailVisible(LIST_PANE))
->assertScript("document.querySelector('[data-md-list-detail-back-row]').checkVisibility()");
$page->click('[data-md-list-detail-back-row] button')
->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'");
$page->click($handle)
->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));
$compactListDetail->click('#example-list-detail a:has-text("Chiara Rossi")')
->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();
});