Plan step 17, on N-06, N-07 and C-07. The shell now changes at 600, 840 and 1200 and nowhere else: a compact window keeps the navigation bar and the modal rail; `medium` (600-839) gets the collapsed rail in the layout and no bar; `expanded` (840-1199) gets a standard rail, collapsed, whose menu button expands it in place rather than over a scrim; `large` and above start it expanded, which is what M3 prefers once there is room. `data-rail` alone could not say "collapsed at expanded, expanded at large", since it carries `rail.default` for a visitor who never chose. <x-theme-script> now also writes `data-rail-auto` while nothing is stored, the `rail-collapsed:` variant reads it in the 840-1199 band, and `$store.rail.auto` mirrors it for Alpine; the first press of the menu button drops it, so a remembered choice still wins in both bands. `rail.default` and the rest of `$store.rail` are unchanged, and the attribute rides through `wire:navigate` with the others. `--material-margin` carries M3's window margin on the shell -- 16px compact, 24px from `medium` -- and the content region is padded with it, so the showcase pages drop their own gutters. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
176 lines
6.6 KiB
JavaScript
176 lines
6.6 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 the stylesheet keys on (the
|
||
* `rail-collapsed:` variant); 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` — see
|
||
* resources/views/components/navigation-rail.blade.php.
|
||
*/
|
||
import { from } 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 resources/css/components/navigation.css.
|
||
*/
|
||
const arriving = new CSSStyleSheet()
|
||
|
||
arriving.replaceSync(`@starting-style {
|
||
:is([data-navigation-bar-item], [data-navigation-rail-item])[data-active],
|
||
:is([data-navigation-bar-item], [data-navigation-rail-item])[data-active] :is([data-navigation-indicator], [data-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) => ({
|
||
wide: mode === 'adaptive' ? from('expanded').matches : false,
|
||
roomy: mode === 'adaptive' ? from('large').matches : false,
|
||
queries: [],
|
||
listeners: [],
|
||
|
||
init() {
|
||
if (mode !== 'adaptive') {
|
||
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
|
||
// the `rail-collapsed:` variant in resources/css/components/navigation.css.
|
||
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]))
|
||
},
|
||
|
||
/** Whether this rail expands over a scrim rather than in the layout. */
|
||
get modal() {
|
||
return mode === 'modal' || (mode === 'adaptive' && !this.wide)
|
||
},
|
||
|
||
get open() {
|
||
return this.modal && this.$store.rail.open
|
||
},
|
||
|
||
get expanded() {
|
||
if (this.open || 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'
|
||
},
|
||
|
||
/** The rail's own menu button: open or close the modal, or collapse and expand in place. */
|
||
menu() {
|
||
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)
|
||
}
|
||
},
|
||
}))
|
||
})
|