From b5c5be1fd757e9fb13c3c76834deb97d6c2abd60 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 14:51:13 +0200 Subject: [PATCH] Add the list-detail canonical layout Plan step 35: , 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 33 ++++ resources/css/layout.css | 1 + resources/css/layout/list-detail.css | 88 +++++++++++ resources/js/layout.js | 113 ++++++++++++++ resources/js/material.js | 1 + .../views/components/list-detail.blade.php | 106 +++++++++++++ .../views/showcase/sections/layout.blade.php | 1 + tests/Browser/LayoutTest.php | 142 ++++++++++++++++++ tests/Feature/Components/ListDetailTest.php | 93 ++++++++++++ 9 files changed, 578 insertions(+) create mode 100644 resources/css/layout/list-detail.css create mode 100644 resources/js/layout.js create mode 100644 resources/views/components/list-detail.blade.php create mode 100644 tests/Feature/Components/ListDetailTest.php diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index e8ba0de9..d2012741 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -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 `` around it (hidden there where both panes show). The arrow mirrors in a right-to-left document. - `navigation` is the pane's section navigation (``, ``), under the bar and above the body with the body's margins — not the bar's leading button. +#### `` + +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 + + + + + @foreach ($messages as $message) + + @endforeach + + + + + + + +``` + +- `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: `` 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. + #### `` A tonal region: ``. diff --git a/resources/css/layout.css b/resources/css/layout.css index 5a1dc00a..f55949d4 100644 --- a/resources/css/layout.css +++ b/resources/css/layout.css @@ -11,3 +11,4 @@ @import './layout/grid.css'; @import './layout/surface.css'; @import './layout/pane.css'; +@import './layout/list-detail.css'; diff --git a/resources/css/layout/list-detail.css b/resources/css/layout/list-detail.css new file mode 100644 index 00000000..2349fa9d --- /dev/null +++ b/resources/css/layout/list-detail.css @@ -0,0 +1,88 @@ +/* + * : 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; + } + } +} diff --git a/resources/js/layout.js b/resources/js/layout.js new file mode 100644 index 00000000..b4b0c9e6 --- /dev/null +++ b/resources/js/layout.js @@ -0,0 +1,113 @@ +/** + * `materialListDetail`: the state of ``, 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() + } + }, + })) +}) diff --git a/resources/js/material.js b/resources/js/material.js index f4ca5f2b..d7632585 100644 --- a/resources/js/material.js +++ b/resources/js/material.js @@ -29,3 +29,4 @@ import './tabs.js' import './app-bar.js' import './navigation.js' import './toolbar.js' +import './layout.js' diff --git a/resources/views/components/list-detail.blade.php b/resources/views/components/list-detail.blade.php new file mode 100644 index 00000000..d92236b2 --- /dev/null +++ b/resources/views/components/list-detail.blade.php @@ -0,0 +1,106 @@ +{{-- M3's list-detail canonical layout: a list, and the detail of what is selected in it. + + + + + + + + + + + + + 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: `` 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 ``'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 }} +> +
{{ $list ?? '' }}
+ +
+ @unless ($ownBack) +
+ + + +
+ @endunless + + {{ $detailSlot ?? '' }} +
+ diff --git a/resources/views/showcase/sections/layout.blade.php b/resources/views/showcase/sections/layout.blade.php index 937ba8f4..35d355f6 100644 --- a/resources/views/showcase/sections/layout.blade.php +++ b/resources/views/showcase/sections/layout.blade.php @@ -114,6 +114,7 @@
  • <x-pane> — a content region with M3's margins and its own app bar.
  • +
  • <x-list-detail> — a list and the detail of its selection: one pane below 840px, two from it.
  • <x-surface> — a tonal region: a surface role, padding, a corner and an outline.
  • <x-stack>, <x-row> and <x-grid> — arrangement inside a pane.
  • diff --git a/tests/Browser/LayoutTest.php b/tests/Browser/LayoutTest.php index 48638a9d..ca659d9d 100644 --- a/tests/Browser/LayoutTest.php +++ b/tests/Browser/LayoutTest.php @@ -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' +
    +

    selected: {{ var_export($messageId, true) }}

    + + + + + + @foreach ([1 => 'Photos', 2 => 'Contract', 3 => 'Trains'] as $id => $subject) + + @endforeach + + + + + + +

    {{ $messageId ? 'The message' : 'Select a message' }}

    +
    +
    +
    +
    + BLADE; + } +} + +function listDetailPage(int $width, string $dir = 'ltr'): mixed +{ + Livewire::component('layout-list-detail-probe', LayoutListDetailProbe::class); + + return layoutPage('', $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' +
    +

    chosen:

    + + +

    +
    +
    + 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')"); +}); diff --git a/tests/Feature/Components/ListDetailTest.php b/tests/Feature/Components/ListDetailTest.php new file mode 100644 index 00000000..182daa97 --- /dev/null +++ b/tests/Feature/Components/ListDetailTest.php @@ -0,0 +1,93 @@ +blade(<<<'BLADE' + +
      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('/
      LIST<\/ul><\/div>/') + ->toMatch('/
      .*
      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('')))->toMatchArray(['data-md-selected' => 'data-md-selected', 'x-data' => 'materialListDetail( 7 )']) + ->and(layoutRoot((string) $this->blade('')))->not->toHaveKey('data-md-selected') + ->and(layoutRoot((string) $this->blade('')))->not->toHaveKey('data-md-selected') + ->and(layoutRoot((string) $this->blade('')))->toHaveKey('data-md-selected'); + + Livewire::component('list-detail-render-probe', new class extends Component + { + public ?int $messageId = 3; + + public function render(): string + { + return '
      LISTDETAIL
      '; + } + }); + + $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('LISTDETAIL'); + $pane = (string) $this->blade(<<<'BLADE' + + LIST + DETAIL + + BLADE); + + expect($own)->toMatch('/x-ref="detail">\s*
      \s*\s*]*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(''))) + ->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'"); +});