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`). - `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. - `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`). - 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)`. - `--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`). - `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. - 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. - 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. - `<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. * 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 { @custom-variant rail-collapsed {
&:where([data-navigation-rail='collapsed'], [data-navigation-rail='collapsed'] *) { &:where([data-navigation-rail='collapsed'], [data-navigation-rail='collapsed'] *) {
@slot; @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; @slot;
} }
@@ -64,7 +66,7 @@
/* Below `medium` a 220360px rail would be most of the screen, so a rail whose width is the /* 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. */ visitor's choice is held collapsed there whatever they chose. */
@media (width < 37.5rem) { @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; @slot;
} }
} }
@@ -80,14 +82,14 @@
`data-rail-auto`, which <x-theme-script> sets while nothing is stored, stands for "no `data-rail-auto`, which <x-theme-script> sets while nothing is stored, stands for "no
choice yet" and the class's own default applies. */ choice yet" and the class's own default applies. */
@media (52.5rem <= width < 75rem) { @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; @slot;
} }
} }
/* Large and extra-large (from 1200): expanded to begin with, which is what M3 prefers there. */ /* Large and extra-large (from 1200): expanded to begin with, which is what M3 prefers there. */
@media (width >= 75rem) { @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; @slot;
} }
} }
@@ -239,6 +241,18 @@
--navigation-rail-collapsed-width: 5rem; --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 /* 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 alone. The label is taken out of the drawing, not out of the page: it is what names the
destination for a screen reader. */ 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] { [data-navigation-rail-scrim] {
position: fixed; position: fixed;
inset: 0; 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 * button opens it over a scrim (`show()`), and Escape, the scrim or leaving the page closes it
* (`hide()`). It is never remembered. * (`hide()`). It is never remembered.
* *
* `materialNavigationRail` is one rail's view of the store for its `mode` — see * `materialNavigationRail` is one rail's view of the store for its `mode` and whether it hides
* resources/views/components/navigation-rail.blade.php. * when collapsed — see resources/views/components/navigation-rail.blade.php.
* *
* `materialNavigationBar` is `<x-navigation-bar hide-on-scroll>` — see the same file's sibling. * `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 * 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. // 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()) 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, wide: mode === 'adaptive' ? from('expanded').matches : false,
roomy: mode === 'adaptive' ? from('large').matches : false, roomy: mode === 'adaptive' ? from('large').matches : false,
tight: mode === 'collapsible' ? upTo('medium').matches : false,
queries: [], queries: [],
listeners: [], listeners: [],
init() { init() {
if (mode !== 'adaptive') { 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 return
} }
@@ -136,17 +151,9 @@ document.addEventListener('alpine:init', () => {
this.queries.forEach((query, index) => query.removeEventListener('change', this.listeners[index])) this.queries.forEach((query, index) => query.removeEventListener('change', this.listeners[index]))
}, },
/** Whether this rail expands over a scrim rather than in the layout. */ /** What this rail's mode and the visitor's choice make of it, before anything opens it. */
get modal() { get standing() {
return mode === 'modal' || (mode === 'adaptive' && !this.wide) if (mode === 'expanded') {
},
get open() {
return this.modal && this.$store.rail.open
},
get expanded() {
if (this.open || mode === 'expanded') {
return true return true
} }
@@ -163,9 +170,49 @@ document.addEventListener('alpine:init', () => {
return mode === 'collapsible' 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. */ /** The rail's own menu button: open or close the modal, or collapse and expand in place. */
menu() { 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() this.$store.rail.open ? this.$store.rail.hide() : this.$store.rail.show()
} else { } else {
// `set`, not `toggle`: with nothing stored the store's `collapsed` is only // `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 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 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 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 `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 search, one account menu, the same on every page — spans the window and the rail starts under
@@ -86,6 +90,7 @@
'railWidth' => '16rem', 'railWidth' => '16rem',
'tallBar' => false, 'tallBar' => false,
'hideBarOnScroll' => false, 'hideBarOnScroll' => false,
'hideRailWhenCollapsed' => false,
]) ])
@php @php
@@ -132,7 +137,7 @@
@endisset @endisset
<div class="flex-1 medium:flex"> <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) @isset($brand)
<x-slot:brand>{{ $brand }}</x-slot:brand> <x-slot:brand>{{ $brand }}</x-slot:brand>
@endisset @endisset
@@ -60,7 +60,13 @@
puts the destinations at the rail's vertical centre M3 prefers that on a tablet, for reach 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 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 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 `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` 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, (`false`) drops the container colour for a transparent rail over the page's own background,
@@ -83,6 +89,7 @@
'label' => null, 'label' => null,
'width' => '16rem', 'width' => '16rem',
'align' => 'top', 'align' => 'top',
'hideWhenCollapsed' => false,
'menu' => null, 'menu' => null,
'divider' => false, 'divider' => false,
'fill' => true, 'fill' => true,
@@ -91,7 +98,10 @@
@php @php
$mode = in_array($mode, ['collapsed', 'expanded', 'collapsible', 'modal', 'adaptive'], true) ? $mode : 'collapsible'; $mode = in_array($mode, ['collapsed', 'expanded', 'collapsible', 'modal', 'adaptive'], true) ? $mode : 'collapsible';
$interactive = in_array($mode, ['collapsible', 'modal', 'adaptive'], true); $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; $menu ??= $interactive;
$collapsedAtFirst = in_array($mode, ['collapsed', 'modal'], true); $collapsedAtFirst = in_array($mode, ['collapsed', 'modal'], true);
$narrow = $width === 'narrow'; $narrow = $width === 'narrow';
@@ -103,10 +113,11 @@
data-navigation-rail="{{ $mode }}" data-navigation-rail="{{ $mode }}"
@if ($narrow) data-width="narrow" @endif @if ($narrow) data-width="narrow" @endif
@if ($centred) data-align="center" @endif @if ($centred) data-align="center" @endif
@if ($hideWhenCollapsed) data-hide-when-collapsed @endif
@if ($divider) data-divider @endif @if ($divider) data-divider @endif
@unless ($fill) data-fill="false" @endunless @unless ($fill) data-fill="false" @endunless
@if ($interactive) @if ($interactive)
x-data="materialNavigationRail('{{ $mode }}')" x-data="materialNavigationRail('{{ $mode }}', {{ $hideWhenCollapsed ? 'true' : 'false' }})"
x-bind:data-open="open" x-bind:data-open="open"
@endif @endif
{{ $attributes->merge(['style' => "--navigation-rail-width: {$width}"]) }} {{ $attributes->merge(['style' => "--navigation-rail-width: {$width}"]) }}
@@ -138,6 +138,24 @@
</div> </div>
</div> </div>
BLADE, 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 @endphp
@@ -115,6 +115,15 @@ it('lets the bar leave the window while the page scrolls down', function () {
->not->toContain('data-hide-on-scroll'); ->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 () { 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()]); $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 />'); $collapsible = (string) $this->blade('<x-navigation-rail />');
expect($collapsible) expect($collapsible)
->toContain('x-data="materialNavigationRail(\'collapsible\')"') ->toContain('x-data="materialNavigationRail(\'collapsible\', false)"')
->toContain('data-navigation-rail-menu') ->toContain('data-navigation-rail-menu')
->toContain('aria-label="Collapse navigation"') ->toContain('aria-label="Collapse navigation"')
->toContain('aria-expanded="true"') ->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;/"); ->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 () { 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 // 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. // beat — the FAB's shadow, its gap, an extended FAB's minimum width — are utilities.