Offer the app shell a window-wide banner above the rail

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:11:49 +02:00
co-authored by Claude Opus 5
parent c3bed743aa
commit 5205aa8842
4 changed files with 81 additions and 46 deletions
@@ -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 `<x-app-shell>`), 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`: `<x-drawer pane>` is the second one, in an `expanded:flex expanded:items-start expanded:gap-6` row inside the page.
+5 -2
View File
@@ -217,14 +217,17 @@
}
}
/* `--material-banner` is the height of a bar pinned across the top of the window — what
<x-app-shell>'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
+57 -42
View File
@@ -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 `<x-navigation-rail>` 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 `<x-navigation-rail>` 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 `<x-app-shell>` — so the rail sticks under it instead
of behind it.
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
@@ -102,7 +111,7 @@
<div
data-app-shell
@class([
'min-h-dvh bg-surface text-on-surface [--material-margin:1rem] medium:flex medium:[--material-margin:1.5rem]',
'flex min-h-dvh flex-col bg-surface text-on-surface [--material-margin:1rem] medium:[--material-margin:1.5rem]',
'max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $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') }}</a>
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
@isset($brand)
<x-slot:brand>{{ $brand }}</x-slot:brand>
@endisset
@isset($banner)
<div data-app-shell-banner class="shrink-0">{{ $banner }}</div>
@endisset
@isset($railHeader)
<x-slot:header>{{ $railHeader }}</x-slot:header>
@endisset
<div class="flex-1 medium:flex">
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
@isset($brand)
<x-slot:brand>{{ $brand }}</x-slot:brand>
@endisset
@foreach ($groups as $group)
@if ($group->first()['section'] !== null)
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
@isset($railHeader)
<x-slot:header>{{ $railHeader }}</x-slot:header>
@endisset
@foreach ($groups as $group)
@if ($group->first()['section'] !== null)
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
@foreach ($group as $item)
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
@endforeach
</x-livewire-material::navigation-rail-section>
@else
@foreach ($group as $item)
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
@endforeach
</x-livewire-material::navigation-rail-section>
@else
@foreach ($group as $item)
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
@endforeach
@endif
@endforeach
@if (isset($railFooter) || isset($actions))
<x-slot:footer>
{{ $railFooter ?? '' }}
@isset($actions)
<div data-app-shell-actions class="flex items-center gap-1 px-5 pt-2 rail-collapsed:flex-col">
{{ $actions }}
</div>
@endisset
</x-slot:footer>
@endif
@endforeach
</x-livewire-material::navigation-rail>
@if (isset($railFooter) || isset($actions))
<x-slot:footer>
{{ $railFooter ?? '' }}
<div class="flex min-w-0 flex-1 flex-col">
{{ $top ?? '' }}
@isset($actions)
<div data-app-shell-actions class="flex items-center gap-1 px-5 pt-2 rail-collapsed:flex-col">
{{ $actions }}
</div>
@endisset
</x-slot:footer>
@endif
</x-livewire-material::navigation-rail>
<div class="flex min-w-0 flex-1 flex-col">
{{ $top ?? '' }}
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 px-(--material-margin) outline-none max-expanded:overflow-x-clip max-medium:pb-(--material-bottom-bar)">
{{ $slot }}
</main>
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 px-(--material-margin) outline-none max-expanded:overflow-x-clip max-medium:pb-(--material-bottom-bar)">
{{ $slot }}
</main>
</div>
</div>
@if ($barItems->isNotEmpty())
+17 -1
View File
@@ -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'
<x-app-shell :destinations="$destinations">
<x-slot:banner><header>WINDOW BAR</header></x-slot:banner>
<x-slot:brand><span>BRAND</span></x-slot:brand>
<x-slot:rail-header><span>FAB</span></x-slot:rail-header>
<x-slot:rail-footer><span>FOOTER</span></x-slot:rail-footer>
@@ -118,13 +119,28 @@ it('places each slot once', function () {
</x-app-shell>
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.*<main.*PAGE/s');
});
it('spans the window with a banner, above the rail, and renders none without the slot', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-app-shell :destinations="$destinations">
<x-slot:banner><header>WINDOW BAR</header></x-slot:banner>
PAGE
</x-app-shell>
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=.*<main/s')
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
->not->toContain('data-app-shell-banner');
});
it('serves the showcase\'s app shell pages', function () {
$html = $this->withoutVite()
->get('/material/shell/starred')