diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md
index e1dd8164..c2d05436 100644
--- a/resources/boost/skills/livewire-material-development/SKILL.md
+++ b/resources/boost/skills/livewire-material-development/SKILL.md
@@ -703,8 +703,7 @@ A visitor who has pressed the menu button keeps that choice in both standard ban
]">
SealShare
-
-
+
@@ -719,7 +718,8 @@ 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: `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 app bar, above the page at every width), 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`).
+- `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)`.
- Two panes side by side are M3's from `expanded`: `` is the second one, in an `expanded:flex expanded:items-start expanded:gap-6` row inside the page.
@@ -749,7 +749,7 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
SealShare
-
+
@@ -766,8 +766,9 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
```
- `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (``'s, one rail per window size class: hidden and opened as a modal on a compact window, collapsed and opened as a modal at `medium`, a standard rail from `expanded` — collapsed there, expanded from `large`).
-- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`). Slots: `brand` (beside the menu button, expanded only), `header` (a FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
-- Anything inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `…expanded only…`, `…collapsed only…`. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
+- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`), `divider` (M3's optional vertical divider on the page's side — use it when the page scrolls under a fixed rail), `fill` (`false` for a transparent container, which M3 allows while the items keep 3:1 contrast). Slots: `brand` (beside the menu button, expanded only), `header` (one ``, 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…`, `…collapsed 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.
- `$store.rail`: `collapsed`, `toggle()`, `collapse()`, `expand()` (the remembered choice; `auto` is true while nothing is stored, so an adaptive rail takes its window size class's default instead, and the first choice clears it), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php` → `rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
@@ -796,6 +797,8 @@ M3 Expressive toolbar, `role="toolbar"` (arrow keys move between controls). `var
```
+A docked toolbar and a navigation bar occupy the same screen region and must never be on screen together: show the bar on a primary page and the toolbar on a secondary or contextual one. A `place="bottom"` toolbar clears `--material-bottom-bar` if a bar is there anyway, so nothing is buried.
+
### ``, ``
M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skipped, the indicator moves in a view transition). `tabs`: `['name', 'label', 'icon', 'badge', 'disabled']`; panels are `` in the slot. Bind with `wire:model` (entangled), or `selected` / `x-model` without Livewire. `variant` `primary` (default) or `secondary`; `stacked` (icon over label), `scrollable`. Give two identical tab sets on one page distinct `id`s.
@@ -809,7 +812,7 @@ M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skip
### ``
-Navigation between the sections of one area (settings, admin): secondary tabs as links from `medium` (600px; wrapping onto a grid rather than a single row below `large`, rather than scrolling), a menu picker on a compact window, whose items mark the current section as the page (`current`) and carry each section's badge. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). `label`, `no-wire-navigate`.
+Navigation between the sections of one area (settings, admin): secondary tabs as links from `medium` (600px), a menu picker on a compact window, whose items mark the current section as the page (`current`) and carry each section's badge. Up to four sections share the row; from five it is M3's scrollable tab bar — tabs as wide as their labels, offset 52dp from the leading edge so it reads as scrollable. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). `label`, `no-wire-navigate`.
### ``
@@ -826,7 +829,7 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
### ``
-Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (a row of three for settings pages), `contrast` (the same row for M3's standard, medium and high levels, marking the one in force). Every toggle on a page shares the store.
+Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (a row of three for settings pages), `contrast` (the same row for M3's standard, medium and high levels, marking the one in force). Every toggle on a page shares the store. Both rows are `` — a connected button group over native radios, so the arrow keys, the wrap and the roving tab stop are the browser's; `label` adds a visible legend, and without one the row is still named for a screen reader. Nothing is checked until Alpine has read the store, because the theme is known only in the browser.
### ``
diff --git a/resources/css/components/app-bar.css b/resources/css/components/app-bar.css
index 6afd0149..f1ce615c 100644
--- a/resources/css/components/app-bar.css
+++ b/resources/css/components/app-bar.css
@@ -4,10 +4,11 @@
*
* small 64px, title-large title (label-medium subtitle), 4px from the edges, the title 16px
* from the start or straight after the navigation icon
- * center the same, the title centred
+ * center the same, the title centred — a three-column grid, so it shrinks against whatever is
+ * beside it rather than ellipsising underneath it
* medium 112px (136px with a subtitle): the 64px row of icons over a headline-medium title
* large 120px (152px with a subtitle): a display-small title
- * search the row holds a search bar
+ * search the row holds a search bar, full width to 312px and then half of what is left
*
* The container is the surface, and surface-container once content scrolls under it. A medium or
* large bar collapses into the small one without script moving anything: the bar is sticky at a
@@ -118,21 +119,44 @@
letter-spacing: var(--md-sys-typescale-label-md-tracking);
}
- /* Centred: the headline spans the row and centres its text between whatever is at either end. */
- [data-app-bar][data-variant="center"] [data-app-bar-headline] {
- position: absolute;
- inset-inline: 3.5rem;
- top: 0;
- bottom: 0;
- align-items: center;
- text-align: center;
- pointer-events: none;
+ /* Centred: a three-column row — leading, headline, trailing — so the headline is centred in the
+ window and shrinks against whatever is actually beside it. M3 allows two trailing icon
+ buttons, which a fixed inset for one would have let a long title ellipsise underneath. */
+ [data-app-bar][data-variant="center"] [data-app-bar-row] {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
}
- /* The search bar fills the row. */
+ [data-app-bar][data-variant="center"] [data-app-bar-leading] {
+ grid-column: 1;
+ justify-self: start;
+ }
+
+ [data-app-bar][data-variant="center"] [data-app-bar-headline] {
+ grid-column: 2;
+ min-width: 0;
+ align-items: center;
+ justify-self: center;
+ padding-inline: 0.75rem;
+ text-align: center;
+ }
+
+ [data-app-bar][data-variant="center"] [data-app-bar-trailing] {
+ grid-column: 3;
+ justify-self: end;
+ margin-inline-start: 0;
+ }
+
+ /* The search bar fills the space between the leading and trailing elements until it is 312dp
+ wide, and then grows into only half of what is left
+ (docs/reference/m3/components-navigation-selection-inputs.md § Top app bar). The percentage
+ is of the row rather than of that space, which CSS cannot name without measuring it; below
+ 312px the cap never binds, so a phone still gets the whole row. */
[data-app-bar-search] {
min-width: 0;
+ max-width: calc(19.5rem + (100% - 19.5rem) / 2);
flex: 1 1 0%;
+ margin-inline: auto;
padding-inline: 0.25rem;
}
diff --git a/resources/css/components/navigation.css b/resources/css/components/navigation.css
index a2f1db1b..2db8f579 100644
--- a/resources/css/components/navigation.css
+++ b/resources/css/components/navigation.css
@@ -41,6 +41,9 @@
* rail in the layout rather than one that opens over a scrim; 75rem (1200px) is `large`, where the
* 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.
*/
@custom-variant rail-collapsed {
@@ -56,6 +59,14 @@
@slot;
}
+ /* 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'] *) {
+ @slot;
+ }
+ }
+
@media (width < 52.5rem) {
&:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) {
@slot;
@@ -157,14 +168,16 @@
min-width: max-content;
}
+ /* The label stays label-medium: NavigationBarTokens.LabelTextFont is the bar's only label
+ 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] {
position: relative;
isolation: isolate;
flex-direction: row;
height: 2.5rem;
padding-inline: 1rem;
- font: var(--md-sys-typescale-label-lg);
- letter-spacing: var(--md-sys-typescale-label-lg-tracking);
}
[data-navigation-bar-item] [data-navigation-label] {
@@ -210,16 +223,27 @@
[data-navigation-rail='adaptive'] {
width: 0;
}
+
+ /* "Compact → don't use a standard rail, use a navigation bar." A `collapsible` rail takes
+ the width the visitor chose, which on a 360px screen would be two-thirds of it, so it
+ is floored at the collapsed 96px. `collapsed` and `expanded` mean what they say and are
+ left alone: wrap one in a `medium:` element if it must not show on a phone. */
+ [data-navigation-rail='collapsible'] {
+ width: 6rem;
+ }
}
+ /* `--material-banner` is the height of a bar pinned across the top of the window — what
+ 's `banner` slot holds, if the application made it sticky. The rail sticks
+ under it rather than behind it; 0 without one. */
[data-navigation-rail-panel] {
position: sticky;
- top: 0;
+ top: var(--material-banner, 0px);
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
- max-height: 100dvh;
+ max-height: calc(100dvh - var(--material-banner, 0px));
/* Clip, not hide: no scroll container, so the destinations below can still scroll and
nothing sticky breaks. What only an expanded rail draws — a label, the brand — is drawn
at once when the rail expands, while the width is still growing; the clip keeps it
@@ -233,6 +257,18 @@
background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
}
+ /* M3's two optional container treatments: "optional vertical divider separates rail from
+ content, placed on the content-adjacent edge" — also its answer to a page scrolling under a
+ fixed rail — and "container fill can be turned off (transparent) as long as items keep ≥3:1
+ contrast". Neither applies to a rail open over a scrim, which is a surface over the page. */
+ [data-navigation-rail][data-divider]:not([data-open]) > [data-navigation-rail-panel] {
+ border-inline-end: 1px solid var(--md-sys-color-outline-variant);
+ }
+
+ [data-navigation-rail][data-fill='false']:not([data-open]) > [data-navigation-rail-panel] {
+ background-color: transparent;
+ }
+
/* Open: expanded over a scrim, in surface-container with a large corner at its inner edge. */
[data-navigation-rail][data-open] > [data-navigation-rail-panel] {
position: fixed;
@@ -535,6 +571,14 @@
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
+ /* A navigation item's state layer is on-secondary-container, active or not: the only
+ state-layer tokens M3 states for one are NavigationRailColorTokens', which give that colour
+ to all six. The rail follows them two blocks below; the bar follows them here, over its
+ secondary-container pill. */
+ [data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill])::before {
+ background-color: var(--md-sys-color-on-secondary-container);
+ }
+
@container (width < 37.5rem) {
[data-navigation-bar-item] [data-navigation-pill]::before {
display: none;
@@ -601,3 +645,45 @@
}
}
}
+
+/* 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
+ * level 0"). Unlayered on purpose: the FAB draws its shadow with a utility, and a rule in any
+ * layer loses to a utility — the same reason toolbar.css gives for its vibrant recolouring. */
+[data-navigation-rail-header] [data-fab],
+[data-navigation-rail-header] [data-fab]:hover {
+ box-shadow: none;
+}
+
+/* "Rail transitions from collapsed to expanded should animate the FAB into and out of an extended
+ * FAB" — so the header takes one and morphs it, rather than swapping two by
+ * `display`, which showed the collapsed one at once while the rail's width was still springing.
+ * The label's width springs on the rail's own spatial spring; with the gap closed and the extended
+ * FAB's own minimum width off, `aspect-ratio` squares what is left against the height its size
+ * already sets — 56, 80 or 96px, the three FAB sizes — so no number is repeated here. The label
+ * stays in the accessibility tree at both widths, so the FAB keeps its name. Unlayered for the
+ * same reason as the rule above: `gap-2` and `min-w-20` are utilities, and a layer would lose to
+ * them. The cap is there because a transition needs a length to travel to; a rail FAB's label is a
+ * word or two. */
+[data-navigation-rail-header] [data-fab] > span {
+ max-width: 16rem;
+ overflow: hidden;
+ white-space: nowrap;
+ transition:
+ max-width var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default),
+ opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
+
+ @variant rail-collapsed {
+ max-width: 0;
+ opacity: 0;
+ }
+}
+
+[data-navigation-rail-header] [data-fab] {
+ @variant rail-collapsed {
+ min-width: 0;
+ aspect-ratio: 1;
+ gap: 0;
+ }
+}
diff --git a/resources/css/components/tabs.css b/resources/css/components/tabs.css
index 9afcef52..4dc43bc4 100644
--- a/resources/css/components/tabs.css
+++ b/resources/css/components/tabs.css
@@ -6,20 +6,38 @@
*
* The active indicator is drawn in every tab and shown under the chosen one, so it is right before
* Alpine starts and after a morph: 3px with rounded top corners under the content of a primary tab
- * (at least 24px wide), 2px across the whole of a secondary one. When the choice changes, a view
- * transition moves it from the old tab to the new (resources/js/tabs.js), as M3's slides.
+ * (at least 24px wide, inset 2px at each side), 2px across the whole of a secondary one. When the
+ * choice changes, a view transition moves it from the old tab to the new (resources/js/tabs.js),
+ * as M3's slides.
+ *
+ * The bar scrolls sideways, so it clips whatever leaves it — including the focus ring, which the
+ * rest of the package draws 3px thick 2px outside the element (tokens/state.css). `--tabs-ring`
+ * is the room that takes: the bar carries it as padding on every side, and the state layer and the
+ * indicator reach back out into it, so the divider still sits against the tabs and the indicator
+ * still sits on the divider. That makes the bar 10px taller than M3's 48px tab.
*/
@layer components {
[data-tabs-bar] {
+ --tabs-ring: 5px;
position: relative;
display: flex;
overflow-x: auto;
overscroll-behavior-x: contain;
+ padding: var(--tabs-ring);
+ scroll-padding-inline: var(--tabs-ring);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
scrollbar-width: thin;
}
+ /* "Offset the first scrollable tab 52dp from the leading edge so it's clear that more content
+ is available" (docs/reference/m3/components-navigation-selection-inputs.md § Tabs). Logical,
+ so a right-to-left page mirrors it. */
+ [data-tabs-bar][data-scrollable] {
+ padding-inline-start: 3.25rem;
+ scroll-padding-inline-start: 3.25rem;
+ }
+
/* A section nav's links sit in list items that share the width. */
[data-tabs-bar] > li {
flex: 1 1 0%;
@@ -47,6 +65,7 @@
transition: color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
+ [data-tabs-bar][data-scrollable] > li,
[data-tabs-bar][data-scrollable] [data-tab] {
flex: none;
}
@@ -55,10 +74,12 @@
height: 4rem;
}
+ /* The state layer covers the tab and the ring's room around it, so the wash meets the divider. */
[data-tab]::before {
content: "";
position: absolute;
- inset: 0;
+ inset-block: calc(-1 * var(--tabs-ring));
+ inset-inline: 0;
pointer-events: none;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@@ -81,10 +102,12 @@
[data-tab]:focus-visible {
color: var(--md-sys-color-on-surface);
outline: 3px solid var(--md-sys-color-secondary);
- outline-offset: -3px;
+ outline-offset: 2px;
}
- [data-tab][aria-selected="true"] {
+ /* A link-based bar marks the page with aria-current, not aria-selected; both are the chosen
+ tab, and both take the variant's active colour. */
+ [data-tab]:is([aria-selected="true"], [aria-current="page"]) {
color: var(--md-sys-color-primary);
}
@@ -116,10 +139,12 @@
gap: 0.125rem;
}
+ /* Primary indicators are inset 2dp at each side; both variants sit on the divider, which the
+ ring's room has pushed below the tab. */
[data-tab-indicator] {
position: absolute;
- inset-inline: 0;
- bottom: 0;
+ inset-inline: 2px;
+ bottom: calc(-1 * var(--tabs-ring));
height: 3px;
border-radius: 3px 3px 0 0;
background-color: var(--md-sys-color-primary);
@@ -134,6 +159,7 @@
}
[data-tabs-bar][data-variant="secondary"] [data-tab-indicator] {
+ inset-inline: 0;
height: 2px;
border-radius: 0;
}
diff --git a/resources/css/components/toolbar.css b/resources/css/components/toolbar.css
index 512e5c42..d53903a3 100644
--- a/resources/css/components/toolbar.css
+++ b/resources/css/components/toolbar.css
@@ -7,8 +7,12 @@
* 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
*
- * Unlayered on purpose: a vibrant toolbar recolours the icon buttons inside it, which draw their
- * ink as utilities, and a rule in any layer loses to a utility.
+ * Placed over the page, a horizontal toolbar keeps 16dp from the window's edge and a vertical one
+ * 24dp, which is M3's minimum for each.
+ *
+ * Unlayered on purpose: a toolbar recolours the icon buttons inside it — primary in a standard
+ * one, on-primary-container in a vibrant one — and they draw their ink as utilities, which a rule
+ * in any layer loses to.
*/
[data-toolbar] {
@@ -60,6 +64,13 @@
color: var(--md-sys-color-on-surface);
}
+/* M3's colour list for a standard toolbar ends "Standard button (Primary)", as the vibrant list
+ ends "Standard button (On primary container)" — the row above. An icon button carries its own
+ ink as a utility, so the container's `color` never reaches it and this rule has to. */
+[data-toolbar]:not([data-vibrant]) [data-icon-button]:not([aria-pressed="true"]) {
+ color: var(--md-sys-color-primary);
+}
+
/* A floating toolbar and its FAB, side by side (or stacked, vertical). */
[data-toolbar-group] {
display: inline-flex;
@@ -71,10 +82,17 @@
flex-direction: column;
}
-/* Placed over the page: centred above the bottom edge, or centred against the end edge. */
+/* Placed over the page: centred above the bottom edge, or centred against the end edge.
+ *
+ * A toolbar at the bottom shares the screen region with a navigation bar, so it clears the one
+ * publishes as --material-bottom-bar, exactly as the FAB and the snackbar do —
+ * `max()`, not a sum, because that height already swallows the bottom safe area; without a bar
+ * the safe area alone applies. A *docked* toolbar and a navigation bar must never be on screen
+ * together at all (docs/reference/m3/components-navigation-selection-inputs.md § Toolbars); the
+ * offset is the backstop, not a licence. */
[data-toolbar-place="bottom"] {
position: fixed;
- bottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
+ bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + 1rem);
left: 50%;
z-index: 30;
translate: -50% 0;
@@ -88,9 +106,17 @@
translate: 0 -50%;
}
+/* "Horizontal toolbars should have a minimum 16dp margin from the edge of the window. Vertical
+ toolbars should have a minimum 24dp margin"
+ (docs/reference/m3/components-navigation-selection-inputs.md § Toolbars). */
+[data-toolbar][data-vertical][data-toolbar-place="end"],
+[data-toolbar-group][data-vertical][data-toolbar-place="end"] {
+ inset-inline-end: calc(1.5rem + var(--material-safe-right, env(safe-area-inset-right)));
+}
+
[data-toolbar][data-variant="docked"][data-toolbar-place="bottom"] {
inset-inline: 0;
- bottom: 0;
+ bottom: var(--material-bottom-bar, 0px);
left: 0;
translate: none;
}
diff --git a/resources/views/components/account-menu.blade.php b/resources/views/components/account-menu.blade.php
index b5417baf..3719ed9b 100644
--- a/resources/views/components/account-menu.blade.php
+++ b/resources/views/components/account-menu.blade.php
@@ -38,10 +38,12 @@
type="button"
aria-label="{{ $label }}"
data-account-menu
- class="focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-corner-full bg-primary-container type-label-lg text-on-primary-container"
+ class="state-layer touch-target focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full bg-primary-container type-label-lg text-on-primary-container"
>
@if ($image)
-
+ {{-- Behind the state layer (`state-layer` paints its ::before at z-index -1), so an
+ avatar image still shows the hover and pressed states over it. --}}
+
@elseif (filled($initials))
{{ $initials }}
@else
diff --git a/resources/views/components/app-shell.blade.php b/resources/views/components/app-shell.blade.php
index a95c2624..f0de2341 100644
--- a/resources/views/components/app-shell.blade.php
+++ b/resources/views/components/app-shell.blade.php
@@ -45,13 +45,23 @@
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
`navigate` (`false` for a full page load instead of `wire:navigate`).
- Slots, each rendered once: `brand` (beside the rail's menu button while it is expanded),
- `rail-header` (under it: a FAB — see `` for its two shapes), `rail-footer`
- (at the foot of the rail: footer destinations, an account), `actions` (a row of icon buttons at
- the very foot, stacked when the rail is collapsed: a theme toggle, sign out), `top` (the app
- bar, above the page at every width) 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.
+ Slots, each rendered once: `banner` (a bar across the whole window, above the rail and the
+ page — M3's scaffold is bars, then rails, then panes), `brand` (beside the rail's menu button
+ while it is expanded), `rail-header` (under it: one ``, which the
+ rail morphs between a FAB and an extended FAB as it opens), `rail-footer` (at the foot of the
+ rail: footer destinations, an account), `actions`
+ (a row of icon buttons at the very foot, stacked when the rail is collapsed: a theme toggle,
+ sign out), `top` (the page's own bar, above the page and *beside* the rail) and the page
+ itself. The rail is one element at every width, so what is in it is also in the modal rail a
+ phone opens. `label` names both navigation landmarks ("Main"); `rail-width` is the expanded
+ rail's width.
+
+ `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
+ it; a bar that titles the page belongs to the page, beside the rail. Put an app bar in one or
+ the other, 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 page is `` with `wire:transition.navigate`, behind a skip link that is
the first thing a keyboard reaches. The snackbar host (``) is part of the shell;
@@ -102,7 +112,7 @@
@if ($barItems->isNotEmpty())
diff --git a/resources/views/components/navigation-rail.blade.php b/resources/views/components/navigation-rail.blade.php
index 469d530c..78bb26e2 100644
--- a/resources/views/components/navigation-rail.blade.php
+++ b/resources/views/components/navigation-rail.blade.php
@@ -5,7 +5,7 @@
Mail
-
+
@@ -36,21 +36,30 @@
the same standard rail, expanded to begin with. A visitor who has used the menu button keeps
that choice in both standard bands.
- Slots: `brand` beside the menu button, only while expanded; `header` under it — a FAB, drawn
- as an extended FAB when expanded (`rail-collapsed:` below); the destinations in the default
- slot, which alone scroll when the window is too short; `footer`, pinned to the foot. Header and
- footer never scroll, so nothing in them is cut off by the scroller's edge.
+ Slots: `brand` beside the menu button, only while expanded; `header` under it — one
+ ``, which the rail morphs: the label's width springs open and shut
+ with the rail, so the FAB becomes an extended FAB and back rather than one being swapped for
+ the other, and its label names it at both widths. It also rests flat, because M3 puts a FAB
+ nested in another component at elevation 0, not the 3 a standalone one has. Then the
+ destinations in the default slot, which alone scroll when the window is too short, and
+ `footer`, pinned to the foot. Header and footer never scroll, so nothing in them is cut off by
+ the scroller's edge.
- Anything inside can take both shapes with the `rail-collapsed:` variant, which applies while
- the rail is drawn collapsed for whatever reason:
- ``
- ``.
+ Anything else inside can take both shapes with the `rail-collapsed:` variant, which applies
+ while the rail is drawn collapsed for whatever reason:
+ `…only while expanded…`
+ `…only while collapsed…`.
Nothing that shows while collapsed may be wider than 96px.
Props: `label` names the landmark ("Main"); `width` is the expanded width (`16rem`, held
between M3's 220 and 360dp); `menu` shows the menu button (by default for `collapsible`,
- `modal` and `adaptive`). The rail does not scroll with the page: in a flex row it sticks to
- the top of the viewport, as tall as the viewport at most.
+ `modal` and `adaptive`); `divider` draws M3's optional vertical divider on the edge the page
+ is on — which is also what M3 asks for when a page scrolls underneath a fixed rail; `fill`
+ (`false`) drops the container colour for a transparent rail over the page's own background,
+ which M3 allows as long as the items keep a 3:1 contrast against what is behind them. A rail
+ open over a scrim keeps its fill and drops the divider whatever those say: it is a surface
+ over the page then. The rail does not scroll with the page: in a flex row it sticks to the top
+ of the viewport, as tall as the viewport at most.
Values from androidx Compose Material 3 (Apache-2.0), androidx-main
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationRailCollapsedTokens.kt,
@@ -66,6 +75,8 @@
'label' => null,
'width' => '16rem',
'menu' => null,
+ 'divider' => false,
+ 'fill' => true,
])
@php
@@ -78,6 +89,8 @@
diff --git a/resources/views/components/section-nav.blade.php b/resources/views/components/section-nav.blade.php
index ccecc959..33bb3870 100644
--- a/resources/views/components/section-nav.blade.php
+++ b/resources/views/components/section-nav.blade.php
@@ -13,10 +13,15 @@
The page's URL is `Livewire::originalUrl()`: while a Livewire component on the page updates, the
request is Livewire's update endpoint, and comparing with it left no section lit.
- A row too long for its column wraps onto a grid rather than scrolling: below `large` (1200px)
- five or six
- sections go 3 + 3 and seven or more go four to a row — tabs that scroll hid the last sections on
- a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless
+ Up to four sections share the row as fixed tabs. From five the row is M3's scrollable tab bar
+ — each tab as wide as its own label, the set scrolling sideways, offset 52dp from the leading
+ edge so it reads as scrollable — which is M3's own answer to a row that will not fit, and the
+ one its accessibility page blesses ("horizontal scrolling tabs meet accessibility requirements
+ because they need to increase in width to respond to label text without affecting the
+ layout"). An earlier version wrapped them onto a grid instead, which left the bar's divider
+ under the last row only and stranded the upper rows' indicators against nothing.
+
+ `label` names the navigation ("Sections"). Links use `wire:navigate` unless
`no-wire-navigate`. --}}
@props([
@@ -31,11 +36,8 @@
$isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && $page === url($item['url']));
$current = collect($items)->first($isCurrent) ?? ($items[0] ?? null);
- $layout = match (true) {
- count($items) < 5 => 'medium:flex',
- count($items) < 7 => 'medium:grid medium:grid-cols-3 large:flex',
- default => 'medium:grid medium:grid-cols-4 large:flex',
- };
+ // Four fit a row at the widths this bar is used at; from five they are M3's scrollable tabs.
+ $scrollable = count($items) >= 5;
@endphp