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.
|
- 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.
|
- `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>`
|
#### `<x-surface>`
|
||||||
|
|
||||||
A tonal region: `<x-surface level="surface-container-low" padding="space300" corner="lg" outlined>…</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/grid.css';
|
||||||
@import './layout/surface.css';
|
@import './layout/surface.css';
|
||||||
@import './layout/pane.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 './app-bar.js'
|
||||||
import './navigation.js'
|
import './navigation.js'
|
||||||
import './toolbar.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">
|
<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-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-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>
|
<li><code><x-stack></code>, <code><x-row></code> and <code><x-grid></code> — arrangement inside a pane.</li>
|
||||||
</x-livewire-material::stack>
|
</x-livewire-material::stack>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\Facades\Route;
|
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
|
* 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')
|
->assertScript(layoutRect('#narrow', 'left').' === 280')
|
||||||
->assertNoJavaScriptErrors();
|
->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'");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user