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>