From 5205aa88423d0a648a68b64a7aaab4f01c1b1ded Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:11:49 +0200 Subject: [PATCH] Offer the app shell a window-wide banner above the rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M3's scaffold is bars, then rails, then panes, and the shell had no slot that rendered above the rail: an application-wide bar could only be a pane bar beside it. `banner` is that slot — the shell is a column now, with the rail and the page as one row inside it — and the rail sticks under a pinned banner through --material-banner rather than behind it. `top` still means the page's own bar. Plan: docs/plans/material-3-alignment.md, step 21 (navigation N-14). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 3 +- resources/css/components/navigation.css | 7 +- .../views/components/app-shell.blade.php | 99 +++++++++++-------- tests/Feature/Components/AppShellTest.php | 18 +++- 4 files changed, 81 insertions(+), 46 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index f5c7c099..26709755 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -712,7 +712,8 @@ A visitor who has pressed the menu button keeps that choice in both standard ban ``` - `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the page's, also during a Livewire update request), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for the badge: "3 unread"), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`). -- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`). +- Slots, each rendered once: `banner` (a bar across the whole window, above the rail and the page), `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the page's own bar, above the page and beside the rail), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`). +- `banner` or `top`: M3's scaffold is bars, then rails, then panes. An application-wide bar — one search, one account menu, the same on every page — goes in `banner` and the rail starts under it; a bar that titles the page goes in `top`, beside the rail. Never both. A banner that pins itself to the top of the window says how tall it is (`style="--material-banner: 4rem"` on ``), so the rail sticks under it instead of behind it. - The rail is one element at every width: what is in it is also what a phone sees in the modal rail. On a compact window nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `medium`). - `--material-margin` is M3's window margin (16px compact, 24px from `medium`) and the content region already carries it, so a page inside the shell writes no gutters of its own; something that must reach the window's edges opts out with `-mx-(--material-margin)`. - Two panes side by side are M3's from `expanded`: `` is the second one, in an `expanded:flex expanded:items-start expanded:gap-6` row inside the page. diff --git a/resources/css/components/navigation.css b/resources/css/components/navigation.css index 4ea61e18..237c446c 100644 --- a/resources/css/components/navigation.css +++ b/resources/css/components/navigation.css @@ -217,14 +217,17 @@ } } + /* `--material-banner` is the height of a bar pinned across the top of the window — what + 's `banner` slot holds, if the application made it sticky. The rail sticks + under it rather than behind it; 0 without one. */ [data-navigation-rail-panel] { position: sticky; - top: 0; + top: var(--material-banner, 0px); display: flex; flex-direction: column; width: 100%; height: 100%; - max-height: 100dvh; + max-height: calc(100dvh - var(--material-banner, 0px)); /* Clip, not hide: no scroll container, so the destinations below can still scroll and nothing sticky breaks. What only an expanded rail draws — a label, the brand — is drawn at once when the rail expands, while the width is still growing; the clip keeps it diff --git a/resources/views/components/app-shell.blade.php b/resources/views/components/app-shell.blade.php index a95c2624..c0843262 100644 --- a/resources/views/components/app-shell.blade.php +++ b/resources/views/components/app-shell.blade.php @@ -45,13 +45,22 @@ it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and `navigate` (`false` for a full page load instead of `wire:navigate`). - Slots, each rendered once: `brand` (beside the rail's menu button while it is expanded), - `rail-header` (under it: a FAB — see `` for its two shapes), `rail-footer` - (at the foot of the rail: footer destinations, an account), `actions` (a row of icon buttons at - the very foot, stacked when the rail is collapsed: a theme toggle, sign out), `top` (the app - bar, above the page at every width) and the page itself. The rail is one element at every - width, so what is in it is also in the modal rail a phone opens. `label` names both navigation - landmarks ("Main"); `rail-width` is the expanded rail's width. + Slots, each rendered once: `banner` (a bar across the whole window, above the rail and the + page — M3's scaffold is bars, then rails, then panes), `brand` (beside the rail's menu button + while it is expanded), `rail-header` (under it: a FAB — see `` for its two + shapes), `rail-footer` (at the foot of the rail: footer destinations, an account), `actions` + (a row of icon buttons at the very foot, stacked when the rail is collapsed: a theme toggle, + sign out), `top` (the page's own bar, above the page and *beside* the rail) and the page + itself. The rail is one element at every width, so what is in it is also in the modal rail a + phone opens. `label` names both navigation landmarks ("Main"); `rail-width` is the expanded + rail's width. + + `banner` or `top` is a decision about what the bar belongs to: an application-wide bar — one + search, one account menu, the same on every page — spans the window and the rail starts under + it; a bar that titles the page belongs to the page, beside the rail. Put an app bar in one or + the other, never both. A banner that pins itself to the top of the window says how tall it is + — `style="--material-banner: 4rem"` on `` — so the rail sticks under it instead + of behind it. The page is `
` with `wire:transition.navigate`, behind a skip link that is the first thing a keyboard reaches. The snackbar host (``) is part of the shell; @@ -102,7 +111,7 @@
$barItems->isNotEmpty(), ]) > @@ -112,48 +121,54 @@ class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none" >{{ __('Skip to content') }} - - @isset($brand) - {{ $brand }} - @endisset + @isset($banner) +
{{ $banner }}
+ @endisset - @isset($railHeader) - {{ $railHeader }} - @endisset +
+ + @isset($brand) + {{ $brand }} + @endisset - @foreach ($groups as $group) - @if ($group->first()['section'] !== null) - + @isset($railHeader) + {{ $railHeader }} + @endisset + + @foreach ($groups as $group) + @if ($group->first()['section'] !== null) + + @foreach ($group as $item) + + @endforeach + + @else @foreach ($group as $item) @endforeach - - @else - @foreach ($group as $item) - - @endforeach + @endif + @endforeach + + @if (isset($railFooter) || isset($actions)) + + {{ $railFooter ?? '' }} + + @isset($actions) +
+ {{ $actions }} +
+ @endisset +
@endif - @endforeach +
- @if (isset($railFooter) || isset($actions)) - - {{ $railFooter ?? '' }} +
+ {{ $top ?? '' }} - @isset($actions) -
- {{ $actions }} -
- @endisset - - @endif - - -
- {{ $top ?? '' }} - -
- {{ $slot }} -
+
+ {{ $slot }} +
+
@if ($barItems->isNotEmpty()) diff --git a/tests/Feature/Components/AppShellTest.php b/tests/Feature/Components/AppShellTest.php index 39758d7b..f5b9d770 100644 --- a/tests/Feature/Components/AppShellTest.php +++ b/tests/Feature/Components/AppShellTest.php @@ -109,6 +109,7 @@ it('reads the safe area and anything docked on the bar through variables an appl it('places each slot once', function () { $html = (string) $this->blade(<<<'BLADE' +
WINDOW BAR
BRAND FAB FOOTER @@ -118,13 +119,28 @@ it('places each slot once', function () {
BLADE, ['destinations' => shellDestinations()]); - foreach (['BRAND', 'FAB', 'FOOTER', 'ACTIONS', 'APP BAR', 'PAGE'] as $slot) { + foreach (['WINDOW BAR', 'BRAND', 'FAB', 'FOOTER', 'ACTIONS', 'APP BAR', 'PAGE'] as $slot) { expect(substr_count($html, $slot))->toBe(1); } expect($html)->toMatch('/data-navigation-rail-header.*BRAND.*FAB.*data-navigation-rail-footer.*FOOTER.*data-app-shell-actions.*ACTIONS.*APP BAR.*blade(<<<'BLADE' + +
WINDOW BAR
+ PAGE +
+ BLADE, ['destinations' => shellDestinations()]); + + // Bars, then rails, then panes: the banner is outside the row the rail and the page share. + expect($html) + ->toMatch('/data-app-shell-banner.*WINDOW BAR.*data-navigation-rail=.*
and((string) $this->blade('', ['destinations' => shellDestinations()])) + ->not->toContain('data-app-shell-banner'); +}); + it('serves the showcase\'s app shell pages', function () { $html = $this->withoutVite() ->get('/material/shell/starred')