Give the app shell one shape per M3 window size class

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
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 05:29:17 +02:00
co-authored by Claude Fable 5.1
parent 17723d2a76
commit fd1e063d4c
8 changed files with 114 additions and 36 deletions
+44 -11
View File
@@ -5,6 +5,9 @@
* 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 (8401199), 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
@@ -48,6 +51,10 @@ document.addEventListener('alpine:init', () => {
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() {
@@ -64,7 +71,9 @@ document.addEventListener('alpine:init', () => {
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)
@@ -87,8 +96,9 @@ document.addEventListener('alpine:init', () => {
window.Alpine.data('materialNavigationRail', (mode) => ({
wide: mode === 'adaptive' ? from('expanded').matches : false,
query: null,
onWidth: null,
roomy: mode === 'adaptive' ? from('large').matches : false,
queries: [],
listeners: [],
init() {
if (mode !== 'adaptive') {
@@ -98,19 +108,30 @@ document.addEventListener('alpine:init', () => {
// 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.query = from('expanded')
this.onWidth = (event) => {
this.wide = event.matches
this.watch(from('expanded'), (matches) => {
this.wide = matches
if (event.matches) {
if (matches) {
this.$store.rail.hide()
}
}
this.query.addEventListener('change', this.onWidth)
})
// 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.query?.removeEventListener('change', this.onWidth)
this.queries.forEach((query, index) => query.removeEventListener('change', this.listeners[index]))
},
/** Whether this rail expands over a scrim rather than in the layout. */
@@ -127,7 +148,17 @@ document.addEventListener('alpine:init', () => {
return true
}
return (mode === 'collapsible' || (mode === 'adaptive' && this.wide)) && !this.$store.rail.collapsed
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. */
@@ -135,7 +166,9 @@ document.addEventListener('alpine:init', () => {
if (this.modal) {
this.$store.rail.open ? this.$store.rail.hide() : this.$store.rail.show()
} else {
this.$store.rail.toggle()
// `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)
}
},
}))