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:
co-authored by
Claude Opus 5
parent
c3bed743aa
commit
5205aa8842
@@ -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`).
|
- `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`).
|
- 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)`.
|
- `--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.
|
- 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.
|
||||||
|
|||||||
@@ -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] {
|
[data-navigation-rail-panel] {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: var(--material-banner, 0px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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
|
/* 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
|
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
|
at once when the rail expands, while the width is still growing; the clip keeps it
|
||||||
|
|||||||
@@ -45,13 +45,22 @@
|
|||||||
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
|
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`).
|
`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),
|
Slots, each rendered once: `banner` (a bar across the whole window, above the rail and the
|
||||||
`rail-header` (under it: a FAB — see `<x-navigation-rail>` for its two shapes), `rail-footer`
|
page — M3's scaffold is bars, then rails, then panes), `brand` (beside the rail's menu button
|
||||||
(at the foot of the rail: footer destinations, an account), `actions` (a row of icon buttons at
|
while it is expanded), `rail-header` (under it: a FAB — see `<x-navigation-rail>` for its two
|
||||||
the very foot, stacked when the rail is collapsed: a theme toggle, sign out), `top` (the app
|
shapes), `rail-footer` (at the foot of the rail: footer destinations, an account), `actions`
|
||||||
bar, above the page at every width) and the page itself. The rail is one element at every
|
(a row of icon buttons at the very foot, stacked when the rail is collapsed: a theme toggle,
|
||||||
width, so what is in it is also in the modal rail a phone opens. `label` names both navigation
|
sign out), `top` (the page's own bar, above the page and *beside* the rail) and the page
|
||||||
landmarks ("Main"); `rail-width` is the expanded rail's width.
|
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 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;
|
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
|
||||||
@@ -102,7 +111,7 @@
|
|||||||
<div
|
<div
|
||||||
data-app-shell
|
data-app-shell
|
||||||
@class([
|
@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(),
|
'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"
|
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>
|
>{{ __('Skip to content') }}</a>
|
||||||
|
|
||||||
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
|
@isset($banner)
|
||||||
@isset($brand)
|
<div data-app-shell-banner class="shrink-0">{{ $banner }}</div>
|
||||||
<x-slot:brand>{{ $brand }}</x-slot:brand>
|
@endisset
|
||||||
@endisset
|
|
||||||
|
|
||||||
@isset($railHeader)
|
<div class="flex-1 medium:flex">
|
||||||
<x-slot:header>{{ $railHeader }}</x-slot:header>
|
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
|
||||||
@endisset
|
@isset($brand)
|
||||||
|
<x-slot:brand>{{ $brand }}</x-slot:brand>
|
||||||
|
@endisset
|
||||||
|
|
||||||
@foreach ($groups as $group)
|
@isset($railHeader)
|
||||||
@if ($group->first()['section'] !== null)
|
<x-slot:header>{{ $railHeader }}</x-slot:header>
|
||||||
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
|
@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)
|
@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']" />
|
<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
|
@endforeach
|
||||||
</x-livewire-material::navigation-rail-section>
|
@endif
|
||||||
@else
|
@endforeach
|
||||||
@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']" />
|
@if (isset($railFooter) || isset($actions))
|
||||||
@endforeach
|
<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
|
@endif
|
||||||
@endforeach
|
</x-livewire-material::navigation-rail>
|
||||||
|
|
||||||
@if (isset($railFooter) || isset($actions))
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<x-slot:footer>
|
{{ $top ?? '' }}
|
||||||
{{ $railFooter ?? '' }}
|
|
||||||
|
|
||||||
@isset($actions)
|
<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)">
|
||||||
<div data-app-shell-actions class="flex items-center gap-1 px-5 pt-2 rail-collapsed:flex-col">
|
{{ $slot }}
|
||||||
{{ $actions }}
|
</main>
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($barItems->isNotEmpty())
|
@if ($barItems->isNotEmpty())
|
||||||
|
|||||||
@@ -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 () {
|
it('places each slot once', function () {
|
||||||
$html = (string) $this->blade(<<<'BLADE'
|
$html = (string) $this->blade(<<<'BLADE'
|
||||||
<x-app-shell :destinations="$destinations">
|
<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:brand><span>BRAND</span></x-slot:brand>
|
||||||
<x-slot:rail-header><span>FAB</span></x-slot:rail-header>
|
<x-slot:rail-header><span>FAB</span></x-slot:rail-header>
|
||||||
<x-slot:rail-footer><span>FOOTER</span></x-slot:rail-footer>
|
<x-slot:rail-footer><span>FOOTER</span></x-slot:rail-footer>
|
||||||
@@ -118,13 +119,28 @@ it('places each slot once', function () {
|
|||||||
</x-app-shell>
|
</x-app-shell>
|
||||||
BLADE, ['destinations' => shellDestinations()]);
|
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(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');
|
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 () {
|
it('serves the showcase\'s app shell pages', function () {
|
||||||
$html = $this->withoutVite()
|
$html = $this->withoutVite()
|
||||||
->get('/material/shell/starred')
|
->get('/material/shell/starred')
|
||||||
|
|||||||
Reference in New Issue
Block a user