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:
co-authored by
Claude Fable 5.1
parent
17723d2a76
commit
fd1e063d4c
@@ -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 (840–1199): 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
@@ -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 (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
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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** (600–839): 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** (840–1199): 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` (600–839) collapsed in the layout, opening as a modal; at `expanded` (840–1199)
|
||||
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 (840–1199) 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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user