From b4f1e005de8564415b7025a9a9f8142b4de5e24d Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 14:51:04 +0200 Subject: [PATCH] Add the pane layout component Plan step 35: , 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 - 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 19 ++++ resources/css/layout.css | 1 + resources/css/layout/pane.css | 72 +++++++++++++++ resources/views/components/pane.blade.php | 88 ++++++++++++++++++ .../views/showcase/sections/layout.blade.php | 1 + tests/Browser/LayoutTest.php | 28 ++++++ tests/Feature/Components/PaneTest.php | 91 +++++++++++++++++++ 7 files changed, 300 insertions(+) create mode 100644 resources/css/layout/pane.css create mode 100644 resources/views/components/pane.blade.php create mode 100644 tests/Feature/Components/PaneTest.php diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 751cb3d8..e8ba0de9 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -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 ``, a new edge, draws it again. +#### `` + +A content region: M3 puts all content in panes, and each may carry its own top app bar. + +```blade + + + + + … + +``` + +- 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 `` 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 ``, 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 `` 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. + #### `` A tonal region: ``. diff --git a/resources/css/layout.css b/resources/css/layout.css index ab0f41e5..5a1dc00a 100644 --- a/resources/css/layout.css +++ b/resources/css/layout.css @@ -10,3 +10,4 @@ @import './layout/row.css'; @import './layout/grid.css'; @import './layout/surface.css'; +@import './layout/pane.css'; diff --git a/resources/css/layout/pane.css b/resources/css/layout/pane.css new file mode 100644 index 00000000..3f3ac44a --- /dev/null +++ b/resources/css/layout/pane.css @@ -0,0 +1,72 @@ +/* + * : 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 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); + } +} diff --git a/resources/views/components/pane.blade.php b/resources/views/components/pane.blade.php new file mode 100644 index 00000000..e978c360 --- /dev/null +++ b/resources/views/components/pane.blade.php @@ -0,0 +1,88 @@ +{{-- A pane: a content region with M3's margins, and its own top app bar if it has a title. + + + + + … + + + 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 `` 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 ``, 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 `` 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 (``, ``), 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) + + @if (isset($leading)) + {{ $leading }} + @elseif ($backLink !== null || $backAction) + + + @if ($backLink !== null) + + @else + + @endif + + + @endif + + @isset($actions) + {{ $actions }} + @endisset + + @endif + + @isset($navigation) +
{{ $navigation }}
+ @endisset + +
{{ $slot }}
+ diff --git a/resources/views/showcase/sections/layout.blade.php b/resources/views/showcase/sections/layout.blade.php index 9660cffb..937ba8f4 100644 --- a/resources/views/showcase/sections/layout.blade.php +++ b/resources/views/showcase/sections/layout.blade.php @@ -113,6 +113,7 @@

+
  • <x-pane> — a content region with M3's margins and its own app bar.
  • <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 d4ae986e..48638a9d 100644 --- a/tests/Browser/LayoutTest.php +++ b/tests/Browser/LayoutTest.php @@ -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'") ->assertNoJavaScriptErrors(); }); + +it('keeps M3\'s margin in a pane, once, with its app bar across the whole pane', function () { + $body = <<<'BLADE' + +

    Body

    +

    Nested

    +

    On a surface

    +
    +

    Narrow

    + 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(); +}); diff --git a/tests/Feature/Components/PaneTest.php b/tests/Feature/Components/PaneTest.php new file mode 100644 index 00000000..f268b932 --- /dev/null +++ b/tests/Feature/Components/PaneTest.php @@ -0,0 +1,91 @@ +blade('The page'); + + 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('/
    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' + + ACTIONS + BODY + + BLADE); + + expect($html) + ->toMatch('/^\s*
    ]*>\s*]*data-app-bar[^>]*data-md-pane-bar/s') + ->toContain('

    Settings

    ') + ->toContain('

    Your account

    ') + ->toMatch('/
    ACTIONS<\/span><\/div>/') + ->toContain('data-sticky') + ->toMatch('/<\/header>\s*
    \s*BODY\s*<\/div>/s') + ->and((string) $this->blade(''))->not->toContain('data-sticky'); +}); + +it('draws a bar for actions alone, and names the heading h1 by default', function () { + expect((string) $this->blade('ACTIONS'))->toContain('data-md-pane-bar') + ->and((string) $this->blade(''))->toContain('

    Inbox

    '); +}); + +it('puts the section navigation under the bar and above the body', function () { + $html = (string) $this->blade(<<<'BLADE' + + + BODY + + BLADE); + + expect($html)->toMatch('/<\/header>\s*