From 4d00d87cd240ed6c3dd791f55fc2ddae5a4e875b Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:33:24 +0200 Subject: [PATCH] Add M3's tall navigation bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step 25 (navigation Missing): NavigationBarTokens.TallContainerHeight, 80dp, had no prop. `` 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])`. `` passes it through and grows `--material-bottom-bar` to 5rem with it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 4 +- resources/css/components/navigation.css | 76 ++++++++++++------- .../views/components/app-shell.blade.php | 9 ++- .../views/components/navigation-bar.blade.php | 9 ++- .../showcase/sections/navigation.blade.php | 15 ++++ tests/Feature/Components/AppShellTest.php | 10 +++ .../Feature/Components/NavigationBarTest.php | 17 +++++ 7 files changed, 104 insertions(+), 36 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index c2d05436..783d6ba1 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -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 ``), 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 ``` -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. ``: `label` ("Main"). ``: `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. ``: `label` ("Main"), `tall` (M3's 80px container, which keeps the icon over the label at every width — `` picks it, and the bottom offset grows with it). ``: `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"). ### ``, ``, `` diff --git a/resources/css/components/navigation.css b/resources/css/components/navigation.css index 2db8f579..7b2f7a9d 100644 --- a/resources/css/components/navigation.css +++ b/resources/css/components/navigation.css @@ -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; } diff --git a/resources/views/components/app-shell.blade.php b/resources/views/components/app-shell.blade.php index f0de2341..83414f47 100644 --- a/resources/views/components/app-shell.blade.php +++ b/resources/views/components/app-shell.blade.php @@ -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, ]) > isNotEmpty())
- + @foreach ($barItems as $item) @endforeach diff --git a/resources/views/components/navigation-bar.blade.php b/resources/views/components/navigation-bar.blade.php index 2e763bca..cf37e4a5 100644 --- a/resources/views/components/navigation-bar.blade.php +++ b/resources/views/components/navigation-bar.blade.php @@ -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. `` 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, ]) -
+ +
+

Tall (80px): icon over label whatever the width

+
+
+
The page
+ + + + + + +
+
+
BLADE, 'Collapsed and expanded rails' => <<<'BLADE' diff --git a/tests/Feature/Components/AppShellTest.php b/tests/Feature/Components/AppShellTest.php index f5b9d770..2d18cad0 100644 --- a/tests/Feature/Components/AppShellTest.php +++ b/tests/Feature/Components/AppShellTest.php @@ -97,6 +97,16 @@ it('lifts the snackbar above the bar only when there is a bar', function () { ->not->toContain('--material-bottom-bar:'); }); +it('passes M3\'s tall bar through, and the bottom offset grows with it', function () { + expect((string) $this->blade('', ['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('', ['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 () { $html = (string) $this->blade('', ['destinations' => shellDestinations()]); diff --git a/tests/Feature/Components/NavigationBarTest.php b/tests/Feature/Components/NavigationBarTest.php index f79b2b4e..5bbad46e 100644 --- a/tests/Feature/Components/NavigationBarTest.php +++ b/tests/Feature/Components/NavigationBarTest.php @@ -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). ->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('')) + ->toContain('data-navigation-bar data-tall ') + ->and((string) $this->blade(''))->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'); +});