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…`, ``. 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…`, ``. 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 @@
$barItems->isNotEmpty(), ]) > @@ -112,48 +122,54 @@ class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none" >{{ __('Skip to content') }} - - @isset($brand) - {{ $brand }} - @endisset + @isset($banner) +
{{ $banner }}
+ @endisset - @isset($railHeader) - {{ $railHeader }} - @endisset +
+ + @isset($brand) + {{ $brand }} + @endisset - @foreach ($groups as $group) - @if ($group->first()['section'] !== null) - + @isset($railHeader) + {{ $railHeader }} + @endisset + + @foreach ($groups as $group) + @if ($group->first()['section'] !== null) + + @foreach ($group as $item) + + @endforeach + + @else @foreach ($group as $item) @endforeach - - @else - @foreach ($group as $item) - - @endforeach + @endif + @endforeach + + @if (isset($railFooter) || isset($actions)) + + {{ $railFooter ?? '' }} + + @isset($actions) +
+ {{ $actions }} +
+ @endisset +
@endif - @endforeach +
- @if (isset($railFooter) || isset($actions)) - - {{ $railFooter ?? '' }} +
+ {{ $top ?? '' }} - @isset($actions) -
- {{ $actions }} -
- @endisset - - @endif - - -
- {{ $top ?? '' }} - -
- {{ $slot }} -
+
+ {{ $slot }} +
+
@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…` + ``. 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
class(['min-w-0']) }} data-section-nav> @@ -45,7 +47,7 @@ - @endforeach -
-@elseif ($mode === 'picker') -
class(['inline-flex h-10 rounded-corner-full border border-outline']) }} - > - @foreach (['light' => ['Light', 'light_mode'], 'dark' => ['Dark', 'dark_mode'], 'system' => ['System', 'brightness_auto']] as $choice => [$text, $icon]) - - @endforeach +
@else