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
@@ -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'");
});