From 4d00d87cd240ed6c3dd791f55fc2ddae5a4e875b Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:33:24 +0200 Subject: [PATCH 1/6] 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'); +}); From ce1cd6eec06b69f9218e91ca0d1cd98fef8edbd3 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:36:57 +0200 Subject: [PATCH 2/6] Let the navigation bar hide on a scroll down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step 25 (navigation Missing): M3's "hides on scroll-down, reappears on scroll-up" had no implementation. `` slides the bar out on the default spatial spring — an instant swap under reduced motion, which zeroes the duration token — and never while a snackbar, a bottom sheet or a drawer is anchored to its edge; focus reaching the bar brings it back. `` picks it, and --material-bottom-bar follows the bar down and up so a `fab` button and the snackbar keep their offsets. 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 | 27 +++++++- resources/js/navigation.js | 64 +++++++++++++++++++ .../views/components/app-shell.blade.php | 6 +- .../views/components/navigation-bar.blade.php | 25 +++++++- .../showcase/sections/navigation.blade.php | 12 ++++ tests/Feature/Components/AppShellTest.php | 10 ++- .../Feature/Components/NavigationBarTest.php | 30 ++++++++- 8 files changed, 167 insertions(+), 11 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 783d6ba1..2f879826 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`); `tall-bar` picks M3's 80px navigation bar over the 64px one. +- 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; `hide-bar-on-scroll` lets the bar leave the window while the page scrolls down. - `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"), `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"). +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), `hide-on-scroll` (M3's scrolling behaviour: the bar slides out on a scroll down and springs back on a scroll up, never before the first screenful and never while a snackbar, bottom sheet or drawer is on screen; focus reaching it brings it back. `` picks it, and `--material-bottom-bar` goes down and comes back with the bar, so a `fab` button and the snackbar keep their distance from 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 7b2f7a9d..92ce57bf 100644 --- a/resources/css/components/navigation.css +++ b/resources/css/components/navigation.css @@ -43,8 +43,9 @@ * rail starts expanded instead of collapsed. The bar's own item layout is a *container* query at * the same 37.5rem, so a bar in a narrow column lays out by its own width. * - * Everything here is in `@layer components` except the last block, which restyles a FAB the - * application put in the rail: that one has to beat a utility, and a layer never does. + * Everything here is in `@layer components` except the blocks after it, which reach past the + * component into what an application wrote — the bottom offset publishes, a FAB put + * in the rail: those have to beat a utility, and a layer never does. */ @custom-variant rail-collapsed { @@ -116,6 +117,19 @@ min-height: 5rem; } + /* "Scrolling: hides on scroll-down, reappears on scroll-up" (the reference's Navigation bar + § Behaviour). It slides out of the window on the default spatial spring, which reduced + motion zeroes to an instant swap along with every other duration token. Whether it may hide + at all is resources/js/navigation.js's call: never while a bottom sheet, a drawer or a + snackbar is on screen, since those are anchored to the bar's edge. */ + [data-navigation-bar][data-hide-on-scroll] { + transition: translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default); + } + + [data-navigation-bar][data-hide-on-scroll][data-hidden] { + translate: 0 100%; + } + [data-navigation-bar-item] { --navigation-layer: 0; position: relative; @@ -664,6 +678,15 @@ } } +/* A bar that has slid off the bottom of the window is no longer there to clear, so the offset + * everything pinned to the bottom reads drops to the bottom safe area and whatever the application + * has docked on the bar — a `fab` button, the snackbar and the page's own bottom padding all follow + * it down and come back up with the bar. Unlayered on purpose: publishes the variable + * with a utility, and a rule in any layer loses to one. */ +[data-app-shell]:has([data-navigation-bar][data-hide-on-scroll][data-hidden]) { + --material-bottom-bar: calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px)); +} + /* A FAB in the rail's header rests at elevation 0, not the 3 a standalone FAB has * (docs/reference/m3/components-navigation-selection-inputs.md § Navigation rail: "when nested * within another component, such as the navigation rail, the FAB's resting elevation should be diff --git a/resources/js/navigation.js b/resources/js/navigation.js index 51000f42..c6c05939 100644 --- a/resources/js/navigation.js +++ b/resources/js/navigation.js @@ -15,6 +15,8 @@ * * `materialNavigationRail` is one rail's view of the store for its `mode` — see * resources/views/components/navigation-rail.blade.php. + * + * `materialNavigationBar` is `` — see the same file's sibling. */ import { from } from './breakpoints.js' @@ -172,4 +174,66 @@ document.addEventListener('alpine:init', () => { } }, })) + + window.Alpine.data('materialNavigationBar', () => ({ + away: false, + last: 0, + frame: null, + // Set in init(), so a second bar in the same page scope cannot take the first one's. + schedule: null, + + init() { + this.last = Math.max(window.scrollY, 0) + this.measure = this.measure.bind(this) + this.schedule = () => { + this.frame ??= requestAnimationFrame(this.measure) + } + + window.addEventListener('scroll', this.schedule, { passive: true }) + }, + + destroy() { + window.removeEventListener('scroll', this.schedule) + cancelAnimationFrame(this.frame) + }, + + /** Anything that reaches the bar — the keyboard, a screen reader's focus — brings it back. */ + show() { + this.away = false + }, + + measure() { + this.frame = null + + const at = Math.max(window.scrollY, 0) + const by = at - this.last + + // Smaller than a finger's jitter, or the rubber band at either end of the page: not a + // direction yet, and the bar should not flicker while one is being decided. + if (Math.abs(by) < 8) { + return + } + + this.last = at + + // A bar that is not on screen at this width (the shell hides it from `medium`) has no + // scroll behaviour to have; one with something anchored to its edge keeps still, so + // the snackbar or sheet resting on it does not slide with it. + if (this.$root.getClientRects().length === 0 || anchored()) { + this.away = false + + return + } + + // Never before the first screenful: the bar has to be passed before it can be left. + this.away = by > 0 && at > this.$root.offsetHeight + }, + })) }) + +/** + * Whether something on screen is anchored to the bar's edge and would be dragged along with it: a + * snackbar (``'s, which reads --material-bottom-bar), or a bottom sheet or drawer over the + * page. M3 lets those cover the bar; it is the bar leaving from under them that looks broken. + */ +const anchored = () => [...document.querySelectorAll('[data-toast], [role="dialog"]')].some((over) => over.getClientRects().length > 0) diff --git a/resources/views/components/app-shell.blade.php b/resources/views/components/app-shell.blade.php index 83414f47..3a7e208a 100644 --- a/resources/views/components/app-shell.blade.php +++ b/resources/views/components/app-shell.blade.php @@ -55,7 +55,8 @@ 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; `tall-bar` picks M3's 80px navigation bar over the 64px one, and the bottom - offset every pinned thing reads follows it. + offset every pinned thing reads follows it; `hide-bar-on-scroll` lets the bar leave the window + while the page scrolls down, and `--material-bottom-bar` goes down and comes back with 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 @@ -84,6 +85,7 @@ 'label' => null, 'railWidth' => '16rem', 'tallBar' => false, + 'hideBarOnScroll' => false, ]) @php @@ -177,7 +179,7 @@ @if ($barItems->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 cf37e4a5..6814444f 100644 --- a/resources/views/components/navigation-bar.blade.php +++ b/resources/views/components/navigation-bar.blade.php @@ -20,11 +20,20 @@ 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. + `hide-on-scroll` is M3's scrolling behaviour: the bar slides out of the window on a scroll + down and springs back on a scroll up, never before the first screenful has gone by, and never + while a snackbar, a bottom sheet or a drawer is on screen — those are anchored to the bar's + edge and would slide with it. Focus reaching the bar brings it back, which is as close as the + web gets to M3's "never hide it while a screen reader is active". Inside `` + `--material-bottom-bar` follows the bar down and up, so a `fab` button, the snackbar and the + page's bottom padding keep their distance from it rather than from where it was. + 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); `tall` picks the 80px container. + `label` names the landmark ("Main" by default); `tall` picks the 80px container; + `hide-on-scroll` lets it leave the window while the page scrolls down. Values from androidx Compose Material 3 (Apache-2.0), androidx-main 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationBarTokens.kt, @@ -36,9 +45,21 @@ @props([ 'label' => null, 'tall' => false, + 'hideOnScroll' => false, ]) -
+
+

Hide on scroll: scroll the page down, then up

+
+
The bar follows the window's scrolling, not this box's: scroll the showcase down and it leaves, scroll up and it springs back. It stays while a snackbar or a bottom sheet is on screen.
+ + + + + +
+
+

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

diff --git a/tests/Feature/Components/AppShellTest.php b/tests/Feature/Components/AppShellTest.php index 2d18cad0..8abe61ca 100644 --- a/tests/Feature/Components/AppShellTest.php +++ b/tests/Feature/Components/AppShellTest.php @@ -99,7 +99,7 @@ it('lifts the snackbar above the bar only when there is a bar', function () { 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 ') + ->toMatch('/data-navigation-bar\s+data-tall\s/') ->toContain('max-medium:[--material-bottom-bar:calc(5rem+') ->not->toContain('[--material-bottom-bar:calc(4rem+') ->and((string) $this->blade('', ['destinations' => shellDestinations()])) @@ -107,6 +107,14 @@ it('passes M3\'s tall bar through, and the bottom offset grows with it', functio ->toContain('max-medium:[--material-bottom-bar:calc(4rem+'); }); +it('lets the bar leave the window while the page scrolls down', function () { + expect((string) $this->blade('', ['destinations' => shellDestinations()])) + ->toContain('data-hide-on-scroll') + ->toContain('x-data="materialNavigationBar"') + ->and((string) $this->blade('', ['destinations' => shellDestinations()])) + ->not->toContain('data-hide-on-scroll'); +}); + 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 5bbad46e..43003011 100644 --- a/tests/Feature/Components/NavigationBarTest.php +++ b/tests/Feature/Components/NavigationBarTest.php @@ -4,7 +4,7 @@ use Illuminate\Support\Str; it('draws a navigation landmark around its items', function () { expect((string) $this->blade('')) - ->toContain('
BLADE, - 'Collapsed and expanded rails' => <<<'BLADE' + 'Collapsed, narrow and expanded rails' => <<<'BLADE'
@@ -80,6 +80,22 @@
+
+ + + + + + + + + + + + + +
+
diff --git a/tests/Feature/Components/NavigationRailTest.php b/tests/Feature/Components/NavigationRailTest.php index db4b3f3c..2f073c51 100644 --- a/tests/Feature/Components/NavigationRailTest.php +++ b/tests/Feature/Components/NavigationRailTest.php @@ -63,8 +63,32 @@ it('takes M3\'s optional divider and turns the container fill off', function () // Neither reaches a rail open over a scrim, which is a surface over the page (N-22). ->toContain('[data-navigation-rail][data-divider]:not([data-open]) > [data-navigation-rail-panel] {') ->toContain("[data-navigation-rail][data-fill='false']:not([data-open]) > [data-navigation-rail-panel] {") - // A collapsible rail is held to 96px where M3 asks for a navigation bar instead (N-24). - ->toMatch("/\\[data-navigation-rail='collapsible'\\] \\{\\s+width: 6rem;/"); + // A collapsible rail is held to its collapsed width where M3 asks for a bar instead (N-24). + ->toMatch("/\\[data-navigation-rail='collapsible'\\] \\{\\s+width: var\\(--navigation-rail-collapsed-width\\);/"); +}); + +it('takes M3\'s narrow collapsed width, icons alone but still named', function () { + $narrow = (string) $this->blade(''); + + expect($narrow) + ->toContain('data-width="narrow"') + // A narrow rail expands to the same 16rem; `width` names one or the other, never both. + ->toContain('--navigation-rail-width: 16rem') + // 40px menu button and 56px FAB centred in 80px rather than in 96. + ->toContain('rail-collapsed:ps-5') + ->toContain('rail-collapsed:px-3') + // The label is still rendered: it is what names the destination. + ->toContain('Inbox') + ->and((string) $this->blade('')) + ->not->toContain('data-width') + ->toContain('--navigation-rail-width: 20rem'); + + expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css')) + // NavigationRailCollapsedTokens.NarrowContainerWidth, 80dp against the default 96dp. + ->toMatch("/\\[data-navigation-rail\\]\\[data-width='narrow'\\] \\{\\s+--navigation-rail-collapsed-width: 5rem;/") + ->toMatch('/--navigation-rail-collapsed-width: 6rem;/') + // Out of the drawing, not out of the page. + ->toContain("[data-navigation-rail][data-width='narrow'] [data-navigation-rail-item] [data-navigation-label] {"); }); it('flattens a FAB nested in the rail header and morphs its label', function () { From a5acbe7f6b795366f38e2b3a1496615ad387bbb2 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:40:36 +0200 Subject: [PATCH 4/6] Let the rail centre its destinations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step 25 (navigation Missing): M3's "alignment: top or center; on tablets prefer center alignment for reach" had no implementation — the rail was top-aligned only. `` centres the destinations between the header and the footer, which stay at their ends because M3 keeps the menu icon and the FAB top-aligned. `safe center` gives the top back the moment there are more destinations than fit, where centring would push the first one out of reach above the scroller. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../skills/livewire-material-development/SKILL.md | 2 +- resources/css/components/navigation.css | 10 ++++++++++ resources/views/components/navigation-rail.blade.php | 9 ++++++++- .../views/showcase/sections/navigation.blade.php | 4 ++-- tests/Feature/Components/NavigationRailTest.php | 11 +++++++++++ 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 4b9b8380..3c4da5c9 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -766,7 +766,7 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded ( ``` - `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (``'s, one rail per window size class: hidden and opened as a modal on a compact window, collapsed and opened as a modal at `medium`, a standard rail from `expanded` — collapsed there, expanded from `large`). -- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px — or the word `narrow` for M3's other *collapsed* width, 80px against the default 96, where the items are their icons alone; the labels stay in the accessibility tree and a narrow rail still expands to 16rem), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`), `divider` (M3's optional vertical divider on the page's side — use it when the page scrolls under a fixed rail), `fill` (`false` for a transparent container, which M3 allows while the items keep 3:1 contrast). Slots: `brand` (beside the menu button, expanded only), `header` (one ``, which the rail morphs into an extended FAB and back as it expands — it also rests at elevation 0, as M3 asks of a nested FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport. +- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px — or the word `narrow` for M3's other *collapsed* width, 80px against the default 96, where the items are their icons alone; the labels stay in the accessibility tree and a narrow rail still expands to 16rem), `align` (`top` default, or `center` for M3's centred destinations — preferred on a tablet; the menu button, brand and FAB stay at the top and the footer at the foot), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`), `divider` (M3's optional vertical divider on the page's side — use it when the page scrolls under a fixed rail), `fill` (`false` for a transparent container, which M3 allows while the items keep 3:1 contrast). Slots: `brand` (beside the menu button, expanded only), `header` (one ``, which the rail morphs into an extended FAB and back as it expands — it also rests at elevation 0, as M3 asks of a nested FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport. - Anything else inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `…expanded only…`, ``. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px. - A `collapsible` rail is held to the collapsed 96px below `medium` (600px), where M3 says to use a navigation bar rather than a standard rail. `collapsed` and `expanded` are fixed-width by design: wrap one in a `medium:` element if it must not show on a phone. - ``: the same props as ``. ``: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way. diff --git a/resources/css/components/navigation.css b/resources/css/components/navigation.css index 9bf3bcb8..d646b646 100644 --- a/resources/css/components/navigation.css +++ b/resources/css/components/navigation.css @@ -443,6 +443,16 @@ padding-block: 0.5rem 1rem; } + /* "Alignment: top or center; on tablets prefer center alignment for reach. Menu icon and FAB + are always top-aligned" (docs/reference/m3/components-navigation-selection-inputs.md + § Navigation rail). Only the destinations move, then — the header and the footer stay at + their ends of the rail — and `safe` puts them back at the top the moment there are more of + them than fit, where centring would otherwise push the first one out of reach above the + scroller. */ + [data-navigation-rail][data-align='center'] [data-navigation-rail-destinations] { + justify-content: safe center; + } + [data-navigation-rail-heading] { display: flex; align-items: center; diff --git a/resources/views/components/navigation-rail.blade.php b/resources/views/components/navigation-rail.blade.php index a96cf67f..8db0c46d 100644 --- a/resources/views/components/navigation-rail.blade.php +++ b/resources/views/components/navigation-rail.blade.php @@ -56,7 +56,11 @@ (NavigationRailCollapsedTokens.NarrowContainerWidth, 80px against the default 96), where the items are their icons alone because no label fits under a 56px indicator at that width; the labels stay in the accessibility tree, since they are what name the destinations, and a - narrow rail expands to the default 16rem; `menu` shows the menu button (by default for `collapsible`, + narrow rail expands to the default 16rem; `align` is `top` (the default) or `center`, which + puts the destinations at the rail's vertical centre — M3 prefers that on a tablet, for reach — + while the menu button, the brand and the FAB stay at the top and the footer at the foot, as M3 + asks; more destinations than fit go back to the top rather than out of reach above the + scroller; `menu` shows the menu button (by default for `collapsible`, `modal` and `adaptive`); `divider` draws M3's optional vertical divider on the edge the page is on — which is also what M3 asks for when a page scrolls underneath a fixed rail; `fill` (`false`) drops the container colour for a transparent rail over the page's own background, @@ -78,6 +82,7 @@ 'mode' => 'collapsible', 'label' => null, 'width' => '16rem', + 'align' => 'top', 'menu' => null, 'divider' => false, 'fill' => true, @@ -91,11 +96,13 @@ $collapsedAtFirst = in_array($mode, ['collapsed', 'modal'], true); $narrow = $width === 'narrow'; $width = $narrow ? '16rem' : $width; + $centred = $align === 'center'; @endphp
BLADE, - 'Collapsed, narrow and expanded rails' => <<<'BLADE' + 'Collapsed, narrow (centred) and expanded rails' => <<<'BLADE'
@@ -81,7 +81,7 @@
- + diff --git a/tests/Feature/Components/NavigationRailTest.php b/tests/Feature/Components/NavigationRailTest.php index 2f073c51..ce2b0f8d 100644 --- a/tests/Feature/Components/NavigationRailTest.php +++ b/tests/Feature/Components/NavigationRailTest.php @@ -91,6 +91,17 @@ it('takes M3\'s narrow collapsed width, icons alone but still named', function ( ->toContain("[data-navigation-rail][data-width='narrow'] [data-navigation-rail-item] [data-navigation-label] {"); }); +it('centres the destinations on request, leaving the header and footer at their ends', function () { + expect((string) $this->blade('')) + ->toContain('data-align="center"') + ->and((string) $this->blade(''))->not->toContain('data-align') + ->and((string) $this->blade(''))->not->toContain('data-align'); + + expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css')) + // Only the destinations move, and `safe` gives the top back when there are too many. + ->toMatch("/\\[data-navigation-rail\\]\\[data-align='center'\\] \\[data-navigation-rail-destinations\\] \\{\\s+justify-content: safe center;/"); +}); + it('flattens a FAB nested in the rail header and morphs its label', function () { // Both rules hang off `data-fab` on 's root, and both are unlayered, because what they // beat — the FAB's shadow, its gap, an extended FAB's minimum width — are utilities. From 9c047a8fb12fe05a490c142e4a5cbf2b8d88fb5a Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 07:15:20 +0200 Subject: [PATCH 5/6] Let a rail hide when collapsed instead of narrowing Plan step 25 (navigation Missing): M3's "Expanded behavior | Hide when collapsed" was only implicit below `medium` in the adaptive rail. `` (collapsible and adaptive rails) takes the rail out of the layout when it is collapsed; `$store.rail.show()` from an app-bar menu button brings it back expanded over a scrim, sliding in, and its own menu button docks it again. It does not reach the bands where the window, not the visitor, collapses a rail (below `medium` for collapsible, `medium` for adaptive), since M3's collapsed rail may never hide. Every `rail-collapsed` branch now stops while the rail is open. `` exposes it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 4 +- resources/css/components/navigation.css | 88 ++++++++++++++++++- resources/js/navigation.js | 79 +++++++++++++---- .../views/components/app-shell.blade.php | 9 +- .../components/navigation-rail.blade.php | 17 +++- .../showcase/sections/navigation.blade.php | 18 ++++ tests/Feature/Components/AppShellTest.php | 9 ++ .../Feature/Components/NavigationRailTest.php | 31 ++++++- 8 files changed, 227 insertions(+), 28 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 3c4da5c9..ecdf9984 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`); `tall-bar` picks M3's 80px navigation bar over the 64px one; `hide-bar-on-scroll` lets the bar leave the window while the page scrolls down. +- 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; `hide-bar-on-scroll` lets the bar leave the window while the page scrolls down; `hide-rail-when-collapsed` takes the rail out of the layout from `expanded` when its menu button collapses it, instead of narrowing it to 96px — the only way back is `$store.rail.show()`, so the app bar then needs a menu button at every width. - `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)`. @@ -766,7 +766,7 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded ( ``` - `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (``'s, one rail per window size class: hidden and opened as a modal on a compact window, collapsed and opened as a modal at `medium`, a standard rail from `expanded` — collapsed there, expanded from `large`). -- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px — or the word `narrow` for M3's other *collapsed* width, 80px against the default 96, where the items are their icons alone; the labels stay in the accessibility tree and a narrow rail still expands to 16rem), `align` (`top` default, or `center` for M3's centred destinations — preferred on a tablet; the menu button, brand and FAB stay at the top and the footer at the foot), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`), `divider` (M3's optional vertical divider on the page's side — use it when the page scrolls under a fixed rail), `fill` (`false` for a transparent container, which M3 allows while the items keep 3:1 contrast). Slots: `brand` (beside the menu button, expanded only), `header` (one ``, which the rail morphs into an extended FAB and back as it expands — it also rests at elevation 0, as M3 asks of a nested FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport. +- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px — or the word `narrow` for M3's other *collapsed* width, 80px against the default 96, where the items are their icons alone; the labels stay in the accessibility tree and a narrow rail still expands to 16rem), `align` (`top` default, or `center` for M3's centred destinations — preferred on a tablet; the menu button, brand and FAB stay at the top and the footer at the foot), `hide-when-collapsed` (M3's immersive expanded behaviour, `collapsible` and `adaptive` only: collapsing the rail takes it out of the layout instead of narrowing it, and `$store.rail.show()` brings it back expanded over a scrim — so put a menu button in the app bar; the rail's own button then docks it again. Not below `medium` for a collapsible rail nor at `medium` for an adaptive one, where the window rather than the visitor collapses it and M3's collapsed rail may never hide), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`), `divider` (M3's optional vertical divider on the page's side — use it when the page scrolls under a fixed rail), `fill` (`false` for a transparent container, which M3 allows while the items keep 3:1 contrast). Slots: `brand` (beside the menu button, expanded only), `header` (one ``, which the rail morphs into an extended FAB and back as it expands — it also rests at elevation 0, as M3 asks of a nested FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport. - Anything else inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `…expanded only…`, ``. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px. - A `collapsible` rail is held to the collapsed 96px below `medium` (600px), where M3 says to use a navigation bar rather than a standard rail. `collapsed` and `expanded` are fixed-width by design: wrap one in a `medium:` element if it must not show on a phone. - ``: the same props as ``. ``: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way. diff --git a/resources/css/components/navigation.css b/resources/css/components/navigation.css index d646b646..99059700 100644 --- a/resources/css/components/navigation.css +++ b/resources/css/components/navigation.css @@ -48,12 +48,14 @@ * in the rail: those have to beat a utility, and a layer never does. */ +/* Every branch below reads `:not([data-open])`: a rail open over a scrim is drawn expanded whatever + made it collapsed, which only `hide-when-collapsed` can bring about outside the modal modes. */ @custom-variant rail-collapsed { &:where([data-navigation-rail='collapsed'], [data-navigation-rail='collapsed'] *) { @slot; } - &:where([data-rail='collapsed'] [data-navigation-rail='collapsible'], [data-rail='collapsed'] [data-navigation-rail='collapsible'] *) { + &:where([data-rail='collapsed'] [data-navigation-rail='collapsible']:not([data-open]), [data-rail='collapsed'] [data-navigation-rail='collapsible']:not([data-open]) *) { @slot; } @@ -64,7 +66,7 @@ /* Below `medium` a 220–360px rail would be most of the screen, so a rail whose width is the visitor's choice is held collapsed there whatever they chose. */ @media (width < 37.5rem) { - &:where([data-navigation-rail='collapsible'], [data-navigation-rail='collapsible'] *) { + &:where([data-navigation-rail='collapsible']:not([data-open]), [data-navigation-rail='collapsible']:not([data-open]) *) { @slot; } } @@ -80,14 +82,14 @@ `data-rail-auto`, which sets while nothing is stored, stands for "no choice yet" and the class's own default applies. */ @media (52.5rem <= width < 75rem) { - &:where(:is([data-rail='collapsed'], [data-rail-auto]) [data-navigation-rail='adaptive'], :is([data-rail='collapsed'], [data-rail-auto]) [data-navigation-rail='adaptive'] *) { + &:where(:is([data-rail='collapsed'], [data-rail-auto]) [data-navigation-rail='adaptive']:not([data-open]), :is([data-rail='collapsed'], [data-rail-auto]) [data-navigation-rail='adaptive']:not([data-open]) *) { @slot; } } /* Large and extra-large (from 1200): expanded to begin with, which is what M3 prefers there. */ @media (width >= 75rem) { - &:where([data-rail='collapsed'] [data-navigation-rail='adaptive'], [data-rail='collapsed'] [data-navigation-rail='adaptive'] *) { + &:where([data-rail='collapsed'] [data-navigation-rail='adaptive']:not([data-open]), [data-rail='collapsed'] [data-navigation-rail='adaptive']:not([data-open]) *) { @slot; } } @@ -239,6 +241,18 @@ --navigation-rail-collapsed-width: 5rem; } + /* "Expanded behavior | Hide when collapsed" (the reference's Navigation rail § Variants and + configurations): instead of narrowing, the rail leaves the layout entirely — for an + immersive page — and comes back over it when a menu button somewhere else, the app bar's, + calls `$store.rail.show()`. It is the *expanded* rail that hides, which is why what comes + back is the expanded rail: "collapsed rail may not hide", and the two bands where a window + size class rather than the visitor collapses one are given back below. */ + [data-navigation-rail][data-hide-when-collapsed] { + @variant rail-collapsed { + width: 0; + } + } + /* There is no room for a label under the 56px indicator at 80px, so a narrow rail is the icons alone. The label is taken out of the drawing, not out of the page: it is what names the destination for a screen reader. */ @@ -381,6 +395,72 @@ } } + /* A rail that hides when collapsed is in the same position: nothing is left in the layout for + it to grow out of, so it takes the compact window's slide-in. The whole treatment is under + the `rail-collapsed` variant, which stops the moment the rail is open — that is what makes + it the expanded rail that comes back, on the rule above. */ + [data-navigation-rail][data-hide-when-collapsed] > [data-navigation-rail-panel] { + @variant rail-collapsed { + display: none; + translate: -100% 0; + transition: + translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate), + display var(--md-sys-motion-effects-default-duration) allow-discrete; + + &:dir(rtl) { + translate: 100% 0; + } + } + } + + [data-navigation-rail][data-hide-when-collapsed][data-open] > [data-navigation-rail-panel] { + display: flex; + translate: 0 0; + transition: + translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate), + display var(--md-sys-motion-spatial-default-duration) allow-discrete; + + @starting-style { + translate: -100% 0; + } + + &:dir(rtl) { + translate: 0 0; + + @starting-style { + translate: 100% 0; + } + } + } + + /* The two bands the configuration does not reach, because there it is the window and not the + visitor that collapses the rail: below `medium` for a collapsible rail — where a rail is + held at its collapsed width anyway — and `medium` itself for the adaptive one, which M3 + gives a collapsed rail. (Below `medium` the adaptive rail is already away, over the page, + and the block above it says so.) resources/js/navigation.js reads the same two numbers, so + the menu button and the drawing agree at every width. */ + @media (width < 37.5rem) { + [data-navigation-rail='collapsible'][data-hide-when-collapsed] { + width: var(--navigation-rail-collapsed-width); + } + + [data-navigation-rail='collapsible'][data-hide-when-collapsed] > [data-navigation-rail-panel] { + display: flex; + translate: none; + } + } + + @media (37.5rem <= width < 52.5rem) { + [data-navigation-rail='adaptive'][data-hide-when-collapsed] { + width: var(--navigation-rail-collapsed-width); + } + + [data-navigation-rail='adaptive'][data-hide-when-collapsed] > [data-navigation-rail-panel] { + display: flex; + translate: none; + } + } + [data-navigation-rail-scrim] { position: fixed; inset: 0; diff --git a/resources/js/navigation.js b/resources/js/navigation.js index c6c05939..f6076578 100644 --- a/resources/js/navigation.js +++ b/resources/js/navigation.js @@ -13,12 +13,12 @@ * button opens it over a scrim (`show()`), and Escape, the scrim or leaving the page closes it * (`hide()`). It is never remembered. * - * `materialNavigationRail` is one rail's view of the store for its `mode` — see - * resources/views/components/navigation-rail.blade.php. + * `materialNavigationRail` is one rail's view of the store for its `mode` and whether it hides + * when collapsed — see resources/views/components/navigation-rail.blade.php. * * `materialNavigationBar` is `` — see the same file's sibling. */ -import { from } from './breakpoints.js' +import { from, upTo } from './breakpoints.js' /* * The active indicator grows out of its centre when a page arrives through wire:navigate. The @@ -96,14 +96,29 @@ document.addEventListener('alpine:init', () => { // A destination chosen in the modal rail leaves the page; the next one starts with it shut. document.addEventListener('livewire:navigating', () => window.Alpine.store('rail').hide()) - window.Alpine.data('materialNavigationRail', (mode) => ({ + window.Alpine.data('materialNavigationRail', (mode, hideWhenCollapsed = false) => ({ wide: mode === 'adaptive' ? from('expanded').matches : false, roomy: mode === 'adaptive' ? from('large').matches : false, + tight: mode === 'collapsible' ? upTo('medium').matches : false, queries: [], listeners: [], init() { if (mode !== 'adaptive') { + // Below `medium` a collapsible rail is held at its collapsed width whatever the + // visitor chose, so `hide-when-collapsed` does not reach it there. + // A drawer left open as the window narrows past it is shut, as the adaptive + // rail's is below, or it would spring open again the next time the rail is away. + if (mode === 'collapsible' && hideWhenCollapsed) { + this.watch(upTo('medium'), (matches) => { + this.tight = matches + + if (matches) { + this.$store.rail.hide() + } + }) + } + return } @@ -136,17 +151,9 @@ document.addEventListener('alpine:init', () => { this.queries.forEach((query, index) => query.removeEventListener('change', this.listeners[index])) }, - /** Whether this rail expands over a scrim rather than in the layout. */ - get modal() { - return mode === 'modal' || (mode === 'adaptive' && !this.wide) - }, - - get open() { - return this.modal && this.$store.rail.open - }, - - get expanded() { - if (this.open || mode === 'expanded') { + /** What this rail's mode and the visitor's choice make of it, before anything opens it. */ + get standing() { + if (mode === 'expanded') { return true } @@ -163,9 +170,49 @@ document.addEventListener('alpine:init', () => { return mode === 'collapsible' }, + /** Whether the mode or the window leaves no room for an expanded rail in the layout. */ + get cramped() { + return mode === 'modal' || (mode === 'adaptive' && !this.wide) + }, + + /** + * Whether the rail has left the layout altogether — `hide-when-collapsed`, once the + * visitor collapses it. Not in the two bands where it is the window size class and not the + * visitor that collapses a rail: M3's "collapsed rail may not hide". The same two numbers + * are in resources/css/components/navigation.css. + */ + get away() { + if (!hideWhenCollapsed || this.cramped || this.standing) { + return false + } + + return mode === 'adaptive' ? this.wide : !this.tight + }, + + /** Whether this rail expands over a scrim rather than in the layout. */ + get modal() { + // A rail that is away has nothing left in the layout to expand, so the menu button + // that brings it back — the app bar's — opens it over the page. + return this.cramped || this.away + }, + + get open() { + return this.modal && this.$store.rail.open + }, + + get expanded() { + return this.open || this.standing + }, + /** The rail's own menu button: open or close the modal, or collapse and expand in place. */ menu() { - if (this.modal) { + if (this.open && this.away) { + // This rail is only over the page because it hid itself, so the button docks it + // back into the layout — the same "expand" it means on a rail that is standing + // there. Expanding drops `away`, which closes the drawer behind it. + this.$store.rail.expand() + this.$store.rail.hide() + } else if (this.modal) { this.$store.rail.open ? this.$store.rail.hide() : this.$store.rail.show() } else { // `set`, not `toggle`: with nothing stored the store's `collapsed` is only diff --git a/resources/views/components/app-shell.blade.php b/resources/views/components/app-shell.blade.php index 3a7e208a..d286e3cb 100644 --- a/resources/views/components/app-shell.blade.php +++ b/resources/views/components/app-shell.blade.php @@ -56,7 +56,11 @@ phone opens. `label` names both navigation landmarks ("Main"); `rail-width` is the expanded 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; `hide-bar-on-scroll` lets the bar leave the window - while the page scrolls down, and `--material-bottom-bar` goes down and comes back with it. + while the page scrolls down, and `--material-bottom-bar` goes down and comes back with it; + `hide-rail-when-collapsed` is M3's immersive configuration — from `expanded` the rail leaves + the layout when the menu button collapses it, rather than narrowing to 96px, so the page has + the whole window. The only way back is `$store.rail.show()`, so put a menu button in the app + bar at every width, not just below `medium`. `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 @@ -86,6 +90,7 @@ 'railWidth' => '16rem', 'tallBar' => false, 'hideBarOnScroll' => false, + 'hideRailWhenCollapsed' => false, ]) @php @@ -132,7 +137,7 @@ @endisset
- + @isset($brand) {{ $brand }} @endisset diff --git a/resources/views/components/navigation-rail.blade.php b/resources/views/components/navigation-rail.blade.php index 8db0c46d..c097a3f2 100644 --- a/resources/views/components/navigation-rail.blade.php +++ b/resources/views/components/navigation-rail.blade.php @@ -60,7 +60,13 @@ puts the destinations at the rail's vertical centre — M3 prefers that on a tablet, for reach — while the menu button, the brand and the FAB stay at the top and the footer at the foot, as M3 asks; more destinations than fit go back to the top rather than out of reach above the - scroller; `menu` shows the menu button (by default for `collapsible`, + scroller; `hide-when-collapsed` is M3's other expanded behaviour, for a `collapsible` or + `adaptive` rail: collapsing it takes it out of the layout altogether instead of narrowing it + to 96px, and it comes back expanded over a scrim when something calls `$store.rail.show()` — + a menu button in the app bar, which is the only way back, so put one there. Its own menu + button then docks it into the layout again. Not below `medium` for a collapsible rail, nor at + `medium` for an adaptive one: there it is the window and not the visitor that collapses a + rail, and M3's collapsed rail may never hide; `menu` shows the menu button (by default for `collapsible`, `modal` and `adaptive`); `divider` draws M3's optional vertical divider on the edge the page is on — which is also what M3 asks for when a page scrolls underneath a fixed rail; `fill` (`false`) drops the container colour for a transparent rail over the page's own background, @@ -83,6 +89,7 @@ 'label' => null, 'width' => '16rem', 'align' => 'top', + 'hideWhenCollapsed' => false, 'menu' => null, 'divider' => false, 'fill' => true, @@ -91,7 +98,10 @@ @php $mode = in_array($mode, ['collapsed', 'expanded', 'collapsible', 'modal', 'adaptive'], true) ? $mode : 'collapsible'; $interactive = in_array($mode, ['collapsible', 'modal', 'adaptive'], true); - $canOpen = in_array($mode, ['modal', 'adaptive'], true); + // Only a rail that has a collapsed *and* an expanded state of its own can hide instead of + // narrowing; a `modal` one is already over the page, and the two fixed modes mean what they say. + $hideWhenCollapsed = $hideWhenCollapsed && in_array($mode, ['collapsible', 'adaptive'], true); + $canOpen = in_array($mode, ['modal', 'adaptive'], true) || $hideWhenCollapsed; $menu ??= $interactive; $collapsedAtFirst = in_array($mode, ['collapsed', 'modal'], true); $narrow = $width === 'narrow'; @@ -103,10 +113,11 @@ data-navigation-rail="{{ $mode }}" @if ($narrow) data-width="narrow" @endif @if ($centred) data-align="center" @endif + @if ($hideWhenCollapsed) data-hide-when-collapsed @endif @if ($divider) data-divider @endif @unless ($fill) data-fill="false" @endunless @if ($interactive) - x-data="materialNavigationRail('{{ $mode }}')" + x-data="materialNavigationRail('{{ $mode }}', {{ $hideWhenCollapsed ? 'true' : 'false' }})" x-bind:data-open="open" @endif {{ $attributes->merge(['style' => "--navigation-rail-width: {$width}"]) }} diff --git a/resources/views/showcase/sections/navigation.blade.php b/resources/views/showcase/sections/navigation.blade.php index 5182f2e9..db1e2e46 100644 --- a/resources/views/showcase/sections/navigation.blade.php +++ b/resources/views/showcase/sections/navigation.blade.php @@ -138,6 +138,24 @@
BLADE, + 'Rail that hides when collapsed' => <<<'BLADE' +
+ + + Studio + + + + + + + +
+

M3's other expanded behaviour: collapsing this rail takes it out of the layout instead of narrowing it to 96px, so an immersive page gets the whole window. The only way back is a menu button of the application's own — its own button then docks it again.

+ +
+
+ BLADE, ]; @endphp diff --git a/tests/Feature/Components/AppShellTest.php b/tests/Feature/Components/AppShellTest.php index 8abe61ca..ea309bfc 100644 --- a/tests/Feature/Components/AppShellTest.php +++ b/tests/Feature/Components/AppShellTest.php @@ -115,6 +115,15 @@ it('lets the bar leave the window while the page scrolls down', function () { ->not->toContain('data-hide-on-scroll'); }); +it('exposes the rail that hides when collapsed', function () { + expect((string) $this->blade('', ['destinations' => shellDestinations()])) + ->toContain('data-hide-when-collapsed') + ->toContain("materialNavigationRail('adaptive', true)") + ->and((string) $this->blade('', ['destinations' => shellDestinations()])) + ->not->toContain('data-hide-when-collapsed') + ->toContain("materialNavigationRail('adaptive', false)"); +}); + 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/NavigationRailTest.php b/tests/Feature/Components/NavigationRailTest.php index ce2b0f8d..7fa83ae3 100644 --- a/tests/Feature/Components/NavigationRailTest.php +++ b/tests/Feature/Components/NavigationRailTest.php @@ -18,7 +18,7 @@ it('gives the collapsible, modal and adaptive rails a menu button and the store' $collapsible = (string) $this->blade(''); expect($collapsible) - ->toContain('x-data="materialNavigationRail(\'collapsible\')"') + ->toContain('x-data="materialNavigationRail(\'collapsible\', false)"') ->toContain('data-navigation-rail-menu') ->toContain('aria-label="Collapse navigation"') ->toContain('aria-expanded="true"') @@ -102,6 +102,35 @@ it('centres the destinations on request, leaving the header and footer at their ->toMatch("/\\[data-navigation-rail\\]\\[data-align='center'\\] \\[data-navigation-rail-destinations\\] \\{\\s+justify-content: safe center;/"); }); +it('hides a collapsible or adaptive rail entirely when told to, and only those', function () { + $hiding = (string) $this->blade(''); + + expect($hiding) + ->toContain('data-hide-when-collapsed') + ->toContain("x-data=\"materialNavigationRail('collapsible', true)\"") + // It needs the scrim and the focus trap a modal rail has: it comes back over the page. + ->toContain('data-navigation-rail-scrim') + ->toContain('x-trap.inert.noscroll="open"') + ->and((string) $this->blade('')) + ->toContain("x-data=\"materialNavigationRail('adaptive', true)\"") + // A modal rail is already over the page; the two fixed modes mean what they say. + ->and((string) $this->blade('')) + ->not->toContain('data-hide-when-collapsed') + ->toContain("materialNavigationRail('modal', false)") + ->and((string) $this->blade('')) + ->not->toContain('data-hide-when-collapsed') + ->and((string) $this->blade(''))->not->toContain('data-hide-when-collapsed'); + + expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css')) + ->toMatch('/\[data-navigation-rail\]\[data-hide-when-collapsed\] \{\s+@variant rail-collapsed \{\s+width: 0;/') + // The two bands where the window, not the visitor, collapses a rail keep theirs. + ->toMatch("/@media \\(width < 37\\.5rem\\) \\{\\s+\\[data-navigation-rail='collapsible'\\]\\[data-hide-when-collapsed\\]/") + ->toMatch("/@media \\(37\\.5rem <= width < 52\\.5rem\\) \\{\\s+\\[data-navigation-rail='adaptive'\\]\\[data-hide-when-collapsed\\]/") + // Every collapsed branch stops while the rail is open, or what comes back would be narrow. + ->and(substr_count(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'), ':not([data-open])')) + ->toBeGreaterThanOrEqual(12); +}); + it('flattens a FAB nested in the rail header and morphs its label', function () { // Both rules hang off `data-fab` on 's root, and both are unlayered, because what they // beat — the FAB's shadow, its gap, an extended FAB's minimum width — are utilities. From c975c5c593d9534186fe838980fdb95c9d180d9e Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 07:16:26 +0200 Subject: [PATCH 6/6] Let a docked toolbar carry a FAB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step 25 (navigation Missing): M3's toolbar configuration table lists "With FAB" for both toolbars, but the `fab` slot was gated to the floating one. A docked toolbar now takes it at its end, inside the toolbar where the arrow keys reach it; its controls gather at the start, and the FAB rests flat on the bar — elevation 0 for a FAB nested in another component, as Compose's bottomAppBarFabElevation is. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 2 +- resources/css/components/toolbar.css | 21 ++++++++++++++++++- resources/views/components/toolbar.blade.php | 13 ++++++++++-- .../views/showcase/sections/bars.blade.php | 10 +++++++++ tests/Feature/Components/AppBarTest.php | 21 +++++++++++++++++++ 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index ecdf9984..38431f7b 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -787,7 +787,7 @@ A collapsing bar needs the window to scroll: no ancestor with `overflow-hidden`/ ### `` -M3 Expressive toolbar, `role="toolbar"` (arrow keys move between controls). `variant`: `floating` (default pill at elevation 3; `vibrant`, `vertical`) or `docked` (full-width surface-container bar). `place`: `bottom` or `end` to fix it over the page; `fab` slot sets a FAB beside a floating toolbar; `label` names it. +M3 Expressive toolbar, `role="toolbar"` (arrow keys move between controls). `variant`: `floating` (default pill at elevation 3; `vibrant`, `vertical`) or `docked` (full-width surface-container bar). `place`: `bottom` or `end` to fix it over the page; `fab` slot takes an `` — beside a floating toolbar, or at the end of a docked one, where the controls gather at the start and the FAB rests flat on the bar (M3's elevation 0 for a nested FAB) and the arrow keys reach it; `label` names it. ```blade diff --git a/resources/css/components/toolbar.css b/resources/css/components/toolbar.css index d53903a3..d523c17f 100644 --- a/resources/css/components/toolbar.css +++ b/resources/css/components/toolbar.css @@ -3,7 +3,8 @@ * Apache-2.0). * * docked a 64px bar across the bottom of the screen in surface-container, square, its - * controls spread out 4 to 32px apart between 16px ends + * controls spread out 4 to 32px apart between 16px ends; with a FAB, the controls + * at the start and the FAB flat at the end * floating a 64px pill in surface-container (or primary-container, `vibrant`) at elevation 3, * 8px at its ends and 4px between controls; `vertical` stands it on end * @@ -71,6 +72,24 @@ color: var(--md-sys-color-primary); } +/* A docked toolbar with a FAB: the controls at the start, the FAB at the end, resting on the bar + at elevation 0 — M3 puts a FAB nested in another component there, and Compose's + FloatingActionButtonDefaults.bottomAppBarFabElevation() is 0 in every state. `` draws its + shadow with utilities, which is one more reason this file is unlayered. */ +[data-toolbar][data-variant="docked"]:has(> [data-toolbar-fab]) { + justify-content: flex-start; +} + +[data-toolbar-fab] { + display: flex; + margin-inline-start: auto; +} + +[data-toolbar-fab] [data-fab], +[data-toolbar-fab] [data-fab]:hover { + box-shadow: none; +} + /* A floating toolbar and its FAB, side by side (or stacked, vertical). */ [data-toolbar-group] { display: inline-flex; diff --git a/resources/views/components/toolbar.blade.php b/resources/views/components/toolbar.blade.php index 61b6ecd0..d4af1c60 100644 --- a/resources/views/components/toolbar.blade.php +++ b/resources/views/components/toolbar.blade.php @@ -4,8 +4,12 @@ `vertical` stands it on end) or `docked` (a full-width bar in surface-container, for the bottom of a screen). `place` puts it over the page: `bottom` (centred above the bottom edge; a docked toolbar spans it) or `end` (centred against the end edge, for a vertical one); without it the - toolbar sits where it is written. A `fab` slot sets an `` beside a floating toolbar. - `label` names it for screen readers. + toolbar sits where it is written. A `fab` slot sets an `` beside a floating toolbar, + and at the end of a docked one — M3's "With FAB" configuration for both. On a docked toolbar the + controls then gather at the start and the FAB rests on the bar, flat: M3 gives a FAB nested in + another component elevation 0 (Compose's bottomAppBarFabElevation is 0 at every state), where + one beside a floating toolbar keeps its own. It is one of the toolbar's controls then, so the + arrow keys reach it. `label` names it for screen readers. A docked toolbar and a navigation bar occupy the same region of the screen and M3 says never to show both at once: the bar belongs on a primary page, the toolbar on a secondary or contextual @@ -29,6 +33,7 @@ $vertical = $vertical && $variant === 'floating'; $place = in_array($place, ['bottom', 'end'], true) ? $place : null; $grouped = isset($fab) && $variant === 'floating'; + $docksFab = isset($fab) && $variant === 'docked'; @endphp @if ($grouped) @@ -48,6 +53,10 @@ {{ $attributes }} > {{ $slot }} + + @if ($docksFab) +
{{ $fab }}
+ @endif
@if ($grouped) diff --git a/resources/views/showcase/sections/bars.blade.php b/resources/views/showcase/sections/bars.blade.php index 81467dd5..a0ac1557 100644 --- a/resources/views/showcase/sections/bars.blade.php +++ b/resources/views/showcase/sections/bars.blade.php @@ -73,6 +73,16 @@
+ +
+ + + + + + + +
BLADE, 'Primary tabs' => <<<'BLADE' diff --git a/tests/Feature/Components/AppBarTest.php b/tests/Feature/Components/AppBarTest.php index 369d254e..ca15940a 100644 --- a/tests/Feature/Components/AppBarTest.php +++ b/tests/Feature/Components/AppBarTest.php @@ -69,6 +69,27 @@ it('draws floating and docked toolbars', function () { ->not->toContain('data-toolbar-group'); }); +it('sets a FAB at the end of a docked toolbar, resting flat on it', function () { + $html = (string) $this->blade(<<<'BLADE' + + + + + BLADE); + + expect($html) + // Inside the toolbar, after its controls, where the arrow keys reach it too. + ->toMatch('/role="toolbar".*')) + ->not->toContain('data-toolbar-fab'); + + expect(file_get_contents(__DIR__.'/../../../resources/css/components/toolbar.css')) + ->toMatch('/\[data-toolbar-fab\] \{\s+display: flex;\s+margin-inline-start: auto;/') + // A nested FAB rests at elevation 0. + ->toMatch('/\[data-toolbar-fab\] \[data-fab\],\s+\[data-toolbar-fab\] \[data-fab\]:hover \{\s+box-shadow: none;/'); +}); + it('centres a headline on a three-column row and curves the search container', function () { $css = file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.css');