Morph the rail's FAB, and offer a divider, no fill and a width floor

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 <x-fab label icon> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:16:37 +02:00
co-authored by Claude Opus 5
parent 1218371568
commit 7d14f2bd03
7 changed files with 114 additions and 29 deletions
@@ -696,8 +696,7 @@ A visitor who has pressed the menu button keeps that choice in both standard ban
]">
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
<x-slot:rail-header>
<span class="rail-collapsed:hidden"><x-fab label="New share" icon="add" link="{{ route('upload') }}" /></span>
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="add" tooltip-right="New share" link="{{ route('upload') }}" /></span>
<x-fab label="New share" icon="add" link="{{ route('upload') }}" />
</x-slot:rail-header>
<x-slot:rail-footer>
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
@@ -743,7 +742,7 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
<div class="flex min-h-dvh">
<x-navigation-rail mode="collapsible">
<x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
<x-slot:header><x-fab icon="add" tooltip-right="New share" /></x-slot:header>
<x-slot:header><x-fab label="New share" icon="add" /></x-slot:header>
<x-navigation-rail-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" active badge="3" />
<x-navigation-rail-section label="Admin">
@@ -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` (`<x-app-shell>`'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: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. 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 `<x-fab label icon>`, 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: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. 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.
- `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: 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`).
+56
View File
@@ -59,6 +59,14 @@
@slot;
}
/* Below `medium` a 220360px 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 <x-fab label icon> 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;
}
}
@@ -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 `<x-navigation-rail>` 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 `<x-fab label="…" icon="…">`, 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
@@ -5,7 +5,7 @@
<x-navigation-rail mode="collapsible">
<x-slot:brand><span class="type-title-lg">Mail</span></x-slot:brand>
<x-slot:header>
<x-fab icon="edit" tooltip-right="Compose" />
<x-fab label="Compose" icon="edit" />
</x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
@@ -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
`<x-fab label="…" icon="…">`, 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:
`<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>`
`<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>`.
Anything else inside can take both shapes with the `rail-collapsed:` variant, which applies
while the rail is drawn collapsed for whatever reason:
`<span class="rail-collapsed:hidden">…only while expanded…</span>`
`<span class="hidden rail-collapsed:inline-flex">…only while collapsed…</span>`.
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 @@
<div
data-navigation-rail="{{ $mode }}"
@if ($divider) data-divider @endif
@unless ($fill) data-fill="false" @endunless
@if ($interactive)
x-data="materialNavigationRail('{{ $mode }}')"
x-bind:data-open="open"
@@ -34,9 +34,9 @@
'Collapsed and expanded rails' => <<<'BLADE'
<div class="flex w-full flex-wrap items-start gap-6">
<div class="flex h-[36rem] overflow-hidden rounded-corner-lg border border-outline-variant">
<x-navigation-rail mode="collapsed" label="Collapsed example">
<x-navigation-rail mode="collapsed" label="Collapsed example" divider>
<x-slot:header>
<x-fab icon="edit" tooltip-right="Compose" />
<x-fab label="Compose" icon="edit" />
</x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
@@ -82,8 +82,7 @@
</x-slot:brand>
<x-slot:header>
<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>
<x-fab label="Compose" icon="edit" />
</x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
@@ -92,7 +91,7 @@
</x-navigation-rail>
<div class="min-w-0 flex-1 bg-surface-container-low p-6 type-body-md text-on-surface-variant">
The menu button collapses and expands the rail. The choice is remembered and applied before the next page paints every collapsible rail follows it, <code>&lt;x-app-shell&gt;</code>'s from <code>lg</code> 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, <code>&lt;x-app-shell&gt;</code>'s from <code>expanded</code> (840px) too.
</div>
</div>
BLADE,
@@ -104,7 +103,7 @@
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-navigation-bar&gt;</code> and <code>&lt;x-navigation-bar-item&gt;</code>, <code>&lt;x-navigation-rail&gt;</code>, <code>&lt;x-navigation-rail-item&gt;</code> and <code>&lt;x-navigation-rail-section&gt;</code>,
and <code>&lt;x-app-shell&gt;</code>, which puts them together: a bar below <code>sm</code>, a collapsed rail that opens as a modal to <code>lg</code>, a collapsible rail from there.
and <code>&lt;x-app-shell&gt;</code>, which puts them together on M3's window size classes: a bar below <code>medium</code> (600px), a collapsed rail that opens as a modal through <code>medium</code>, a standard rail from <code>expanded</code> (840px) and an expanded one from <code>large</code> (1200px).
<a href="{{ route('livewire-material.shell') }}" class="link text-primary">Open the app shell</a> and change the window's width.
</p>
+1 -2
View File
@@ -45,8 +45,7 @@
</x-slot:brand>
<x-slot:rail-header>
<span class="rail-collapsed:hidden"><x-livewire-material::fab label="Compose" icon="edit" x-on:click="materialToast('Compose opens here')" /></span>
<span class="hidden rail-collapsed:inline-flex"><x-livewire-material::fab icon="edit" tooltip-right="Compose" x-on:click="materialToast('Compose opens here')" /></span>
<x-livewire-material::fab label="Compose" icon="edit" x-on:click="materialToast('Compose opens here')" />
</x-slot:rail-header>
<x-slot:rail-footer>
@@ -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('<x-navigation-rail mode="expanded" divider :fill="false" />'))
->toContain('data-divider')
->toContain('data-fill="false"')
->and((string) $this->blade('<x-navigation-rail mode="expanded" />'))
->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'
<x-navigation-rail mode="expanded">
@@ -58,12 +74,14 @@ it('flattens a FAB nested in the rail header to elevation 0', function () {
</x-navigation-rail>
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 () {