Files
Andreas Reinhold / reiniandClaude Opus 5 b5c5be1fd7 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
2026-09-14 14:51:13 +02:00

107 lines
5.0 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{-- 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 }}>