Let the navigation bar hide on a scroll down

Plan step 25 (navigation Missing): M3's "hides on scroll-down, reappears on
scroll-up" had no implementation. `<x-navigation-bar hide-on-scroll>` 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.
`<x-app-shell hide-bar-on-scroll>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:36:57 +02:00
co-authored by Claude Fable 5.1
parent 4d00d87cd2
commit ce1cd6eec0
8 changed files with 167 additions and 11 deletions
+64
View File
@@ -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 `<x-navigation-bar hide-on-scroll>` — 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 (`<x-toast>`'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)