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:
co-authored by
Claude Fable 5.1
parent
4d00d87cd2
commit
ce1cd6eec0
@@ -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 `<x-app-shell>`), so the rail sticks under it instead of behind it.
|
||||
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. On a compact window nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `medium`).
|
||||
- `--material-margin` is M3's window margin (16px compact, 24px from `medium`) and the content region already carries it, so a page inside the shell writes no gutters of its own; something that must reach the window's edges opts out with `-mx-(--material-margin)`.
|
||||
@@ -739,7 +739,7 @@ M3 Expressive's flexible navigation bar, for three to five destinations. It does
|
||||
</div>
|
||||
```
|
||||
|
||||
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"), `tall` (M3's 80px container, which keeps the icon over the label at every width — `<x-app-shell tall-bar>` picks it, and the bottom offset grows with it). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
|
||||
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"), `tall` (M3's 80px container, which keeps the icon over the label at every width — `<x-app-shell tall-bar>` picks it, and the bottom offset grows with it), `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. `<x-app-shell hide-bar-on-scroll>` 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). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
|
||||
|
||||
### `<x-navigation-rail>`, `<x-navigation-rail-item>`, `<x-navigation-rail-section>`
|
||||
|
||||
|
||||
@@ -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 <x-app-shell> 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: <x-app-shell> 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 medium:hidden">
|
||||
<x-livewire-material::navigation-bar :label="$label" :tall="$tallBar">
|
||||
<x-livewire-material::navigation-bar :label="$label" :tall="$tallBar" :hide-on-scroll="$hideBarOnScroll">
|
||||
@foreach ($barItems as $item)
|
||||
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||||
@endforeach
|
||||
|
||||
@@ -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 `<x-app-shell>`
|
||||
`--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. `<x-app-shell>` does both, and lifts the snackbar and a
|
||||
`fab` button above it through `--material-bottom-bar`.
|
||||
|
||||
`label` names the landmark ("Main" by default); `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,
|
||||
])
|
||||
|
||||
<nav aria-label="{{ $label ?? __('Main') }}" data-navigation-bar @if ($tall) data-tall @endif {{ $attributes }}>
|
||||
<nav
|
||||
aria-label="{{ $label ?? __('Main') }}"
|
||||
data-navigation-bar
|
||||
@if ($tall) data-tall @endif
|
||||
@if ($hideOnScroll)
|
||||
data-hide-on-scroll
|
||||
x-data="materialNavigationBar"
|
||||
x-bind:data-hidden="away ? '' : null"
|
||||
x-on:focusin="show()"
|
||||
@endif
|
||||
{{ $attributes }}
|
||||
>
|
||||
<div data-navigation-bar-items>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-[25rem] space-y-2">
|
||||
<p class="type-label-lg text-on-surface-variant">Hide on scroll: scroll the page down, then up</p>
|
||||
<div class="flex h-56 flex-col overflow-hidden rounded-corner-lg border border-outline-variant bg-surface">
|
||||
<div class="flex-1 p-4 type-body-md text-on-surface-variant">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.</div>
|
||||
<x-navigation-bar hide-on-scroll>
|
||||
<x-navigation-bar-item label="Shares" icon="folder_shared" link="#navigation" no-wire-navigate active />
|
||||
<x-navigation-bar-item label="Upload" icon="upload" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-bar-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate badge="3" />
|
||||
</x-navigation-bar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-[50rem] space-y-2">
|
||||
<p class="type-label-lg text-on-surface-variant">Tall (80px): icon over label whatever the width</p>
|
||||
<div class="overflow-x-auto rounded-corner-lg border border-outline-variant">
|
||||
|
||||
Reference in New Issue
Block a user