diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 1fca51ee..9379edcc 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -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 ``), 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 ``` -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. ``: `label` ("Main"). ``: `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. ``: `label` ("Main"), `tall` (M3's 80px container, which keeps the icon over the label at every width — `` 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. `` 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). ``: `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"). ### ``, ``, `` @@ -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` (``'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 ``, 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 ``, which the rail morphs into an extended FAB and back as it expands — it also rests at elevation 0, as M3 asks of a nested FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport. - Anything else inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `…expanded only…`, ``. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px. - A `collapsible` rail is held to the collapsed 96px below `medium` (600px), where M3 says to use a navigation bar rather than a standard rail. `collapsed` and `expanded` are fixed-width by design: wrap one in a `medium:` element if it must not show on a phone. - ``: the same props as ``. ``: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way. @@ -855,7 +855,7 @@ A collapsing bar needs the window to scroll: no ancestor with `overflow-hidden`/ ### `` -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 `` — 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 diff --git a/resources/css/components/navigation.css b/resources/css/components/navigation.css index 2db8f579..99059700 100644 --- a/resources/css/components/navigation.css +++ b/resources/css/components/navigation.css @@ -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 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 220–360px rail would be most of the screen, so a rail whose width is the visitor's choice is held collapsed there whatever they chose. */ @media (width < 37.5rem) { - &:where([data-navigation-rail='collapsible'], [data-navigation-rail='collapsible'] *) { + &:where([data-navigation-rail='collapsible']:not([data-open]), [data-navigation-rail='collapsible']:not([data-open]) *) { @slot; } } @@ -78,14 +82,14 @@ `data-rail-auto`, which 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: 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 diff --git a/resources/css/components/toolbar.css b/resources/css/components/toolbar.css index d53903a3..d523c17f 100644 --- a/resources/css/components/toolbar.css +++ b/resources/css/components/toolbar.css @@ -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. `` 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; diff --git a/resources/js/navigation.js b/resources/js/navigation.js index 51000f42..f6076578 100644 --- a/resources/js/navigation.js +++ b/resources/js/navigation.js @@ -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 `` — 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 (``'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) diff --git a/resources/views/components/app-shell.blade.php b/resources/views/components/app-shell.blade.php index f0de2341..d286e3cb 100644 --- a/resources/views/components/app-shell.blade.php +++ b/resources/views/components/app-shell.blade.php @@ -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, ]) > - + @isset($brand) {{ $brand }} @endisset @@ -174,7 +184,7 @@ @if ($barItems->isNotEmpty())
- + @foreach ($barItems as $item) @endforeach diff --git a/resources/views/components/navigation-bar.blade.php b/resources/views/components/navigation-bar.blade.php index 2e763bca..6814444f 100644 --- a/resources/views/components/navigation-bar.blade.php +++ b/resources/views/components/navigation-bar.blade.php @@ -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 `` + `--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. `` 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, ]) -
BLADE, 'Primary tabs' => <<<'BLADE' diff --git a/resources/views/showcase/sections/navigation.blade.php b/resources/views/showcase/sections/navigation.blade.php index a7eafb05..db1e2e46 100644 --- a/resources/views/showcase/sections/navigation.blade.php +++ b/resources/views/showcase/sections/navigation.blade.php @@ -29,9 +29,36 @@ + +
+

Hide on scroll: scroll the page down, then up

+
+
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.
+ + + + + +
+
+ +
+

Tall (80px): icon over label whatever the width

+
+
+
The page
+ + + + + + +
+
+
BLADE, - 'Collapsed and expanded rails' => <<<'BLADE' + 'Collapsed, narrow (centred) and expanded rails' => <<<'BLADE'
@@ -53,6 +80,22 @@
+
+ + + + + + + + + + + + + +
+
@@ -95,6 +138,24 @@
BLADE, + 'Rail that hides when collapsed' => <<<'BLADE' +
+ + + Studio + + + + + + + +
+

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.

+ +
+
+ BLADE, ]; @endphp diff --git a/tests/Feature/Components/AppBarTest.php b/tests/Feature/Components/AppBarTest.php index 369d254e..ca15940a 100644 --- a/tests/Feature/Components/AppBarTest.php +++ b/tests/Feature/Components/AppBarTest.php @@ -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' + + + + + BLADE); + + expect($html) + // Inside the toolbar, after its controls, where the arrow keys reach it too. + ->toMatch('/role="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'); diff --git a/tests/Feature/Components/AppShellTest.php b/tests/Feature/Components/AppShellTest.php index f5b9d770..ea309bfc 100644 --- a/tests/Feature/Components/AppShellTest.php +++ b/tests/Feature/Components/AppShellTest.php @@ -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('', ['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('', ['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('', ['destinations' => shellDestinations()])) + ->toContain('data-hide-on-scroll') + ->toContain('x-data="materialNavigationBar"') + ->and((string) $this->blade('', ['destinations' => shellDestinations()])) + ->not->toContain('data-hide-on-scroll'); +}); + +it('exposes the rail that hides when collapsed', function () { + expect((string) $this->blade('', ['destinations' => shellDestinations()])) + ->toContain('data-hide-when-collapsed') + ->toContain("materialNavigationRail('adaptive', true)") + ->and((string) $this->blade('', ['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('', ['destinations' => shellDestinations()]); diff --git a/tests/Feature/Components/NavigationBarTest.php b/tests/Feature/Components/NavigationBarTest.php index f79b2b4e..43003011 100644 --- a/tests/Feature/Components/NavigationBarTest.php +++ b/tests/Feature/Components/NavigationBarTest.php @@ -4,7 +4,7 @@ use Illuminate\Support\Str; it('draws a navigation landmark around its items', function () { expect((string) $this->blade('')) - ->toContain('