Three FAB position tests chained ->assertScript() off a bare $page->script(), which returns the script's value, not the page. Fixing that surfaced a real settling issue: layoutPage() opens wider than the 599/600px these tests probe before resize() narrows it, so the FAB's inset-block-end (a spatial spring, scaffold.css) is still animating in from the wider default margin when the page first reports ready. Any baseline captured then is mid-flight, not the resting value, so both tests now wait past the spring before taking one. The safe-area test's inline-end (right) assertion also assumed its margin and the safe area add together; the CSS is max(margin, safe-right), an alternative to the margin, not additive with it, unlike the bottom edge (plan step 36, navigation group). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
554 lines
28 KiB
PHP
554 lines
28 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
|
|
{
|
|
// Until the Workbench leaves Tailwind, its build turns `:dir(rtl)` into a list of `:lang()`s, so a
|
|
// right-to-left page also says which language it is in.
|
|
$lang = $dir === 'rtl' ? 'ar' : 'en';
|
|
$path = '/layout-probe/'.md5($body.$dir);
|
|
|
|
Route::middleware('web')->get($path, fn () => Blade::render(<<<BLADE
|
|
<!DOCTYPE html>
|
|
<html dir="{$dir}" lang="{$lang}">
|
|
<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('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 the showcase\'s Layout page', function () {
|
|
$example = fn (string $title): string => "#example-{$title}";
|
|
|
|
$page = layoutReady(visit('/material/layout')->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())")
|
|
->assertScript(layoutRect($example('supporting-pane').' [data-md-supporting-pane-supporting]', 'top').' === '.layoutRect($example('supporting-pane').' [data-md-supporting-pane-main]', 'top'))
|
|
->assertScript(layoutColumns($example('feed').' [data-md-feed]').' > 1');
|
|
|
|
$page->click('#example-list-detail a:has-text("Ben Keller")')
|
|
->assertSeeIn('#example-list-detail [data-md-list-detail-pane="detail"]', 'Contract, second draft')
|
|
->assertNoJavaScriptErrors();
|
|
|
|
$compact = layoutReady(visit('/material/layout')->resize(599, 900));
|
|
|
|
$compact->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()")
|
|
->assertScript(layoutColumns($example('feed').' [data-md-feed]').' === 1')
|
|
->assertScript("document.querySelector('#example-supporting-pane [data-md-supporting-pane-handle]').checkVisibility()")
|
|
->assertNoJavaScriptErrors();
|
|
});
|