Files
livewire-material/tests/Browser/LayoutTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 92d3d761f3 Keep a button at its label's width in a stack and a form
<x-stack> stretches its children across by default and <x-form> is a
one-track grid, so a button written directly in either drew as a
full-width pill. M3 keeps a button's width "dynamic to fit label" and
says not to "stretch buttons into long flat shapes on large windows".
A button there now sits at the start edge at its own width; a stack
aligned start, centre or end is untouched, and an application's own
width still wins (a cap on the button itself was rejected for exactly
that: it would have limited an app's inline-size, which CascadeTest
guards). Chromium test in both directions, failing without the rules.
Decided with the user (plan step 46).

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

582 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 (plan step 35): 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 $page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
/** 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();
});