diff --git a/resources/css/components/navigation.css b/resources/css/components/navigation.css index e64b44d8..a2f1db1b 100644 --- a/resources/css/components/navigation.css +++ b/resources/css/components/navigation.css @@ -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 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; } diff --git a/resources/js/navigation.js b/resources/js/navigation.js index 8987199b..51000f42 100644 --- a/resources/js/navigation.js +++ b/resources/js/navigation.js @@ -5,6 +5,9 @@ * localStorage. has already applied it before the first paint as * , 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) } }, })) diff --git a/resources/views/components/app-shell.blade.php b/resources/views/components/app-shell.blade.php index be0c2900..a95c2624 100644 --- a/resources/views/components/app-shell.blade.php +++ b/resources/views/components/app-shell.blade.php @@ -11,13 +11,31 @@ …the page… - - 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`: - ``. - - `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`, ). + 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`: + ``. + - **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`, ), 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`: `` 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 `
` with `wire:transition.navigate`, behind a skip link that is the first thing a keyboard reaches. The snackbar host (``) 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 @@
$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(), ]) > {{ $top ?? '' }} -
+
{{ $slot }}
@if ($barItems->isNotEmpty()) -
+
@foreach ($barItems as $item) diff --git a/resources/views/components/navigation-rail.blade.php b/resources/views/components/navigation-rail.blade.php index 8eece1c3..469d530c 100644 --- a/resources/views/components/navigation-rail.blade.php +++ b/resources/views/components/navigation-rail.blade.php @@ -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).
@@ -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 `` 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 `` 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 diff --git a/resources/views/components/theme-script.blade.php b/resources/views/components/theme-script.blade.php index 41fa9ecc..ba0acf67 100644 --- a/resources/views/components/theme-script.blade.php +++ b/resources/views/components/theme-script.blade.php @@ -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. rides with it and says nothing was stored — the value is only + `rail.default`, not a choice — so ``'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 without a `media` attribute — one is added to 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)]; }); diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php index eb70c1f8..582b06af 100644 --- a/resources/views/showcase/index.blade.php +++ b/resources/views/showcase/index.blade.php @@ -1,7 +1,7 @@ @extends('livewire-material::showcase.layout') @section('content') -
+

Livewire Material

Material 3 Expressive for Laravel and Livewire.

diff --git a/resources/views/showcase/section.blade.php b/resources/views/showcase/section.blade.php index 269fca53..271d27df 100644 --- a/resources/views/showcase/section.blade.php +++ b/resources/views/showcase/section.blade.php @@ -11,7 +11,7 @@ @endphp @section('content') -
+

{{ $sections[$section]['group'] }}

{{ $sections[$section]['title'] }}

diff --git a/resources/views/showcase/shell.blade.php b/resources/views/showcase/shell.blade.php index 96794fdf..9f92612e 100644 --- a/resources/views/showcase/shell.blade.php +++ b/resources/views/showcase/shell.blade.php @@ -66,7 +66,7 @@
-
+

This is the {{ strtolower($current['title']) }} page.