Let a rail hide when collapsed instead of narrowing

Plan step 25 (navigation Missing): M3's "Expanded behavior | Hide when
collapsed" was only implicit below `medium` in the adaptive rail.
`<x-navigation-rail hide-when-collapsed>` (collapsible and adaptive rails) takes
the rail out of the layout when it is collapsed; `$store.rail.show()` from an
app-bar menu button brings it back expanded over a scrim, sliding in, and its
own menu button docks it again. It does not reach the bands where the window,
not the visitor, collapses a rail (below `medium` for collapsible, `medium` for
adaptive), since M3's collapsed rail may never hide. Every `rail-collapsed`
branch now stops while the rail is open. `<x-app-shell hide-rail-when-collapsed>`
exposes it.

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 07:15:20 +02:00
co-authored by Claude Opus 5
parent a5acbe7f6b
commit 9c047a8fb1
8 changed files with 227 additions and 28 deletions
@@ -718,7 +718,7 @@ A visitor who has pressed the menu button keeps that choice in both standard ban
```
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the page's, also during a Livewire update request), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for the badge: "3 unread"), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
- Slots, each rendered once: `banner` (a bar across the whole window, above the rail and the page), `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the page's own bar, above the page and beside the rail), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`); `tall-bar` picks M3's 80px navigation bar over the 64px one; `hide-bar-on-scroll` lets the bar leave the window while the page scrolls down.
- Slots, each rendered once: `banner` (a bar across the whole window, above the rail and the page), `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the page's own bar, above the page and beside the rail), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`); `tall-bar` picks M3's 80px navigation bar over the 64px one; `hide-bar-on-scroll` lets the bar leave the window while the page scrolls down; `hide-rail-when-collapsed` takes the rail out of the layout from `expanded` when its menu button collapses it, instead of narrowing it to 96px — the only way back is `$store.rail.show()`, so the app bar then needs a menu button at every width.
- `banner` or `top`: M3's scaffold is bars, then rails, then panes. An application-wide bar — one search, one account menu, the same on every page — goes in `banner` and the rail starts under it; a bar that titles the page goes in `top`, beside the rail. Never both. A banner that pins itself to the top of the window says how tall it is (`style="--material-banner: 4rem"` on `<x-app-shell>`), so the rail sticks under it instead of behind it.
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. On a compact window nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `medium`).
- `--material-margin` is M3's window margin (16px compact, 24px from `medium`) and the content region already carries it, so a page inside the shell writes no gutters of its own; something that must reach the window's edges opts out with `-mx-(--material-margin)`.
@@ -766,7 +766,7 @@ 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 — or the word `narrow` for M3's other *collapsed* width, 80px against the default 96, where the items are their icons alone; the labels stay in the accessibility tree and a narrow rail still expands to 16rem), `align` (`top` default, or `center` for M3's centred destinations — preferred on a tablet; the menu button, brand and FAB stay at the top and the footer at the foot), `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.
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px — or the word `narrow` for M3's other *collapsed* width, 80px against the default 96, where the items are their icons alone; the labels stay in the accessibility tree and a narrow rail still expands to 16rem), `align` (`top` default, or `center` for M3's centred destinations — preferred on a tablet; the menu button, brand and FAB stay at the top and the footer at the foot), `hide-when-collapsed` (M3's immersive expanded behaviour, `collapsible` and `adaptive` only: collapsing the rail takes it out of the layout instead of narrowing it, and `$store.rail.show()` brings it back expanded over a scrim — so put a menu button in the app bar; the rail's own button then docks it again. Not below `medium` for a collapsible rail nor at `medium` for an adaptive one, where the window rather than the visitor collapses it and M3's collapsed rail may never hide), `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.
+84 -4
View File
@@ -48,12 +48,14 @@
* in the rail: those have to beat a utility, and a layer never does.
*/
/* Every branch below reads `:not([data-open])`: a rail open over a scrim is drawn expanded whatever
made it collapsed, which only `hide-when-collapsed` can bring about outside the modal modes. */
@custom-variant rail-collapsed {
&:where([data-navigation-rail='collapsed'], [data-navigation-rail='collapsed'] *) {
@slot;
}
&:where([data-rail='collapsed'] [data-navigation-rail='collapsible'], [data-rail='collapsed'] [data-navigation-rail='collapsible'] *) {
&:where([data-rail='collapsed'] [data-navigation-rail='collapsible']:not([data-open]), [data-rail='collapsed'] [data-navigation-rail='collapsible']:not([data-open]) *) {
@slot;
}
@@ -64,7 +66,7 @@
/* 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'] *) {
&:where([data-navigation-rail='collapsible']:not([data-open]), [data-navigation-rail='collapsible']:not([data-open]) *) {
@slot;
}
}
@@ -80,14 +82,14 @@
`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'] *) {
&:where(:is([data-rail='collapsed'], [data-rail-auto]) [data-navigation-rail='adaptive']:not([data-open]), :is([data-rail='collapsed'], [data-rail-auto]) [data-navigation-rail='adaptive']:not([data-open]) *) {
@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'] *) {
&:where([data-rail='collapsed'] [data-navigation-rail='adaptive']:not([data-open]), [data-rail='collapsed'] [data-navigation-rail='adaptive']:not([data-open]) *) {
@slot;
}
}
@@ -239,6 +241,18 @@
--navigation-rail-collapsed-width: 5rem;
}
/* "Expanded behavior | Hide when collapsed" (the reference's Navigation rail § Variants and
configurations): instead of narrowing, the rail leaves the layout entirely — for an
immersive page — and comes back over it when a menu button somewhere else, the app bar's,
calls `$store.rail.show()`. It is the *expanded* rail that hides, which is why what comes
back is the expanded rail: "collapsed rail may not hide", and the two bands where a window
size class rather than the visitor collapses one are given back below. */
[data-navigation-rail][data-hide-when-collapsed] {
@variant rail-collapsed {
width: 0;
}
}
/* There is no room for a label under the 56px indicator at 80px, so a narrow rail is the icons
alone. The label is taken out of the drawing, not out of the page: it is what names the
destination for a screen reader. */
@@ -381,6 +395,72 @@
}
}
/* A rail that hides when collapsed is in the same position: nothing is left in the layout for
it to grow out of, so it takes the compact window's slide-in. The whole treatment is under
the `rail-collapsed` variant, which stops the moment the rail is open — that is what makes
it the expanded rail that comes back, on the rule above. */
[data-navigation-rail][data-hide-when-collapsed] > [data-navigation-rail-panel] {
@variant rail-collapsed {
display: none;
translate: -100% 0;
transition:
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
display var(--md-sys-motion-effects-default-duration) allow-discrete;
&:dir(rtl) {
translate: 100% 0;
}
}
}
[data-navigation-rail][data-hide-when-collapsed][data-open] > [data-navigation-rail-panel] {
display: flex;
translate: 0 0;
transition:
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate),
display var(--md-sys-motion-spatial-default-duration) allow-discrete;
@starting-style {
translate: -100% 0;
}
&:dir(rtl) {
translate: 0 0;
@starting-style {
translate: 100% 0;
}
}
}
/* The two bands the configuration does not reach, because there it is the window and not the
visitor that collapses the rail: below `medium` for a collapsible rail — where a rail is
held at its collapsed width anyway — and `medium` itself for the adaptive one, which M3
gives a collapsed rail. (Below `medium` the adaptive rail is already away, over the page,
and the block above it says so.) resources/js/navigation.js reads the same two numbers, so
the menu button and the drawing agree at every width. */
@media (width < 37.5rem) {
[data-navigation-rail='collapsible'][data-hide-when-collapsed] {
width: var(--navigation-rail-collapsed-width);
}
[data-navigation-rail='collapsible'][data-hide-when-collapsed] > [data-navigation-rail-panel] {
display: flex;
translate: none;
}
}
@media (37.5rem <= width < 52.5rem) {
[data-navigation-rail='adaptive'][data-hide-when-collapsed] {
width: var(--navigation-rail-collapsed-width);
}
[data-navigation-rail='adaptive'][data-hide-when-collapsed] > [data-navigation-rail-panel] {
display: flex;
translate: none;
}
}
[data-navigation-rail-scrim] {
position: fixed;
inset: 0;
+63 -16
View File
@@ -13,12 +13,12 @@
* button opens it over a scrim (`show()`), and Escape, the scrim or leaving the page closes it
* (`hide()`). It is never remembered.
*
* `materialNavigationRail` is one rail's view of the store for its `mode` — see
* resources/views/components/navigation-rail.blade.php.
* `materialNavigationRail` is one rail's view of the store for its `mode` and whether it hides
* when collapsed — see resources/views/components/navigation-rail.blade.php.
*
* `materialNavigationBar` is `<x-navigation-bar hide-on-scroll>` — see the same file's sibling.
*/
import { from } from './breakpoints.js'
import { from, upTo } from './breakpoints.js'
/*
* The active indicator grows out of its centre when a page arrives through wire:navigate. The
@@ -96,14 +96,29 @@ document.addEventListener('alpine:init', () => {
// A destination chosen in the modal rail leaves the page; the next one starts with it shut.
document.addEventListener('livewire:navigating', () => window.Alpine.store('rail').hide())
window.Alpine.data('materialNavigationRail', (mode) => ({
window.Alpine.data('materialNavigationRail', (mode, hideWhenCollapsed = false) => ({
wide: mode === 'adaptive' ? from('expanded').matches : false,
roomy: mode === 'adaptive' ? from('large').matches : false,
tight: mode === 'collapsible' ? upTo('medium').matches : false,
queries: [],
listeners: [],
init() {
if (mode !== 'adaptive') {
// Below `medium` a collapsible rail is held at its collapsed width whatever the
// visitor chose, so `hide-when-collapsed` does not reach it there.
// A drawer left open as the window narrows past it is shut, as the adaptive
// rail's is below, or it would spring open again the next time the rail is away.
if (mode === 'collapsible' && hideWhenCollapsed) {
this.watch(upTo('medium'), (matches) => {
this.tight = matches
if (matches) {
this.$store.rail.hide()
}
})
}
return
}
@@ -136,17 +151,9 @@ document.addEventListener('alpine:init', () => {
this.queries.forEach((query, index) => query.removeEventListener('change', this.listeners[index]))
},
/** Whether this rail expands over a scrim rather than in the layout. */
get modal() {
return mode === 'modal' || (mode === 'adaptive' && !this.wide)
},
get open() {
return this.modal && this.$store.rail.open
},
get expanded() {
if (this.open || mode === 'expanded') {
/** What this rail's mode and the visitor's choice make of it, before anything opens it. */
get standing() {
if (mode === 'expanded') {
return true
}
@@ -163,9 +170,49 @@ document.addEventListener('alpine:init', () => {
return mode === 'collapsible'
},
/** Whether the mode or the window leaves no room for an expanded rail in the layout. */
get cramped() {
return mode === 'modal' || (mode === 'adaptive' && !this.wide)
},
/**
* Whether the rail has left the layout altogether — `hide-when-collapsed`, once the
* visitor collapses it. Not in the two bands where it is the window size class and not the
* visitor that collapses a rail: M3's "collapsed rail may not hide". The same two numbers
* are in resources/css/components/navigation.css.
*/
get away() {
if (!hideWhenCollapsed || this.cramped || this.standing) {
return false
}
return mode === 'adaptive' ? this.wide : !this.tight
},
/** Whether this rail expands over a scrim rather than in the layout. */
get modal() {
// A rail that is away has nothing left in the layout to expand, so the menu button
// that brings it back — the app bar's — opens it over the page.
return this.cramped || this.away
},
get open() {
return this.modal && this.$store.rail.open
},
get expanded() {
return this.open || this.standing
},
/** The rail's own menu button: open or close the modal, or collapse and expand in place. */
menu() {
if (this.modal) {
if (this.open && this.away) {
// This rail is only over the page because it hid itself, so the button docks it
// back into the layout — the same "expand" it means on a rail that is standing
// there. Expanding drops `away`, which closes the drawer behind it.
this.$store.rail.expand()
this.$store.rail.hide()
} else if (this.modal) {
this.$store.rail.open ? this.$store.rail.hide() : this.$store.rail.show()
} else {
// `set`, not `toggle`: with nothing stored the store's `collapsed` is only
@@ -56,7 +56,11 @@
phone opens. `label` names both navigation landmarks ("Main"); `rail-width` is the expanded
rail's width; `tall-bar` picks M3's 80px navigation bar over the 64px one, and the bottom
offset every pinned thing reads follows it; `hide-bar-on-scroll` lets the bar leave the window
while the page scrolls down, and `--material-bottom-bar` goes down and comes back with it.
while the page scrolls down, and `--material-bottom-bar` goes down and comes back with it;
`hide-rail-when-collapsed` is M3's immersive configuration — from `expanded` the rail leaves
the layout when the menu button collapses it, rather than narrowing to 96px, so the page has
the whole window. The only way back is `$store.rail.show()`, so put a menu button in the app
bar at every width, not just below `medium`.
`banner` or `top` is a decision about what the bar belongs to: an application-wide bar — one
search, one account menu, the same on every page — spans the window and the rail starts under
@@ -86,6 +90,7 @@
'railWidth' => '16rem',
'tallBar' => false,
'hideBarOnScroll' => false,
'hideRailWhenCollapsed' => false,
])
@php
@@ -132,7 +137,7 @@
@endisset
<div class="flex-1 medium:flex">
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth" :hide-when-collapsed="$hideRailWhenCollapsed">
@isset($brand)
<x-slot:brand>{{ $brand }}</x-slot:brand>
@endisset
@@ -60,7 +60,13 @@
puts the destinations at the rail's vertical centre M3 prefers that on a tablet, for reach
while the menu button, the brand and the FAB stay at the top and the footer at the foot, as M3
asks; more destinations than fit go back to the top rather than out of reach above the
scroller; `menu` shows the menu button (by default for `collapsible`,
scroller; `hide-when-collapsed` is M3's other expanded behaviour, for a `collapsible` or
`adaptive` rail: collapsing it takes it out of the layout altogether instead of narrowing it
to 96px, and it comes back expanded over a scrim when something calls `$store.rail.show()` —
a menu button in the app bar, which is the only way back, so put one there. Its own menu
button then docks it into the layout again. Not below `medium` for a collapsible rail, nor at
`medium` for an adaptive one: there it is the window and not the visitor that collapses a
rail, and M3's collapsed rail may never hide; `menu` shows the menu button (by default for `collapsible`,
`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,
@@ -83,6 +89,7 @@
'label' => null,
'width' => '16rem',
'align' => 'top',
'hideWhenCollapsed' => false,
'menu' => null,
'divider' => false,
'fill' => true,
@@ -91,7 +98,10 @@
@php
$mode = in_array($mode, ['collapsed', 'expanded', 'collapsible', 'modal', 'adaptive'], true) ? $mode : 'collapsible';
$interactive = in_array($mode, ['collapsible', 'modal', 'adaptive'], true);
$canOpen = in_array($mode, ['modal', 'adaptive'], true);
// Only a rail that has a collapsed *and* an expanded state of its own can hide instead of
// narrowing; a `modal` one is already over the page, and the two fixed modes mean what they say.
$hideWhenCollapsed = $hideWhenCollapsed && in_array($mode, ['collapsible', 'adaptive'], true);
$canOpen = in_array($mode, ['modal', 'adaptive'], true) || $hideWhenCollapsed;
$menu ??= $interactive;
$collapsedAtFirst = in_array($mode, ['collapsed', 'modal'], true);
$narrow = $width === 'narrow';
@@ -103,10 +113,11 @@
data-navigation-rail="{{ $mode }}"
@if ($narrow) data-width="narrow" @endif
@if ($centred) data-align="center" @endif
@if ($hideWhenCollapsed) data-hide-when-collapsed @endif
@if ($divider) data-divider @endif
@unless ($fill) data-fill="false" @endunless
@if ($interactive)
x-data="materialNavigationRail('{{ $mode }}')"
x-data="materialNavigationRail('{{ $mode }}', {{ $hideWhenCollapsed ? 'true' : 'false' }})"
x-bind:data-open="open"
@endif
{{ $attributes->merge(['style' => "--navigation-rail-width: {$width}"]) }}
@@ -138,6 +138,24 @@
</div>
</div>
BLADE,
'Rail that hides when collapsed' => <<<'BLADE'
<div class="flex h-[30rem] w-full overflow-hidden rounded-corner-lg border border-outline-variant">
<x-navigation-rail label="Immersive example" hide-when-collapsed>
<x-slot:brand>
<span class="block truncate type-title-lg">Studio</span>
</x-slot:brand>
<x-navigation-rail-item label="Canvas" icon="brush" link="#navigation" no-wire-navigate active />
<x-navigation-rail-item label="Layers" icon="layers" link="#navigation" no-wire-navigate />
<x-navigation-rail-item label="Exports" icon="download" link="#navigation" no-wire-navigate />
</x-navigation-rail>
<div class="min-w-0 flex-1 space-y-4 bg-surface-container-low p-6 type-body-md text-on-surface-variant">
<p>M3's other expanded behaviour: collapsing this rail takes it out of the layout instead of narrowing it to 96px, so an immersive page gets the whole window. The only way back is a menu button of the application's own — its own button then docks it again.</p>
<x-button icon="menu" label="Open navigation" x-on:click="$store.rail.show()" />
</div>
</div>
BLADE,
];
@endphp
@@ -115,6 +115,15 @@ it('lets the bar leave the window while the page scrolls down', function () {
->not->toContain('data-hide-on-scroll');
});
it('exposes the rail that hides when collapsed', function () {
expect((string) $this->blade('<x-app-shell :destinations="$destinations" hide-rail-when-collapsed />', ['destinations' => shellDestinations()]))
->toContain('data-hide-when-collapsed')
->toContain("materialNavigationRail('adaptive', true)")
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
->not->toContain('data-hide-when-collapsed')
->toContain("materialNavigationRail('adaptive', false)");
});
it('reads the safe area and anything docked on the bar through variables an application can set', function () {
$html = (string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]);
@@ -18,7 +18,7 @@ it('gives the collapsible, modal and adaptive rails a menu button and the store'
$collapsible = (string) $this->blade('<x-navigation-rail />');
expect($collapsible)
->toContain('x-data="materialNavigationRail(\'collapsible\')"')
->toContain('x-data="materialNavigationRail(\'collapsible\', false)"')
->toContain('data-navigation-rail-menu')
->toContain('aria-label="Collapse navigation"')
->toContain('aria-expanded="true"')
@@ -102,6 +102,35 @@ it('centres the destinations on request, leaving the header and footer at their
->toMatch("/\\[data-navigation-rail\\]\\[data-align='center'\\] \\[data-navigation-rail-destinations\\] \\{\\s+justify-content: safe center;/");
});
it('hides a collapsible or adaptive rail entirely when told to, and only those', function () {
$hiding = (string) $this->blade('<x-navigation-rail hide-when-collapsed />');
expect($hiding)
->toContain('data-hide-when-collapsed')
->toContain("x-data=\"materialNavigationRail('collapsible', true)\"")
// It needs the scrim and the focus trap a modal rail has: it comes back over the page.
->toContain('data-navigation-rail-scrim')
->toContain('x-trap.inert.noscroll="open"')
->and((string) $this->blade('<x-navigation-rail mode="adaptive" hide-when-collapsed />'))
->toContain("x-data=\"materialNavigationRail('adaptive', true)\"")
// A modal rail is already over the page; the two fixed modes mean what they say.
->and((string) $this->blade('<x-navigation-rail mode="modal" hide-when-collapsed />'))
->not->toContain('data-hide-when-collapsed')
->toContain("materialNavigationRail('modal', false)")
->and((string) $this->blade('<x-navigation-rail mode="expanded" hide-when-collapsed />'))
->not->toContain('data-hide-when-collapsed')
->and((string) $this->blade('<x-navigation-rail />'))->not->toContain('data-hide-when-collapsed');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
->toMatch('/\[data-navigation-rail\]\[data-hide-when-collapsed\] \{\s+@variant rail-collapsed \{\s+width: 0;/')
// The two bands where the window, not the visitor, collapses a rail keep theirs.
->toMatch("/@media \\(width < 37\\.5rem\\) \\{\\s+\\[data-navigation-rail='collapsible'\\]\\[data-hide-when-collapsed\\]/")
->toMatch("/@media \\(37\\.5rem <= width < 52\\.5rem\\) \\{\\s+\\[data-navigation-rail='adaptive'\\]\\[data-hide-when-collapsed\\]/")
// Every collapsed branch stops while the rail is open, or what comes back would be narrow.
->and(substr_count(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'), ':not([data-open])'))
->toBeGreaterThanOrEqual(12);
});
it('flattens a FAB nested in the rail header and morphs its label', function () {
// Both rules hang off `data-fab` on <x-fab>'s root, and both are unlayered, because what they
// beat — the FAB's shadow, its gap, an extended FAB's minimum width — are utilities.