get($path, fn () => Blade::render(<< @vite(config('livewire-material.showcase.vite')) @livewireStyles {$body} @livewireScripts 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 = '

One

Two

Three

' .'

Compact only

'; 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 = 'OneTwo' .'One' .'Medium and up'; 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('FirstSecond', 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'
A
B
2
3
4
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 = '
1
2
3
4
' .'
1
2
3
4
'; 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 = 'Surface' .'
'; 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'

Body

Nested

On a surface

Narrow

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'

selected: {{ var_export($messageId, true) }}

@foreach ([1 => 'Photos', 2 => 'Contract', 3 => 'Trains'] as $id => $subject) @endforeach

{{ $messageId ? 'The message' : 'Select a message' }}

BLADE; } } function listDetailPage(int $width, string $dir = 'ltr'): mixed { Livewire::component('layout-list-detail-probe', LayoutListDetailProbe::class); return layoutPage('', $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'

chosen:

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 = <<
The proposal

Friday works for me.

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 = '' .collect(range(1, 6))->map(fn (int $item): string => "
Item {$item}
")->implode('') .'
'; 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'

The page

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'

The page

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' 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'

Body

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(); });