Files
livewire-material/resources/js/navigation.js
T
Andreas Reinhold / reiniandClaude Opus 5 247c596c3a Cut duplicated and speculative code across the package
An over-engineering audit of the whole tree, applied in five reviewed
batches. Behaviour stays the same except where UPGRADE.md says otherwise.

PHP: the showcase and error-page stylesheets are prebuilt into
resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import
(first occurrence kept, the order an application's build gives), instead
of Stylesheets::bundle() inlining imports on every request; only the
import walk DesignGuard needs stays. SchemeStylesheet::withProfiles()
replaces three copies of the scheme-plus-profiles loop, material:scheme
leaves spec and contrast checks to the node script that already made
them, and the error page's scheme cache, the hashed view namespace, the
translations path with no lang/ folder and DesignGuard's 1.x-name hints
are gone.

JS: the androidx shape port progress.js and both bin scripts each carried
lives once in resources/js/shapes.js (the generated SVGs are unchanged);
util.js holds ringIndex(), ms(), reopenGuard() and remember(), which
were written out several times; listeners are released through
AbortController; tooltip.js's hoverPopover() serves the rich tooltip too.

CSS: every rule for an element inside the navigation rail queries
`--md-navigation-rail-value` instead of repeating the seven collapsed
conditions under five media branches; badge, alert, progress, slider and
button read one non-inheriting colour-role table (components/color.css);
the dialog chrome, the submenu's popover chrome, the chip's state layer
and touch target, and the visually-hidden inputs use the shared rules
they copied; foundation/tokens.css is folded into foundation.css.

Views: Support\Field and Support\Link replace the error-key, bound-value
and link-attribute blocks copied into the fields and link components;
the timepicker period group, the menu filter and the showcase head are
partials; the datepicker's steppers and entry fields are loops; component
docblocks no longer restate SKILL.md.

Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces
four per-group files, DesignGuardTest and the layout-component tests use
datasets, browser tests share one ready() helper, CSS parsing lives in
ComponentStylesheet alone. docs/audits and the finding IDs citing it are
removed, as are pestphp/pest-plugin-laravel, the unused composer scripts
and check:font; the lint job runs in the feature job, which now installs
node packages so the prebuilt-stylesheet staleness test runs in CI.

Feature suite 1177 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:29:21 +02:00

363 lines
15 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.
* So `collapsed` is not always what is drawn, and `toggle()` asks the rail on the page instead.
*
* `$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'
import { remember } from './util.js'
/** The root of every interactive rail on the page once its view is initialised: what `toggle()` asks. */
const rails = new Set()
/*
* 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,
/**
* Flips what the first rail on the page draws: shut when it is open over the page, open
* over a scrim where the mode or the window leaves it no room to expand (`show()`,
* remembering nothing), and otherwise the choice, from what is drawn — a rail that hides
* when collapsed comes back into the layout. Not `set(!collapsed)`: with nothing stored,
* an adaptive rail in the expanded class is drawn collapsed whatever `rail.default` says.
* With no rail on the page, the choice itself.
*/
toggle() {
const root = [...document.querySelectorAll('[data-md-navigation-rail]')].find((element) => rails.has(element))
const rail = root && window.Alpine.$data(root)
if (!rail) {
this.set(!this.collapsed)
} else if (rail.open) {
this.hide()
} else if (rail.cramped) {
this.show()
} else {
this.set(rail.expanded)
}
},
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')
remember(root.dataset.railKey || 'material-rail', root.dataset.rail)
},
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,
controller: null,
/**
* `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() {
rails.add(this.$root)
this.controller = new AbortController()
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) {
query.addEventListener('change', (event) => onChange(event.matches), { signal: this.controller.signal })
},
destroy() {
rails.delete(this.$root)
this.controller.abort()
},
/**
* 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`: the button flips what this rail draws, and `toggle()` asks
// the first rail on the page.
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)