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>
344 lines
14 KiB
JavaScript
344 lines
14 KiB
JavaScript
/**
|
||
* 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 (840–1199), 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)
|