Add the pane layout component
Plan step 35: <x-pane>, a content region with M3's margins (16px below medium, 24px from it) drawn once however panes and layouts nest, a width cap, and an optional pane app bar - <x-app-bar> as a direct child of the pane with title, subtitle, actions, a back link or action, a leading slot, and the section navigation under it. 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
e99c5993d8
commit
b4f1e005de
@@ -783,6 +783,25 @@ Breakpoints are M3's five, in px: compact below 600, `medium` 600, `expanded` 84
|
|||||||
|
|
||||||
M3's margin — 16px on a compact window, 24px from `medium` — is drawn once: by the scaffold's content region, or by the outermost pane or canonical layout when there is no scaffold. A pane inside one of those draws none, and one on an `<x-surface>`, a new edge, draws it again.
|
M3's margin — 16px on a compact window, 24px from `medium` — is drawn once: by the scaffold's content region, or by the outermost pane or canonical layout when there is no scaffold. A pane inside one of those draws none, and one on an `<x-surface>`, a new edge, draws it again.
|
||||||
|
|
||||||
|
#### `<x-pane>`
|
||||||
|
|
||||||
|
A content region: M3 puts all content in panes, and each may carry its own top app bar.
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-pane title="Settings" subtitle="Your account" width="narrow">
|
||||||
|
<x-slot:actions><x-button icon="help" tooltip="Help" /></x-slot:actions>
|
||||||
|
<x-slot:navigation><x-section-nav :items="$sections" /></x-slot:navigation>
|
||||||
|
|
||||||
|
…
|
||||||
|
</x-pane>
|
||||||
|
```
|
||||||
|
|
||||||
|
- The body keeps M3's margin (16px below `medium`, 24px from it) unless something around it already does — the scaffold's content region, a canonical layout, another pane's body; on an `<x-surface>` it keeps it again.
|
||||||
|
- `width`: `full` (default, the room it has), `narrow` (40rem, M3's 40–60 characters a line), `medium` (60rem), `wide` (80rem); capped widths are centred.
|
||||||
|
- The app bar is an `<x-app-bar>`, a direct child of the pane so it spans the pane and stays sticky for its height; it is drawn when there is a `title`, `actions`, a `leading` slot or `back`. `title`, `subtitle`, `heading` (`h1` default — the second pane of a canonical layout passes `h2`), `sticky` (default true), the `actions` slot.
|
||||||
|
- 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-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>`.
|
||||||
|
|||||||
@@ -10,3 +10,4 @@
|
|||||||
@import './layout/row.css';
|
@import './layout/row.css';
|
||||||
@import './layout/grid.css';
|
@import './layout/grid.css';
|
||||||
@import './layout/surface.css';
|
@import './layout/surface.css';
|
||||||
|
@import './layout/pane.css';
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* <x-pane>: a content region, with M3's margins and, optionally, a top app bar of its own.
|
||||||
|
*
|
||||||
|
* M3 puts all content in panes (docs/reference/m3/foundations.md § Layout → Scaffold, "all content
|
||||||
|
* must live in a pane") and lets each carry its own app bar. The margin is the space between the
|
||||||
|
* window's edge and what is inside: 16px below medium and 24px from it (600px), from the
|
||||||
|
* per-breakpoint table in docs/reference/m3/foundations-supplement.md § Breakpoints, as
|
||||||
|
* `--md-sys-measurement-space200` and `space300`.
|
||||||
|
*
|
||||||
|
* The margin sits on the pane's body and its section navigation, not on the pane: the app bar is a
|
||||||
|
* direct child of the pane, so it spans the pane from edge to edge and its `position: sticky` holds
|
||||||
|
* for the pane's whole height (a sticky element never leaves its parent). A margin is drawn once:
|
||||||
|
* a region that already keeps its content off the window's edge — a pane's body, the canonical
|
||||||
|
* layouts, the scaffold's content region — sets `--md-layout-margin` to 0px for what is inside it,
|
||||||
|
* and a pane there draws none of its own, until an <x-surface> gives it a new edge to keep off.
|
||||||
|
*
|
||||||
|
* `data-md-width` caps the pane and centres it: `narrow` 40rem, M3's 40–60 characters a line
|
||||||
|
* (§ Layout → Breakpoints, "keep text to 40–60 characters per line"), in rem because it is a measure
|
||||||
|
* of text; `medium` 60rem and `wide` 80rem are the package's steps above it; `full` (the default)
|
||||||
|
* fills the room it has, M3's flexible pane. The back button of `data-md-pane-back` points the
|
||||||
|
* way it goes, so it mirrors in a right-to-left document (§ Layout → Bidirectionality / RTL).
|
||||||
|
*
|
||||||
|
* In `material.layout`; `hide-below`/`hide-from` come from visibility.css.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||||
|
|
||||||
|
@import './visibility.css';
|
||||||
|
|
||||||
|
@layer material.layout {
|
||||||
|
[data-md-pane] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
inline-size: 100%;
|
||||||
|
min-inline-size: 0;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-pane][data-md-width='narrow'] {
|
||||||
|
max-inline-size: 40rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-pane][data-md-width='medium'] {
|
||||||
|
max-inline-size: 60rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-pane][data-md-width='wide'] {
|
||||||
|
max-inline-size: 80rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-pane-navigation],
|
||||||
|
[data-md-pane-body] {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-pane-body] {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-pane-body] > * {
|
||||||
|
--md-layout-margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-md-pane-back] [data-md-icon]:dir(rtl) {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{{-- A pane: a content region with M3's margins, and its own top app bar if it has a title.
|
||||||
|
|
||||||
|
<x-pane title="Settings" subtitle="Your account" width="narrow">
|
||||||
|
<x-slot:actions><x-button icon="help" tooltip="Help" /></x-slot:actions>
|
||||||
|
<x-slot:navigation><x-section-nav :items="$sections" /></x-slot:navigation>
|
||||||
|
…
|
||||||
|
</x-pane>
|
||||||
|
|
||||||
|
M3 puts every piece of content in a pane and lets each carry its own top app bar
|
||||||
|
(docs/reference/m3/foundations.md § Layout → Scaffold). The body is kept off the window's edge
|
||||||
|
by M3's margin, 16px below medium (600px) and 24px from it
|
||||||
|
(docs/reference/m3/foundations-supplement.md § Breakpoints); a pane inside something that
|
||||||
|
already keeps that margin — the scaffold's content region, a canonical layout, another pane's
|
||||||
|
body — draws none, so the margin is never doubled; on an `<x-surface>` it draws it again.
|
||||||
|
|
||||||
|
`width` caps the pane and centres it: `narrow` (40rem, M3's 40–60 characters a line),
|
||||||
|
`medium` (60rem), `wide` (80rem) or `full` (the default, all the room it has).
|
||||||
|
|
||||||
|
The app bar is `<x-app-bar>`, drawn when there is a `title`, `actions`, a `leading` slot or
|
||||||
|
`back`: `title` and `subtitle`, `heading` (`h1` by default; the second pane of a canonical
|
||||||
|
layout passes `h2`), `sticky` (true: it holds to the top of the window for as long as the pane
|
||||||
|
is on screen), the `actions` slot at its end. Its leading icon button is the `leading` slot,
|
||||||
|
or `back`: a URL makes it a link back there, and `true` calls `back()` in the Alpine scope
|
||||||
|
around it, which `<x-list-detail>` provides — and which it hides where both panes show, as M3
|
||||||
|
shows a back button only in a single-pane list-detail. The back 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; it is not the bar's leading button.
|
||||||
|
|
||||||
|
It takes `as` (`div` by default: `section`, `article`, `main` …), `hide-below` and `hide-from`
|
||||||
|
like every layout component, and the caller's `class` and `style` land on it untouched. Drawn
|
||||||
|
by resources/css/layout/pane.css. --}}
|
||||||
|
|
||||||
|
@props([
|
||||||
|
'as' => null,
|
||||||
|
'title' => null,
|
||||||
|
'subtitle' => null,
|
||||||
|
'heading' => 'h1',
|
||||||
|
'sticky' => true,
|
||||||
|
'back' => null,
|
||||||
|
'width' => null,
|
||||||
|
'hideBelow' => null,
|
||||||
|
'hideFrom' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$layout = \NoNameWeb\LivewireMaterial\Support\Layout::class;
|
||||||
|
$element = $layout::element($as);
|
||||||
|
$backLink = is_string($back) && $back !== '' ? $back : null;
|
||||||
|
$backAction = $back === true;
|
||||||
|
$bar = filled($title) || isset($actions) || isset($leading) || $backLink !== null || $backAction;
|
||||||
|
|
||||||
|
$attributes = $attributes->merge(array_filter([
|
||||||
|
'data-md-pane' => true,
|
||||||
|
'data-md-width' => $layout::choice($width, ['full', 'narrow', 'medium', 'wide']),
|
||||||
|
] + $layout::visibility($hideBelow, $hideFrom), fn ($value): bool => $value !== null));
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<{{ $element }} {{ $attributes }}>
|
||||||
|
@if ($bar)
|
||||||
|
<x-livewire-material::app-bar :title="$title" :subtitle="$subtitle" :heading="$heading" :sticky="$sticky" data-md-pane-bar>
|
||||||
|
@if (isset($leading))
|
||||||
|
<x-slot:navigation>{{ $leading }}</x-slot:navigation>
|
||||||
|
@elseif ($backLink !== null || $backAction)
|
||||||
|
<x-slot:navigation>
|
||||||
|
<span data-md-pane-back @if ($backAction) data-md-list-detail-back @endif>
|
||||||
|
@if ($backLink !== null)
|
||||||
|
<x-livewire-material::button icon="arrow_back" :tooltip="__('Back')" :link="$backLink" />
|
||||||
|
@else
|
||||||
|
<x-livewire-material::button icon="arrow_back" :tooltip="__('Back')" x-on:click="back()" />
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
</x-slot:navigation>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@isset($actions)
|
||||||
|
<x-slot:actions>{{ $actions }}</x-slot:actions>
|
||||||
|
@endisset
|
||||||
|
</x-livewire-material::app-bar>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@isset($navigation)
|
||||||
|
<div data-md-pane-navigation>{{ $navigation }}</div>
|
||||||
|
@endisset
|
||||||
|
|
||||||
|
<div data-md-pane-body>{{ $slot }}</div>
|
||||||
|
</{{ $element }}>
|
||||||
@@ -113,6 +113,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<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-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>
|
||||||
|
|||||||
@@ -142,3 +142,31 @@ it('draws a surface in its role, padding and corner, and hides it below a breakp
|
|||||||
->assertScript(layoutStyle('#surface', 'borderTopWidth')." === '1px'")
|
->assertScript(layoutStyle('#surface', 'borderTopWidth')." === '1px'")
|
||||||
->assertNoJavaScriptErrors();
|
->assertNoJavaScriptErrors();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps M3\'s margin in a pane, once, with its app bar across the whole pane', function () {
|
||||||
|
$body = <<<'BLADE'
|
||||||
|
<x-pane id="pane" title="Settings">
|
||||||
|
<p id="text">Body</p>
|
||||||
|
<x-pane id="inner"><p>Nested</p></x-pane>
|
||||||
|
<x-surface id="card"><x-pane id="on-surface"><p id="surface-text">On a surface</p></x-pane></x-surface>
|
||||||
|
</x-pane>
|
||||||
|
<x-pane id="narrow" width="narrow"><p>Narrow</p></x-pane>
|
||||||
|
BLADE;
|
||||||
|
|
||||||
|
layoutPage($body, 599)
|
||||||
|
->assertScript(layoutRect('#text', 'left').' === 16')
|
||||||
|
->assertScript(layoutRect('#pane [data-md-pane-bar]', 'width').' === '.layoutRect('#pane', 'width'))
|
||||||
|
->assertScript(layoutStyle('#inner [data-md-pane-body]', 'paddingLeft')." === '0px'");
|
||||||
|
|
||||||
|
layoutPage($body, 600)
|
||||||
|
->assertScript(layoutRect('#text', 'left').' === 24')
|
||||||
|
->assertScript(layoutRect('#pane [data-md-pane-bar]', 'left').' === 0')
|
||||||
|
->assertScript(layoutStyle('#inner [data-md-pane-body]', 'paddingLeft')." === '0px'")
|
||||||
|
// A surface is a new edge, so a pane on it keeps its margin again.
|
||||||
|
->assertScript(layoutRect('#surface-text', 'left').' - '.layoutRect('#card', 'left').' === 24');
|
||||||
|
|
||||||
|
layoutPage($body, 1200)
|
||||||
|
->assertScript(layoutRect('#narrow', 'width').' === 640')
|
||||||
|
->assertScript(layoutRect('#narrow', 'left').' === 280')
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('draws a content region with a body and no app bar when it has no title', function () {
|
||||||
|
$html = (string) $this->blade('<x-pane>The page</x-pane>');
|
||||||
|
|
||||||
|
expect(layoutRoot($html))->toMatchArray(['<' => 'div', 'data-md-pane' => 'data-md-pane'])
|
||||||
|
->not->toHaveKeys(['data-md-width', 'data-md-hide-below', 'data-md-hide-from'])
|
||||||
|
->and($html)->toMatch('/<div data-md-pane-body>The page<\/div>/')
|
||||||
|
->not->toContain('data-app-bar')
|
||||||
|
->not->toContain('data-md-pane-navigation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the pane its own app bar, a direct child spanning it, with the title, subtitle and actions', function () {
|
||||||
|
$html = (string) $this->blade(<<<'BLADE'
|
||||||
|
<x-pane title="Settings" subtitle="Your account" heading="h2">
|
||||||
|
<x-slot:actions><span>ACTIONS</span></x-slot:actions>
|
||||||
|
BODY
|
||||||
|
</x-pane>
|
||||||
|
BLADE);
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toMatch('/^\s*<div data-md-pane[^>]*>\s*<header\b[^>]*data-app-bar[^>]*data-md-pane-bar/s')
|
||||||
|
->toContain('<h2 data-app-bar-title>Settings</h2>')
|
||||||
|
->toContain('<p data-app-bar-subtitle>Your account</p>')
|
||||||
|
->toMatch('/<div data-app-bar-trailing><span>ACTIONS<\/span><\/div>/')
|
||||||
|
->toContain('data-sticky')
|
||||||
|
->toMatch('/<\/header>\s*<div data-md-pane-body>\s*BODY\s*<\/div>/s')
|
||||||
|
->and((string) $this->blade('<x-pane title="Settings" :sticky="false" />'))->not->toContain('data-sticky');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a bar for actions alone, and names the heading h1 by default', function () {
|
||||||
|
expect((string) $this->blade('<x-pane><x-slot:actions><span>ACTIONS</span></x-slot:actions></x-pane>'))->toContain('data-md-pane-bar')
|
||||||
|
->and((string) $this->blade('<x-pane title="Inbox" />'))->toContain('<h1 data-app-bar-title>Inbox</h1>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the section navigation under the bar and above the body', function () {
|
||||||
|
$html = (string) $this->blade(<<<'BLADE'
|
||||||
|
<x-pane title="Settings">
|
||||||
|
<x-slot:navigation><nav>SECTIONS</nav></x-slot:navigation>
|
||||||
|
BODY
|
||||||
|
</x-pane>
|
||||||
|
BLADE);
|
||||||
|
|
||||||
|
expect($html)->toMatch('/<\/header>\s*<div data-md-pane-navigation><nav>SECTIONS<\/nav><\/div>\s*<div data-md-pane-body>\s*BODY/s')
|
||||||
|
// It is the pane's section navigation, not the bar's leading button.
|
||||||
|
->not->toContain('data-app-bar-leading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leads the bar with a back link, a back action for a list-detail, or the leading slot', function () {
|
||||||
|
$link = (string) $this->blade('<x-pane title="Recipients" back="/shares" />');
|
||||||
|
$action = (string) $this->blade('<x-pane title="Message" back />');
|
||||||
|
$leading = (string) $this->blade('<x-pane title="Inbox" back><x-slot:leading><button>MENU</button></x-slot:leading></x-pane>');
|
||||||
|
|
||||||
|
expect($link)->toMatch('/data-app-bar-leading><span data-md-pane-back\s*>/')
|
||||||
|
->toMatch('/<a[^>]*href="\/shares"[^>]*aria-label="Back"/s')
|
||||||
|
->not->toContain('data-md-list-detail-back')
|
||||||
|
->and($action)->toMatch('/<span data-md-pane-back\s+data-md-list-detail-back\s*>/')
|
||||||
|
->toMatch('/<button[^>]*aria-label="Back"[^>]*x-on:click="back\(\)"/s')
|
||||||
|
->and($leading)->toContain('<div data-app-bar-leading><button>MENU</button></div>')
|
||||||
|
->not->toContain('data-md-pane-back');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps its width with M3\'s narrow measure or the wider steps', function () {
|
||||||
|
foreach (['full', 'narrow', 'medium', 'wide'] as $width) {
|
||||||
|
expect(layoutRoot((string) $this->blade('<x-pane :width="$width" />', ['width' => $width]))['data-md-width'])->toBe($width);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(layoutRoot((string) $this->blade('<x-pane width="40rem" />')))->not->toHaveKey('data-md-width');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the element, the visibility props and the caller\'s class and style', function () {
|
||||||
|
expect(layoutRoot((string) $this->blade('<x-pane as="section" hide-below="expanded" class="checkout" style="max-width: 50rem" aria-labelledby="checkout-title" />')))
|
||||||
|
->toMatchArray([
|
||||||
|
'<' => 'section',
|
||||||
|
'data-md-hide-below' => 'expanded',
|
||||||
|
'class' => 'checkout',
|
||||||
|
'style' => 'max-width: 50rem;',
|
||||||
|
'aria-labelledby' => 'checkout-title',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps M3\'s margin on the body, once, in the layout layer', function () {
|
||||||
|
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/pane.css');
|
||||||
|
|
||||||
|
expect($css)->toContain('@layer material.layout')
|
||||||
|
->toContain('padding-inline: var(--md-layout-margin, var(--md-sys-measurement-space200));')
|
||||||
|
->toContain("@media (width >= 600px) {\n padding-inline: var(--md-layout-margin, var(--md-sys-measurement-space300));")
|
||||||
|
->toContain("[data-md-pane-body] > * {\n --md-layout-margin: 0px;")
|
||||||
|
->toContain("[data-md-pane][data-md-width='narrow'] {\n max-inline-size: 40rem;")
|
||||||
|
->and((string) file_get_contents(__DIR__.'/../../../resources/css/layout.css'))->toContain("@import './layout/pane.css';");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user