From ce1cd6eec06b69f9218e91ca0d1cd98fef8edbd3 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:36:57 +0200 Subject: [PATCH] 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('