Merge branch 'worktree-agent-ab53265ffbd837742'
This commit is contained in:
@@ -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:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
|
||||||
<x-slot:rail-header>
|
<x-slot:rail-header>
|
||||||
<span class="rail-collapsed:hidden"><x-fab label="New share" icon="add" link="{{ route('upload') }}" /></span>
|
<x-fab label="New share" icon="add" link="{{ route('upload') }}" />
|
||||||
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="add" tooltip-right="New share" link="{{ route('upload') }}" /></span>
|
|
||||||
</x-slot:rail-header>
|
</x-slot:rail-header>
|
||||||
<x-slot:rail-footer>
|
<x-slot:rail-footer>
|
||||||
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
|
<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`).
|
- `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`).
|
- 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)`.
|
- `--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.
|
- 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">
|
<div class="flex min-h-dvh">
|
||||||
<x-navigation-rail mode="collapsible">
|
<x-navigation-rail mode="collapsible">
|
||||||
<x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
|
<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-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" active badge="3" />
|
||||||
<x-navigation-rail-section label="Admin">
|
<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`).
|
- `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.
|
- 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 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.
|
- 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.
|
- `<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`).
|
- `$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>
|
</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>`
|
### `<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.
|
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>`
|
### `<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>`
|
### `<x-account-menu>`
|
||||||
|
|
||||||
@@ -826,7 +829,7 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
|
|||||||
|
|
||||||
### `<x-theme-toggle>`
|
### `<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>`
|
### `<x-scheme-picker>`
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
*
|
*
|
||||||
* small 64px, title-large title (label-medium subtitle), 4px from the edges, the title 16px
|
* small 64px, title-large title (label-medium subtitle), 4px from the edges, the title 16px
|
||||||
* from the start or straight after the navigation icon
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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);
|
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. */
|
/* Centred: a three-column row — leading, headline, trailing — so the headline is centred in the
|
||||||
[data-app-bar][data-variant="center"] [data-app-bar-headline] {
|
window and shrinks against whatever is actually beside it. M3 allows two trailing icon
|
||||||
position: absolute;
|
buttons, which a fixed inset for one would have let a long title ellipsise underneath. */
|
||||||
inset-inline: 3.5rem;
|
[data-app-bar][data-variant="center"] [data-app-bar-row] {
|
||||||
top: 0;
|
display: grid;
|
||||||
bottom: 0;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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] {
|
[data-app-bar-search] {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: calc(19.5rem + (100% - 19.5rem) / 2);
|
||||||
flex: 1 1 0%;
|
flex: 1 1 0%;
|
||||||
|
margin-inline: auto;
|
||||||
padding-inline: 0.25rem;
|
padding-inline: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@
|
|||||||
* rail in the layout rather than one that opens over a scrim; 75rem (1200px) is `large`, where the
|
* 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
|
* 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.
|
* 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 {
|
@custom-variant rail-collapsed {
|
||||||
@@ -56,6 +59,14 @@
|
|||||||
@slot;
|
@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) {
|
@media (width < 52.5rem) {
|
||||||
&:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) {
|
&:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) {
|
||||||
@slot;
|
@slot;
|
||||||
@@ -157,14 +168,16 @@
|
|||||||
min-width: max-content;
|
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] {
|
[data-navigation-bar-item] [data-navigation-pill] {
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
padding-inline: 1rem;
|
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] {
|
[data-navigation-bar-item] [data-navigation-label] {
|
||||||
@@ -210,16 +223,27 @@
|
|||||||
[data-navigation-rail='adaptive'] {
|
[data-navigation-rail='adaptive'] {
|
||||||
width: 0;
|
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] {
|
[data-navigation-rail-panel] {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: var(--material-banner, 0px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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
|
/* 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
|
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
|
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);
|
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. */
|
/* 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] {
|
[data-navigation-rail][data-open] > [data-navigation-rail-panel] {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -535,6 +571,14 @@
|
|||||||
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
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) {
|
@container (width < 37.5rem) {
|
||||||
[data-navigation-bar-item] [data-navigation-pill]::before {
|
[data-navigation-bar-item] [data-navigation-pill]::before {
|
||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,20 +6,38 @@
|
|||||||
*
|
*
|
||||||
* The active indicator is drawn in every tab and shown under the chosen one, so it is right before
|
* 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
|
* 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
|
* (at least 24px wide, inset 2px at each side), 2px across the whole of a secondary one. When the
|
||||||
* transition moves it from the old tab to the new (resources/js/tabs.js), as M3's slides.
|
* 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 {
|
@layer components {
|
||||||
[data-tabs-bar] {
|
[data-tabs-bar] {
|
||||||
|
--tabs-ring: 5px;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overscroll-behavior-x: contain;
|
overscroll-behavior-x: contain;
|
||||||
|
padding: var(--tabs-ring);
|
||||||
|
scroll-padding-inline: var(--tabs-ring);
|
||||||
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||||
scrollbar-width: thin;
|
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. */
|
/* A section nav's links sit in list items that share the width. */
|
||||||
[data-tabs-bar] > li {
|
[data-tabs-bar] > li {
|
||||||
flex: 1 1 0%;
|
flex: 1 1 0%;
|
||||||
@@ -47,6 +65,7 @@
|
|||||||
transition: color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
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] {
|
[data-tabs-bar][data-scrollable] [data-tab] {
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
@@ -55,10 +74,12 @@
|
|||||||
height: 4rem;
|
height: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The state layer covers the tab and the ring's room around it, so the wash meets the divider. */
|
||||||
[data-tab]::before {
|
[data-tab]::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset-block: calc(-1 * var(--tabs-ring));
|
||||||
|
inset-inline: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||||
}
|
}
|
||||||
@@ -81,10 +102,12 @@
|
|||||||
[data-tab]:focus-visible {
|
[data-tab]:focus-visible {
|
||||||
color: var(--md-sys-color-on-surface);
|
color: var(--md-sys-color-on-surface);
|
||||||
outline: 3px solid var(--md-sys-color-secondary);
|
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);
|
color: var(--md-sys-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,10 +139,12 @@
|
|||||||
gap: 0.125rem;
|
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] {
|
[data-tab-indicator] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-inline: 0;
|
inset-inline: 2px;
|
||||||
bottom: 0;
|
bottom: calc(-1 * var(--tabs-ring));
|
||||||
height: 3px;
|
height: 3px;
|
||||||
border-radius: 3px 3px 0 0;
|
border-radius: 3px 3px 0 0;
|
||||||
background-color: var(--md-sys-color-primary);
|
background-color: var(--md-sys-color-primary);
|
||||||
@@ -134,6 +159,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-tabs-bar][data-variant="secondary"] [data-tab-indicator] {
|
[data-tabs-bar][data-variant="secondary"] [data-tab-indicator] {
|
||||||
|
inset-inline: 0;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,12 @@
|
|||||||
* floating a 64px pill in surface-container (or primary-container, `vibrant`) at elevation 3,
|
* 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
|
* 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
|
* Placed over the page, a horizontal toolbar keeps 16dp from the window's edge and a vertical one
|
||||||
* ink as utilities, and a rule in any layer loses to a utility.
|
* 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] {
|
[data-toolbar] {
|
||||||
@@ -60,6 +64,13 @@
|
|||||||
color: var(--md-sys-color-on-surface);
|
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). */
|
/* A floating toolbar and its FAB, side by side (or stacked, vertical). */
|
||||||
[data-toolbar-group] {
|
[data-toolbar-group] {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -71,10 +82,17 @@
|
|||||||
flex-direction: column;
|
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"] {
|
[data-toolbar-place="bottom"] {
|
||||||
position: fixed;
|
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%;
|
left: 50%;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
translate: -50% 0;
|
translate: -50% 0;
|
||||||
@@ -88,9 +106,17 @@
|
|||||||
translate: 0 -50%;
|
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"] {
|
[data-toolbar][data-variant="docked"][data-toolbar-place="bottom"] {
|
||||||
inset-inline: 0;
|
inset-inline: 0;
|
||||||
bottom: 0;
|
bottom: var(--material-bottom-bar, 0px);
|
||||||
left: 0;
|
left: 0;
|
||||||
translate: none;
|
translate: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,12 @@
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="{{ $label }}"
|
aria-label="{{ $label }}"
|
||||||
data-account-menu
|
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)
|
@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))
|
@elseif (filled($initials))
|
||||||
{{ $initials }}
|
{{ $initials }}
|
||||||
@else
|
@else
|
||||||
|
|||||||
@@ -45,13 +45,23 @@
|
|||||||
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
|
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`).
|
`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),
|
Slots, each rendered once: `banner` (a bar across the whole window, above the rail and the
|
||||||
`rail-header` (under it: a FAB — see `<x-navigation-rail>` for its two shapes), `rail-footer`
|
page — M3's scaffold is bars, then rails, then panes), `brand` (beside the rail's menu button
|
||||||
(at the foot of the rail: footer destinations, an account), `actions` (a row of icon buttons at
|
while it is expanded), `rail-header` (under it: one `<x-fab label="…" icon="…">`, which the
|
||||||
the very foot, stacked when the rail is collapsed: a theme toggle, sign out), `top` (the app
|
rail morphs between a FAB and an extended FAB as it opens), `rail-footer` (at the foot of the
|
||||||
bar, above the page at every width) and the page itself. The rail is one element at every
|
rail: footer destinations, an account), `actions`
|
||||||
width, so what is in it is also in the modal rail a phone opens. `label` names both navigation
|
(a row of icon buttons at the very foot, stacked when the rail is collapsed: a theme toggle,
|
||||||
landmarks ("Main"); `rail-width` is the expanded rail's width.
|
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 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;
|
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
|
||||||
@@ -102,7 +112,7 @@
|
|||||||
<div
|
<div
|
||||||
data-app-shell
|
data-app-shell
|
||||||
@class([
|
@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(),
|
'max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $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"
|
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>
|
>{{ __('Skip to content') }}</a>
|
||||||
|
|
||||||
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
|
@isset($banner)
|
||||||
@isset($brand)
|
<div data-app-shell-banner class="shrink-0">{{ $banner }}</div>
|
||||||
<x-slot:brand>{{ $brand }}</x-slot:brand>
|
@endisset
|
||||||
@endisset
|
|
||||||
|
|
||||||
@isset($railHeader)
|
<div class="flex-1 medium:flex">
|
||||||
<x-slot:header>{{ $railHeader }}</x-slot:header>
|
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
|
||||||
@endisset
|
@isset($brand)
|
||||||
|
<x-slot:brand>{{ $brand }}</x-slot:brand>
|
||||||
|
@endisset
|
||||||
|
|
||||||
@foreach ($groups as $group)
|
@isset($railHeader)
|
||||||
@if ($group->first()['section'] !== null)
|
<x-slot:header>{{ $railHeader }}</x-slot:header>
|
||||||
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
|
@endisset
|
||||||
|
|
||||||
|
@foreach ($groups as $group)
|
||||||
|
@if ($group->first()['section'] !== null)
|
||||||
|
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
|
||||||
|
@foreach ($group as $item)
|
||||||
|
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||||||
|
@endforeach
|
||||||
|
</x-livewire-material::navigation-rail-section>
|
||||||
|
@else
|
||||||
@foreach ($group as $item)
|
@foreach ($group as $item)
|
||||||
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||||||
@endforeach
|
@endforeach
|
||||||
</x-livewire-material::navigation-rail-section>
|
@endif
|
||||||
@else
|
@endforeach
|
||||||
@foreach ($group as $item)
|
|
||||||
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
@if (isset($railFooter) || isset($actions))
|
||||||
@endforeach
|
<x-slot:footer>
|
||||||
|
{{ $railFooter ?? '' }}
|
||||||
|
|
||||||
|
@isset($actions)
|
||||||
|
<div data-app-shell-actions class="flex items-center gap-1 px-5 pt-2 rail-collapsed:flex-col">
|
||||||
|
{{ $actions }}
|
||||||
|
</div>
|
||||||
|
@endisset
|
||||||
|
</x-slot:footer>
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
</x-livewire-material::navigation-rail>
|
||||||
|
|
||||||
@if (isset($railFooter) || isset($actions))
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<x-slot:footer>
|
{{ $top ?? '' }}
|
||||||
{{ $railFooter ?? '' }}
|
|
||||||
|
|
||||||
@isset($actions)
|
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 px-(--material-margin) outline-none max-expanded:overflow-x-clip max-medium:pb-(--material-bottom-bar)">
|
||||||
<div data-app-shell-actions class="flex items-center gap-1 px-5 pt-2 rail-collapsed:flex-col">
|
{{ $slot }}
|
||||||
{{ $actions }}
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@endisset
|
|
||||||
</x-slot:footer>
|
|
||||||
@endif
|
|
||||||
</x-livewire-material::navigation-rail>
|
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
|
||||||
{{ $top ?? '' }}
|
|
||||||
|
|
||||||
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 px-(--material-margin) outline-none max-expanded:overflow-x-clip max-medium:pb-(--material-bottom-bar)">
|
|
||||||
{{ $slot }}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($barItems->isNotEmpty())
|
@if ($barItems->isNotEmpty())
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<x-navigation-rail mode="collapsible">
|
<x-navigation-rail mode="collapsible">
|
||||||
<x-slot:brand><span class="type-title-lg">Mail</span></x-slot:brand>
|
<x-slot:brand><span class="type-title-lg">Mail</span></x-slot:brand>
|
||||||
<x-slot:header>
|
<x-slot:header>
|
||||||
<x-fab icon="edit" tooltip-right="Compose" />
|
<x-fab label="Compose" icon="edit" />
|
||||||
</x-slot:header>
|
</x-slot:header>
|
||||||
|
|
||||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
|
<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
|
the same standard rail, expanded to begin with. A visitor who has used the menu button keeps
|
||||||
that choice in both standard bands.
|
that choice in both standard bands.
|
||||||
|
|
||||||
Slots: `brand` beside the menu button, only while expanded; `header` under it — a FAB, drawn
|
Slots: `brand` beside the menu button, only while expanded; `header` under it — one
|
||||||
as an extended FAB when expanded (`rail-collapsed:` below); the destinations in the default
|
`<x-fab label="…" icon="…">`, which the rail morphs: the label's width springs open and shut
|
||||||
slot, which alone scroll when the window is too short; `footer`, pinned to the foot. Header and
|
with the rail, so the FAB becomes an extended FAB and back rather than one being swapped for
|
||||||
footer never scroll, so nothing in them is cut off by the scroller's edge.
|
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
|
Anything else inside can take both shapes with the `rail-collapsed:` variant, which applies
|
||||||
the rail is drawn collapsed for whatever reason:
|
while the rail is drawn collapsed for whatever reason:
|
||||||
`<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>`
|
`<span class="rail-collapsed:hidden">…only while expanded…</span>`
|
||||||
`<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>`.
|
`<span class="hidden rail-collapsed:inline-flex">…only while collapsed…</span>`.
|
||||||
Nothing that shows while collapsed may be wider than 96px.
|
Nothing that shows while collapsed may be wider than 96px.
|
||||||
|
|
||||||
Props: `label` names the landmark ("Main"); `width` is the expanded width (`16rem`, held
|
Props: `label` names the landmark ("Main"); `width` is the expanded width (`16rem`, held
|
||||||
between M3's 220 and 360dp); `menu` shows the menu button (by default for `collapsible`,
|
between M3's 220 and 360dp); `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
|
`modal` and `adaptive`); `divider` draws M3's optional vertical divider on the edge the page
|
||||||
the top of the viewport, as tall as the viewport at most.
|
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
|
Values from androidx Compose Material 3 (Apache-2.0), androidx-main
|
||||||
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationRailCollapsedTokens.kt,
|
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationRailCollapsedTokens.kt,
|
||||||
@@ -66,6 +75,8 @@
|
|||||||
'label' => null,
|
'label' => null,
|
||||||
'width' => '16rem',
|
'width' => '16rem',
|
||||||
'menu' => null,
|
'menu' => null,
|
||||||
|
'divider' => false,
|
||||||
|
'fill' => true,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
@@ -78,6 +89,8 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
data-navigation-rail="{{ $mode }}"
|
data-navigation-rail="{{ $mode }}"
|
||||||
|
@if ($divider) data-divider @endif
|
||||||
|
@unless ($fill) data-fill="false" @endunless
|
||||||
@if ($interactive)
|
@if ($interactive)
|
||||||
x-data="materialNavigationRail('{{ $mode }}')"
|
x-data="materialNavigationRail('{{ $mode }}')"
|
||||||
x-bind:data-open="open"
|
x-bind:data-open="open"
|
||||||
@@ -109,7 +122,7 @@
|
|||||||
x-on:click="menu()"
|
x-on:click="menu()"
|
||||||
x-bind:aria-label="expanded ? @js(__('Collapse navigation')) : @js(__('Expand navigation'))"
|
x-bind:aria-label="expanded ? @js(__('Collapse navigation')) : @js(__('Expand navigation'))"
|
||||||
x-bind:aria-expanded="expanded.toString()"
|
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="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>
|
<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
|
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.
|
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)
|
Up to four sections share the row as fixed tabs. From five the row is M3's scrollable tab bar
|
||||||
five or six
|
— each tab as wide as its own label, the set scrolling sideways, offset 52dp from the leading
|
||||||
sections go 3 + 3 and seven or more go four to a row — tabs that scroll hid the last sections on
|
edge so it reads as scrollable — which is M3's own answer to a row that will not fit, and the
|
||||||
a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless
|
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`. --}}
|
`no-wire-navigate`. --}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
@@ -31,11 +36,8 @@
|
|||||||
$isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && $page === url($item['url']));
|
$isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && $page === url($item['url']));
|
||||||
$current = collect($items)->first($isCurrent) ?? ($items[0] ?? null);
|
$current = collect($items)->first($isCurrent) ?? ($items[0] ?? null);
|
||||||
|
|
||||||
$layout = match (true) {
|
// Four fit a row at the widths this bar is used at; from five they are M3's scrollable tabs.
|
||||||
count($items) < 5 => 'medium:flex',
|
$scrollable = count($items) >= 5;
|
||||||
count($items) < 7 => 'medium:grid medium:grid-cols-3 large:flex',
|
|
||||||
default => 'medium:grid medium:grid-cols-4 large:flex',
|
|
||||||
};
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div {{ $attributes->class(['min-w-0']) }} data-section-nav>
|
<div {{ $attributes->class(['min-w-0']) }} data-section-nav>
|
||||||
@@ -45,7 +47,7 @@
|
|||||||
<x-slot:trigger>
|
<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">
|
<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'])
|
@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
|
@endisset
|
||||||
<span class="min-w-0 flex-1 truncate">{{ $current['title'] }}</span>
|
<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" />
|
<x-livewire-material::icon name="arrow_drop_down" class="size-6 text-on-surface-variant" />
|
||||||
@@ -60,7 +62,7 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
<nav aria-label="{{ $label }}" class="max-medium:hidden">
|
<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)
|
@foreach ($items as $item)
|
||||||
@php($on = $isCurrent($item))
|
@php($on = $isCurrent($item))
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@
|
|||||||
>
|
>
|
||||||
<span data-tab-content>
|
<span data-tab-content>
|
||||||
@isset($item['icon'])
|
@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
|
@endisset
|
||||||
<span class="truncate">{{ $item['title'] }}</span>
|
<span class="truncate">{{ $item['title'] }}</span>
|
||||||
@if (filled($item['badge'] ?? null))
|
@if (filled($item['badge'] ?? null))
|
||||||
|
|||||||
@@ -1,17 +1,43 @@
|
|||||||
{{-- One tab's panel, inside `<x-tabs>`, which draws the bar from its own `tabs` list. Shown while
|
{{-- 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.
|
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([
|
@props([
|
||||||
'name',
|
'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
|
<div
|
||||||
x-show="selected === @js($name)"
|
x-show="selected === @js($name)"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
x-bind:id="tabsId + '-' + @js($name) + '-panel'"
|
id="{{ $tabsId }}-{{ $name }}-panel"
|
||||||
x-bind:aria-labelledby="tabsId + '-' + @js($name)"
|
aria-labelledby="{{ $tabsId }}-{{ $name }}"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
@if ((string) $name !== (string) $initial) style="display: none" @endif
|
||||||
{{ $attributes->class(['pt-6 outline-none']) }}
|
{{ $attributes->class(['pt-6 outline-none']) }}
|
||||||
>
|
>
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
The bar is rendered on the server from `tabs`, a list of `['name' => …, 'label' => …]` with an
|
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.
|
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
|
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
|
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
|
server at once), or, without it, `selected` (the first tab by default) and `x-model`. The bar is
|
||||||
@@ -24,6 +25,8 @@
|
|||||||
@php
|
@php
|
||||||
$model = $attributes->wire('model')->value() ?: null;
|
$model = $attributes->wire('model')->value() ?: null;
|
||||||
$variant = $variant === 'secondary' ? 'secondary' : 'primary';
|
$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);
|
$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);
|
$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,
|
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.
|
"Dark theme", pressed while dark.
|
||||||
- `cycle`: an icon button that steps light → dark → system, showing the choice it is on.
|
- `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
|
- `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
|
(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
|
(`resolvedContrast`), so the operating system's setting shows while the choice is
|
||||||
`system`, and choosing one is an explicit choice from then on.
|
`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
|
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([
|
@props([
|
||||||
'mode' => 'toggle',
|
'mode' => 'toggle',
|
||||||
|
'label' => null,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$mode = in_array($mode, ['toggle', 'cycle', 'picker', 'contrast'], true) ? $mode : 'toggle';
|
$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
|
@endphp
|
||||||
|
|
||||||
@if ($mode === 'contrast')
|
@if ($row !== null)
|
||||||
<div
|
<div
|
||||||
role="radiogroup"
|
role="group"
|
||||||
aria-label="{{ __('Contrast') }}"
|
aria-label="{{ $label ?? $row['name'] }}"
|
||||||
x-data
|
data-theme-toggle="{{ $mode }}"
|
||||||
data-theme-toggle="contrast"
|
x-data="{
|
||||||
{{ $attributes->class(['inline-flex h-10 rounded-corner-full border border-outline']) }}
|
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])
|
<x-livewire-material::group
|
||||||
<button
|
inline
|
||||||
type="button"
|
x-model="chosen"
|
||||||
role="radio"
|
:label="$label"
|
||||||
aria-checked="false"
|
:name="$row['field']"
|
||||||
x-bind:aria-checked="($store.theme.resolvedContrast === '{{ $level }}').toString()"
|
:options="$row['options']"
|
||||||
x-bind:tabindex="$store.theme.resolvedContrast === '{{ $level }}' ? 0 : -1"
|
{{ $attributes }}
|
||||||
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
|
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<button
|
<button
|
||||||
@@ -93,7 +96,7 @@
|
|||||||
x-bind:aria-pressed="($store.theme.resolved === 'dark').toString()"
|
x-bind:aria-pressed="($store.theme.resolved === 'dark').toString()"
|
||||||
x-on:click="$store.theme.toggle()"
|
x-on:click="$store.theme.toggle()"
|
||||||
@endif
|
@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')
|
@if ($mode === 'cycle')
|
||||||
<x-livewire-material::icon name="light_mode" x-cloak x-show="$store.theme.choice === 'light'" />
|
<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.
|
toolbar sits where it is written. A `fab` slot sets an `<x-fab>` beside a floating toolbar.
|
||||||
`label` names it for screen readers.
|
`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
|
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,
|
`role="toolbar"`: the arrow keys move between its controls (resources/js/toolbar.js,
|
||||||
resources/css/components/toolbar.css). --}}
|
resources/css/components/toolbar.css). --}}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
fetched on first focus, and / or Ctrl+K (⌘K) reaches it from anywhere. Its names
|
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. --}}
|
keep clear of <x-search>'s own (`results`, `open`), which the slot also sees. --}}
|
||||||
<div
|
<div
|
||||||
class="mx-auto w-full max-w-2xl"
|
class="w-full"
|
||||||
x-data="{
|
x-data="{
|
||||||
query: '',
|
query: '',
|
||||||
entries: null,
|
entries: null,
|
||||||
|
|||||||
@@ -34,9 +34,9 @@
|
|||||||
'Collapsed and expanded rails' => <<<'BLADE'
|
'Collapsed and expanded rails' => <<<'BLADE'
|
||||||
<div class="flex w-full flex-wrap items-start gap-6">
|
<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">
|
<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-slot:header>
|
||||||
<x-fab icon="edit" tooltip-right="Compose" />
|
<x-fab label="Compose" icon="edit" />
|
||||||
</x-slot:header>
|
</x-slot:header>
|
||||||
|
|
||||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
|
<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:brand>
|
||||||
|
|
||||||
<x-slot:header>
|
<x-slot:header>
|
||||||
<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>
|
<x-fab label="Compose" icon="edit" />
|
||||||
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>
|
|
||||||
</x-slot:header>
|
</x-slot:header>
|
||||||
|
|
||||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
|
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
|
||||||
@@ -92,7 +91,7 @@
|
|||||||
</x-navigation-rail>
|
</x-navigation-rail>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1 bg-surface-container-low p-6 type-body-md text-on-surface-variant">
|
<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><x-app-shell></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><x-app-shell></code>'s from <code>expanded</code> (840px) too.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
BLADE,
|
BLADE,
|
||||||
@@ -104,7 +103,7 @@
|
|||||||
|
|
||||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||||
<code><x-navigation-bar></code> and <code><x-navigation-bar-item></code>, <code><x-navigation-rail></code>, <code><x-navigation-rail-item></code> and <code><x-navigation-rail-section></code>,
|
<code><x-navigation-bar></code> and <code><x-navigation-bar-item></code>, <code><x-navigation-rail></code>, <code><x-navigation-rail-item></code> and <code><x-navigation-rail-section></code>,
|
||||||
and <code><x-app-shell></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><x-app-shell></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.
|
<a href="{{ route('livewire-material.shell') }}" class="link text-primary">Open the app shell</a> and change the window's width.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,7 @@
|
|||||||
</x-slot:brand>
|
</x-slot:brand>
|
||||||
|
|
||||||
<x-slot:rail-header>
|
<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>
|
<x-livewire-material::fab label="Compose" icon="edit" x-on:click="materialToast('Compose opens here')" />
|
||||||
<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-slot:rail-header>
|
</x-slot:rail-header>
|
||||||
|
|
||||||
<x-slot:rail-footer>
|
<x-slot:rail-footer>
|
||||||
|
|||||||
@@ -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 () {
|
it('switches the theme from a toggle, a cycle and a picker', function () {
|
||||||
$page = barsProbe()
|
$page = barsProbe()
|
||||||
->click('[data-theme-option="light"]')
|
->click('label:has(input[name="material-theme"][value="light"])')
|
||||||
->assertScript("document.documentElement.dataset.theme === 'light'")
|
->assertScript("document.documentElement.dataset.theme === 'light'")
|
||||||
->assertAttribute('#toggle', 'aria-pressed', 'false');
|
->assertAttribute('#toggle', 'aria-pressed', 'false');
|
||||||
|
|
||||||
$page->click('#toggle')
|
$page->click('#toggle')
|
||||||
->assertScript("document.documentElement.dataset.theme === 'dark'")
|
->assertScript("document.documentElement.dataset.theme === 'dark'")
|
||||||
->assertAttribute('#toggle', 'aria-pressed', 'true')
|
->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')
|
$page->click('#cycle')
|
||||||
->assertScript("document.documentElement.dataset.themeChoice === 'system'")
|
->assertScript("document.documentElement.dataset.themeChoice === 'system'")
|
||||||
|
|||||||
@@ -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("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
||||||
->assertScript(theme('data-theme', 'dark'));
|
->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'))
|
->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'");
|
->assertScript("localStorage.getItem('material-theme') === 'light'");
|
||||||
|
|
||||||
$page->refresh()
|
$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(themeColorIs($profiles['baseline']['light']['surface']))
|
||||||
->assertScript(pageSurfaceIs($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(theme('data-theme', 'dark'))
|
||||||
->assertScript(themeColorIs($profiles['baseline']['dark']['surface']))
|
->assertScript(themeColorIs($profiles['baseline']['dark']['surface']))
|
||||||
->assertScript(pageSurfaceIs($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(themeColorIs($profiles['rose']['dark']['surface']))
|
||||||
->assertScript(pageSurfaceIs($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(themeColorIs($profiles['rose']['light']['surface']))
|
||||||
->assertScript(pageSurfaceIs($profiles['rose']['light']['surface']))
|
->assertScript(pageSurfaceIs($profiles['rose']['light']['surface']))
|
||||||
->assertNoJavaScriptErrors();
|
->assertNoJavaScriptErrors();
|
||||||
|
|||||||
@@ -69,31 +69,70 @@ it('draws floating and docked toolbars', function () {
|
|||||||
->not->toContain('data-toolbar-group');
|
->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 () {
|
it('offers M3\'s three contrast levels, marking the one in force', function () {
|
||||||
expect((string) $this->blade('<x-theme-toggle mode="contrast" />'))
|
expect((string) $this->blade('<x-theme-toggle mode="contrast" />'))
|
||||||
->toContain('data-theme-toggle="contrast"')
|
->toContain('data-theme-toggle="contrast"')
|
||||||
->toContain('role="radiogroup"')
|
|
||||||
->toContain('aria-label="Contrast"')
|
->toContain('aria-label="Contrast"')
|
||||||
->toContain('data-contrast-option="standard"')
|
// A connected button group over native radios, not the deprecated segmented button (N-04).
|
||||||
->toContain('data-contrast-option="medium"')
|
->toContain('data-button-group="connected"')
|
||||||
->toContain('data-contrast-option="high"')
|
->toContain('name="material-contrast"')
|
||||||
->toContain("\$store.theme.setContrast('high')")
|
->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`.
|
// 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 () {
|
it('switches the theme through the store in three shapes', function () {
|
||||||
expect((string) $this->blade('<x-theme-toggle />'))
|
expect((string) $this->blade('<x-theme-toggle />'))
|
||||||
->toContain('data-theme-toggle="toggle"')
|
->toContain('data-theme-toggle="toggle"')
|
||||||
->toContain('aria-label="Dark theme"')
|
->toContain('aria-label="Dark theme"')
|
||||||
|
// 40px drawn, 48px reached: M3's minimum target (N-01).
|
||||||
|
->toContain('touch-target')
|
||||||
->toContain('$store.theme.toggle()')
|
->toContain('$store.theme.toggle()')
|
||||||
->and((string) $this->blade('<x-theme-toggle mode="cycle" />'))
|
->and((string) $this->blade('<x-theme-toggle mode="cycle" />'))
|
||||||
->toContain('data-theme-toggle="cycle"')
|
->toContain('data-theme-toggle="cycle"')
|
||||||
->toContain("{ light: 'dark', dark: 'system', system: 'light' }")
|
->toContain("{ light: 'dark', dark: 'system', system: 'light' }")
|
||||||
->and((string) $this->blade('<x-theme-toggle mode="picker" />'))
|
->and((string) $this->blade('<x-theme-toggle mode="picker" />'))
|
||||||
->toContain('role="radiogroup"')
|
->toContain('data-theme-toggle="picker"')
|
||||||
->toContain('data-theme-option="system"')
|
->toContain('x-model="chosen"')
|
||||||
->toContain("\$store.theme.set('dark')");
|
->toContain('name="material-theme"')
|
||||||
|
->toContain('value="system"')
|
||||||
|
->toContain('$store.theme.set(value)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens an account menu from the initials of a name', function () {
|
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)
|
expect($html)
|
||||||
->toContain('aria-label="Account"')
|
->toContain('aria-label="Account"')
|
||||||
->toContain('data-account-menu')
|
->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>/')
|
->toMatch('/>\s*AM\s*<\/button>/')
|
||||||
->toContain('anna@example.com')
|
->toContain('anna@example.com')
|
||||||
->toContain('Settings')
|
->toContain('Settings')
|
||||||
->toContain('data-account-theme')
|
->toContain('data-account-theme')
|
||||||
->toContain('Sign out')
|
->toContain('Sign out')
|
||||||
->and((string) $this->blade('<x-account-menu avatar="/anna.jpg" :theme="false" />'))
|
->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');
|
->not->toContain('data-account-theme');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () {
|
it('places each slot once', function () {
|
||||||
$html = (string) $this->blade(<<<'BLADE'
|
$html = (string) $this->blade(<<<'BLADE'
|
||||||
<x-app-shell :destinations="$destinations">
|
<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:brand><span>BRAND</span></x-slot:brand>
|
||||||
<x-slot:rail-header><span>FAB</span></x-slot:rail-header>
|
<x-slot:rail-header><span>FAB</span></x-slot:rail-header>
|
||||||
<x-slot:rail-footer><span>FOOTER</span></x-slot:rail-footer>
|
<x-slot:rail-footer><span>FOOTER</span></x-slot:rail-footer>
|
||||||
@@ -118,13 +119,28 @@ it('places each slot once', function () {
|
|||||||
</x-app-shell>
|
</x-app-shell>
|
||||||
BLADE, ['destinations' => shellDestinations()]);
|
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(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');
|
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 () {
|
it('serves the showcase\'s app shell pages', function () {
|
||||||
$html = $this->withoutVite()
|
$html = $this->withoutVite()
|
||||||
->get('/material/shell/starred')
|
->get('/material/shell/starred')
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
it('draws a navigation landmark around its items', function () {
|
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>'))
|
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')
|
->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($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');
|
->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('data-navigation-rail-menu')
|
||||||
->toContain('aria-label="Collapse navigation"')
|
->toContain('aria-label="Collapse navigation"')
|
||||||
->toContain('aria-expanded="true"')
|
->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('data-navigation-rail-scrim')
|
||||||
->not->toContain('x-trap')
|
->not->toContain('x-trap')
|
||||||
->and((string) $this->blade('<x-navigation-rail mode="modal" />'))
|
->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');
|
->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 () {
|
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" />');
|
$html = (string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />');
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,27 @@ it('renders the tablist on the server with the first enabled tab chosen', functi
|
|||||||
->toContain('data-tab-indicator')
|
->toContain('data-tab-indicator')
|
||||||
->toContain('--tabs-indicator: share-indicator')
|
->toContain('--tabs-indicator: share-indicator')
|
||||||
->toContain('x-modelable="selected"')
|
->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>');
|
->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 () {
|
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\']]" />'))
|
expect((string) $this->blade('<x-tabs variant="secondary" stacked scrollable :tabs="[[\'name\' => \'a\', \'label\' => \'A\']]" />'))
|
||||||
->toContain('data-variant="secondary"')
|
->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
|
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())
|
expect(Livewire::test('tabs-probe')->html())
|
||||||
->toContain(".entangle('tab').live")
|
->toContain(".entangle('tab').live")
|
||||||
->not->toContain('x-modelable')
|
->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 () {
|
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('aria-label="Settings"')
|
||||||
->toContain('data-section-picker')
|
->toContain('data-section-picker')
|
||||||
->toContain('data-variant="secondary"')
|
->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/')
|
->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/')
|
->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,
|
// 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');
|
->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('/');
|
$this->get('/');
|
||||||
|
|
||||||
$items = collect(range(1, 7))->map(fn (int $n): array => ['title' => "S{$n}", 'url' => $n === 3 ? url('/') : "/s/{$n}"])->all();
|
$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]))
|
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/')
|
->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 () {
|
it('keeps the page\'s section current while a Livewire component on it updates', function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user