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
@@ -802,6 +802,39 @@ A content region: M3 puts all content in panes, and each may carry its own top a
- The bar's leading button: the `leading` slot, or `back` — a URL makes it a link, `true` calls `back()` from `<x-list-detail>` around it (hidden there where both panes show). The arrow mirrors in a right-to-left document.
- `navigation` is the pane's section navigation (`<x-section-nav>`, `<x-tabs>`), under the bar and above the body with the body's margins — not the bar's leading button.
#### `<x-list-detail>`
M3's list-detail canonical layout, for parent and child content: an inbox and a message, folders and a file, settings and a category. `list` and `detail` slots.
| Breakpoint | Visible panes |
| --- | --- |
| Compact, below 600px | 1: the list, or the detail once something is selected, with a back button |
| Medium, 600839 | 1, as compact (M3's recommendation) |
| Expanded, 8401199 | 2: the list 360px, the detail the rest, 24px apart, no back button |
| Large and extra-large, from 1200 | 2: the list 412px |
```blade
<x-list-detail wire:model.live="messageId">
<x-slot:list>
<x-pane title="Inbox">
<x-list>
@foreach ($messages as $message)
<x-list-item :title="$message->subject" :link="route('messages.show', $message)" no-wire-navigate wire:click.prevent="$set('messageId', {{ $message->id }})" :selected="$message->id === $messageId" wire:key="message-{{ $message->id }}" />
@endforeach
</x-list>
</x-pane>
</x-slot:list>
<x-slot:detail>
<x-pane :title="$current?->subject" heading="h2" back>…</x-pane>
</x-slot:detail>
</x-list-detail>
```
- `selected` is what is selected: bound with `wire:model` (the server renders the right pane from the property, so the first paint is right) or `x-model`, or given once. Nothing selected is `null`, `false` or `''`; `0` is an id. Inside both slots `selected` is in Alpine scope, and `back()` clears it (a boolean to `false`, anything else to `null`). An item selects with `wire:click.prevent="$set('messageId', 7)"` or `x-on:click.prevent="selected = 7"` on a list item whose `link` (with `no-wire-navigate`) opens the same detail without script, so the keyboard reaches it.
- The back button: `<x-pane back>` in the detail slot puts it in that pane's app bar; without one, the layout draws its own row above the detail. Hidden from `expanded` either way.
- Focus: below `expanded`, selecting moves focus to the detail pane and `back()` returns it to the item it came from (or the list's `aria-current`/`aria-selected` item, or the list). From `expanded` focus stays where it is. Mark the selected item (`:selected`, `aria-current`) — M3 shows a selected state in the list where both panes show.
- A right-to-left document puts the list on the right. The root's attributes belong to Alpine (`wire:ignore.self`); the slots morph as usual.
#### `<x-surface>`
A tonal region: `<x-surface level="surface-container-low" padding="space300" corner="lg" outlined>…</x-surface>`.
+1
View File
@@ -11,3 +11,4 @@
@import './layout/grid.css';
@import './layout/surface.css';
@import './layout/pane.css';
@import './layout/list-detail.css';
+88
View File
@@ -0,0 +1,88 @@
/*
* <x-list-detail>: M3's list-detail canonical layout — a list, and the detail of what it selected.
*
* Its visible-panes table, row by row (docs/reference/m3/foundations-supplement.md § Canonical
* layouts → List-detail):
*
* Compact (0599) 1 pane list, or the detail once something is selected
* Medium (600839) 1 or 2 1, M3's recommendation for a layout that is not low-density
* Expanded (840+) 2 list and detail side by side
* Large (12001599) 2 the same
* Extra-large (1600+) 2 the same
*
* Below 840px one pane shows: the list while `data-md-selected` is absent, the detail while it is
* there, with a back button — M3: "a Back button appears in the detail view only for single-pane
* layouts". From 840px the list is a fixed pane and the detail flexible, 24px apart: the fixed pane
* is 360dp at expanded and 412dp from large (§ Breakpoints, "fixed-and-flexible layout's fixed pane
* defaults to 360dp" at expanded and "412dp" at large and extra-large; 24dp margins and spacer),
* and every back button inside is hidden. Grid columns run in the inline direction, so in a
* right-to-left document the list is on the right — the mirror M3 requires of every canonical
* layout (docs/reference/m3/foundations.md § Layout → Bidirectionality / RTL) — and the back arrow
* turns with it.
*
* The layout keeps its content off the window's edge by M3's margin (16px below medium, 24px from
* it) unless it is inside something that already does (`--md-layout-margin`, pane.css), and tells
* what is inside it that the margin is drawn. A pane that is focused as a whole draws no outline:
* it is not a control, and the element focus lands on inside it is what shows the ring.
*
* In `material.layout`; the state and focus are resources/js/layout.js.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './visibility.css';
@layer material.layout {
[data-md-list-detail] {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
padding-inline: var(--md-layout-margin, var(--md-sys-measurement-space200));
@media (width >= 600px) {
padding-inline: var(--md-layout-margin, var(--md-sys-measurement-space300));
}
@media (width >= 840px) {
grid-template-columns: 360px minmax(0, 1fr);
column-gap: var(--md-sys-measurement-space300);
}
@media (width >= 1200px) {
grid-template-columns: 412px minmax(0, 1fr);
}
}
[data-md-list-detail] > * {
--md-layout-margin: 0px;
}
[data-md-list-detail-pane] {
min-inline-size: 0;
outline: none;
}
[data-md-list-detail-back-row] {
display: flex;
align-items: center;
min-block-size: var(--md-sys-measurement-space800);
}
[data-md-list-detail-back] [data-md-icon]:dir(rtl) {
transform: scaleX(-1);
}
@media (width < 840px) {
[data-md-list-detail]:not([data-md-selected]) > [data-md-list-detail-pane='detail'],
[data-md-list-detail][data-md-selected] > [data-md-list-detail-pane='list'] {
display: none;
}
}
@media (width >= 840px) {
[data-md-list-detail-back-row],
[data-md-list-detail] [data-md-list-detail-back] {
display: none;
}
}
}
+113
View File
@@ -0,0 +1,113 @@
/**
* `materialListDetail`: the state of `<x-list-detail>`, and where focus goes when it changes.
*
* Which pane shows is CSS (resources/css/layout/list-detail.css), keyed on `data-md-selected`, so
* the first paint is right before this runs. What CSS cannot do is focus. Below `expanded` (840px)
* one pane shows, and a selection hides the list the focused item is in, which would drop focus on
* the page itself; so a selection there moves focus to the detail pane, and `back()` returns it to
* the item it came from (or the list's current item, or the list), as a dialog returns focus to its
* trigger (WAI-ARIA APG, Dialog (Modal) → Keyboard interaction). From `expanded` both panes are on
* screen side by side and focus stays where the person put it: M3 asks co-planar panes for a focus
* order that follows what is on screen, not for focus to jump
* (docs/reference/m3/foundations.md § Layout → Scaffold, Panes → Accessibility). A window crossing
* 840px with focus in the pane that is about to hide hands it to the one that stays.
*
* The item a selection came from is the last thing focused or clicked inside the list, recorded as
* it happens: by the time a watcher runs, the list may already be hidden and the browser may
* already have moved focus to the page.
*/
import { from } from './breakpoints.js'
/** A selection is anything but nothing: null, undefined, false and '' select nothing; 0 is an id. */
const chosen = (value) => value !== null && value !== undefined && value !== false && value !== ''
const FOCUSABLE = 'a[href], button, input, select, textarea, summary, [tabindex]'
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialListDetail', (selected = null) => ({
selected,
origin: null,
focused: null,
query: null,
onBreakpoint: null,
init() {
this.query = from('expanded')
this.onBreakpoint = () => this.handOver()
this.query.addEventListener('change', this.onBreakpoint)
this.$watch('selected', (value, previous) => {
if (chosen(value) === chosen(previous) || this.query.matches) {
return
}
this.$nextTick(() => (chosen(value) ? this.$refs.detail.focus() : this.returnFocus()))
})
},
destroy() {
this.query?.removeEventListener('change', this.onBreakpoint)
},
/** Whether anything is selected, for `data-md-selected`. */
get hasSelection() {
return chosen(this.selected)
},
/** Back to the list: a boolean selection becomes false, anything else null. */
back() {
this.selected = typeof this.selected === 'boolean' ? false : null
},
/** Remembers what inside the list a selection may come from. */
remember(event) {
const target = event.target instanceof Element ? event.target.closest(FOCUSABLE) : null
if (target && target !== this.$refs.list && this.$refs.list.contains(target)) {
this.origin = target
}
},
returnFocus() {
const list = this.$refs.list
const marked = list.querySelector('[aria-current]:not([aria-current="false"]), [aria-selected="true"]')
const current = marked && (marked.matches(FOCUSABLE) ? marked : marked.querySelector(FOCUSABLE))
const target = [this.origin, current].find((element) => element && element.isConnected && list.contains(element) && element.checkVisibility()) ?? list
target.focus()
},
/** Remembers the last thing focused inside the layout, and forgets it once focus leaves for another element. */
track(event) {
if (event.type === 'focusin') {
this.focused = event.target
} else if (event.relatedTarget && !this.$root.contains(event.relatedTarget)) {
this.focused = null
}
},
/**
* The window crossed `expanded`: focus in a pane that no longer shows moves to the one that
* does. The browser may already have dropped focus from the hidden element to the page by
* the time the media query reports, so the element that had it is the one remembered.
*/
handOver() {
if (this.query.matches) {
return
}
const active = document.activeElement
const source = active && active !== document.body ? active : this.focused
if (!source || (source !== active && source.checkVisibility())) {
return
}
if (this.hasSelection && this.$refs.list.contains(source)) {
this.$refs.detail.focus()
} else if (!this.hasSelection && this.$refs.detail.contains(source)) {
this.returnFocus()
}
},
}))
})
+1
View File
@@ -29,3 +29,4 @@ import './tabs.js'
import './app-bar.js'
import './navigation.js'
import './toolbar.js'
import './layout.js'
@@ -0,0 +1,106 @@
{{-- M3's list-detail canonical layout: a list, and the detail of what is selected in it.
<x-list-detail wire:model.live="selectedId">
<x-slot:list>
<x-pane title="Inbox">
<x-list>… <x-list-item wire:click="$set('selectedId', {{ $message->id }})" …/> …</x-list>
</x-pane>
</x-slot:list>
<x-slot:detail>
<x-pane :title="$message?->subject" heading="h2" back>…</x-pane>
</x-slot:detail>
</x-list-detail>
Parent and child content: an inbox and a message, folders and a file, settings and a category
(docs/reference/m3/foundations-supplement.md § Canonical layouts → List-detail). Its
visible-panes table, row by row: compact (below 600px) shows 1 pane; medium (600839) 1 or 2,
and this layout shows 1, M3's recommendation for anything but low-density content; expanded
(840+), large (12001599) and extra-large (1600+) show 2.
So below 840px the list shows until something is selected, then the detail replaces it with
a back button; from 840px the list is a fixed pane, 360px wide (412px from 1200px, M3's
fixed-pane widths at expanded and at large, § Breakpoints), the detail takes the rest, 24px
apart, and no back button shows. Resizing moves between the two as M3 describes ("rotating
from expanded to medium collapses two panes back to one"). A right-to-left document puts the
list on the right and turns the back arrow.
`selected` is what is selected, bound with `wire:model` (the Livewire property, whose value
the server also renders from, so the first paint shows the right pane) or `x-model`, or given
once as `selected`. Nothing selected is `null`, `false` or `''`. Inside both slots `selected`
is in Alpine scope, and `back()` clears it (a boolean to `false`, anything else to `null`).
A list item can select with `wire:click="$set('selectedId', 7)"` or `x-on:click="selected = 7"`.
M3 shows the list's selected state only where both panes show; give the item `aria-current`
for it, which also tells `back()` where to return focus.
The back button: `<x-pane back>` puts it in the detail pane's own app bar. Without one in the
detail slot, the layout draws its own row with it above the detail. Either way it is hidden
from 840px.
Focus (resources/js/layout.js): below 840px a selection moves focus to the detail pane, and
`back()` returns it to the list item it came from — or the item marked `aria-current` or
`aria-selected`, or the list — as a dialog returns focus to its trigger (WAI-ARIA APG). From
840px focus stays where it is, since both panes are on screen.
The layout keeps M3's margin (16px below medium, 24px from it) unless it sits inside something
that already does, such as `<x-scaffold>`'s content region. It takes `as`, `hide-below` and
`hide-from` like every layout component, and the caller's `class` and `style` land on it
untouched. Its own attributes belong to Alpine (`wire:ignore.self`), while both slots morph as
usual. Drawn by resources/css/layout/list-detail.css. --}}
@props([
'as' => null,
'selected' => null,
'hideBelow' => null,
'hideFrom' => null,
])
@php
$layout = \NoNameWeb\LivewireMaterial\Support\Layout::class;
$element = $layout::element($as);
$model = $attributes->wire('model')->value() ?: null;
$initial = $selected;
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$initial = data_get($component, $model);
}
$chosen = $initial !== null && $initial !== false && $initial !== '';
$wire = $attributes->wire('model');
$detailSlot = $detail ?? null;
$ownBack = $detailSlot !== null && str_contains((string) $detailSlot, 'data-md-list-detail-back');
$attributes = $attributes->whereDoesntStartWith('wire:model')->merge(array_filter([
'data-md-list-detail' => true,
'data-md-selected' => $chosen ? true : null,
] + $layout::visibility($hideBelow, $hideFrom), fn ($value): bool => $value !== null));
@endphp
<{{ $element }}
x-data="materialListDetail(@if ($model !== null) @entangle($wire) @else @js($initial) @endif)"
@if ($model === null) x-modelable="selected" @endif
x-bind:data-md-selected="hasSelection ? '' : null"
x-on:focusin="track($event)"
x-on:focusout="track($event)"
wire:ignore.self
{{ $attributes }}
>
<div
data-md-list-detail-pane="list"
tabindex="-1"
x-ref="list"
x-on:focusin="remember($event)"
x-on:click="remember($event)"
>{{ $list ?? '' }}</div>
<div data-md-list-detail-pane="detail" tabindex="-1" x-ref="detail">
@unless ($ownBack)
<div data-md-list-detail-back-row>
<span data-md-list-detail-back>
<x-livewire-material::button icon="arrow_back" :tooltip="__('Back')" x-on:click="back()" />
</span>
</div>
@endunless
{{ $detailSlot ?? '' }}
</div>
</{{ $element }}>
@@ -114,6 +114,7 @@
<x-livewire-material::stack as="ul" gap="space100" class="md-type-body-md">
<li><code>&lt;x-pane&gt;</code> — a content region with M3's margins and its own app bar.</li>
<li><code>&lt;x-list-detail&gt;</code> a list and the detail of its selection: one pane below 840px, two from it.</li>
<li><code>&lt;x-surface&gt;</code> a tonal region: a surface role, padding, a corner and an outline.</li>
<li><code>&lt;x-stack&gt;</code>, <code>&lt;x-row&gt;</code> and <code>&lt;x-grid&gt;</code> arrangement inside a pane.</li>
</x-livewire-material::stack>
+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'");
});