Add M3's tall navigation bar

Plan step 25 (navigation Missing): NavigationBarTokens.TallContainerHeight,
80dp, had no prop. `<x-navigation-bar tall>` picks it, and the tall container
keeps the vertical item layout at every width — the 600px horizontal layout is
the short bar's alone, so every rule behind that container query now names
`:not([data-tall])`. `<x-app-shell tall-bar>` passes it through and grows
`--material-bottom-bar` to 5rem with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:33:24 +02:00
co-authored by Claude Fable 5.1
parent ce04101f89
commit 4d00d87cd2
7 changed files with 104 additions and 36 deletions
@@ -718,7 +718,7 @@ 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: `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`). - 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`); `tall-bar` picks M3's 80px navigation bar over the 64px one.
- `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. - `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)`.
@@ -739,7 +739,7 @@ M3 Expressive's flexible navigation bar, for three to five destinations. It does
</div> </div>
``` ```
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3"). 64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"), `tall` (M3's 80px container, which keeps the icon over the label at every width — `<x-app-shell tall-bar>` picks it, and the bottom offset grows with it). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
### `<x-navigation-rail>`, `<x-navigation-rail-item>`, `<x-navigation-rail-section>` ### `<x-navigation-rail>`, `<x-navigation-rail-item>`, `<x-navigation-rail-section>`
+42 -24
View File
@@ -10,8 +10,9 @@
* NavigationRailHorizontalItemTokens.kt, NavigationRailColorTokens.kt, and the layout in * NavigationRailHorizontalItemTokens.kt, NavigationRailColorTokens.kt, and the layout in
* ShortNavigationBar.kt, WideNavigationRail.kt and NavigationItem.kt. * ShortNavigationBar.kt, WideNavigationRail.kt and NavigationItem.kt.
* *
* [data-navigation-bar] surface-container, 64px, the bottom safe area under it * [data-navigation-bar] surface-container, 64px (data-tall: 80px), the bottom
* [data-navigation-bar-items] equal widths; centred from a 600px-wide bar * safe area under it
* [data-navigation-bar-items] equal widths; centred from a 600px-wide short bar
* [data-navigation-bar-item] data-active * [data-navigation-bar-item] data-active
* [data-navigation-pill] icon and label; the indicator itself from 600px * [data-navigation-pill] icon and label; the indicator itself from 600px
* [data-navigation-indicator] the 56×32 indicator around the icon below 600px * [data-navigation-indicator] the 56×32 indicator around the icon below 600px
@@ -108,6 +109,13 @@
margin-inline: auto; margin-inline: auto;
} }
/* The tall configuration: NavigationBarTokens.TallContainerHeight, 80dp. It keeps the vertical
item layout at every width — the horizontal one below is the short bar's alone — so the
label always sits under its icon, which is what the extra 16px is for. */
[data-navigation-bar][data-tall] [data-navigation-bar-items] {
min-height: 5rem;
}
[data-navigation-bar-item] { [data-navigation-bar-item] {
--navigation-layer: 0; --navigation-layer: 0;
position: relative; position: relative;
@@ -149,22 +157,24 @@
} }
/* From 600dp (M3's medium window), icon and label side by side in a 40px indicator, and the /* From 600dp (M3's medium window), icon and label side by side in a 40px indicator, and the
items centred with the padding ShortNavigationBar's Centered arrangement computes. */ items centred with the padding ShortNavigationBar's Centered arrangement computes. The
short bar only: a tall bar is the vertical layout at every width, so every rule here that
the vertical layout also has an opinion about names the short bar. */
@container (width >= 37.5rem) { @container (width >= 37.5rem) {
[data-navigation-bar-items] { [data-navigation-bar]:not([data-tall]) [data-navigation-bar-items] {
width: calc(10% * (var(--navigation-bar-count, 7) + 3)); width: calc(10% * (var(--navigation-bar-count, 7) + 3));
min-width: fit-content; min-width: fit-content;
max-width: 100%; max-width: 100%;
} }
[data-navigation-bar-items]:has(> :last-child:nth-child(1)) { --navigation-bar-count: 1; } [data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(1)) { --navigation-bar-count: 1; }
[data-navigation-bar-items]:has(> :last-child:nth-child(2)) { --navigation-bar-count: 2; } [data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(2)) { --navigation-bar-count: 2; }
[data-navigation-bar-items]:has(> :last-child:nth-child(3)) { --navigation-bar-count: 3; } [data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(3)) { --navigation-bar-count: 3; }
[data-navigation-bar-items]:has(> :last-child:nth-child(4)) { --navigation-bar-count: 4; } [data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(4)) { --navigation-bar-count: 4; }
[data-navigation-bar-items]:has(> :last-child:nth-child(5)) { --navigation-bar-count: 5; } [data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(5)) { --navigation-bar-count: 5; }
[data-navigation-bar-items]:has(> :last-child:nth-child(6)) { --navigation-bar-count: 6; } [data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(6)) { --navigation-bar-count: 6; }
[data-navigation-bar-item] { [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item] {
min-width: max-content; min-width: max-content;
} }
@@ -172,7 +182,7 @@
token, and Compose's ShortNavigationBarItem passes it for both icon positions. (The token, and Compose's ShortNavigationBarItem passes it for both icon positions. (The
*rail's* horizontal item is label-large — NavigationRailHorizontalItemTokens — which is *rail's* horizontal item is label-large — NavigationRailHorizontalItemTokens — which is
a different component.) */ a different component.) */
[data-navigation-bar-item] [data-navigation-pill] { [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item] [data-navigation-pill] {
position: relative; position: relative;
isolation: isolate; isolation: isolate;
flex-direction: row; flex-direction: row;
@@ -180,15 +190,15 @@
padding-inline: 1rem; padding-inline: 1rem;
} }
[data-navigation-bar-item] [data-navigation-label] { [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item] [data-navigation-label] {
padding-inline: 0; padding-inline: 0;
} }
[data-navigation-bar-item][data-active] { [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item][data-active] {
color: var(--md-sys-color-on-secondary-container); color: var(--md-sys-color-on-secondary-container);
} }
[data-navigation-bar-item][data-active] [data-navigation-pill] { [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item][data-active] [data-navigation-pill] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container)); background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
} }
} }
@@ -513,13 +523,17 @@
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
} }
/* The vertical layout — a compact bar, and a tall one at any width — fills the 56×32 box
behind the icon; the horizontal layout fills the pill around icon and label instead, so it
takes the fill off the box again. */
[data-navigation-bar-item][data-active] [data-navigation-indicator] { [data-navigation-bar-item][data-active] [data-navigation-indicator] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container); color: var(--md-sys-color-on-secondary-container);
} }
@container (width < 37.5rem) { @container (width >= 37.5rem) {
[data-navigation-bar-item][data-active] [data-navigation-indicator] { [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item][data-active] [data-navigation-indicator] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container)); background-image: none;
} }
} }
@@ -579,14 +593,16 @@
background-color: var(--md-sys-color-on-secondary-container); background-color: var(--md-sys-color-on-secondary-container);
} }
@container (width < 37.5rem) {
[data-navigation-bar-item] [data-navigation-pill]::before { [data-navigation-bar-item] [data-navigation-pill]::before {
display: none; display: none;
} }
}
@container (width >= 37.5rem) { @container (width >= 37.5rem) {
[data-navigation-bar-item] [data-navigation-indicator]::before { [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item] [data-navigation-pill]::before {
display: block;
}
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-item] [data-navigation-indicator]::before {
display: none; display: none;
} }
} }
@@ -615,15 +631,17 @@
} }
/* M3's focus indicator, 3px of secondary 2px out, around the same shape. */ /* M3's focus indicator, 3px of secondary 2px out, around the same shape. */
@container (width < 37.5rem) {
[data-navigation-bar-item]:focus-visible [data-navigation-indicator] { [data-navigation-bar-item]:focus-visible [data-navigation-indicator] {
outline: 3px solid var(--md-sys-color-secondary); outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px; outline-offset: 2px;
} }
}
@container (width >= 37.5rem) { @container (width >= 37.5rem) {
[data-navigation-bar-item]:focus-visible [data-navigation-pill] { [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item]:focus-visible [data-navigation-indicator] {
outline: none;
}
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-item]:focus-visible [data-navigation-pill] {
outline: 3px solid var(--md-sys-color-secondary); outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -54,7 +54,8 @@
sign out), `top` (the page's own bar, above the page and *beside* the rail) and the page 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 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 phone opens. `label` names both navigation landmarks ("Main"); `rail-width` is the expanded
rail's width. rail's width; `tall-bar` picks M3's 80px navigation bar over the 64px one, and the bottom
offset every pinned thing reads follows it.
`banner` or `top` is a decision about what the bar belongs to: an application-wide bar one `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 search, one account menu, the same on every page spans the window and the rail starts under
@@ -82,6 +83,7 @@
'destinations' => [], 'destinations' => [],
'label' => null, 'label' => null,
'railWidth' => '16rem', 'railWidth' => '16rem',
'tallBar' => false,
]) ])
@php @php
@@ -113,7 +115,8 @@
data-app-shell data-app-shell
@class([ @class([
'flex min-h-dvh flex-col bg-surface text-on-surface [--material-margin:1rem] 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() && ! $tallBar,
'max-medium:[--material-bottom-bar:calc(5rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty() && $tallBar,
]) ])
> >
<a <a
@@ -174,7 +177,7 @@
@if ($barItems->isNotEmpty()) @if ($barItems->isNotEmpty())
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 medium:hidden"> <div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 medium:hidden">
<x-livewire-material::navigation-bar :label="$label"> <x-livewire-material::navigation-bar :label="$label" :tall="$tallBar">
@foreach ($barItems as $item) @foreach ($barItems as $item)
<x-livewire-material::navigation-bar-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-bar-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
@@ -16,11 +16,15 @@
Centered arrangement gives three to six items. Both follow the bar's own width (a container Centered arrangement gives three to six items. Both follow the bar's own width (a container
query), so a bar in a narrow column keeps the compact items. query), so a bar in a narrow column keeps the compact items.
`tall` is M3's other container: 80px (NavigationBarTokens.TallContainerHeight) with the
vertical item layout at every width icon over label, never side by side for a bar whose
labels need the room. The short bar stays the default.
It does not position itself: wrap it in the element that pins it (`fixed inset-x-0 bottom-0`) It does not position itself: wrap it in the element that pins it (`fixed inset-x-0 bottom-0`)
and hides it where a rail takes over. `<x-app-shell>` does both, and lifts the snackbar and a and hides it where a rail takes over. `<x-app-shell>` does both, and lifts the snackbar and a
`fab` button above it through `--material-bottom-bar`. `fab` button above it through `--material-bottom-bar`.
`label` names the landmark ("Main" by default). `label` names the landmark ("Main" by default); `tall` picks the 80px container.
Values from androidx Compose Material 3 (Apache-2.0), androidx-main Values from androidx Compose Material 3 (Apache-2.0), androidx-main
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationBarTokens.kt, 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationBarTokens.kt,
@@ -31,9 +35,10 @@
@props([ @props([
'label' => null, 'label' => null,
'tall' => false,
]) ])
<nav aria-label="{{ $label ?? __('Main') }}" data-navigation-bar {{ $attributes }}> <nav aria-label="{{ $label ?? __('Main') }}" data-navigation-bar @if ($tall) data-tall @endif {{ $attributes }}>
<div data-navigation-bar-items> <div data-navigation-bar-items>
{{ $slot }} {{ $slot }}
</div> </div>
@@ -29,6 +29,21 @@
</div> </div>
</div> </div>
</div> </div>
<div class="max-w-[50rem] space-y-2">
<p class="type-label-lg text-on-surface-variant">Tall (80px): icon over label whatever the width</p>
<div class="overflow-x-auto rounded-corner-lg border border-outline-variant">
<div class="flex h-56 min-w-[37.5rem] flex-col bg-surface">
<div class="flex-1 p-4 type-body-md text-on-surface-variant">The page</div>
<x-navigation-bar tall>
<x-navigation-bar-item label="Shares" icon="folder_shared" link="#navigation" no-wire-navigate active />
<x-navigation-bar-item label="Upload" icon="upload" link="#navigation" no-wire-navigate />
<x-navigation-bar-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate badge="3" />
<x-navigation-bar-item label="Account" icon="account_circle" link="#navigation" no-wire-navigate />
</x-navigation-bar>
</div>
</div>
</div>
</div> </div>
BLADE, BLADE,
'Collapsed and expanded rails' => <<<'BLADE' 'Collapsed and expanded rails' => <<<'BLADE'
+10
View File
@@ -97,6 +97,16 @@ it('lifts the snackbar above the bar only when there is a bar', function () {
->not->toContain('--material-bottom-bar:'); ->not->toContain('--material-bottom-bar:');
}); });
it('passes M3\'s tall bar through, and the bottom offset grows with it', function () {
expect((string) $this->blade('<x-app-shell :destinations="$destinations" tall-bar />', ['destinations' => shellDestinations()]))
->toContain('data-navigation-bar data-tall ')
->toContain('max-medium:[--material-bottom-bar:calc(5rem+')
->not->toContain('[--material-bottom-bar:calc(4rem+')
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
->not->toContain('data-tall')
->toContain('max-medium:[--material-bottom-bar:calc(4rem+');
});
it('reads the safe area and anything docked on the bar through variables an application can set', function () { it('reads the safe area and anything docked on the bar through variables an application can set', function () {
$html = (string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]); $html = (string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]);
@@ -60,3 +60,20 @@ it('keeps the bar item on the label and state-layer colours M3 tokens', function
// The indicator and the pill wash in on-secondary-container, as the rail's do (N-19). // The indicator and the pill wash in on-secondary-container, as the rail's do (N-19).
->toContain('[data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill])::before {'); ->toContain('[data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill])::before {');
}); });
it('takes M3\'s tall container, which keeps the vertical item layout at every width', function () {
expect((string) $this->blade('<x-navigation-bar tall><x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-bar>'))
->toContain('data-navigation-bar data-tall ')
->and((string) $this->blade('<x-navigation-bar />'))->not->toContain('data-tall');
$css = file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css');
expect($css)
// NavigationBarTokens.TallContainerHeight, 80dp, against the short bar's 64.
->toMatch('/\[data-navigation-bar\]\[data-tall\] \[data-navigation-bar-items\] \{\s+min-height: 5rem;/')
// The vertical layout is the base — a compact bar and a tall one at any width share it —
// and only the short bar's horizontal layout is behind the 600px container query, so no
// rule inside one reaches an item without saying `:not([data-tall])` first.
->and(preg_match_all('/@container \(width [<>]=? 37\.5rem\) \{\n(.*?)\n \}/s', $css, $blocks) > 0)->toBeTrue()
->and(implode("\n", $blocks[1]))->not->toMatch('/^ \[data-navigation-bar-item/m');
});