Plan step 35: <x-feed min-item gap>, following the reference's feed rules - one column below medium, then as many equal columns of at least the minimum item width as the feed has room for, 24px apart from medium, items in their source order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
410 lines
19 KiB
PHP
410 lines
19 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'));
|
|
});
|