Add the list-detail canonical layout

Plan step 35: <x-list-detail>, following the reference's visible-panes
table row by row - one pane below expanded, the detail replacing the list
with a back button once something is selected; from 840px the list a
fixed 360px (412px from 1200px) beside the detail, 24px apart. The
selection binds with wire:model or x-model; below expanded focus moves to
the detail and back() returns it to the item (resources/js/layout.js).
Grid columns mirror in RTL, and the back arrow turns with them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 14:51:13 +02:00
co-authored by Claude Opus 5
parent b4f1e005de
commit b5c5be1fd7
9 changed files with 578 additions and 0 deletions
+142
View File
@@ -2,6 +2,8 @@
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
@@ -170,3 +172,143 @@ it('keeps M3\'s margin in a pane, once, with its app bar across the whole pane',
->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')");
});
@@ -0,0 +1,93 @@
<?php
use Livewire\Component;
use Livewire\Livewire;
it('draws the list pane and the detail pane, each focusable as a whole, with nothing selected', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-list-detail>
<x-slot:list><ul>LIST</ul></x-slot:list>
<x-slot:detail><article>DETAIL</article></x-slot:detail>
</x-list-detail>
BLADE);
expect(layoutRoot($html))->toMatchArray([
'<' => 'div',
'data-md-list-detail' => 'data-md-list-detail',
'x-data' => 'materialListDetail( null )',
'x-modelable' => 'selected',
'x-bind:data-md-selected' => "hasSelection ? '' : null",
'wire:ignore.self' => '',
])->not->toHaveKey('data-md-selected')
->and($html)->toMatch('/<div\s+data-md-list-detail-pane="list"\s+tabindex="-1"\s+x-ref="list"\s+x-on:focusin="remember\(\$event\)"\s+x-on:click="remember\(\$event\)"\s*><ul>LIST<\/ul><\/div>/')
->toMatch('/<div data-md-list-detail-pane="detail" tabindex="-1" x-ref="detail">.*<article>DETAIL<\/article>\s*<\/div>\s*<\/div>\s*$/s');
});
it('renders the pane that is selected from the first paint, given once or bound to Livewire', function () {
expect(layoutRoot((string) $this->blade('<x-list-detail :selected="7" />')))->toMatchArray(['data-md-selected' => 'data-md-selected', 'x-data' => 'materialListDetail( 7 )'])
->and(layoutRoot((string) $this->blade('<x-list-detail selected="" />')))->not->toHaveKey('data-md-selected')
->and(layoutRoot((string) $this->blade('<x-list-detail :selected="false" />')))->not->toHaveKey('data-md-selected')
->and(layoutRoot((string) $this->blade('<x-list-detail :selected="0" />')))->toHaveKey('data-md-selected');
Livewire::component('list-detail-render-probe', new class extends Component
{
public ?int $messageId = 3;
public function render(): string
{
return '<div><x-list-detail wire:model.live="messageId"><x-slot:list>LIST</x-slot:list><x-slot:detail>DETAIL</x-slot:detail></x-list-detail></div>';
}
});
$probe = Livewire::test('list-detail-render-probe');
$html = $probe->html();
// The server renders the pane from the property, and Alpine takes the property over; the
// binding stays off the element, where Livewire would look for an input.
expect($html)->toContain('data-md-selected')
->toMatch("/x-data=\"materialListDetail\\(\\s*window\\.Livewire\\.find\\('[^']+'\\)\\.entangle\\('messageId'\\)\\.live\\s*\\)\"/")
->not->toContain('x-modelable')
->not->toContain('wire:model');
$probe->set('messageId', null);
expect($probe->html())->not->toMatch('/data-md-list-detail[^>]*data-md-selected/');
});
it('draws its own back row above the detail, unless the detail brings one', function () {
$own = (string) $this->blade('<x-list-detail><x-slot:list>LIST</x-slot:list><x-slot:detail>DETAIL</x-slot:detail></x-list-detail>');
$pane = (string) $this->blade(<<<'BLADE'
<x-list-detail>
<x-slot:list>LIST</x-slot:list>
<x-slot:detail><x-pane title="Message" back>DETAIL</x-pane></x-slot:detail>
</x-list-detail>
BLADE);
expect($own)->toMatch('/x-ref="detail">\s*<div data-md-list-detail-back-row>\s*<span data-md-list-detail-back>\s*<button[^>]*aria-label="Back"[^>]*x-on:click="back\(\)"/s')
->and(substr_count($pane, 'data-md-list-detail-back'))->toBe(1)
->and($pane)->not->toContain('data-md-list-detail-back-row');
});
it('takes the element, the visibility props and the caller\'s class and style', function () {
expect(layoutRoot((string) $this->blade('<x-list-detail as="section" hide-below="medium" class="inbox" style="min-height: 30rem" x-model="chosen" />')))
->toMatchArray([
'<' => 'section',
'data-md-hide-below' => 'medium',
'class' => 'inbox',
'style' => 'min-height: 30rem;',
'x-model' => 'chosen',
'x-modelable' => 'selected',
]);
});
it('shows one pane below expanded and both from it, at M3\'s fixed pane widths', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/list-detail.css');
expect($css)->toContain('@layer material.layout')
->toContain("@media (width >= 840px) {\n grid-template-columns: 360px minmax(0, 1fr);\n column-gap: var(--md-sys-measurement-space300);")
->toContain("@media (width >= 1200px) {\n grid-template-columns: 412px minmax(0, 1fr);")
->toContain("@media (width < 840px) {\n [data-md-list-detail]:not([data-md-selected]) > [data-md-list-detail-pane='detail'],\n [data-md-list-detail][data-md-selected] > [data-md-list-detail-pane='list'] {\n display: none;")
->toContain('[data-md-list-detail-back] [data-md-icon]:dir(rtl)')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/layout.css'))->toContain("@import './layout/list-detail.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/js/material.js'))->toContain("import './layout.js'");
});