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
+15 -3
View File
@@ -38,8 +38,9 @@
* (docs/reference/m3/foundations.md § Layout, foundations-supplement.md § Breakpoints): 37.5rem
* (600px) is the compact/medium boundary — below it the navigation bar carries the destinations and
* the rail takes no room in the layout; 52.5rem (840px) is `expanded`, where M3 asks for a standard
* rail in the layout rather than one that opens over a scrim. The bar's own item layout is a
* *container* query at the same 37.5rem, so a bar in a narrow column lays out by its own width.
* rail in the layout rather than one that opens over a scrim; 75rem (1200px) is `large`, where the
* rail starts expanded instead of collapsed. The bar's own item layout is a *container* query at
* the same 37.5rem, so a bar in a narrow column lays out by its own width.
*/
@custom-variant rail-collapsed {
@@ -61,7 +62,18 @@
}
}
@media (width >= 52.5rem) {
/* Expanded (8401199): a standard rail, collapsed unless the visitor expanded it. `data-rail`
alone cannot say that — it carries `rail.default` for a visitor who never chose — so
`data-rail-auto`, which <x-theme-script> sets while nothing is stored, stands for "no
choice yet" and the class's own default applies. */
@media (52.5rem <= width < 75rem) {
&:where(:is([data-rail='collapsed'], [data-rail-auto]) [data-navigation-rail='adaptive'], :is([data-rail='collapsed'], [data-rail-auto]) [data-navigation-rail='adaptive'] *) {
@slot;
}
}
/* Large and extra-large (from 1200): expanded to begin with, which is what M3 prefers there. */
@media (width >= 75rem) {
&:where([data-rail='collapsed'] [data-navigation-rail='adaptive'], [data-rail='collapsed'] [data-navigation-rail='adaptive'] *) {
@slot;
}
+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)
}
},
}))
+32 -14
View File
@@ -11,13 +11,31 @@
…the page…
</x-app-shell>
- Below `sm`: a navigation bar with the destinations marked `bar`, pinned to the bottom.
Everything else is in the modal rail, which slides in when something calls
`$store.rail.show()` — put a menu button in the app bar for it, hidden from `sm`:
`<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>`.
- `sm` to `lg`: the collapsed rail, whose menu button opens it expanded, as a modal.
- From `lg`: the expanded rail, collapsed and expanded again by its menu button; the choice is
remembered and applied before the first paint (`$store.rail`, <x-theme-script>).
The navigation is M3's per window size class (docs/reference/m3/foundations.md § Layout and
foundations-supplement.md § Breakpoints), and only those four numbers:
- **Compact**, below `medium` (600px): a navigation bar with the destinations marked `bar`,
pinned to the bottom. Everything else is in the modal rail, which slides in when something
calls `$store.rail.show()` put a menu button in the app bar for it, hidden from `medium`:
`<span class="medium:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>`.
- **Medium** (600839): the collapsed rail in the layout, 96px, and no bar; its menu button
opens it expanded over a scrim, since 256px beside the page would leave the page too little.
- **Expanded** (8401199): a standard rail in the layout, nothing covered collapsed until
the menu button expands it in place.
- **Large and extra-large** (from 1200): the same standard rail, expanded to begin with
(`rail.default`), which is what M3 prefers once there is room.
From `expanded` the choice the menu button makes is remembered and applied before the first
paint (`$store.rail`, <x-theme-script>), so the rail never paints one width and snaps to the
other. A visitor who has chosen keeps that choice in both bands; one who never has gets the
class's own default.
`--material-margin` is M3's window margin 16px on a compact window, 24px from `medium` and
the content region is padded with it, so a page inside the shell writes no gutters of its own.
Something meant to reach the window's edges opts out with `-mx-(--material-margin)`.
Two panes side by side are M3's from `expanded`: `<x-drawer pane>` is the second one, 360dp
wide, in an `expanded:flex expanded:items-start expanded:gap-6` row inside the page.
`destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally
`active` (by default: the URL is the page's; during a Livewire update request, the page the
@@ -37,15 +55,15 @@
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`:
on a compact window it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`:
the bar's 64px, the bottom safe area (`--material-safe-bottom`, else the device's inset) and
`--material-bottom-extra` (0px unless the application docks something, an offline banner, on
top of the bar).
`max-lg:overflow-x-clip` on the content region is the backstop under every page, and it stays
`max-expanded:overflow-x-clip` on the content region is the backstop under every page, and it stays
`clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll
container and break every `position: sticky` inside it (an app bar, a list-detail pane). Below
`lg` only, so a wide window never clips what overhangs on purpose.
`expanded` only, so a wide window never clips what overhangs on purpose.
Nothing application-specific belongs in here: an app's destinations and chrome come in through
the props and slots. --}}
@@ -84,8 +102,8 @@
<div
data-app-shell
@class([
'min-h-dvh bg-surface text-on-surface sm:flex',
'max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty(),
'min-h-dvh bg-surface text-on-surface [--material-margin:1rem] medium:flex medium:[--material-margin:1.5rem]',
'max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty(),
])
>
<a
@@ -133,13 +151,13 @@
<div class="flex min-w-0 flex-1 flex-col">
{{ $top ?? '' }}
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 outline-none max-lg:overflow-x-clip max-sm:pb-(--material-bottom-bar)">
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 px-(--material-margin) outline-none max-expanded:overflow-x-clip max-medium:pb-(--material-bottom-bar)">
{{ $slot }}
</main>
</div>
@if ($barItems->isNotEmpty())
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 medium:hidden">
<x-livewire-material::navigation-bar :label="$label">
@foreach ($barItems as $item)
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
@@ -1,4 +1,4 @@
{{-- M3 Expressive's navigation rail: destinations down the start edge of a medium or wider
{{-- M3 Expressive's navigation rail: destinations down the start edge of a `medium` or wider
window, collapsed (96px, icon over label) or expanded (icon beside label in a full-width pill).
<div class="flex min-h-dvh">
@@ -29,8 +29,12 @@
- `modal` collapsed in the layout; the menu button (or `$store.rail.show()` from anywhere)
opens it expanded over a scrim, holding focus until Escape, the scrim, the menu button or
leaving the page closes it (Compose's ModalWideNavigationRail).
- `adaptive` — what `<x-app-shell>` uses: below `sm` nothing until `$store.rail.show()` slides
it in as a modal; from `sm` collapsed, opening as a modal; from `lg` collapsible.
- `adaptive` — what `<x-app-shell>` uses, one rail per M3 window size class: on a compact
window (below `medium`, 600px) nothing until `$store.rail.show()` slides it in as a modal;
at `medium` (600839) collapsed in the layout, opening as a modal; at `expanded` (8401199)
a standard rail, collapsed until its menu button expands it in place; from `large` (1200)
the same standard rail, expanded to begin with. A visitor who has used the menu button keeps
that choice in both standard bands.
Slots: `brand` beside the menu button, only while expanded; `header` under it — a FAB, drawn
as an extended FAB when expanded (`rail-collapsed:` below); the destinations in the default
@@ -19,7 +19,10 @@
(`livewire-material.rail.storage_key`, falling back to `rail.default`), and a collapsible
rail's width is CSS keyed on it (the `rail-collapsed:` variant). Set any later, a collapsed
rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
changes it.
changes it. <html data-rail-auto> rides with it and says nothing was stored — the value is only
`rail.default`, not a choice — so `<x-app-shell>`'s adaptive rail can start collapsed in the
expanded class (8401199) and expanded from large, as M3 asks, while still obeying a visitor
who has chosen. `$store.rail` drops it the first time they do.
With `theme.meta` on, the browser's own chrome follows too: the `content` of every
<meta name="theme-color"> without a `media` attribute — one is added to <head> when there is
@@ -72,12 +75,14 @@
var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; };
var choice = settings.default;
var rail = settings.rail.default;
var railChosen = false;
try {
var storedRail = localStorage.getItem(settings.rail.key);
if (storedRail === 'collapsed' || storedRail === 'expanded') {
rail = storedRail;
railChosen = true;
}
var stored = localStorage.getItem(settings.key);
@@ -114,6 +119,12 @@
root.setAttribute('data-theme-choice', choice);
root.setAttribute('data-rail-key', settings.rail.key);
root.setAttribute('data-rail', rail);
if (railChosen) {
root.removeAttribute('data-rail-auto');
} else {
root.setAttribute('data-rail-auto', '');
}
apply();
media.addEventListener('change', apply);
@@ -152,7 +163,7 @@
@endif
document.addEventListener('livewire:navigating', function (event) {
var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) {
var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-auto', 'data-rail-key'].map(function (name) {
return [name, root.getAttribute(name)];
});
+1 -1
View File
@@ -1,7 +1,7 @@
@extends('livewire-material::showcase.layout')
@section('content')
<div class="mx-auto w-full max-w-6xl space-y-12 px-4 pt-4 pb-16 sm:px-6">
<div class="mx-auto w-full max-w-6xl space-y-12 pt-4 pb-16">
<div class="max-w-3xl space-y-3">
<h1 class="type-headline-lg">Livewire Material</h1>
<p class="type-title-lg">Material 3 Expressive for Laravel and Livewire.</p>
+1 -1
View File
@@ -11,7 +11,7 @@
@endphp
@section('content')
<div class="mx-auto w-full max-w-6xl px-4 pt-4 pb-16 sm:px-6">
<div class="mx-auto w-full max-w-6xl pt-4 pb-16">
<header class="mb-6 space-y-1">
<p class="type-label-lg text-on-surface-variant">{{ $sections[$section]['group'] }}</p>
<h1 class="type-headline-lg">{{ $sections[$section]['title'] }}</h1>
+1 -1
View File
@@ -66,7 +66,7 @@
</header>
</x-slot:top>
<div class="mx-auto w-full max-w-5xl space-y-4 px-4 pb-6 sm:px-6">
<div class="mx-auto w-full max-w-5xl space-y-4 pb-6">
<p class="type-body-md text-on-surface-variant" data-test="shell-page">This is the {{ strtolower($current['title']) }} page.</p>
<x-livewire-material::list segmented>