Merge branch 'worktree-agent-aed4618fbd0b6aeac'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 10:38:57 +02:00
14 changed files with 663 additions and 79 deletions
@@ -786,7 +786,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`).
- 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)`.
@@ -807,7 +807,7 @@ M3 Expressive's flexible navigation bar, for three to five destinations. It does
</div>
```
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"), `tall` (M3's 80px container, which keeps the icon over the label at every width — `<x-app-shell tall-bar>` picks it, and the bottom offset grows with it), `hide-on-scroll` (M3's scrolling behaviour: the bar slides out on a scroll down and springs back on a scroll up, never before the first screenful and never while a snackbar, bottom sheet or drawer is on screen; focus reaching it brings it back. `<x-app-shell hide-bar-on-scroll>` picks it, and `--material-bottom-bar` goes down and comes back with the bar, so a `fab` button and the snackbar keep their distance from it). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
### `<x-navigation-rail>`, `<x-navigation-rail-item>`, `<x-navigation-rail-section>`
@@ -834,7 +834,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), `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.
@@ -855,7 +855,7 @@ A collapsing bar needs the window to scroll: no ancestor with `overflow-hidden`/
### `<x-toolbar>`
M3 Expressive toolbar, `role="toolbar"` (arrow keys move between controls). `variant`: `floating` (default pill at elevation 3; `vibrant`, `vertical`) or `docked` (full-width surface-container bar). `place`: `bottom` or `end` to fix it over the page; `fab` slot sets a FAB beside a floating toolbar; `label` names it.
M3 Expressive toolbar, `role="toolbar"` (arrow keys move between controls). `variant`: `floating` (default pill at elevation 3; `vibrant`, `vertical`) or `docked` (full-width surface-container bar). `place`: `bottom` or `end` to fix it over the page; `fab` slot takes an `<x-fab>` beside a floating toolbar, or at the end of a docked one, where the controls gather at the start and the FAB rests flat on the bar (M3's elevation 0 for a nested FAB) and the arrow keys reach it; `label` names it.
```blade
<x-toolbar label="Selection" place="bottom" vibrant>
+195 -40
View File
@@ -10,8 +10,9 @@
* NavigationRailHorizontalItemTokens.kt, NavigationRailColorTokens.kt, and the layout in
* ShortNavigationBar.kt, WideNavigationRail.kt and NavigationItem.kt.
*
* [data-navigation-bar] surface-container, 64px, the bottom safe area under it
* [data-navigation-bar-items] equal widths; centred from a 600px-wide bar
* [data-navigation-bar] surface-container, 64px (data-tall: 80px), the bottom
* safe area under it
* [data-navigation-bar-items] equal widths; centred from a 600px-wide short bar
* [data-navigation-bar-item] data-active
* [data-navigation-pill] icon and label; the indicator itself from 600px
* [data-navigation-indicator] the 56×32 indicator around the icon below 600px
@@ -42,16 +43,19 @@
* rail starts expanded instead of collapsed. The bar's own item layout is a *container* query at
* the same 37.5rem, so a bar in a narrow column lays out by its own width.
*
* Everything here is in `@layer components` except the last block, which restyles a FAB the
* application put in the rail: that one has to beat a utility, and a layer never does.
* Everything here is in `@layer components` except the blocks after it, which reach past the
* component into what an application wrote the bottom offset <x-app-shell> publishes, a FAB put
* 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;
}
@@ -62,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;
}
}
@@ -78,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;
}
}
@@ -108,6 +112,26 @@
margin-inline: auto;
}
/* The tall configuration: NavigationBarTokens.TallContainerHeight, 80dp. It keeps the vertical
item layout at every width the horizontal one below is the short bar's alone so the
label always sits under its icon, which is what the extra 16px is for. */
[data-navigation-bar][data-tall] [data-navigation-bar-items] {
min-height: 5rem;
}
/* "Scrolling: hides on scroll-down, reappears on scroll-up" (the reference's Navigation bar
§ Behaviour). It slides out of the window on the default spatial spring, which reduced
motion zeroes to an instant swap along with every other duration token. Whether it may hide
at all is resources/js/navigation.js's call: never while a bottom sheet, a drawer or a
snackbar is on screen, since those are anchored to the bar's edge. */
[data-navigation-bar][data-hide-on-scroll] {
transition: translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
}
[data-navigation-bar][data-hide-on-scroll][data-hidden] {
translate: 0 100%;
}
[data-navigation-bar-item] {
--navigation-layer: 0;
position: relative;
@@ -149,22 +173,24 @@
}
/* From 600dp (M3's medium window), icon and label side by side in a 40px indicator, and the
items centred with the padding ShortNavigationBar's Centered arrangement computes. */
items centred with the padding ShortNavigationBar's Centered arrangement computes. The
short bar only: a tall bar is the vertical layout at every width, so every rule here that
the vertical layout also has an opinion about names the short bar. */
@container (width >= 37.5rem) {
[data-navigation-bar-items] {
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-items] {
width: calc(10% * (var(--navigation-bar-count, 7) + 3));
min-width: fit-content;
max-width: 100%;
}
[data-navigation-bar-items]:has(> :last-child:nth-child(1)) { --navigation-bar-count: 1; }
[data-navigation-bar-items]:has(> :last-child:nth-child(2)) { --navigation-bar-count: 2; }
[data-navigation-bar-items]:has(> :last-child:nth-child(3)) { --navigation-bar-count: 3; }
[data-navigation-bar-items]:has(> :last-child:nth-child(4)) { --navigation-bar-count: 4; }
[data-navigation-bar-items]:has(> :last-child:nth-child(5)) { --navigation-bar-count: 5; }
[data-navigation-bar-items]:has(> :last-child:nth-child(6)) { --navigation-bar-count: 6; }
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(1)) { --navigation-bar-count: 1; }
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(2)) { --navigation-bar-count: 2; }
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(3)) { --navigation-bar-count: 3; }
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(4)) { --navigation-bar-count: 4; }
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(5)) { --navigation-bar-count: 5; }
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(6)) { --navigation-bar-count: 6; }
[data-navigation-bar-item] {
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-item] {
min-width: max-content;
}
@@ -172,7 +198,7 @@
token, and Compose's ShortNavigationBarItem passes it for both icon positions. (The
*rail's* horizontal item is label-large NavigationRailHorizontalItemTokens which is
a different component.) */
[data-navigation-bar-item] [data-navigation-pill] {
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-item] [data-navigation-pill] {
position: relative;
isolation: isolate;
flex-direction: row;
@@ -180,15 +206,15 @@
padding-inline: 1rem;
}
[data-navigation-bar-item] [data-navigation-label] {
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-item] [data-navigation-label] {
padding-inline: 0;
}
[data-navigation-bar-item][data-active] {
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-item][data-active] {
color: var(--md-sys-color-on-secondary-container);
}
[data-navigation-bar-item][data-active] [data-navigation-pill] {
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-item][data-active] [data-navigation-pill] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
}
}
@@ -197,25 +223,61 @@
[data-navigation-rail] {
--navigation-rail-expanded-width: clamp(13.75rem, var(--navigation-rail-width, 16rem), 22.5rem);
--navigation-rail-collapsed-width: 6rem;
position: relative;
flex-shrink: 0;
width: var(--navigation-rail-expanded-width);
transition: width var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
@variant rail-collapsed {
width: 6rem;
width: var(--navigation-rail-collapsed-width);
}
}
/* M3's other collapsed width: NavigationRailCollapsedTokens.NarrowContainerWidth, 80dp against
the default 96. A variable rather than a width here, so the rules below which each have
their own reason to name a collapsed rail's width do not have to know about it. */
[data-navigation-rail][data-width='narrow'] {
--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. */
[data-navigation-rail][data-width='narrow'] [data-navigation-rail-item] [data-navigation-label] {
@variant rail-collapsed {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}
/* A modal rail keeps its collapsed width in the layout while it is open over it, as Compose's
ModalWideNavigationRail does; the adaptive rail does below `expanded`, and takes no room on a compact window. */
[data-navigation-rail='modal'] {
width: 6rem;
width: var(--navigation-rail-collapsed-width);
}
@media (width < 52.5rem) {
[data-navigation-rail='adaptive'] {
width: 6rem;
width: var(--navigation-rail-collapsed-width);
}
}
@@ -226,10 +288,10 @@
/* "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
is floored at its collapsed width. `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;
width: var(--navigation-rail-collapsed-width);
}
}
@@ -333,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;
@@ -395,6 +523,16 @@
padding-block: 0.5rem 1rem;
}
/* "Alignment: top or center; on tablets prefer center alignment for reach. Menu icon and FAB
are always top-aligned" (docs/reference/m3/components-navigation-selection-inputs.md
§ Navigation rail). Only the destinations move, then the header and the footer stay at
their ends of the rail and `safe` puts them back at the top the moment there are more of
them than fit, where centring would otherwise push the first one out of reach above the
scroller. */
[data-navigation-rail][data-align='center'] [data-navigation-rail-destinations] {
justify-content: safe center;
}
[data-navigation-rail-heading] {
display: flex;
align-items: center;
@@ -513,13 +651,17 @@
border-radius: var(--md-sys-shape-corner-full);
}
/* The vertical layout a compact bar, and a tall one at any width fills the 56×32 box
behind the icon; the horizontal layout fills the pill around icon and label instead, so it
takes the fill off the box again. */
[data-navigation-bar-item][data-active] [data-navigation-indicator] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
@container (width < 37.5rem) {
[data-navigation-bar-item][data-active] [data-navigation-indicator] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
@container (width >= 37.5rem) {
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-item][data-active] [data-navigation-indicator] {
background-image: none;
}
}
@@ -579,14 +721,16 @@
background-color: var(--md-sys-color-on-secondary-container);
}
@container (width < 37.5rem) {
[data-navigation-bar-item] [data-navigation-pill]::before {
display: none;
}
[data-navigation-bar-item] [data-navigation-pill]::before {
display: none;
}
@container (width >= 37.5rem) {
[data-navigation-bar-item] [data-navigation-indicator]::before {
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-item] [data-navigation-pill]::before {
display: block;
}
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-item] [data-navigation-indicator]::before {
display: none;
}
}
@@ -615,15 +759,17 @@
}
/* M3's focus indicator, 3px of secondary 2px out, around the same shape. */
@container (width < 37.5rem) {
[data-navigation-bar-item]:focus-visible [data-navigation-indicator] {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-navigation-bar-item]:focus-visible [data-navigation-indicator] {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
@container (width >= 37.5rem) {
[data-navigation-bar-item]:focus-visible [data-navigation-pill] {
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-item]:focus-visible [data-navigation-indicator] {
outline: none;
}
[data-navigation-bar]:not([data-tall]) [data-navigation-bar-item]:focus-visible [data-navigation-pill] {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
@@ -646,6 +792,15 @@
}
}
/* A bar that has slid off the bottom of the window is no longer there to clear, so the offset
* everything pinned to the bottom reads drops to the bottom safe area and whatever the application
* has docked on the bar a `fab` button, the snackbar and the page's own bottom padding all follow
* it down and come back up with the bar. Unlayered on purpose: <x-app-shell> publishes the variable
* with a utility, and a rule in any layer loses to one. */
[data-app-shell]:has([data-navigation-bar][data-hide-on-scroll][data-hidden]) {
--material-bottom-bar: calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px));
}
/* A FAB in the rail's header rests at elevation 0, not the 3 a standalone FAB has
* (docs/reference/m3/components-navigation-selection-inputs.md § Navigation rail: "when nested
* within another component, such as the navigation rail, the FAB's resting elevation should be
+20 -1
View File
@@ -3,7 +3,8 @@
* Apache-2.0).
*
* docked a 64px bar across the bottom of the screen in surface-container, square, its
* controls spread out 4 to 32px apart between 16px ends
* controls spread out 4 to 32px apart between 16px ends; with a FAB, the controls
* at the start and the FAB flat at the end
* floating a 64px pill in surface-container (or primary-container, `vibrant`) at elevation 3,
* 8px at its ends and 4px between controls; `vertical` stands it on end
*
@@ -71,6 +72,24 @@
color: var(--md-sys-color-primary);
}
/* A docked toolbar with a FAB: the controls at the start, the FAB at the end, resting on the bar
at elevation 0 M3 puts a FAB nested in another component there, and Compose's
FloatingActionButtonDefaults.bottomAppBarFabElevation() is 0 in every state. `<x-fab>` draws its
shadow with utilities, which is one more reason this file is unlayered. */
[data-toolbar][data-variant="docked"]:has(> [data-toolbar-fab]) {
justify-content: flex-start;
}
[data-toolbar-fab] {
display: flex;
margin-inline-start: auto;
}
[data-toolbar-fab] [data-fab],
[data-toolbar-fab] [data-fab]:hover {
box-shadow: none;
}
/* A floating toolbar and its FAB, side by side (or stacked, vertical). */
[data-toolbar-group] {
display: inline-flex;
+127 -16
View File
@@ -13,10 +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
@@ -94,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
}
@@ -134,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
}
@@ -161,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
@@ -172,4 +221,66 @@ document.addEventListener('alpine:init', () => {
}
},
}))
window.Alpine.data('materialNavigationBar', () => ({
away: false,
last: 0,
frame: null,
// Set in init(), so a second bar in the same page scope cannot take the first one's.
schedule: null,
init() {
this.last = Math.max(window.scrollY, 0)
this.measure = this.measure.bind(this)
this.schedule = () => {
this.frame ??= requestAnimationFrame(this.measure)
}
window.addEventListener('scroll', this.schedule, { passive: true })
},
destroy() {
window.removeEventListener('scroll', this.schedule)
cancelAnimationFrame(this.frame)
},
/** Anything that reaches the bar — the keyboard, a screen reader's focus — brings it back. */
show() {
this.away = false
},
measure() {
this.frame = null
const at = Math.max(window.scrollY, 0)
const by = at - this.last
// Smaller than a finger's jitter, or the rubber band at either end of the page: not a
// direction yet, and the bar should not flicker while one is being decided.
if (Math.abs(by) < 8) {
return
}
this.last = at
// A bar that is not on screen at this width (the shell hides it from `medium`) has no
// scroll behaviour to have; one with something anchored to its edge keeps still, so
// the snackbar or sheet resting on it does not slide with it.
if (this.$root.getClientRects().length === 0 || anchored()) {
this.away = false
return
}
// Never before the first screenful: the bar has to be passed before it can be left.
this.away = by > 0 && at > this.$root.offsetHeight
},
}))
})
/**
* Whether something on screen is anchored to the bar's edge and would be dragged along with it: a
* snackbar (`<x-toast>`'s, which reads --material-bottom-bar), or a bottom sheet or drawer over the
* page. M3 lets those cover the bar; it is the bar leaving from under them that looks broken.
*/
const anchored = () => [...document.querySelectorAll('[data-toast], [role="dialog"]')].some((over) => over.getClientRects().length > 0)
+14 -4
View File
@@ -54,7 +54,13 @@
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
phone opens. `label` names both navigation landmarks ("Main"); `rail-width` is the expanded
rail's width.
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;
`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
@@ -82,6 +88,9 @@
'destinations' => [],
'label' => null,
'railWidth' => '16rem',
'tallBar' => false,
'hideBarOnScroll' => false,
'hideRailWhenCollapsed' => false,
])
@php
@@ -113,7 +122,8 @@
data-app-shell
@class([
'flex min-h-dvh flex-col bg-surface text-on-surface [--material-margin:1rem] medium:[--material-margin:1.5rem]',
'max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty(),
'max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty() && ! $tallBar,
'max-medium:[--material-bottom-bar:calc(5rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty() && $tallBar,
])
>
<a
@@ -127,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
@@ -174,7 +184,7 @@
@if ($barItems->isNotEmpty())
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 medium:hidden">
<x-livewire-material::navigation-bar :label="$label">
<x-livewire-material::navigation-bar :label="$label" :tall="$tallBar" :hide-on-scroll="$hideBarOnScroll">
@foreach ($barItems as $item)
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
@endforeach
@@ -16,11 +16,24 @@
Centered arrangement gives three to six items. Both follow the bar's own width (a container
query), so a bar in a narrow column keeps the compact items.
`tall` is M3's other container: 80px (NavigationBarTokens.TallContainerHeight) with the
vertical item layout at every width icon over label, never side by side for a bar whose
labels need the room. The short bar stays the default.
`hide-on-scroll` is M3's scrolling behaviour: the bar slides out of the window on a scroll
down and springs back on a scroll up, never before the first screenful has gone by, and never
while a snackbar, a bottom sheet or a drawer is on screen those are anchored to the bar's
edge and would slide with it. Focus reaching the bar brings it back, which is as close as the
web gets to M3's "never hide it while a screen reader is active". Inside `<x-app-shell>`
`--material-bottom-bar` follows the bar down and up, so a `fab` button, the snackbar and the
page's bottom padding keep their distance from it rather than from where it was.
It does not position itself: wrap it in the element that pins it (`fixed inset-x-0 bottom-0`)
and hides it where a rail takes over. `<x-app-shell>` does both, and lifts the snackbar and a
`fab` button above it through `--material-bottom-bar`.
`label` names the landmark ("Main" by default).
`label` names the landmark ("Main" by default); `tall` picks the 80px container;
`hide-on-scroll` lets it leave the window while the page scrolls down.
Values from androidx Compose Material 3 (Apache-2.0), androidx-main
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationBarTokens.kt,
@@ -31,9 +44,22 @@
@props([
'label' => null,
'tall' => false,
'hideOnScroll' => false,
])
<nav aria-label="{{ $label ?? __('Main') }}" data-navigation-bar {{ $attributes }}>
<nav
aria-label="{{ $label ?? __('Main') }}"
data-navigation-bar
@if ($tall) data-tall @endif
@if ($hideOnScroll)
data-hide-on-scroll
x-data="materialNavigationBar"
x-bind:data-hidden="away ? '' : null"
x-on:focusin="show()"
@endif
{{ $attributes }}
>
<div data-navigation-bar-items>
{{ $slot }}
</div>
@@ -52,7 +52,21 @@
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`,
between M3's 220 and 360dp) — or the word `narrow`, M3's other *collapsed* width
(NavigationRailCollapsedTokens.NarrowContainerWidth, 80px against the default 96), where the
items are their icons alone because no label fits under a 56px indicator at that width; the
labels stay in the accessibility tree, since they are what name the destinations, and a
narrow rail expands to the default 16rem; `align` is `top` (the default) or `center`, which
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; `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,
@@ -74,6 +88,8 @@
'mode' => 'collapsible',
'label' => null,
'width' => '16rem',
'align' => 'top',
'hideWhenCollapsed' => false,
'menu' => null,
'divider' => false,
'fill' => true,
@@ -82,17 +98,26 @@
@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';
$width = $narrow ? '16rem' : $width;
$centred = $align === 'center';
@endphp
<div
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}"]) }}
@@ -112,7 +137,9 @@
@if ($menu || isset($brand) || isset($header))
<div data-navigation-rail-header>
@if ($menu || isset($brand))
<div @class(['flex w-full min-w-0 items-center gap-3 pe-5', 'ps-7' => $menu, 'ps-5' => ! $menu])>
{{-- The menu button is centred on the collapsed rail: 28px either side of a
40px button in 96, 20px in a narrow 80. --}}
<div @class(['flex w-full min-w-0 items-center gap-3 pe-5', 'ps-7' => $menu, 'rail-collapsed:ps-5' => $menu && $narrow, 'ps-5' => ! $menu])>
@if ($menu)
<button
type="button"
@@ -136,7 +163,8 @@
@endif
@isset($header)
<div {{ $header->attributes->class(['flex w-full flex-col items-start gap-2 px-5']) }}>{{ $header }}</div>
{{-- And so is a 56px FAB: 20px either side in 96, 12px in a narrow 80. --}}
<div {{ $header->attributes->class(['flex w-full flex-col items-start gap-2 px-5', 'rail-collapsed:px-3' => $narrow]) }}>{{ $header }}</div>
@endisset
</div>
@endif
+11 -2
View File
@@ -4,8 +4,12 @@
`vertical` stands it on end) or `docked` (a full-width bar in surface-container, for the bottom
of a screen). `place` puts it over the page: `bottom` (centred above the bottom edge; a docked
toolbar spans it) or `end` (centred against the end edge, for a vertical one); without it the
toolbar sits where it is written. A `fab` slot sets an `<x-fab>` beside a floating toolbar.
`label` names it for screen readers.
toolbar sits where it is written. A `fab` slot sets an `<x-fab>` beside a floating toolbar,
and at the end of a docked one M3's "With FAB" configuration for both. On a docked toolbar the
controls then gather at the start and the FAB rests on the bar, flat: M3 gives a FAB nested in
another component elevation 0 (Compose's bottomAppBarFabElevation is 0 at every state), where
one beside a floating toolbar keeps its own. It is one of the toolbar's controls then, so the
arrow keys reach it. `label` names it for screen readers.
A docked toolbar and a navigation bar occupy the same region of the screen and M3 says never to
show both at once: the bar belongs on a primary page, the toolbar on a secondary or contextual
@@ -29,6 +33,7 @@
$vertical = $vertical && $variant === 'floating';
$place = in_array($place, ['bottom', 'end'], true) ? $place : null;
$grouped = isset($fab) && $variant === 'floating';
$docksFab = isset($fab) && $variant === 'docked';
@endphp
@if ($grouped)
@@ -48,6 +53,10 @@
{{ $attributes }}
>
{{ $slot }}
@if ($docksFab)
<div data-toolbar-fab>{{ $fab }}</div>
@endif
</div>
@if ($grouped)
@@ -73,6 +73,16 @@
<x-button icon="delete" tooltip="Delete" />
</x-toolbar>
</div>
<div class="w-full overflow-hidden rounded-corner-lg border border-outline-variant">
<x-toolbar variant="docked" label="Editing">
<x-button icon="check_box" tooltip="Select" />
<x-button icon="brush" tooltip="Draw" />
<x-button icon="mic" tooltip="Record" />
<x-button icon="image" tooltip="Add image" />
<x-slot:fab><x-fab icon="add" tooltip="New note" color="secondary" /></x-slot:fab>
</x-toolbar>
</div>
</div>
BLADE,
'Primary tabs' => <<<'BLADE'
@@ -29,9 +29,36 @@
</div>
</div>
</div>
<div class="max-w-[25rem] space-y-2">
<p class="type-label-lg text-on-surface-variant">Hide on scroll: scroll the page down, then up</p>
<div class="flex h-56 flex-col overflow-hidden rounded-corner-lg border border-outline-variant bg-surface">
<div class="flex-1 p-4 type-body-md text-on-surface-variant">The bar follows the window's scrolling, not this box's: scroll the showcase down and it leaves, scroll up and it springs back. It stays while a snackbar or a bottom sheet is on screen.</div>
<x-navigation-bar hide-on-scroll>
<x-navigation-bar-item label="Shares" icon="folder_shared" link="#navigation" no-wire-navigate active />
<x-navigation-bar-item label="Upload" icon="upload" link="#navigation" no-wire-navigate />
<x-navigation-bar-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate badge="3" />
</x-navigation-bar>
</div>
</div>
<div class="max-w-[50rem] space-y-2">
<p class="type-label-lg text-on-surface-variant">Tall (80px): icon over label whatever the width</p>
<div class="overflow-x-auto rounded-corner-lg border border-outline-variant">
<div class="flex h-56 min-w-[37.5rem] flex-col bg-surface">
<div class="flex-1 p-4 type-body-md text-on-surface-variant">The page</div>
<x-navigation-bar tall>
<x-navigation-bar-item label="Shares" icon="folder_shared" link="#navigation" no-wire-navigate active />
<x-navigation-bar-item label="Upload" icon="upload" link="#navigation" no-wire-navigate />
<x-navigation-bar-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate badge="3" />
<x-navigation-bar-item label="Account" icon="account_circle" link="#navigation" no-wire-navigate />
</x-navigation-bar>
</div>
</div>
</div>
</div>
BLADE,
'Collapsed and expanded rails' => <<<'BLADE'
'Collapsed, narrow (centred) 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" divider>
@@ -53,6 +80,22 @@
</x-navigation-rail>
</div>
<div class="flex h-[36rem] overflow-hidden rounded-corner-lg border border-outline-variant">
<x-navigation-rail mode="collapsed" width="narrow" align="center" label="Narrow example" divider>
<x-slot:header>
<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" />
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
<x-slot:footer>
<x-navigation-rail-item label="Settings" icon="settings" link="#navigation" no-wire-navigate />
</x-slot:footer>
</x-navigation-rail>
</div>
<div class="flex h-[36rem] overflow-hidden rounded-corner-lg border border-outline-variant">
<x-navigation-rail mode="expanded" label="Expanded example">
<x-slot:header>
@@ -95,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
+21
View File
@@ -69,6 +69,27 @@ it('draws floating and docked toolbars', function () {
->not->toContain('data-toolbar-group');
});
it('sets a FAB at the end of a docked toolbar, resting flat on it', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-toolbar variant="docked" label="Editing">
<button>Draw</button>
<x-slot:fab><button>+</button></x-slot:fab>
</x-toolbar>
BLADE);
expect($html)
// Inside the toolbar, after its controls, where the arrow keys reach it too.
->toMatch('/role="toolbar".*<button>Draw<\/button>\s*<div data-toolbar-fab><button>\+<\/button><\/div>\s*<\/div>/s')
->not->toContain('data-toolbar-group')
->and((string) $this->blade('<x-toolbar variant="docked"><button>Draw</button></x-toolbar>'))
->not->toContain('data-toolbar-fab');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/toolbar.css'))
->toMatch('/\[data-toolbar-fab\] \{\s+display: flex;\s+margin-inline-start: auto;/')
// A nested FAB rests at elevation 0.
->toMatch('/\[data-toolbar-fab\] \[data-fab\],\s+\[data-toolbar-fab\] \[data-fab\]:hover \{\s+box-shadow: none;/');
});
it('centres a headline on a three-column row and curves the search container', function () {
$css = file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.css');
+27
View File
@@ -97,6 +97,33 @@ it('lifts the snackbar above the bar only when there is a bar', function () {
->not->toContain('--material-bottom-bar:');
});
it('passes M3\'s tall bar through, and the bottom offset grows with it', function () {
expect((string) $this->blade('<x-app-shell :destinations="$destinations" tall-bar />', ['destinations' => shellDestinations()]))
->toMatch('/data-navigation-bar\s+data-tall\s/')
->toContain('max-medium:[--material-bottom-bar:calc(5rem+')
->not->toContain('[--material-bottom-bar:calc(4rem+')
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
->not->toContain('data-tall')
->toContain('max-medium:[--material-bottom-bar:calc(4rem+');
});
it('lets the bar leave the window while the page scrolls down', function () {
expect((string) $this->blade('<x-app-shell :destinations="$destinations" hide-bar-on-scroll />', ['destinations' => shellDestinations()]))
->toContain('data-hide-on-scroll')
->toContain('x-data="materialNavigationBar"')
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
->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()]);
+44 -1
View File
@@ -4,7 +4,7 @@ use Illuminate\Support\Str;
it('draws a navigation landmark around its items', function () {
expect((string) $this->blade('<x-navigation-bar><x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-bar>'))
->toContain('<nav aria-label="Main" data-navigation-bar')
->toMatch('/<nav\s+aria-label="Main"\s+data-navigation-bar/')
->toContain('data-navigation-bar-items')
->and((string) $this->blade('<x-navigation-bar label="Sections" />'))->toContain('aria-label="Sections"');
});
@@ -60,3 +60,46 @@ it('keeps the bar item on the label and state-layer colours M3 tokens', function
// The indicator and the pill wash in on-secondary-container, as the rail's do (N-19).
->toContain('[data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill])::before {');
});
it('takes M3\'s tall container, which keeps the vertical item layout at every width', function () {
expect((string) $this->blade('<x-navigation-bar tall><x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-bar>'))
->toMatch('/data-navigation-bar\s+data-tall\s/')
->and((string) $this->blade('<x-navigation-bar />'))->not->toContain('data-tall');
$css = file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css');
expect($css)
// NavigationBarTokens.TallContainerHeight, 80dp, against the short bar's 64.
->toMatch('/\[data-navigation-bar\]\[data-tall\] \[data-navigation-bar-items\] \{\s+min-height: 5rem;/')
// The vertical layout is the base — a compact bar and a tall one at any width share it —
// and only the short bar's horizontal layout is behind the 600px container query, so no
// rule inside one reaches an item without saying `:not([data-tall])` first.
->and(preg_match_all('/@container \(width [<>]=? 37\.5rem\) \{\n(.*?)\n \}/s', $css, $blocks) > 0)->toBeTrue()
->and(implode("\n", $blocks[1]))->not->toMatch('/^ \[data-navigation-bar-item/m');
});
it('hides on a scroll down and springs back on a scroll up', function () {
expect((string) $this->blade('<x-navigation-bar hide-on-scroll />'))
->toContain('data-hide-on-scroll')
->toContain('x-data="materialNavigationBar"')
->toContain('x-bind:data-hidden="away ? \'\' : null"')
// Focus reaching the bar brings it back — the web's nearest answer to M3's "never hide it
// while a screen reader is active".
->toContain('x-on:focusin="show()"')
->and((string) $this->blade('<x-navigation-bar />'))
->not->toContain('data-hide-on-scroll')
->not->toContain('materialNavigationBar');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
// The spatial spring, which reduced motion zeroes along with every other duration token.
->toMatch('/\[data-navigation-bar\]\[data-hide-on-scroll\] \{\s+transition: translate var\(--md-sys-motion-spatial-default-duration\) var\(--md-sys-motion-spatial-default\);/')
->toMatch('/\[data-navigation-bar\]\[data-hide-on-scroll\]\[data-hidden\] \{\s+translate: 0 100%;/')
// Unlayered, or the utility <x-app-shell> publishes the offset with would win.
->toContain('[data-app-shell]:has([data-navigation-bar][data-hide-on-scroll][data-hidden]) {')
->and(Str::of(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))->after('[data-app-shell]:has(')->toString())
->not->toContain('@layer');
expect(file_get_contents(__DIR__.'/../../../resources/js/navigation.js'))
// Never out from under a snackbar, a bottom sheet or a drawer resting on its edge.
->toContain("document.querySelectorAll('[data-toast], [role=\"dialog\"]')");
});
@@ -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"')
@@ -63,8 +63,72 @@ it('takes M3\'s optional divider and turns the container fill off', function ()
// 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;/");
// A collapsible rail is held to its collapsed width where M3 asks for a bar instead (N-24).
->toMatch("/\\[data-navigation-rail='collapsible'\\] \\{\\s+width: var\\(--navigation-rail-collapsed-width\\);/");
});
it('takes M3\'s narrow collapsed width, icons alone but still named', function () {
$narrow = (string) $this->blade('<x-navigation-rail width="narrow"><x-slot:header><button>FAB</button></x-slot:header><x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-rail>');
expect($narrow)
->toContain('data-width="narrow"')
// A narrow rail expands to the same 16rem; `width` names one or the other, never both.
->toContain('--navigation-rail-width: 16rem')
// 40px menu button and 56px FAB centred in 80px rather than in 96.
->toContain('rail-collapsed:ps-5')
->toContain('rail-collapsed:px-3')
// The label is still rendered: it is what names the destination.
->toContain('<span data-navigation-label>Inbox</span>')
->and((string) $this->blade('<x-navigation-rail mode="collapsed" width="20rem" />'))
->not->toContain('data-width')
->toContain('--navigation-rail-width: 20rem');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
// NavigationRailCollapsedTokens.NarrowContainerWidth, 80dp against the default 96dp.
->toMatch("/\\[data-navigation-rail\\]\\[data-width='narrow'\\] \\{\\s+--navigation-rail-collapsed-width: 5rem;/")
->toMatch('/--navigation-rail-collapsed-width: 6rem;/')
// Out of the drawing, not out of the page.
->toContain("[data-navigation-rail][data-width='narrow'] [data-navigation-rail-item] [data-navigation-label] {");
});
it('centres the destinations on request, leaving the header and footer at their ends', function () {
expect((string) $this->blade('<x-navigation-rail mode="collapsed" align="center" />'))
->toContain('data-align="center"')
->and((string) $this->blade('<x-navigation-rail mode="collapsed" />'))->not->toContain('data-align')
->and((string) $this->blade('<x-navigation-rail mode="collapsed" align="middle" />'))->not->toContain('data-align');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
// Only the destinations move, and `safe` gives the top back when there are too many.
->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 () {