Merge branch 'worktree-agent-ab53265ffbd837742'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:21:01 +02:00
23 changed files with 572 additions and 195 deletions
@@ -703,8 +703,7 @@ A visitor who has pressed the menu button keeps that choice in both standard ban
]">
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
<x-slot:rail-header>
<span class="rail-collapsed:hidden"><x-fab label="New share" icon="add" link="{{ route('upload') }}" /></span>
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="add" tooltip-right="New share" link="{{ route('upload') }}" /></span>
<x-fab label="New share" icon="add" link="{{ route('upload') }}" />
</x-slot:rail-header>
<x-slot:rail-footer>
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
@@ -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 `<x-app-shell>`), so the rail sticks under it instead of behind it.
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. On a compact window nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `medium`).
- `--material-margin` is M3's window margin (16px compact, 24px from `medium`) and the content region already carries it, so a page inside the shell writes no gutters of its own; something that must reach the window's edges opts out with `-mx-(--material-margin)`.
- Two panes side by side are M3's from `expanded`: `<x-drawer pane>` 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 (
<div class="flex min-h-dvh">
<x-navigation-rail mode="collapsible">
<x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
<x-slot:header><x-fab icon="add" tooltip-right="New share" /></x-slot:header>
<x-slot:header><x-fab label="New share" icon="add" /></x-slot:header>
<x-navigation-rail-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" active badge="3" />
<x-navigation-rail-section label="Admin">
@@ -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` (`<x-app-shell>`'s, one rail per window size class: hidden and opened as a modal on a compact window, collapsed and opened as a modal at `medium`, a standard rail from `expanded` — collapsed there, expanded from `large`).
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`). 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: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`), `divider` (M3's optional vertical divider on the page's side — use it when the page scrolls under a fixed rail), `fill` (`false` for a transparent container, which M3 allows while the items keep 3:1 contrast). Slots: `brand` (beside the menu button, expanded only), `header` (one `<x-fab label icon>`, which the rail morphs into an extended FAB and back as it expands — it also rests at elevation 0, as M3 asks of a nested FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
- Anything else inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
- A `collapsible` rail is held to the collapsed 96px below `medium` (600px), where M3 says to use a navigation bar rather than a standard rail. `collapsed` and `expanded` are fixed-width by design: wrap one in a `medium:` element if it must not show on a phone.
- `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way.
- `$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
</x-toolbar>
```
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.
### `<x-tabs>`, `<x-tab>`
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 `<x-tab name>` 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
### `<x-section-nav>`
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`.
### `<x-account-menu>`
@@ -826,7 +829,7 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
### `<x-theme-toggle>`
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 `<x-group>` — 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.
### `<x-scheme-picker>`
+36 -12
View File
@@ -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;
}
+90 -4
View File
@@ -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 220360px rail would be most of the screen, so a rail whose width is the
visitor's choice is held collapsed there whatever they chose. */
@media (width < 37.5rem) {
&:where([data-navigation-rail='collapsible'], [data-navigation-rail='collapsible'] *) {
@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
<x-app-shell>'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 <x-fab label icon> 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;
}
}
+33 -7
View File
@@ -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;
}
+31 -5
View File
@@ -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
* <x-app-shell> 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;
}
@@ -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)
<img src="{{ $avatar }}" alt="" class="size-full object-cover" />
{{-- 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. --}}
<img src="{{ $avatar }}" alt="" class="relative -z-20 size-full rounded-corner-full object-cover" />
@elseif (filled($initials))
{{ $initials }}
@else
+24 -8
View File
@@ -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 `<x-navigation-rail>` 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 `<x-fab label="…" icon="…">`, 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 `<x-app-shell>` so the rail sticks under it instead
of behind it.
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
@@ -102,7 +112,7 @@
<div
data-app-shell
@class([
'min-h-dvh bg-surface text-on-surface [--material-margin:1rem] medium:flex medium:[--material-margin:1.5rem]',
'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(),
])
>
@@ -112,6 +122,11 @@
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') }}</a>
@isset($banner)
<div data-app-shell-banner class="shrink-0">{{ $banner }}</div>
@endisset
<div class="flex-1 medium:flex">
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
@isset($brand)
<x-slot:brand>{{ $brand }}</x-slot:brand>
@@ -155,6 +170,7 @@
{{ $slot }}
</main>
</div>
</div>
@if ($barItems->isNotEmpty())
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 medium:hidden">
@@ -5,7 +5,7 @@
<x-navigation-rail mode="collapsible">
<x-slot:brand><span class="type-title-lg">Mail</span></x-slot:brand>
<x-slot:header>
<x-fab icon="edit" tooltip-right="Compose" />
<x-fab label="Compose" icon="edit" />
</x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
@@ -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
`<x-fab label="…" icon="…">`, 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:
`<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>`
`<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>`.
Anything else inside can take both shapes with the `rail-collapsed:` variant, which applies
while the rail is drawn collapsed for whatever reason:
`<span class="rail-collapsed:hidden">…only while expanded…</span>`
`<span class="hidden rail-collapsed:inline-flex">…only while collapsed…</span>`.
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 @@
<div
data-navigation-rail="{{ $mode }}"
@if ($divider) data-divider @endif
@unless ($fill) data-fill="false" @endunless
@if ($interactive)
x-data="materialNavigationRail('{{ $mode }}')"
x-bind:data-open="open"
@@ -109,7 +122,7 @@
x-on:click="menu()"
x-bind:aria-label="expanded ? @js(__('Collapse navigation')) : @js(__('Expand navigation'))"
x-bind:aria-expanded="expanded.toString()"
class="state-layer focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant after:absolute after:top-1/2 after:left-1/2 after:size-12 after:-translate-x-1/2 after:-translate-y-1/2"
class="state-layer touch-target focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant"
>
<span class="contents rail-collapsed:hidden"><x-livewire-material::icon name="menu_open" class="size-6" /></span>
<span class="hidden rail-collapsed:contents"><x-livewire-material::icon name="menu" class="size-6" /></span>
@@ -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
<div {{ $attributes->class(['min-w-0']) }} data-section-nav>
@@ -45,7 +47,7 @@
<x-slot:trigger>
<button type="button" class="focus-ring flex h-12 w-[min(20rem,calc(100vw-2rem))] cursor-pointer items-center gap-3 rounded-corner-xs border border-outline px-4 text-start type-body-lg text-on-surface">
@isset($current['icon'])
<x-livewire-material::icon :name="$current['icon']" class="size-5 text-on-surface-variant" />
<x-livewire-material::icon :name="$current['icon']" optical="20" class="size-5 text-on-surface-variant" />
@endisset
<span class="min-w-0 flex-1 truncate">{{ $current['title'] }}</span>
<x-livewire-material::icon name="arrow_drop_down" class="size-6 text-on-surface-variant" />
@@ -60,7 +62,7 @@
@endif
<nav aria-label="{{ $label }}" class="max-medium:hidden">
<ul data-tabs-bar data-variant="secondary" class="{{ $layout }}">
<ul data-tabs-bar data-variant="secondary" @if ($scrollable) data-scrollable @endif>
@foreach ($items as $item)
@php($on = $isCurrent($item))
@@ -73,7 +75,7 @@
>
<span data-tab-content>
@isset($item['icon'])
<x-livewire-material::icon :name="$item['icon']" :filled="$on" class="size-5" />
<x-livewire-material::icon :name="$item['icon']" :filled="$on" optical="20" class="size-5" />
@endisset
<span class="truncate">{{ $item['title'] }}</span>
@if (filled($item['badge'] ?? null))
+29 -3
View File
@@ -1,17 +1,43 @@
{{-- One tab's panel, inside `<x-tabs>`, which draws the bar from its own `tabs` list. Shown while
its `name` is the chosen one; every panel is rendered, so switching never waits on the server.
`class` lands on the panel (it has `pt-6` above its content by default). --}}
`class` lands on the panel (it has `pt-6` above its content by default).
The id and the `hidden` panels come from the server, not from Alpine: until Alpine boots, every
panel would otherwise be on screen at once four `role="tabpanel"` regions for a screen reader
to walk and the tab buttons' `aria-controls` would point at nothing. `<x-tabs>` renders this
slot before its own view, so it cannot hand the panel anything; the panel reads the attributes
written on `<x-tabs>` off the component stack (Blade's `@aware` mechanism) and repeats the two
lines tabs.blade.php runs on them. The two must agree, so change them together. --}}
@props([
'name',
])
@php
$aware = fn (string $key, $default = null) => $__env->getConsumableComponentData($key, $default);
$tabs = $aware('tabs', []);
// `wire:model` is a key, not a prop, and its modifiers are part of the key: the spellings that
// make sense on a tab set. An exotic one only means the panel falls back to the first tab.
$model = collect(['wire:model', 'wire:model.live', 'wire:model.blur', 'wire:model.change', 'wire:model.lazy'])
->map(fn (string $key) => $aware($key))
->first(fn ($value): bool => filled($value));
$tabsId = $aware('id') ?? 'tabs-'.substr(md5($model.'|'.implode('|', array_column($tabs, 'name'))), 0, 8);
$initial = $aware('selected') ?? (collect($tabs)->first(fn (array $tab): bool => ! ($tab['disabled'] ?? false))['name'] ?? null);
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null && filled(data_get($component, $model))) {
$initial = data_get($component, $model);
}
@endphp
<div
x-show="selected === @js($name)"
role="tabpanel"
x-bind:id="tabsId + '-' + @js($name) + '-panel'"
x-bind:aria-labelledby="tabsId + '-' + @js($name)"
id="{{ $tabsId }}-{{ $name }}-panel"
aria-labelledby="{{ $tabsId }}-{{ $name }}"
tabindex="0"
@if ((string) $name !== (string) $initial) style="display: none" @endif
{{ $attributes->class(['pt-6 outline-none']) }}
>
{{ $slot }}
+4 -1
View File
@@ -3,7 +3,8 @@
The bar is rendered on the server from `tabs`, a list of `['name' => …, 'label' => …]` with an
optional `icon`, `badge` and `disabled` so it arrives with the page, never a moment after it.
The panels are `<x-tab name="…">` in the slot; every panel is rendered, so switching never waits
on the server.
on the server, and each arrives with its id and with every panel but the chosen one already
hidden, so nothing flashes and no screen reader meets four panels at once.
The chosen tab is the Livewire property in `wire:model` (entangled; `wire:model.live` tells the
server at once), or, without it, `selected` (the first tab by default) and `x-model`. The bar is
@@ -24,6 +25,8 @@
@php
$model = $attributes->wire('model')->value() ?: null;
$variant = $variant === 'secondary' ? 'secondary' : 'primary';
// tab.blade.php repeats these two lines off the component stack, because a slot renders before
// the component around it: change them together, or a panel's id stops matching aria-controls.
$id = $attributes->get('id') ?? 'tabs-'.substr(md5($model.'|'.implode('|', array_column($tabs, 'name'))), 0, 8);
$initial = $selected ?? (collect($tabs)->first(fn (array $tab): bool => ! ($tab['disabled'] ?? false))['name'] ?? null);
@@ -6,76 +6,79 @@
you a sun while the page is dark, a moon while it is light and it is a toggle button,
"Dark theme", pressed while dark.
- `cycle`: an icon button that steps light dark system, showing the choice it is on.
- `picker`: M3's segmented buttons for the three choices, for a settings page.
- `picker`: the three choices as a connected button group, for a settings page.
- `contrast`: the same row for M3's three contrast levels standard, medium (3:1) and high
(7:1), the three the scheme is generated in. The row marks the level in force
(`resolvedContrast`), so the operating system's setting shows while the choice is
`system`, and choosing one is an explicit choice from then on.
The two rows are `<x-group>` M3 Expressive's connected button group, the successor of the
segmented button, which the expressive update deprecates. Its segments are native radios, so
the browser supplies the radiogroup semantics, both arrow axes, the wrap and the roving tab
stop; the store is bound through an `x-model` accessor on the wrapper, since the level a
contrast row marks is the resolved one but the level it writes goes through `setContrast()`.
`label` adds a visible legend (a settings page); without one the row is still named for a
screen reader. `class` lands on the button or on the group.
The theme is the visitor's, known only in the browser, so the parts that depend on it wait for
Alpine (`x-cloak`) rather than render a guess. `class` lands on the button or the group. --}}
Alpine (`x-cloak`, an unchecked radio) rather than render a guess. --}}
@props([
'mode' => 'toggle',
'label' => null,
])
@php
$mode = in_array($mode, ['toggle', 'cycle', 'picker', 'contrast'], true) ? $mode : 'toggle';
$row = [
'picker' => [
'name' => __('Theme'),
'field' => 'material-theme',
'read' => '$store.theme.choice',
'write' => '$store.theme.set(value)',
'options' => [
['id' => 'light', 'name' => __('Light'), 'icon' => 'light_mode'],
['id' => 'dark', 'name' => __('Dark'), 'icon' => 'dark_mode'],
['id' => 'system', 'name' => __('System'), 'icon' => 'brightness_auto'],
],
],
'contrast' => [
'name' => __('Contrast'),
'field' => 'material-contrast',
'read' => '$store.theme.resolvedContrast',
'write' => '$store.theme.setContrast(value)',
'options' => [
['id' => 'standard', 'name' => __('Standard'), 'icon' => 'contrast'],
['id' => 'medium', 'name' => __('Medium'), 'icon' => 'contrast_circle'],
['id' => 'high', 'name' => __('High'), 'icon' => 'contrast_square'],
],
],
][$mode] ?? null;
@endphp
@if ($mode === 'contrast')
@if ($row !== null)
<div
role="radiogroup"
aria-label="{{ __('Contrast') }}"
x-data
data-theme-toggle="contrast"
{{ $attributes->class(['inline-flex h-10 rounded-corner-full border border-outline']) }}
role="group"
aria-label="{{ $label ?? $row['name'] }}"
data-theme-toggle="{{ $mode }}"
x-data="{
get chosen() {
return this.{{ $row['read'] }}
},
set chosen(value) {
this.{{ $row['write'] }}
},
}"
>
@foreach (['standard' => ['Standard', 'contrast'], 'medium' => ['Medium', 'contrast_circle'], 'high' => ['High', 'contrast_square']] as $level => [$text, $icon])
<button
type="button"
role="radio"
aria-checked="false"
x-bind:aria-checked="($store.theme.resolvedContrast === '{{ $level }}').toString()"
x-bind:tabindex="$store.theme.resolvedContrast === '{{ $level }}' ? 0 : -1"
x-on:click="$store.theme.setContrast('{{ $level }}')"
x-on:keydown.arrow-right.prevent="$el.nextElementSibling?.click(); $el.nextElementSibling?.focus();"
x-on:keydown.arrow-left.prevent="$el.previousElementSibling?.click(); $el.previousElementSibling?.focus();"
data-contrast-option="{{ $level }}"
class="state-layer focus-ring inline-flex min-w-0 flex-1 cursor-pointer items-center justify-center gap-2 border-outline px-3 type-label-lg text-on-surface not-first:border-s first:rounded-s-corner-full last:rounded-e-corner-full aria-checked:bg-secondary-container aria-checked:text-on-secondary-container"
>
<x-livewire-material::icon name="check" class="hidden size-4.5 in-aria-checked:block" />
<x-livewire-material::icon :name="$icon" class="size-4.5 in-aria-checked:hidden" />
{{ __($text) }}
</button>
@endforeach
</div>
@elseif ($mode === 'picker')
<div
role="radiogroup"
aria-label="{{ __('Theme') }}"
x-data
data-theme-toggle="picker"
{{ $attributes->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])
<button
type="button"
role="radio"
aria-checked="false"
x-bind:aria-checked="($store.theme.choice === '{{ $choice }}').toString()"
x-bind:tabindex="$store.theme.choice === '{{ $choice }}' ? 0 : -1"
x-on:click="$store.theme.set('{{ $choice }}')"
x-on:keydown.arrow-right.prevent="$el.nextElementSibling?.click(); $el.nextElementSibling?.focus();"
x-on:keydown.arrow-left.prevent="$el.previousElementSibling?.click(); $el.previousElementSibling?.focus();"
data-theme-option="{{ $choice }}"
class="state-layer focus-ring inline-flex min-w-0 flex-1 cursor-pointer items-center justify-center gap-2 border-outline px-3 type-label-lg text-on-surface not-first:border-s first:rounded-s-corner-full last:rounded-e-corner-full aria-checked:bg-secondary-container aria-checked:text-on-secondary-container"
>
<x-livewire-material::icon name="check" class="hidden size-4.5 in-aria-checked:block" />
<x-livewire-material::icon :name="$icon" class="size-4.5 in-aria-checked:hidden" />
{{ __($text) }}
</button>
@endforeach
<x-livewire-material::group
inline
x-model="chosen"
:label="$label"
:name="$row['field']"
:options="$row['options']"
{{ $attributes }}
/>
</div>
@else
<button
@@ -93,7 +96,7 @@
x-bind:aria-pressed="($store.theme.resolved === 'dark').toString()"
x-on:click="$store.theme.toggle()"
@endif
{{ $attributes->class(['state-layer focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant transition-[border-radius] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast active:rounded-corner-sm']) }}
{{ $attributes->class(['state-layer touch-target focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant transition-[border-radius] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast active:rounded-corner-sm']) }}
>
@if ($mode === 'cycle')
<x-livewire-material::icon name="light_mode" x-cloak x-show="$store.theme.choice === 'light'" />
@@ -7,6 +7,11 @@
toolbar sits where it is written. A `fab` slot sets an `<x-fab>` beside a floating toolbar.
`label` names it for screen readers.
A docked toolbar and a navigation bar occupy the same region of the screen and M3 says never to
show both at once: the bar belongs on a primary page, the toolbar on a secondary or contextual
one. Either way a toolbar placed at `bottom` clears `--material-bottom-bar`, so it is never
buried under `<x-app-shell>`'s bar.
Put `<x-button icon="…" tooltip="…" />` controls in the slot (`:selected` for toggles). It is a
`role="toolbar"`: the arrow keys move between its controls (resources/js/toolbar.js,
resources/css/components/toolbar.css). --}}
+1 -1
View File
@@ -57,7 +57,7 @@
fetched on first focus, and / or Ctrl+K (⌘K) reaches it from anywhere. Its names
keep clear of <x-search>'s own (`results`, `open`), which the slot also sees. --}}
<div
class="mx-auto w-full max-w-2xl"
class="w-full"
x-data="{
query: '',
entries: null,
@@ -34,9 +34,9 @@
'Collapsed and expanded rails' => <<<'BLADE'
<div class="flex w-full flex-wrap items-start gap-6">
<div class="flex h-[36rem] overflow-hidden rounded-corner-lg border border-outline-variant">
<x-navigation-rail mode="collapsed" label="Collapsed example">
<x-navigation-rail mode="collapsed" label="Collapsed example" divider>
<x-slot:header>
<x-fab icon="edit" tooltip-right="Compose" />
<x-fab label="Compose" icon="edit" />
</x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
@@ -82,8 +82,7 @@
</x-slot:brand>
<x-slot:header>
<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>
<x-fab label="Compose" icon="edit" />
</x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
@@ -92,7 +91,7 @@
</x-navigation-rail>
<div class="min-w-0 flex-1 bg-surface-container-low p-6 type-body-md text-on-surface-variant">
The menu button collapses and expands the rail. The choice is remembered and applied before the next page paints every collapsible rail follows it, <code>&lt;x-app-shell&gt;</code>'s from <code>lg</code> too.
The menu button collapses and expands the rail, and the FAB morphs into an extended FAB with it. The choice is remembered and applied before the next page paints every collapsible rail follows it, <code>&lt;x-app-shell&gt;</code>'s from <code>expanded</code> (840px) too.
</div>
</div>
BLADE,
@@ -104,7 +103,7 @@
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-navigation-bar&gt;</code> and <code>&lt;x-navigation-bar-item&gt;</code>, <code>&lt;x-navigation-rail&gt;</code>, <code>&lt;x-navigation-rail-item&gt;</code> and <code>&lt;x-navigation-rail-section&gt;</code>,
and <code>&lt;x-app-shell&gt;</code>, which puts them together: a bar below <code>sm</code>, a collapsed rail that opens as a modal to <code>lg</code>, a collapsible rail from there.
and <code>&lt;x-app-shell&gt;</code>, which puts them together on M3's window size classes: a bar below <code>medium</code> (600px), a collapsed rail that opens as a modal through <code>medium</code>, a standard rail from <code>expanded</code> (840px) and an expanded one from <code>large</code> (1200px).
<a href="{{ route('livewire-material.shell') }}" class="link text-primary">Open the app shell</a> and change the window's width.
</p>
+1 -2
View File
@@ -45,8 +45,7 @@
</x-slot:brand>
<x-slot:rail-header>
<span class="rail-collapsed:hidden"><x-livewire-material::fab label="Compose" icon="edit" x-on:click="materialToast('Compose opens here')" /></span>
<span class="hidden rail-collapsed:inline-flex"><x-livewire-material::fab icon="edit" tooltip-right="Compose" x-on:click="materialToast('Compose opens here')" /></span>
<x-livewire-material::fab label="Compose" icon="edit" x-on:click="materialToast('Compose opens here')" />
</x-slot:rail-header>
<x-slot:rail-footer>
+2 -2
View File
@@ -120,14 +120,14 @@ it('moves focus along a toolbar with the arrow keys', function () {
it('switches the theme from a toggle, a cycle and a picker', function () {
$page = barsProbe()
->click('[data-theme-option="light"]')
->click('label:has(input[name="material-theme"][value="light"])')
->assertScript("document.documentElement.dataset.theme === 'light'")
->assertAttribute('#toggle', 'aria-pressed', 'false');
$page->click('#toggle')
->assertScript("document.documentElement.dataset.theme === 'dark'")
->assertAttribute('#toggle', 'aria-pressed', 'true')
->assertAttribute('[data-theme-option="dark"]', 'aria-checked', 'true');
->assertScript('document.querySelector(\'input[name="material-theme"][value="dark"]\').checked');
$page->click('#cycle')
->assertScript("document.documentElement.dataset.themeChoice === 'system'")
+4 -4
View File
@@ -31,9 +31,9 @@ it('keeps the visitor\'s choice over the operating system', function () {
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
->assertScript(theme('data-theme', 'dark'));
$page->click('[data-theme-option="light"]')
$page->click('label:has(input[name="material-theme"][value="light"])')
->assertScript(theme('data-theme', 'light'))
->assertAttribute('[data-theme-option="light"]', 'aria-checked', 'true')
->assertScript('document.querySelector(\'input[name="material-theme"][value="light"]\').checked')
->assertScript("localStorage.getItem('material-theme') === 'light'");
$page->refresh()
@@ -98,7 +98,7 @@ it('adds a theme-color meta in the painted surface, and follows the theme and th
->assertScript(themeColorIs($profiles['baseline']['light']['surface']))
->assertScript(pageSurfaceIs($profiles['baseline']['light']['surface']));
$page->click('[data-theme-option="dark"]')
$page->click('label:has(input[name="material-theme"][value="dark"])')
->assertScript(theme('data-theme', 'dark'))
->assertScript(themeColorIs($profiles['baseline']['dark']['surface']))
->assertScript(pageSurfaceIs($profiles['baseline']['dark']['surface']));
@@ -108,7 +108,7 @@ it('adds a theme-color meta in the painted surface, and follows the theme and th
->assertScript(themeColorIs($profiles['rose']['dark']['surface']))
->assertScript(pageSurfaceIs($profiles['rose']['dark']['surface']));
$page->click('[data-theme-option="light"]')
$page->click('label:has(input[name="material-theme"][value="light"])')
->assertScript(themeColorIs($profiles['rose']['light']['surface']))
->assertScript(pageSurfaceIs($profiles['rose']['light']['surface']))
->assertNoJavaScriptErrors();
+53 -10
View File
@@ -69,31 +69,70 @@ it('draws floating and docked toolbars', function () {
->not->toContain('data-toolbar-group');
});
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');
expect($css)
// Not a fixed 56px inset, which fits exactly one of M3's two trailing buttons (N-11).
->not->toContain('inset-inline: 3.5rem;')
->toMatch('/\[data-variant="center"\] \[data-app-bar-row\] \{\s+display: grid;\s+grid-template-columns: 1fr auto 1fr;/')
// 312dp, then half of what is left (N-09).
->toContain('max-width: calc(19.5rem + (100% - 19.5rem) / 2);');
});
it('keeps a toolbar at the bottom clear of the navigation bar', function () {
$css = file_get_contents(__DIR__.'/../../../resources/css/components/toolbar.css');
expect($css)
// Never a sum: --material-bottom-bar already swallows the bottom safe area (N-02).
->toContain('bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + 1rem);')
->toContain('bottom: var(--material-bottom-bar, 0px);')
// A vertical toolbar keeps 24dp from the edge where a horizontal one keeps 16 (N-12).
->toContain('inset-inline-end: calc(1.5rem + var(--material-safe-right, env(safe-area-inset-right)));')
// A standard toolbar's standard buttons are primary (N-13).
->toContain('[data-toolbar]:not([data-vibrant]) [data-icon-button]:not([aria-pressed="true"]) {');
});
it('offers M3\'s three contrast levels, marking the one in force', function () {
expect((string) $this->blade('<x-theme-toggle mode="contrast" />'))
->toContain('data-theme-toggle="contrast"')
->toContain('role="radiogroup"')
->toContain('aria-label="Contrast"')
->toContain('data-contrast-option="standard"')
->toContain('data-contrast-option="medium"')
->toContain('data-contrast-option="high"')
->toContain("\$store.theme.setContrast('high')")
// A connected button group over native radios, not the deprecated segmented button (N-04).
->toContain('data-button-group="connected"')
->toContain('name="material-contrast"')
->toContain('value="standard"')
->toContain('value="medium"')
->toContain('value="high"')
->toContain('$store.theme.setContrast(value)')
// The row follows the resolved level, so the operating system's shows under `system`.
->toContain("(\$store.theme.resolvedContrast === 'medium').toString()");
->toContain('return this.$store.theme.resolvedContrast');
});
it('names a theme row for a screen reader and legends it on request', function () {
expect((string) $this->blade('<x-theme-toggle mode="picker" />'))
->toContain('aria-label="Theme"')
->not->toContain('<legend')
->and((string) $this->blade('<x-theme-toggle mode="picker" label="Appearance" />'))
->toContain('aria-label="Appearance"')
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Appearance</legend>');
});
it('switches the theme through the store in three shapes', function () {
expect((string) $this->blade('<x-theme-toggle />'))
->toContain('data-theme-toggle="toggle"')
->toContain('aria-label="Dark theme"')
// 40px drawn, 48px reached: M3's minimum target (N-01).
->toContain('touch-target')
->toContain('$store.theme.toggle()')
->and((string) $this->blade('<x-theme-toggle mode="cycle" />'))
->toContain('data-theme-toggle="cycle"')
->toContain("{ light: 'dark', dark: 'system', system: 'light' }")
->and((string) $this->blade('<x-theme-toggle mode="picker" />'))
->toContain('role="radiogroup"')
->toContain('data-theme-option="system"')
->toContain("\$store.theme.set('dark')");
->toContain('data-theme-toggle="picker"')
->toContain('x-model="chosen"')
->toContain('name="material-theme"')
->toContain('value="system"')
->toContain('$store.theme.set(value)');
});
it('opens an account menu from the initials of a name', function () {
@@ -107,12 +146,16 @@ it('opens an account menu from the initials of a name', function () {
expect($html)
->toContain('aria-label="Account"')
->toContain('data-account-menu')
// The 40px avatar reaches 48px, and nothing clips the pseudo-target (N-01); the trigger
// has a hover and a pressed state like every other trigger in the library (N-15).
->toContain('state-layer touch-target focus-ring')
->not->toContain('overflow-hidden')
->toMatch('/>\s*AM\s*<\/button>/')
->toContain('anna@example.com')
->toContain('Settings')
->toContain('data-account-theme')
->toContain('Sign out')
->and((string) $this->blade('<x-account-menu avatar="/anna.jpg" :theme="false" />'))
->toContain('<img src="/anna.jpg"')
->toContain('<img src="/anna.jpg" alt="" class="relative -z-20 size-full rounded-corner-full object-cover" />')
->not->toContain('data-account-theme');
});
+17 -1
View File
@@ -109,6 +109,7 @@ it('reads the safe area and anything docked on the bar through variables an appl
it('places each slot once', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-app-shell :destinations="$destinations">
<x-slot:banner><header>WINDOW BAR</header></x-slot:banner>
<x-slot:brand><span>BRAND</span></x-slot:brand>
<x-slot:rail-header><span>FAB</span></x-slot:rail-header>
<x-slot:rail-footer><span>FOOTER</span></x-slot:rail-footer>
@@ -118,13 +119,28 @@ it('places each slot once', function () {
</x-app-shell>
BLADE, ['destinations' => shellDestinations()]);
foreach (['BRAND', 'FAB', 'FOOTER', 'ACTIONS', 'APP BAR', 'PAGE'] as $slot) {
foreach (['WINDOW BAR', 'BRAND', 'FAB', 'FOOTER', 'ACTIONS', 'APP BAR', 'PAGE'] as $slot) {
expect(substr_count($html, $slot))->toBe(1);
}
expect($html)->toMatch('/data-navigation-rail-header.*BRAND.*FAB.*data-navigation-rail-footer.*FOOTER.*data-app-shell-actions.*ACTIONS.*APP BAR.*<main.*PAGE/s');
});
it('spans the window with a banner, above the rail, and renders none without the slot', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-app-shell :destinations="$destinations">
<x-slot:banner><header>WINDOW BAR</header></x-slot:banner>
PAGE
</x-app-shell>
BLADE, ['destinations' => shellDestinations()]);
// Bars, then rails, then panes: the banner is outside the row the rail and the page share.
expect($html)
->toMatch('/data-app-shell-banner.*WINDOW BAR.*data-navigation-rail=.*<main/s')
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
->not->toContain('data-app-shell-banner');
});
it('serves the showcase\'s app shell pages', function () {
$html = $this->withoutVite()
->get('/material/shell/starred')
@@ -1,5 +1,7 @@
<?php
use Illuminate\Support\Str;
it('draws a navigation landmark around its items', function () {
expect((string) $this->blade('<x-navigation-bar><x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-bar>'))
->toContain('<nav aria-label="Main" data-navigation-bar')
@@ -46,3 +48,15 @@ it('badges the icon with a dot or a count that screen readers hear', function ()
->and($dot)->toContain('size-1.5')->toContain('<span class="sr-only">, New mail</span>')
->and((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" />'))->not->toContain('sr-only');
});
it('keeps the bar item on the label and state-layer colours M3 tokens', function () {
$css = file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css');
$horizontal = Str::of($css)->after('@container (width >= 37.5rem)')->before('/* ------')->toString();
expect($horizontal)
// NavigationBarTokens names one label font for both icon positions: label-medium (N-08).
->not->toContain('--md-sys-typescale-label-lg')
->and($css)
// The indicator and the pill wash in on-secondary-container, as the rail's do (N-19).
->toContain('[data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill])::before {');
});
@@ -22,6 +22,8 @@ it('gives the collapsible, modal and adaptive rails a menu button and the store'
->toContain('data-navigation-rail-menu')
->toContain('aria-label="Collapse navigation"')
->toContain('aria-expanded="true"')
// The 40px menu button reaches M3's 48px target through the shared utility (N-01).
->toContain('touch-target')
->not->toContain('data-navigation-rail-scrim')
->not->toContain('x-trap')
->and((string) $this->blade('<x-navigation-rail mode="modal" />'))
@@ -49,6 +51,34 @@ it('keeps the header and footer out of the scrolling destinations', function ()
->toMatch('/data-navigation-rail-header.*Brand.*FAB.*data-navigation-rail-destinations.*Inbox.*data-navigation-rail-footer.*Account/s');
});
it('takes M3\'s optional divider and turns the container fill off', function () {
expect((string) $this->blade('<x-navigation-rail mode="expanded" divider :fill="false" />'))
->toContain('data-divider')
->toContain('data-fill="false"')
->and((string) $this->blade('<x-navigation-rail mode="expanded" />'))
->not->toContain('data-divider')
->not->toContain('data-fill');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
// Neither reaches a rail open over a scrim, which is a surface over the page (N-22).
->toContain('[data-navigation-rail][data-divider]:not([data-open]) > [data-navigation-rail-panel] {')
->toContain("[data-navigation-rail][data-fill='false']:not([data-open]) > [data-navigation-rail-panel] {")
// A collapsible rail is held to 96px where M3 asks for a navigation bar instead (N-24).
->toMatch("/\\[data-navigation-rail='collapsible'\\] \\{\\s+width: 6rem;/");
});
it('flattens a FAB nested in the rail header and morphs its label', function () {
// Both rules hang off `data-fab` on <x-fab>'s root, and both are unlayered, because what they
// beat — the FAB's shadow, its gap, an extended FAB's minimum width — are utilities.
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
// A nested FAB rests at elevation 0, not the 3 a standalone one has (N-03).
->toContain('[data-navigation-rail-header] [data-fab],')
->toContain('[data-navigation-rail-header] [data-fab]:hover {')
// One FAB whose label springs shut, not two swapped by display (N-23).
->toContain('[data-navigation-rail-header] [data-fab] > span {')
->toMatch('/@variant rail-collapsed \{\s+min-width: 0;\s+aspect-ratio: 1;\s+gap: 0;/');
});
it('draws a destination in both shapes, with its count on the icon and at the end', function () {
$html = (string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />');
+48 -7
View File
@@ -21,10 +21,27 @@ it('renders the tablist on the server with the first enabled tab chosen', functi
->toContain('data-tab-indicator')
->toContain('--tabs-indicator: share-indicator')
->toContain('x-modelable="selected"')
->toContain('role="tabpanel"')
// The panel's id is the server's, so aria-controls never dangles (N-05).
->toMatch('/role="tabpanel"\s+id="share-people-panel"\s+aria-labelledby="share-people"/')
->toContain('>3</span>');
});
it('renders every panel but the chosen one hidden, before Alpine boots', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-tabs :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'people', 'label' => 'People']]" selected="people">
<x-tab name="files">Three files.</x-tab>
<x-tab name="people">Anna and Ben.</x-tab>
</x-tabs>
BLADE);
expect($html)
->toMatch('/id="[^"]+-files-panel"[^>]*style="display: none"/')
->toMatch('/id="[^"]+-people-panel"(?![^>]*style="display: none")/')
// The tab button and its panel agree on the id the slot worked out for itself.
->toMatch('/aria-controls="(tabs-[0-9a-f]{8})-people-panel"/')
->toMatch('/id="(tabs-[0-9a-f]{8})-people-panel"/');
});
it('takes a variant, stacks icons and scrolls on request', function () {
expect((string) $this->blade('<x-tabs variant="secondary" stacked scrollable :tabs="[[\'name\' => \'a\', \'label\' => \'A\']]" />'))
->toContain('data-variant="secondary"')
@@ -42,14 +59,33 @@ it('entangles the chosen tab with Livewire and renders the one the property name
public function render(): string
{
return '<div><x-tabs wire:model.live="tab" :tabs="[[\'name\' => \'files\', \'label\' => \'Files\'], [\'name\' => \'people\', \'label\' => \'People\']]" /></div>';
return '<div><x-tabs wire:model.live="tab" :tabs="[[\'name\' => \'files\', \'label\' => \'Files\'], [\'name\' => \'people\', \'label\' => \'People\']]"><x-tab name="files">Three files.</x-tab><x-tab name="people">Anna and Ben.</x-tab></x-tabs></div>';
}
});
expect(Livewire::test('tabs-probe')->html())
->toContain(".entangle('tab').live")
->not->toContain('x-modelable')
->toMatch('/data-tab="people"\s+aria-controls="[^"]+"\s+aria-selected="true"/');
->toMatch('/data-tab="people"\s+aria-controls="[^"]+"\s+aria-selected="true"/')
// The panel the property names is the one drawn, before Alpine entangles anything (N-05).
->toMatch('/id="[^"]+-files-panel"[^>]*style="display: none"/')
->toMatch('/id="[^"]+-people-panel"(?![^>]*style="display: none")/');
});
it('keeps the tab bar on M3\'s offsets, indicator inset and focus ring', function () {
$css = file_get_contents(__DIR__.'/../../../resources/css/components/tabs.css');
expect($css)
// 52dp before the first of a scrollable set (N-10).
->toContain("[data-tabs-bar][data-scrollable] {\n padding-inline-start: 3.25rem;")
// A primary indicator is inset 2dp each side; a secondary one spans the tab (N-18).
->toMatch('/\[data-tab-indicator\] \{\s+position: absolute;\s+inset-inline: 2px;/')
->toMatch('/\[data-variant="secondary"\] \[data-tab-indicator\] \{\s+inset-inline: 0;/')
// The ring is 2px outside, as everywhere else in the package (N-20).
->toContain('outline-offset: 2px;')
->not->toContain('outline-offset: -3px;')
// A link marked as the page is the chosen tab too (N-21).
->toContain('[data-tab]:is([aria-selected="true"], [aria-current="page"]) {');
});
it('draws section navigation as secondary tabs and a picker', function () {
@@ -64,7 +100,7 @@ it('draws section navigation as secondary tabs and a picker', function () {
->toContain('aria-label="Settings"')
->toContain('data-section-picker')
->toContain('data-variant="secondary"')
->toContain('medium:flex')
->toContain('max-medium:hidden')
->toMatch('/href="\/settings\/security"\s+data-tab\s+aria-current="page"\s+wire:navigate/')
->not->toMatch('/href="\/settings\/profile"\s+data-tab\s+aria-current/')
// The picker is a menu of places: the current one is the page, not a checked choice,
@@ -74,15 +110,20 @@ it('draws section navigation as secondary tabs and a picker', function () {
->toMatch('/data-section-picker.*Security.*>\s*1\s*<.*<nav/s');
});
it('marks the section whose url is the request\'s, and wraps many sections onto a grid', function () {
it('marks the section whose url is the request\'s, and scrolls many sections', function () {
$this->get('/');
$items = collect(range(1, 7))->map(fn (int $n): array => ['title' => "S{$n}", 'url' => $n === 3 ? url('/') : "/s/{$n}"])->all();
expect((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => $items]))
->toContain('medium:grid medium:grid-cols-4 large:flex')
// Five or more sections are M3's scrollable tabs, not a grid of wrapped rows (N-16).
->toMatch('/<ul data-tabs-bar data-variant="secondary"\s+data-scrollable\s*>/')
->not->toContain('grid-cols-')
->toMatch('/data-tab\s+aria-current="page"\s*>\s*<span data-tab-content>\s*<span class="truncate">S3/')
->not->toContain('wire:navigate');
->not->toContain('wire:navigate')
// Four still share the row.
->and((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => array_slice($items, 0, 4)]))
->not->toContain('data-scrollable');
});
it('keeps the page\'s section current while a Livewire component on it updates', function () {