Plan step 36 (navigation group, second batch): <x-navigation-rail>'s, <x-navigation-rail-item>'s and <x-navigation-rail-section>'s class lists move into navigation-rail.css, navigation-rail-item.css and navigation-rail-section.css, keyed on data-md-navigation-rail (the mode, data-md-width, data-md-align, data-md-hide-when-collapsed, data-md-divider, data-md-fill, data-md-open), data-md-navigation-rail- item (data-md-active) and data-md-navigation-rail-section. Every Tailwind wrapper class in the view — the menu row's centring padding, the FAB row, the two swapped menu glyphs, the brand's visibility — becomes a hook the stylesheet draws instead; the menu button itself renders the shared md-state-layer/md-focus-ring/md-touch-target classes (N-01's pattern) since it draws its own layer on itself, not a child. `<x-icon>` and `<x-badge>` take size and floating props instead of size/position classes; a small data-md-navigation-icon hook (the shared navigation-item.css) replaces the ad hoc "relative inline-flex" wrapper a floating badge anchors to. `rail-collapsed` (a Tailwind @custom-variant, forbidden in Phase F) is reproduced as plain selectors, branch for branch: the three width- independent conditions (a fixed collapsed mode; a collapsible rail the visitor collapsed and not open; a modal rail not open) merge into one :where() group, provably the same match set as three separate rules since :where(A, B, C) on an element is true exactly when :where(A) or :where(B) or :where(C) is; the four width-gated conditions stay separate media blocks, since CSS cannot merge different `@media` queries. Every rem length becomes px, since these are dp-based M3 tokens, not a text measure (unlike <x-pane>'s rem widths). The FAB overrides for a rail's header — elevation 0 (N-03), morphing into an extended FAB instead of swapping two by display (N-23) — move from unlayered into this file's own material.components, like toolbar.css's FAB override: fab.css's `[data-md-fab]` is one attribute, so a doubled selector here always outranks it without needing to sit outside the layer. navigation.js: the arriving-indicator stylesheet and every code comment follow the new hooks; resources/css/components/navigation.css is deleted (nothing imports it any more) and its line in tailwind.css with it. Behaviour is unchanged except one thing Tailwind's `rail-collapsed:` variant could do that plain CSS in this shape cannot: it is gone for consuming applications too, since the definition lived only in the file this commit removes. The development skill's guidance for it is rewritten to point at navigation-rail.css's own selectors instead of teaching a Tailwind variant that no longer exists. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
288 lines
11 KiB
JavaScript
288 lines
11 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.
|
||
*
|
||
* `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: [],
|
||
|
||
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]))
|
||
},
|
||
|
||
/** 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)
|