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:
co-authored by
Claude Opus 5
parent
b4f1e005de
commit
b5c5be1fd7
@@ -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, 600–839 | 1, as compact (M3's recommendation) |
|
||||
| Expanded, 840–1199 | 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>`.
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
@import './layout/grid.css';
|
||||
@import './layout/surface.css';
|
||||
@import './layout/pane.css';
|
||||
@import './layout/list-detail.css';
|
||||
|
||||
@@ -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 (0–599) 1 pane list, or the detail once something is selected
|
||||
* Medium (600–839) 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 (1200–1599) 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -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 (600–839) 1 or 2,
|
||||
and this layout shows 1, M3's recommendation for anything but low-density content; expanded
|
||||
(840+), large (1200–1599) 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><x-pane></code> — a content region with M3's margins and its own app bar.</li>
|
||||
<li><code><x-list-detail></code> — a list and the detail of its selection: one pane below 840px, two from it.</li>
|
||||
<li><code><x-surface></code> — a tonal region: a surface role, padding, a corner and an outline.</li>
|
||||
<li><code><x-stack></code>, <code><x-row></code> and <code><x-grid></code> — arrangement inside a pane.</li>
|
||||
</x-livewire-material::stack>
|
||||
|
||||
Reference in New Issue
Block a user