From 7d14f2bd034e3ae8e940a72e2e82bfff86279799 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:16:23 +0200 Subject: [PATCH] Morph the rail's FAB, and offer a divider, no fill and a width floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M3 asks the rail to animate its FAB into an extended FAB as it expands; the documented pattern was two FABs swapped by `display`, so the collapsed one appeared at once while the rail's width was still springing. The header now takes one whose label springs open and shut on the rail's own spring — and names it at both widths, where the collapsed FAB had needed a tooltip to have a name at all. M3 also lists two container treatments as optional that the library had neither of: `divider` draws the vertical divider on the page's edge, which is its own answer to a page scrolling under a fixed rail, and `fill="false"` drops the container colour. Neither reaches a rail open over a scrim. And a `collapsible` rail, whose width is the visitor's choice rather than the window's, is held to the collapsed 96px below `medium`, where M3 asks for a navigation bar instead. Plan: docs/plans/material-3-alignment.md, step 21 (navigation N-22, N-23, N-24). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 10 ++-- resources/css/components/navigation.css | 56 +++++++++++++++++++ .../views/components/app-shell.blade.php | 5 +- .../components/navigation-rail.blade.php | 36 ++++++++---- .../showcase/sections/navigation.blade.php | 11 ++-- resources/views/showcase/shell.blade.php | 3 +- .../Feature/Components/NavigationRailTest.php | 22 +++++++- 7 files changed, 114 insertions(+), 29 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 26709755..b15395c9 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -696,8 +696,7 @@ A visitor who has pressed the menu button keeps that choice in both standard ban ]"> SealShare - - + @@ -743,7 +742,7 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
SealShare - + @@ -760,8 +759,9 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded ( ``` - `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (``'s, one rail per window size class: hidden and opened as a modal on a compact window, collapsed and opened as a modal at `medium`, a standard rail from `expanded` — collapsed there, expanded from `large`). -- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`). Slots: `brand` (beside the menu button, expanded only), `header` (a FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport. -- Anything inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `…expanded only…`, ``. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px. +- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`), `divider` (M3's optional vertical divider on the page's side — use it when the page scrolls under a fixed rail), `fill` (`false` for a transparent container, which M3 allows while the items keep 3:1 contrast). Slots: `brand` (beside the menu button, expanded only), `header` (one ``, which the rail morphs into an extended FAB and back as it expands — it also rests at elevation 0, as M3 asks of a nested FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport. +- Anything else inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `…expanded only…`, ``. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px. +- A `collapsible` rail is held to the collapsed 96px below `medium` (600px), where M3 says to use a navigation bar rather than a standard rail. `collapsed` and `expanded` are fixed-width by design: wrap one in a `medium:` element if it must not show on a phone. - ``: the same props as ``. ``: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way. - `$store.rail`: `collapsed`, `toggle()`, `collapse()`, `expand()` (the remembered choice; `auto` is true while nothing is stored, so an adaptive rail takes its window size class's default instead, and the first choice clears it), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php` → `rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`). diff --git a/resources/css/components/navigation.css b/resources/css/components/navigation.css index 237c446c..d8374d15 100644 --- a/resources/css/components/navigation.css +++ b/resources/css/components/navigation.css @@ -59,6 +59,14 @@ @slot; } + /* Below `medium` a 220–360px rail would be most of the screen, so a rail whose width is the + visitor's choice is held collapsed there whatever they chose. */ + @media (width < 37.5rem) { + &:where([data-navigation-rail='collapsible'], [data-navigation-rail='collapsible'] *) { + @slot; + } + } + @media (width < 52.5rem) { &:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) { @slot; @@ -215,6 +223,14 @@ [data-navigation-rail='adaptive'] { width: 0; } + + /* "Compact → don't use a standard rail, use a navigation bar." A `collapsible` rail takes + the width the visitor chose, which on a 360px screen would be two-thirds of it, so it + is floored at the collapsed 96px. `collapsed` and `expanded` mean what they say and are + left alone: wrap one in a `medium:` element if it must not show on a phone. */ + [data-navigation-rail='collapsible'] { + width: 6rem; + } } /* `--material-banner` is the height of a bar pinned across the top of the window — what @@ -241,6 +257,18 @@ background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default); } + /* M3's two optional container treatments: "optional vertical divider separates rail from + content, placed on the content-adjacent edge" — also its answer to a page scrolling under a + fixed rail — and "container fill can be turned off (transparent) as long as items keep ≥3:1 + contrast". Neither applies to a rail open over a scrim, which is a surface over the page. */ + [data-navigation-rail][data-divider]:not([data-open]) > [data-navigation-rail-panel] { + border-inline-end: 1px solid var(--md-sys-color-outline-variant); + } + + [data-navigation-rail][data-fill='false']:not([data-open]) > [data-navigation-rail-panel] { + background-color: transparent; + } + /* Open: expanded over a scrim, in surface-container with a large corner at its inner edge. */ [data-navigation-rail][data-open] > [data-navigation-rail-panel] { position: fixed; @@ -627,3 +655,31 @@ [data-navigation-rail-header] [data-fab]:hover { box-shadow: none; } + +/* "Rail transitions from collapsed to expanded should animate the FAB into and out of an extended + * FAB" — so the header takes one and morphs it, rather than swapping two by + * `display`, which showed the collapsed one at once while the rail's width was still springing. + * The label's width springs on the rail's own spatial spring; with the gap closed, what is left is + * 16px of padding, the 24px icon and 16px again — the 56px square a collapsed FAB is. The label + * stays in the accessibility tree at both widths, so the FAB keeps its name. Unlayered for the + * same reason as the rule above: `gap-2` is a utility, and a layer would lose to it. The cap is a + * transition needs a length to travel to; a rail FAB's label is a word or two. */ +[data-navigation-rail-header] [data-fab] > span { + max-width: 16rem; + overflow: hidden; + white-space: nowrap; + transition: + max-width var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default), + opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default); + + @variant rail-collapsed { + max-width: 0; + opacity: 0; + } +} + +[data-navigation-rail-header] [data-fab] { + @variant rail-collapsed { + gap: 0; + } +} diff --git a/resources/views/components/app-shell.blade.php b/resources/views/components/app-shell.blade.php index c0843262..f0de2341 100644 --- a/resources/views/components/app-shell.blade.php +++ b/resources/views/components/app-shell.blade.php @@ -47,8 +47,9 @@ Slots, each rendered once: `banner` (a bar across the whole window, above the rail and the page — M3's scaffold is bars, then rails, then panes), `brand` (beside the rail's menu button - while it is expanded), `rail-header` (under it: a FAB — see `` for its two - shapes), `rail-footer` (at the foot of the rail: footer destinations, an account), `actions` + while it is expanded), `rail-header` (under it: one ``, which the + rail morphs between a FAB and an extended FAB as it opens), `rail-footer` (at the foot of the + rail: footer destinations, an account), `actions` (a row of icon buttons at the very foot, stacked when the rail is collapsed: a theme toggle, sign out), `top` (the page's own bar, above the page and *beside* the rail) and the page itself. The rail is one element at every width, so what is in it is also in the modal rail a diff --git a/resources/views/components/navigation-rail.blade.php b/resources/views/components/navigation-rail.blade.php index 1f712275..78bb26e2 100644 --- a/resources/views/components/navigation-rail.blade.php +++ b/resources/views/components/navigation-rail.blade.php @@ -5,7 +5,7 @@ Mail - + @@ -36,22 +36,30 @@ 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 - slot, which alone scroll when the window is too short; `footer`, pinned to the foot. Header and - footer never scroll, so nothing in them is cut off by the scroller's edge. A FAB in the header - rests flat: M3 puts a nested FAB at elevation 0, not the 3 a standalone one has. + Slots: `brand` beside the menu button, only while expanded; `header` under it — one + ``, which the rail morphs: the label's width springs open and shut + with the rail, so the FAB becomes an extended FAB and back rather than one being swapped for + the other, and its label names it at both widths. It also rests flat, because M3 puts a FAB + nested in another component at elevation 0, not the 3 a standalone one has. Then the + destinations in the default slot, which alone scroll when the window is too short, and + `footer`, pinned to the foot. Header and footer never scroll, so nothing in them is cut off by + the scroller's edge. - Anything inside can take both shapes with the `rail-collapsed:` variant, which applies while - the rail is drawn collapsed for whatever reason: - `` - ``. + Anything else inside can take both shapes with the `rail-collapsed:` variant, which applies + while the rail is drawn collapsed for whatever reason: + `…only while expanded…` + ``. Nothing that shows while collapsed may be wider than 96px. Props: `label` names the landmark ("Main"); `width` is the expanded width (`16rem`, held between M3's 220 and 360dp); `menu` shows the menu button (by default for `collapsible`, - `modal` and `adaptive`). The rail does not scroll with the page: in a flex row it sticks to - the top of the viewport, as tall as the viewport at most. + `modal` and `adaptive`); `divider` draws M3's optional vertical divider on the edge the page + is on — which is also what M3 asks for when a page scrolls underneath a fixed rail; `fill` + (`false`) drops the container colour for a transparent rail over the page's own background, + which M3 allows as long as the items keep a 3:1 contrast against what is behind them. A rail + open over a scrim keeps its fill and drops the divider whatever those say: it is a surface + over the page then. The rail does not scroll with the page: in a flex row it sticks to the top + of the viewport, as tall as the viewport at most. Values from androidx Compose Material 3 (Apache-2.0), androidx-main 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationRailCollapsedTokens.kt, @@ -67,6 +75,8 @@ 'label' => null, 'width' => '16rem', 'menu' => null, + 'divider' => false, + 'fill' => true, ]) @php @@ -79,6 +89,8 @@
<<<'BLADE'
- + - + @@ -82,8 +82,7 @@ - - + @@ -92,7 +91,7 @@
- The menu button collapses and expands the rail. The choice is remembered and applied before the next page paints — every collapsible rail follows it, <x-app-shell>'s from lg too. + The menu button collapses and expands the rail, and the FAB morphs into an extended FAB with it. The choice is remembered and applied before the next page paints — every collapsible rail follows it, <x-app-shell>'s from expanded (840px) too.
BLADE, @@ -104,7 +103,7 @@

<x-navigation-bar> and <x-navigation-bar-item>, <x-navigation-rail>, <x-navigation-rail-item> and <x-navigation-rail-section>, - and <x-app-shell>, which puts them together: a bar below sm, a collapsed rail that opens as a modal to lg, a collapsible rail from there. + and <x-app-shell>, which puts them together on M3's window size classes: a bar below medium (600px), a collapsed rail that opens as a modal through medium, a standard rail from expanded (840px) and an expanded one from large (1200px). Open the app shell and change the window's width.

diff --git a/resources/views/showcase/shell.blade.php b/resources/views/showcase/shell.blade.php index 9f92612e..09190a86 100644 --- a/resources/views/showcase/shell.blade.php +++ b/resources/views/showcase/shell.blade.php @@ -45,8 +45,7 @@ - - + diff --git a/tests/Feature/Components/NavigationRailTest.php b/tests/Feature/Components/NavigationRailTest.php index 4f0c54e3..17df10d3 100644 --- a/tests/Feature/Components/NavigationRailTest.php +++ b/tests/Feature/Components/NavigationRailTest.php @@ -51,6 +51,22 @@ it('keeps the header and footer out of the scrolling destinations', function () ->toMatch('/data-navigation-rail-header.*Brand.*FAB.*data-navigation-rail-destinations.*Inbox.*data-navigation-rail-footer.*Account/s'); }); +it('takes M3\'s optional divider and turns the container fill off', function () { + expect((string) $this->blade('')) + ->toContain('data-divider') + ->toContain('data-fill="false"') + ->and((string) $this->blade('')) + ->not->toContain('data-divider') + ->not->toContain('data-fill'); + + expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css')) + // Neither reaches a rail open over a scrim, which is a surface over the page (N-22). + ->toContain('[data-navigation-rail][data-divider]:not([data-open]) > [data-navigation-rail-panel] {') + ->toContain("[data-navigation-rail][data-fill='false']:not([data-open]) > [data-navigation-rail-panel] {") + // A collapsible rail is held to 96px where M3 asks for a navigation bar instead (N-24). + ->toMatch("/\\[data-navigation-rail='collapsible'\\] \\{\\s+width: 6rem;/"); +}); + it('flattens a FAB nested in the rail header to elevation 0', function () { $html = (string) $this->blade(<<<'BLADE' @@ -58,12 +74,14 @@ it('flattens a FAB nested in the rail header to elevation 0', function () { BLADE); - // The hook the unlayered rule in navigation.css needs (N-03). + // The hook the unlayered rules in navigation.css need (N-03). expect($html)->toContain('data-fab'); expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css')) ->toContain('[data-navigation-rail-header] [data-fab],') - ->toContain('[data-navigation-rail-header] [data-fab]:hover {'); + ->toContain('[data-navigation-rail-header] [data-fab]:hover {') + // One FAB whose label springs shut, not two swapped by display (N-23). + ->toContain('[data-navigation-rail-header] [data-fab] > span {'); }); it('draws a destination in both shapes, with its count on the icon and at the end', function () {