/** * 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. has already applied it before the first paint as * , 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` and whether it hides * when collapsed — see resources/views/components/navigation-rail.blade.php. * * `materialNavigationBar` is `` — 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 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, 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: [], 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 // 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])) }, /** 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 resources/css/components/navigation.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 (``'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)