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`).
- 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.
- 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)`.
@@ -739,7 +739,7 @@ M3 Expressive's flexible navigation bar, for three to five destinations. It does
</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>`
+47 -29
View File
@@ -10,8 +10,9 @@
* NavigationRailHorizontalItemTokens.kt, NavigationRailColorTokens.kt, and the layout in
* ShortNavigationBar.kt, WideNavigationRail.kt and NavigationItem.kt.
*
* [data-navigation-bar] surface-container, 64px, the bottom safe area under it
* [data-navigation-bar-items] equal widths; centred from a 600px-wide bar
* [data-navigation-bar] surface-container, 64px (data-tall: 80px), the bottom
* 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-pill] icon and label; the indicator itself from 600px
* [data-navigation-indicator] the 56×32 indicator around the icon below 600px
@@ -108,6 +109,13 @@
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] {
--navigation-layer: 0;
position: relative;
@@ -149,22 +157,24 @@
}
/* 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) {
[data-navigation-bar-items] {
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-items] {
width: calc(10% * (var(--navigation-bar-count, 7) + 3));
min-width: fit-content;
max-width: 100%;
}
[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-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-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(1)) { --navigation-bar-count: 1; }
[data-navigation-bar]:not([data-tall]) [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(3)) { --navigation-bar-count: 3; }
[data-navigation-bar]:not([data-tall]) [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(5)) { --navigation-bar-count: 5; }
[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;
}
@@ -172,7 +182,7 @@
token, and Compose's ShortNavigationBarItem passes it for both icon positions. (The
*rail's* horizontal item is label-large — NavigationRailHorizontalItemTokens — which is
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;
isolation: isolate;
flex-direction: row;
@@ -180,15 +190,15 @@
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;
}
[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);
}
[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));
}
}
@@ -513,13 +523,17 @@
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] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
@container (width < 37.5rem) {
[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));
@container (width >= 37.5rem) {
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-item][data-active] [data-navigation-indicator] {
background-image: none;
}
}
@@ -579,14 +593,16 @@
background-color: var(--md-sys-color-on-secondary-container);
}
@container (width < 37.5rem) {
[data-navigation-bar-item] [data-navigation-pill]::before {
display: none;
}
[data-navigation-bar-item] [data-navigation-pill]::before {
display: none;
}
@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;
}
}
@@ -615,15 +631,17 @@
}
/* 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] {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-navigation-bar-item]:focus-visible [data-navigation-indicator] {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
@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-offset: 2px;
}
@@ -54,7 +54,8 @@
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.
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
search, one account menu, the same on every page spans the window and the rail starts under
@@ -82,6 +83,7 @@
'destinations' => [],
'label' => null,
'railWidth' => '16rem',
'tallBar' => false,
])
@php
@@ -113,7 +115,8 @@
data-app-shell
@class([
'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
@@ -174,7 +177,7 @@
@if ($barItems->isNotEmpty())
<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)
<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
@@ -16,11 +16,15 @@
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.
`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`)
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`.
`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
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationBarTokens.kt,
@@ -31,9 +35,10 @@
@props([
'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>
{{ $slot }}
</div>
@@ -29,6 +29,21 @@
</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>
BLADE,
'Collapsed and expanded rails' => <<<'BLADE'