Files
livewire-material/resources/js/navigation.js
T
Andreas Reinhold / reiniandClaude Opus 5 92e560bef6 Slide the navigation rail and fade its scrim out in Firefox too
A rail that was open over the page left it on `transition: … display …
allow-discrete`: the compact adaptive rail's slide-out, the slide-out of
a rail that hides when collapsed, and every rail scrim's fade. Firefox
does not transition `display`, even with `allow-discrete` (Chrome 117
and Safari 18 do), so there the panel and the scrim vanished on the
first frame. Unlike the sheets' scrims these are drawn by attribute
rules on `data-md-open`, not `x-show`, so Alpine had nothing to hold.

navigation.js now marks a rail that closes `data-md-closing` — `sheet`
when the panel leaves the window, `scrim` when it stands in the layout
again — and drops it once the panel's `translate` and the scrim's
`opacity` transitions have finished (at once under reduced motion, or
when the rail opens again). navigation-rail.css keeps what is leaving
displayed, and a sliding panel in its open geometry, while the
attribute is set, and no longer transitions `display` anywhere, so
Chrome and Safari do not hold a second time. The view sets it from
`x-effect`, beside the `x-bind` that drops `data-md-open`, so both
attributes change in one flush: a `$watch` a microtask later let a
style read in between settle the scrim as already hidden. The collapsed
branches and `--md-navigation-rail-value` are untouched.

NavigationTest samples each exit part-way in the page (the compact
slide, the hide-when-collapsed slide, a modal rail's scrim) and checks
it ends hidden; all three fail on main in Firefox and pass in Chrome,
Firefox and Safari. They wait for the entry to finish first: Firefox
creates a transition on its next refresh tick, so straight after a
change its computed style already reads the end value. NavigationRailTest
pins the holds and that no rule transitions `display`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:24:43 +02:00

344 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Navigation: the rail's state, shared by every rail and menu button on the page.
*
* `$store.rail.collapsed` is the visitor's choice for a collapsible rail, remembered in
* localStorage. <x-theme-script> has already applied it before the first paint as
* <html data-rail="expanded|collapsed">, which is what navigation-rail.css keys on (every branch
* of "collapsed" reads it, or `data-rail-auto` where no choice has been made); the store starts
* from that attribute and writes it back.
* `$store.rail.auto` is true while nothing is stored — the value is `rail.default`, not a choice —
* and the adaptive rail then takes its window size class's default instead: collapsed in the
* expanded class (8401199), expanded from `large` (1200), as M3 asks. The first `set()` drops it.
*
* `$store.rail.open` is the modal rail: on a window too narrow for an expanded rail, a menu
* 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` and whether it hides
* when collapsed — see resources/views/components/navigation-rail.blade.php. It also holds a
* closing rail on screen for its exit (`closing`, below).
*
* `materialNavigationBar` is `<x-navigation-bar hide-on-scroll>` — see the same file's sibling.
*/
import { from, upTo } from './breakpoints.js'
/*
* The active indicator grows out of its centre when a page arrives through wire:navigate. The
* new page's indicator is new markup, so the only way to animate it is a starting style — and
* only while a navigation swaps the page in, or every full load would animate it too. The sheet
* is adopted as the navigation starts and dropped two frames after it ends; the transition itself
* is navigation-item.css.
*/
const arriving = new CSSStyleSheet()
arriving.replaceSync(`@starting-style {
:is([data-md-navigation-bar-item], [data-md-navigation-rail-item])[data-md-active],
:is([data-md-navigation-bar-item], [data-md-navigation-rail-item])[data-md-active] :is([data-md-navigation-indicator], [data-md-navigation-pill]) {
background-size: 0% 100%;
}
}`)
document.addEventListener('livewire:navigating', () => {
if (!document.adoptedStyleSheets.includes(arriving)) {
document.adoptedStyleSheets = [...document.adoptedStyleSheets, arriving]
}
})
document.addEventListener('livewire:navigated', () => {
requestAnimationFrame(() => requestAnimationFrame(() => {
document.adoptedStyleSheets = document.adoptedStyleSheets.filter((sheet) => sheet !== arriving)
}))
})
document.addEventListener('alpine:init', () => {
const root = document.documentElement
window.Alpine.store('rail', {
collapsed: root.dataset.rail === 'collapsed',
// Nothing stored yet: `collapsed` is only `rail.default`, so an adaptive rail may still
// take its window size class's own default. The first choice made here clears it.
auto: root.hasAttribute('data-rail-auto'),
open: false,
toggle() {
this.set(!this.collapsed)
},
collapse() {
this.set(true)
},
expand() {
this.set(false)
},
set(collapsed) {
this.collapsed = collapsed
this.auto = false
root.dataset.rail = collapsed ? 'collapsed' : 'expanded'
root.removeAttribute('data-rail-auto')
try {
localStorage.setItem(root.dataset.railKey || 'material-rail', root.dataset.rail)
} catch {
// Blocked storage: the rail still toggles, it just will not remember.
}
},
show() {
this.open = true
},
hide() {
this.open = false
},
})
// 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, 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: [],
/**
* `data-md-closing` while a rail that was open over the page leaves it: `sheet` when the
* panel slides away (a compact adaptive rail, or one that hides when collapsed), `scrim`
* when only the scrim fades and the panel stands in the layout again. navigation-rail.css
* keeps what is leaving displayed while it is set, because the exit cannot hold `display`
* itself: Firefox does not transition `display`, even with `allow-discrete`, so the slide
* and the fade were cut to nothing there. It is dropped once the exit's own transitions
* have finished — none under reduced motion, whose durations are zero — or at once when
* the rail opens again.
*/
closing: null,
closings: 0,
wasOpen: false,
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
}
// From `expanded` (840px) the adaptive rail is a standard, collapsible rail — what M3
// asks for at expanded and above. A modal left open while the window widens is shut,
// or its focus trap would hold a page that has no scrim.
this.watch(from('expanded'), (matches) => {
this.wide = matches
if (matches) {
this.$store.rail.hide()
}
})
// From `large` (1200px) it starts expanded rather than collapsed, until someone
// chooses otherwise; below that the expanded class starts it collapsed. Both mirror
// navigation-rail.css's own branches for those two bands.
this.watch(from('large'), (matches) => (this.roomy = matches))
},
watch(query, onChange) {
const listener = (event) => onChange(event.matches)
query.addEventListener('change', listener)
this.queries.push(query)
this.listeners.push(listener)
},
destroy() {
this.queries.forEach((query, index) => query.removeEventListener('change', this.listeners[index]))
},
/**
* Holds a rail that has just closed on screen until its exit has run; see `closing`. The
* view calls it from `x-effect`, beside the `x-bind` that drops `data-md-open`, so both
* attributes change in the same flush: a `$watch` would set `data-md-closing` a microtask
* later, and a style read in between would settle what is leaving as already hidden.
*/
settle(open) {
if (open === this.wasOpen) {
return
}
this.wasOpen = open
const closing = ++this.closings
if (open) {
this.closing = null
return
}
// Whether the panel leaves the window, rather than returning to the layout: a compact
// adaptive rail has no place in the layout, and a rail that hides when collapsed has
// left it. Decided from the state, not measured: reading the panel's style between the
// two attribute changes would settle it as hidden, and nothing would transition.
this.closing = this.away || (mode === 'adaptive' && upTo('medium').matches) ? 'sheet' : 'scrim'
// A frame on, the attributes have met the style, and the exit's transitions exist.
requestAnimationFrame(() => {
const exits = [...this.$root.querySelectorAll(':scope > :is([data-md-navigation-rail-panel], [data-md-navigation-rail-scrim])')]
.flatMap((element) => element.getAnimations())
.filter((animation) => ['translate', 'opacity'].includes(animation.transitionProperty))
Promise.allSettled(exits.map((animation) => animation.finished)).then(() => {
if (closing === this.closings) {
this.closing = null
}
})
})
},
/** What this rail's mode and the visitor's choice make of it, before anything opens it. */
get standing() {
if (mode === 'expanded') {
return true
}
if (this.$store.rail.collapsed) {
return false
}
if (mode === 'adaptive') {
// A standard rail from `expanded`; with no choice stored it is the window size
// class that decides, and only `large` and above start it expanded.
return this.wide && (this.roomy || !this.$store.rail.auto)
}
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 navigation-rail.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.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
// `rail.default`, so the button has to flip what is actually drawn.
this.$store.rail.set(this.expanded)
}
},
}))
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-md-toast-snackbar], [role="dialog"]')].some((over) => over.getClientRects().length > 0)