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:rail-header>
|
||||
<span class="rail-collapsed:hidden"><x-fab label="New share" icon="add" link="{{ route('upload') }}" /></span>
|
||||
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="add" tooltip-right="New share" link="{{ route('upload') }}" /></span>
|
||||
<x-fab label="New share" icon="add" link="{{ route('upload') }}" />
|
||||
</x-slot:rail-header>
|
||||
<x-slot:rail-footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
|
||||
@@ -719,7 +718,8 @@ A visitor who has pressed the menu button keeps that choice in both standard ban
|
||||
```
|
||||
|
||||
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the page's, also during a Livewire update request), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for the badge: "3 unread"), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||
- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
|
||||
- Slots, each rendered once: `banner` (a bar across the whole window, above the rail and the page), `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the page's own bar, above the page and beside the rail), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
|
||||
- `banner` or `top`: M3's scaffold is bars, then rails, then panes. An application-wide bar — one search, one account menu, the same on every page — goes in `banner` and the rail starts under it; a bar that titles the page goes in `top`, beside the rail. Never both. A banner that pins itself to the top of the window says how tall it is (`style="--material-banner: 4rem"` on `<x-app-shell>`), so the rail sticks under it instead of behind it.
|
||||
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. On a compact window nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `medium`).
|
||||
- `--material-margin` is M3's window margin (16px compact, 24px from `medium`) and the content region already carries it, so a page inside the shell writes no gutters of its own; something that must reach the window's edges opts out with `-mx-(--material-margin)`.
|
||||
- Two panes side by side are M3's from `expanded`: `<x-drawer pane>` is the second one, in an `expanded:flex expanded:items-start expanded:gap-6` row inside the page.
|
||||
@@ -749,7 +749,7 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
|
||||
<div class="flex min-h-dvh">
|
||||
<x-navigation-rail mode="collapsible">
|
||||
<x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
|
||||
<x-slot:header><x-fab icon="add" tooltip-right="New share" /></x-slot:header>
|
||||
<x-slot:header><x-fab label="New share" icon="add" /></x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" active badge="3" />
|
||||
<x-navigation-rail-section label="Admin">
|
||||
@@ -766,8 +766,9 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
|
||||
```
|
||||
|
||||
- `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (`<x-app-shell>`'s, one rail per window size class: hidden and opened as a modal on a compact window, collapsed and opened as a modal at `medium`, a standard rail from `expanded` — collapsed there, expanded from `large`).
|
||||
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`). Slots: `brand` (beside the menu button, expanded only), `header` (a FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
|
||||
- Anything inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
|
||||
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`), `divider` (M3's optional vertical divider on the page's side — use it when the page scrolls under a fixed rail), `fill` (`false` for a transparent container, which M3 allows while the items keep 3:1 contrast). Slots: `brand` (beside the menu button, expanded only), `header` (one `<x-fab label icon>`, which the rail morphs into an extended FAB and back as it expands — it also rests at elevation 0, as M3 asks of a nested FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
|
||||
- Anything else inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
|
||||
- A `collapsible` rail is held to the collapsed 96px below `medium` (600px), where M3 says to use a navigation bar rather than a standard rail. `collapsed` and `expanded` are fixed-width by design: wrap one in a `medium:` element if it must not show on a phone.
|
||||
- `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way.
|
||||
- `$store.rail`: `collapsed`, `toggle()`, `collapse()`, `expand()` (the remembered choice; `auto` is true while nothing is stored, so an adaptive rail takes its window size class's default instead, and the first choice clears it), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php` → `rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
|
||||
|
||||
@@ -796,6 +797,8 @@ M3 Expressive toolbar, `role="toolbar"` (arrow keys move between controls). `var
|
||||
</x-toolbar>
|
||||
```
|
||||
|
||||
A docked toolbar and a navigation bar occupy the same screen region and must never be on screen together: show the bar on a primary page and the toolbar on a secondary or contextual one. A `place="bottom"` toolbar clears `--material-bottom-bar` if a bar is there anyway, so nothing is buried.
|
||||
|
||||
### `<x-tabs>`, `<x-tab>`
|
||||
|
||||
M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skipped, the indicator moves in a view transition). `tabs`: `['name', 'label', 'icon', 'badge', 'disabled']`; panels are `<x-tab name>` in the slot. Bind with `wire:model` (entangled), or `selected` / `x-model` without Livewire. `variant` `primary` (default) or `secondary`; `stacked` (icon over label), `scrollable`. Give two identical tab sets on one page distinct `id`s.
|
||||
@@ -809,7 +812,7 @@ M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skip
|
||||
|
||||
### `<x-section-nav>`
|
||||
|
||||
Navigation between the sections of one area (settings, admin): secondary tabs as links from `medium` (600px; wrapping onto a grid rather than a single row below `large`, rather than scrolling), a menu picker on a compact window, whose items mark the current section as the page (`current`) and carry each section's badge. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). `label`, `no-wire-navigate`.
|
||||
Navigation between the sections of one area (settings, admin): secondary tabs as links from `medium` (600px), a menu picker on a compact window, whose items mark the current section as the page (`current`) and carry each section's badge. Up to four sections share the row; from five it is M3's scrollable tab bar — tabs as wide as their labels, offset 52dp from the leading edge so it reads as scrollable. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). `label`, `no-wire-navigate`.
|
||||
|
||||
### `<x-account-menu>`
|
||||
|
||||
@@ -826,7 +829,7 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
|
||||
|
||||
### `<x-theme-toggle>`
|
||||
|
||||
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (a row of three for settings pages), `contrast` (the same row for M3's standard, medium and high levels, marking the one in force). Every toggle on a page shares the store.
|
||||
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (a row of three for settings pages), `contrast` (the same row for M3's standard, medium and high levels, marking the one in force). Every toggle on a page shares the store. Both rows are `<x-group>` — a connected button group over native radios, so the arrow keys, the wrap and the roving tab stop are the browser's; `label` adds a visible legend, and without one the row is still named for a screen reader. Nothing is checked until Alpine has read the store, because the theme is known only in the browser.
|
||||
|
||||
### `<x-scheme-picker>`
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
*
|
||||
* small 64px, title-large title (label-medium subtitle), 4px from the edges, the title 16px
|
||||
* from the start or straight after the navigation icon
|
||||
* center the same, the title centred
|
||||
* center the same, the title centred — a three-column grid, so it shrinks against whatever is
|
||||
* beside it rather than ellipsising underneath it
|
||||
* medium 112px (136px with a subtitle): the 64px row of icons over a headline-medium title
|
||||
* large 120px (152px with a subtitle): a display-small title
|
||||
* search the row holds a search bar
|
||||
* search the row holds a search bar, full width to 312px and then half of what is left
|
||||
*
|
||||
* The container is the surface, and surface-container once content scrolls under it. A medium or
|
||||
* large bar collapses into the small one without script moving anything: the bar is sticky at a
|
||||
@@ -118,21 +119,44 @@
|
||||
letter-spacing: var(--md-sys-typescale-label-md-tracking);
|
||||
}
|
||||
|
||||
/* Centred: the headline spans the row and centres its text between whatever is at either end. */
|
||||
[data-app-bar][data-variant="center"] [data-app-bar-headline] {
|
||||
position: absolute;
|
||||
inset-inline: 3.5rem;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
/* Centred: a three-column row — leading, headline, trailing — so the headline is centred in the
|
||||
window and shrinks against whatever is actually beside it. M3 allows two trailing icon
|
||||
buttons, which a fixed inset for one would have let a long title ellipsise underneath. */
|
||||
[data-app-bar][data-variant="center"] [data-app-bar-row] {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
}
|
||||
|
||||
/* The search bar fills the row. */
|
||||
[data-app-bar][data-variant="center"] [data-app-bar-leading] {
|
||||
grid-column: 1;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
[data-app-bar][data-variant="center"] [data-app-bar-headline] {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-self: center;
|
||||
padding-inline: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-app-bar][data-variant="center"] [data-app-bar-trailing] {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
/* The search bar fills the space between the leading and trailing elements until it is 312dp
|
||||
wide, and then grows into only half of what is left
|
||||
(docs/reference/m3/components-navigation-selection-inputs.md § Top app bar). The percentage
|
||||
is of the row rather than of that space, which CSS cannot name without measuring it; below
|
||||
312px the cap never binds, so a phone still gets the whole row. */
|
||||
[data-app-bar-search] {
|
||||
min-width: 0;
|
||||
max-width: calc(19.5rem + (100% - 19.5rem) / 2);
|
||||
flex: 1 1 0%;
|
||||
margin-inline: auto;
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@
|
||||
* rail in the layout rather than one that opens over a scrim; 75rem (1200px) is `large`, where the
|
||||
* rail starts expanded instead of collapsed. The bar's own item layout is a *container* query at
|
||||
* the same 37.5rem, so a bar in a narrow column lays out by its own width.
|
||||
*
|
||||
* Everything here is in `@layer components` except the last block, which restyles a FAB the
|
||||
* application put in the rail: that one has to beat a utility, and a layer never does.
|
||||
*/
|
||||
|
||||
@custom-variant rail-collapsed {
|
||||
@@ -56,6 +59,14 @@
|
||||
@slot;
|
||||
}
|
||||
|
||||
/* Below `medium` a 220–360px rail would be most of the screen, so a rail whose width is the
|
||||
visitor's choice is held collapsed there whatever they chose. */
|
||||
@media (width < 37.5rem) {
|
||||
&:where([data-navigation-rail='collapsible'], [data-navigation-rail='collapsible'] *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 52.5rem) {
|
||||
&:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) {
|
||||
@slot;
|
||||
@@ -157,14 +168,16 @@
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
/* The label stays label-medium: NavigationBarTokens.LabelTextFont is the bar's only label
|
||||
token, and Compose's ShortNavigationBarItem passes it for both icon positions. (The
|
||||
*rail's* horizontal item is label-large — NavigationRailHorizontalItemTokens — which is
|
||||
a different component.) */
|
||||
[data-navigation-bar-item] [data-navigation-pill] {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
flex-direction: row;
|
||||
height: 2.5rem;
|
||||
padding-inline: 1rem;
|
||||
font: var(--md-sys-typescale-label-lg);
|
||||
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
|
||||
}
|
||||
|
||||
[data-navigation-bar-item] [data-navigation-label] {
|
||||
@@ -210,16 +223,27 @@
|
||||
[data-navigation-rail='adaptive'] {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
/* "Compact → don't use a standard rail, use a navigation bar." A `collapsible` rail takes
|
||||
the width the visitor chose, which on a 360px screen would be two-thirds of it, so it
|
||||
is floored at the collapsed 96px. `collapsed` and `expanded` mean what they say and are
|
||||
left alone: wrap one in a `medium:` element if it must not show on a phone. */
|
||||
[data-navigation-rail='collapsible'] {
|
||||
width: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* `--material-banner` is the height of a bar pinned across the top of the window — what
|
||||
<x-app-shell>'s `banner` slot holds, if the application made it sticky. The rail sticks
|
||||
under it rather than behind it; 0 without one. */
|
||||
[data-navigation-rail-panel] {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
top: var(--material-banner, 0px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100dvh;
|
||||
max-height: calc(100dvh - var(--material-banner, 0px));
|
||||
/* Clip, not hide: no scroll container, so the destinations below can still scroll and
|
||||
nothing sticky breaks. What only an expanded rail draws — a label, the brand — is drawn
|
||||
at once when the rail expands, while the width is still growing; the clip keeps it
|
||||
@@ -233,6 +257,18 @@
|
||||
background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
||||
}
|
||||
|
||||
/* M3's two optional container treatments: "optional vertical divider separates rail from
|
||||
content, placed on the content-adjacent edge" — also its answer to a page scrolling under a
|
||||
fixed rail — and "container fill can be turned off (transparent) as long as items keep ≥3:1
|
||||
contrast". Neither applies to a rail open over a scrim, which is a surface over the page. */
|
||||
[data-navigation-rail][data-divider]:not([data-open]) > [data-navigation-rail-panel] {
|
||||
border-inline-end: 1px solid var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
[data-navigation-rail][data-fill='false']:not([data-open]) > [data-navigation-rail-panel] {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Open: expanded over a scrim, in surface-container with a large corner at its inner edge. */
|
||||
[data-navigation-rail][data-open] > [data-navigation-rail-panel] {
|
||||
position: fixed;
|
||||
@@ -535,6 +571,14 @@
|
||||
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
/* A navigation item's state layer is on-secondary-container, active or not: the only
|
||||
state-layer tokens M3 states for one are NavigationRailColorTokens', which give that colour
|
||||
to all six. The rail follows them two blocks below; the bar follows them here, over its
|
||||
secondary-container pill. */
|
||||
[data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill])::before {
|
||||
background-color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
@container (width < 37.5rem) {
|
||||
[data-navigation-bar-item] [data-navigation-pill]::before {
|
||||
display: none;
|
||||
@@ -601,3 +645,45 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* A FAB in the rail's header rests at elevation 0, not the 3 a standalone FAB has
|
||||
* (docs/reference/m3/components-navigation-selection-inputs.md § Navigation rail: "when nested
|
||||
* within another component, such as the navigation rail, the FAB's resting elevation should be
|
||||
* level 0"). Unlayered on purpose: the FAB draws its shadow with a utility, and a rule in any
|
||||
* layer loses to a utility — the same reason toolbar.css gives for its vibrant recolouring. */
|
||||
[data-navigation-rail-header] [data-fab],
|
||||
[data-navigation-rail-header] [data-fab]:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* "Rail transitions from collapsed to expanded should animate the FAB into and out of an extended
|
||||
* FAB" — so the header takes one <x-fab label icon> and morphs it, rather than swapping two by
|
||||
* `display`, which showed the collapsed one at once while the rail's width was still springing.
|
||||
* The label's width springs on the rail's own spatial spring; with the gap closed and the extended
|
||||
* FAB's own minimum width off, `aspect-ratio` squares what is left against the height its size
|
||||
* already sets — 56, 80 or 96px, the three FAB sizes — so no number is repeated here. The label
|
||||
* stays in the accessibility tree at both widths, so the FAB keeps its name. Unlayered for the
|
||||
* same reason as the rule above: `gap-2` and `min-w-20` are utilities, and a layer would lose to
|
||||
* them. The cap is there because a transition needs a length to travel to; a rail FAB's label is a
|
||||
* word or two. */
|
||||
[data-navigation-rail-header] [data-fab] > span {
|
||||
max-width: 16rem;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
max-width var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default),
|
||||
opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
||||
|
||||
@variant rail-collapsed {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-header] [data-fab] {
|
||||
@variant rail-collapsed {
|
||||
min-width: 0;
|
||||
aspect-ratio: 1;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,20 +6,38 @@
|
||||
*
|
||||
* The active indicator is drawn in every tab and shown under the chosen one, so it is right before
|
||||
* Alpine starts and after a morph: 3px with rounded top corners under the content of a primary tab
|
||||
* (at least 24px wide), 2px across the whole of a secondary one. When the choice changes, a view
|
||||
* transition moves it from the old tab to the new (resources/js/tabs.js), as M3's slides.
|
||||
* (at least 24px wide, inset 2px at each side), 2px across the whole of a secondary one. When the
|
||||
* choice changes, a view transition moves it from the old tab to the new (resources/js/tabs.js),
|
||||
* as M3's slides.
|
||||
*
|
||||
* The bar scrolls sideways, so it clips whatever leaves it — including the focus ring, which the
|
||||
* rest of the package draws 3px thick 2px outside the element (tokens/state.css). `--tabs-ring`
|
||||
* is the room that takes: the bar carries it as padding on every side, and the state layer and the
|
||||
* indicator reach back out into it, so the divider still sits against the tabs and the indicator
|
||||
* still sits on the divider. That makes the bar 10px taller than M3's 48px tab.
|
||||
*/
|
||||
|
||||
@layer components {
|
||||
[data-tabs-bar] {
|
||||
--tabs-ring: 5px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
padding: var(--tabs-ring);
|
||||
scroll-padding-inline: var(--tabs-ring);
|
||||
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* "Offset the first scrollable tab 52dp from the leading edge so it's clear that more content
|
||||
is available" (docs/reference/m3/components-navigation-selection-inputs.md § Tabs). Logical,
|
||||
so a right-to-left page mirrors it. */
|
||||
[data-tabs-bar][data-scrollable] {
|
||||
padding-inline-start: 3.25rem;
|
||||
scroll-padding-inline-start: 3.25rem;
|
||||
}
|
||||
|
||||
/* A section nav's links sit in list items that share the width. */
|
||||
[data-tabs-bar] > li {
|
||||
flex: 1 1 0%;
|
||||
@@ -47,6 +65,7 @@
|
||||
transition: color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
[data-tabs-bar][data-scrollable] > li,
|
||||
[data-tabs-bar][data-scrollable] [data-tab] {
|
||||
flex: none;
|
||||
}
|
||||
@@ -55,10 +74,12 @@
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
/* The state layer covers the tab and the ring's room around it, so the wash meets the divider. */
|
||||
[data-tab]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
inset-block: calc(-1 * var(--tabs-ring));
|
||||
inset-inline: 0;
|
||||
pointer-events: none;
|
||||
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
@@ -81,10 +102,12 @@
|
||||
[data-tab]:focus-visible {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: -3px;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
[data-tab][aria-selected="true"] {
|
||||
/* A link-based bar marks the page with aria-current, not aria-selected; both are the chosen
|
||||
tab, and both take the variant's active colour. */
|
||||
[data-tab]:is([aria-selected="true"], [aria-current="page"]) {
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
@@ -116,10 +139,12 @@
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
/* Primary indicators are inset 2dp at each side; both variants sit on the divider, which the
|
||||
ring's room has pushed below the tab. */
|
||||
[data-tab-indicator] {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
inset-inline: 2px;
|
||||
bottom: calc(-1 * var(--tabs-ring));
|
||||
height: 3px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
background-color: var(--md-sys-color-primary);
|
||||
@@ -134,6 +159,7 @@
|
||||
}
|
||||
|
||||
[data-tabs-bar][data-variant="secondary"] [data-tab-indicator] {
|
||||
inset-inline: 0;
|
||||
height: 2px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
* floating a 64px pill in surface-container (or primary-container, `vibrant`) at elevation 3,
|
||||
* 8px at its ends and 4px between controls; `vertical` stands it on end
|
||||
*
|
||||
* Unlayered on purpose: a vibrant toolbar recolours the icon buttons inside it, which draw their
|
||||
* ink as utilities, and a rule in any layer loses to a utility.
|
||||
* Placed over the page, a horizontal toolbar keeps 16dp from the window's edge and a vertical one
|
||||
* 24dp, which is M3's minimum for each.
|
||||
*
|
||||
* Unlayered on purpose: a toolbar recolours the icon buttons inside it — primary in a standard
|
||||
* one, on-primary-container in a vibrant one — and they draw their ink as utilities, which a rule
|
||||
* in any layer loses to.
|
||||
*/
|
||||
|
||||
[data-toolbar] {
|
||||
@@ -60,6 +64,13 @@
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
/* M3's colour list for a standard toolbar ends "Standard button (Primary)", as the vibrant list
|
||||
ends "Standard button (On primary container)" — the row above. An icon button carries its own
|
||||
ink as a utility, so the container's `color` never reaches it and this rule has to. */
|
||||
[data-toolbar]:not([data-vibrant]) [data-icon-button]:not([aria-pressed="true"]) {
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
/* A floating toolbar and its FAB, side by side (or stacked, vertical). */
|
||||
[data-toolbar-group] {
|
||||
display: inline-flex;
|
||||
@@ -71,10 +82,17 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Placed over the page: centred above the bottom edge, or centred against the end edge. */
|
||||
/* Placed over the page: centred above the bottom edge, or centred against the end edge.
|
||||
*
|
||||
* A toolbar at the bottom shares the screen region with a navigation bar, so it clears the one
|
||||
* <x-app-shell> publishes as --material-bottom-bar, exactly as the FAB and the snackbar do —
|
||||
* `max()`, not a sum, because that height already swallows the bottom safe area; without a bar
|
||||
* the safe area alone applies. A *docked* toolbar and a navigation bar must never be on screen
|
||||
* together at all (docs/reference/m3/components-navigation-selection-inputs.md § Toolbars); the
|
||||
* offset is the backstop, not a licence. */
|
||||
[data-toolbar-place="bottom"] {
|
||||
position: fixed;
|
||||
bottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
|
||||
bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + 1rem);
|
||||
left: 50%;
|
||||
z-index: 30;
|
||||
translate: -50% 0;
|
||||
@@ -88,9 +106,17 @@
|
||||
translate: 0 -50%;
|
||||
}
|
||||
|
||||
/* "Horizontal toolbars should have a minimum 16dp margin from the edge of the window. Vertical
|
||||
toolbars should have a minimum 24dp margin"
|
||||
(docs/reference/m3/components-navigation-selection-inputs.md § Toolbars). */
|
||||
[data-toolbar][data-vertical][data-toolbar-place="end"],
|
||||
[data-toolbar-group][data-vertical][data-toolbar-place="end"] {
|
||||
inset-inline-end: calc(1.5rem + var(--material-safe-right, env(safe-area-inset-right)));
|
||||
}
|
||||
|
||||
[data-toolbar][data-variant="docked"][data-toolbar-place="bottom"] {
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
bottom: var(--material-bottom-bar, 0px);
|
||||
left: 0;
|
||||
translate: none;
|
||||
}
|
||||
|
||||
@@ -38,10 +38,12 @@
|
||||
type="button"
|
||||
aria-label="{{ $label }}"
|
||||
data-account-menu
|
||||
class="focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-corner-full bg-primary-container type-label-lg text-on-primary-container"
|
||||
class="state-layer touch-target focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full bg-primary-container type-label-lg text-on-primary-container"
|
||||
>
|
||||
@if ($image)
|
||||
<img src="{{ $avatar }}" alt="" class="size-full object-cover" />
|
||||
{{-- Behind the state layer (`state-layer` paints its ::before at z-index -1), so an
|
||||
avatar image still shows the hover and pressed states over it. --}}
|
||||
<img src="{{ $avatar }}" alt="" class="relative -z-20 size-full rounded-corner-full object-cover" />
|
||||
@elseif (filled($initials))
|
||||
{{ $initials }}
|
||||
@else
|
||||
|
||||
@@ -45,13 +45,23 @@
|
||||
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
|
||||
`navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||
|
||||
Slots, each rendered once: `brand` (beside the rail's menu button while it is expanded),
|
||||
`rail-header` (under it: a FAB — see `<x-navigation-rail>` for its two shapes), `rail-footer`
|
||||
(at the foot of the rail: footer destinations, an account), `actions` (a row of icon buttons at
|
||||
the very foot, stacked when the rail is collapsed: a theme toggle, sign out), `top` (the app
|
||||
bar, above the page at every width) and the page itself. The rail is one element at every
|
||||
width, so what is in it is also in the modal rail a phone opens. `label` names both navigation
|
||||
landmarks ("Main"); `rail-width` is the expanded rail's width.
|
||||
Slots, each rendered once: `banner` (a bar across the whole window, above the rail and the
|
||||
page — M3's scaffold is bars, then rails, then panes), `brand` (beside the rail's menu button
|
||||
while it is expanded), `rail-header` (under it: one `<x-fab label="…" icon="…">`, which the
|
||||
rail morphs between a FAB and an extended FAB as it opens), `rail-footer` (at the foot of the
|
||||
rail: footer destinations, an account), `actions`
|
||||
(a row of icon buttons at the very foot, stacked when the rail is collapsed: a theme toggle,
|
||||
sign out), `top` (the page's own bar, above the page and *beside* the rail) and the page
|
||||
itself. The rail is one element at every width, so what is in it is also in the modal rail a
|
||||
phone opens. `label` names both navigation landmarks ("Main"); `rail-width` is the expanded
|
||||
rail's width.
|
||||
|
||||
`banner` or `top` is a decision about what the bar belongs to: an application-wide bar — one
|
||||
search, one account menu, the same on every page — spans the window and the rail starts under
|
||||
it; a bar that titles the page belongs to the page, beside the rail. Put an app bar in one or
|
||||
the other, never both. A banner that pins itself to the top of the window says how tall it is
|
||||
— `style="--material-banner: 4rem"` on `<x-app-shell>` — so the rail sticks under it instead
|
||||
of behind it.
|
||||
|
||||
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
|
||||
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
|
||||
@@ -102,7 +112,7 @@
|
||||
<div
|
||||
data-app-shell
|
||||
@class([
|
||||
'min-h-dvh bg-surface text-on-surface [--material-margin:1rem] medium:flex medium:[--material-margin:1.5rem]',
|
||||
'flex min-h-dvh flex-col bg-surface text-on-surface [--material-margin:1rem] medium:[--material-margin:1.5rem]',
|
||||
'max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty(),
|
||||
])
|
||||
>
|
||||
@@ -112,48 +122,54 @@
|
||||
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
|
||||
>{{ __('Skip to content') }}</a>
|
||||
|
||||
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
|
||||
@isset($brand)
|
||||
<x-slot:brand>{{ $brand }}</x-slot:brand>
|
||||
@endisset
|
||||
@isset($banner)
|
||||
<div data-app-shell-banner class="shrink-0">{{ $banner }}</div>
|
||||
@endisset
|
||||
|
||||
@isset($railHeader)
|
||||
<x-slot:header>{{ $railHeader }}</x-slot:header>
|
||||
@endisset
|
||||
<div class="flex-1 medium:flex">
|
||||
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
|
||||
@isset($brand)
|
||||
<x-slot:brand>{{ $brand }}</x-slot:brand>
|
||||
@endisset
|
||||
|
||||
@foreach ($groups as $group)
|
||||
@if ($group->first()['section'] !== null)
|
||||
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
|
||||
@isset($railHeader)
|
||||
<x-slot:header>{{ $railHeader }}</x-slot:header>
|
||||
@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)
|
||||
<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)
|
||||
<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
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if (isset($railFooter) || isset($actions))
|
||||
<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
|
||||
@endforeach
|
||||
</x-livewire-material::navigation-rail>
|
||||
|
||||
@if (isset($railFooter) || isset($actions))
|
||||
<x-slot:footer>
|
||||
{{ $railFooter ?? '' }}
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
{{ $top ?? '' }}
|
||||
|
||||
@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
|
||||
</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>
|
||||
<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())
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<x-navigation-rail mode="collapsible">
|
||||
<x-slot:brand><span class="type-title-lg">Mail</span></x-slot:brand>
|
||||
<x-slot:header>
|
||||
<x-fab icon="edit" tooltip-right="Compose" />
|
||||
<x-fab label="Compose" icon="edit" />
|
||||
</x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
|
||||
@@ -36,21 +36,30 @@
|
||||
the same standard rail, expanded to begin with. A visitor who has used the menu button keeps
|
||||
that choice in both standard bands.
|
||||
|
||||
Slots: `brand` beside the menu button, only while expanded; `header` under it — a FAB, drawn
|
||||
as an extended FAB when expanded (`rail-collapsed:` below); the destinations in the default
|
||||
slot, which alone scroll when the window is too short; `footer`, pinned to the foot. Header and
|
||||
footer never scroll, so nothing in them is cut off by the scroller's edge.
|
||||
Slots: `brand` beside the menu button, only while expanded; `header` under it — one
|
||||
`<x-fab label="…" icon="…">`, which the rail morphs: the label's width springs open and shut
|
||||
with the rail, so the FAB becomes an extended FAB and back rather than one being swapped for
|
||||
the other, and its label names it at both widths. It also rests flat, because M3 puts a FAB
|
||||
nested in another component at elevation 0, not the 3 a standalone one has. Then the
|
||||
destinations in the default slot, which alone scroll when the window is too short, and
|
||||
`footer`, pinned to the foot. Header and footer never scroll, so nothing in them is cut off by
|
||||
the scroller's edge.
|
||||
|
||||
Anything inside can take both shapes with the `rail-collapsed:` variant, which applies while
|
||||
the rail is drawn collapsed for whatever reason:
|
||||
`<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>`
|
||||
`<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>`.
|
||||
Anything else inside can take both shapes with the `rail-collapsed:` variant, which applies
|
||||
while the rail is drawn collapsed for whatever reason:
|
||||
`<span class="rail-collapsed:hidden">…only while expanded…</span>`
|
||||
`<span class="hidden rail-collapsed:inline-flex">…only while collapsed…</span>`.
|
||||
Nothing that shows while collapsed may be wider than 96px.
|
||||
|
||||
Props: `label` names the landmark ("Main"); `width` is the expanded width (`16rem`, held
|
||||
between M3's 220 and 360dp); `menu` shows the menu button (by default for `collapsible`,
|
||||
`modal` and `adaptive`). The rail does not scroll with the page: in a flex row it sticks to
|
||||
the top of the viewport, as tall as the viewport at most.
|
||||
`modal` and `adaptive`); `divider` draws M3's optional vertical divider on the edge the page
|
||||
is on — which is also what M3 asks for when a page scrolls underneath a fixed rail; `fill`
|
||||
(`false`) drops the container colour for a transparent rail over the page's own background,
|
||||
which M3 allows as long as the items keep a 3:1 contrast against what is behind them. A rail
|
||||
open over a scrim keeps its fill and drops the divider whatever those say: it is a surface
|
||||
over the page then. The rail does not scroll with the page: in a flex row it sticks to the top
|
||||
of the viewport, as tall as the viewport at most.
|
||||
|
||||
Values from androidx Compose Material 3 (Apache-2.0), androidx-main
|
||||
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationRailCollapsedTokens.kt,
|
||||
@@ -66,6 +75,8 @@
|
||||
'label' => null,
|
||||
'width' => '16rem',
|
||||
'menu' => null,
|
||||
'divider' => false,
|
||||
'fill' => true,
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -78,6 +89,8 @@
|
||||
|
||||
<div
|
||||
data-navigation-rail="{{ $mode }}"
|
||||
@if ($divider) data-divider @endif
|
||||
@unless ($fill) data-fill="false" @endunless
|
||||
@if ($interactive)
|
||||
x-data="materialNavigationRail('{{ $mode }}')"
|
||||
x-bind:data-open="open"
|
||||
@@ -109,7 +122,7 @@
|
||||
x-on:click="menu()"
|
||||
x-bind:aria-label="expanded ? @js(__('Collapse navigation')) : @js(__('Expand navigation'))"
|
||||
x-bind:aria-expanded="expanded.toString()"
|
||||
class="state-layer focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant after:absolute after:top-1/2 after:left-1/2 after:size-12 after:-translate-x-1/2 after:-translate-y-1/2"
|
||||
class="state-layer touch-target focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant"
|
||||
>
|
||||
<span class="contents rail-collapsed:hidden"><x-livewire-material::icon name="menu_open" class="size-6" /></span>
|
||||
<span class="hidden rail-collapsed:contents"><x-livewire-material::icon name="menu" class="size-6" /></span>
|
||||
|
||||
@@ -13,10 +13,15 @@
|
||||
The page's URL is `Livewire::originalUrl()`: while a Livewire component on the page updates, the
|
||||
request is Livewire's update endpoint, and comparing with it left no section lit.
|
||||
|
||||
A row too long for its column wraps onto a grid rather than scrolling: below `large` (1200px)
|
||||
five or six
|
||||
sections go 3 + 3 and seven or more go four to a row — tabs that scroll hid the last sections on
|
||||
a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless
|
||||
Up to four sections share the row as fixed tabs. From five the row is M3's scrollable tab bar
|
||||
— each tab as wide as its own label, the set scrolling sideways, offset 52dp from the leading
|
||||
edge so it reads as scrollable — which is M3's own answer to a row that will not fit, and the
|
||||
one its accessibility page blesses ("horizontal scrolling tabs meet accessibility requirements
|
||||
because they need to increase in width to respond to label text without affecting the
|
||||
layout"). An earlier version wrapped them onto a grid instead, which left the bar's divider
|
||||
under the last row only and stranded the upper rows' indicators against nothing.
|
||||
|
||||
`label` names the navigation ("Sections"). Links use `wire:navigate` unless
|
||||
`no-wire-navigate`. --}}
|
||||
|
||||
@props([
|
||||
@@ -31,11 +36,8 @@
|
||||
$isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && $page === url($item['url']));
|
||||
$current = collect($items)->first($isCurrent) ?? ($items[0] ?? null);
|
||||
|
||||
$layout = match (true) {
|
||||
count($items) < 5 => 'medium:flex',
|
||||
count($items) < 7 => 'medium:grid medium:grid-cols-3 large:flex',
|
||||
default => 'medium:grid medium:grid-cols-4 large:flex',
|
||||
};
|
||||
// Four fit a row at the widths this bar is used at; from five they are M3's scrollable tabs.
|
||||
$scrollable = count($items) >= 5;
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->class(['min-w-0']) }} data-section-nav>
|
||||
@@ -45,7 +47,7 @@
|
||||
<x-slot:trigger>
|
||||
<button type="button" class="focus-ring flex h-12 w-[min(20rem,calc(100vw-2rem))] cursor-pointer items-center gap-3 rounded-corner-xs border border-outline px-4 text-start type-body-lg text-on-surface">
|
||||
@isset($current['icon'])
|
||||
<x-livewire-material::icon :name="$current['icon']" class="size-5 text-on-surface-variant" />
|
||||
<x-livewire-material::icon :name="$current['icon']" optical="20" class="size-5 text-on-surface-variant" />
|
||||
@endisset
|
||||
<span class="min-w-0 flex-1 truncate">{{ $current['title'] }}</span>
|
||||
<x-livewire-material::icon name="arrow_drop_down" class="size-6 text-on-surface-variant" />
|
||||
@@ -60,7 +62,7 @@
|
||||
@endif
|
||||
|
||||
<nav aria-label="{{ $label }}" class="max-medium:hidden">
|
||||
<ul data-tabs-bar data-variant="secondary" class="{{ $layout }}">
|
||||
<ul data-tabs-bar data-variant="secondary" @if ($scrollable) data-scrollable @endif>
|
||||
@foreach ($items as $item)
|
||||
@php($on = $isCurrent($item))
|
||||
|
||||
@@ -73,7 +75,7 @@
|
||||
>
|
||||
<span data-tab-content>
|
||||
@isset($item['icon'])
|
||||
<x-livewire-material::icon :name="$item['icon']" :filled="$on" class="size-5" />
|
||||
<x-livewire-material::icon :name="$item['icon']" :filled="$on" optical="20" class="size-5" />
|
||||
@endisset
|
||||
<span class="truncate">{{ $item['title'] }}</span>
|
||||
@if (filled($item['badge'] ?? null))
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
{{-- One tab's panel, inside `<x-tabs>`, which draws the bar from its own `tabs` list. Shown while
|
||||
its `name` is the chosen one; every panel is rendered, so switching never waits on the server.
|
||||
`class` lands on the panel (it has `pt-6` above its content by default). --}}
|
||||
`class` lands on the panel (it has `pt-6` above its content by default).
|
||||
|
||||
The id and the `hidden` panels come from the server, not from Alpine: until Alpine boots, every
|
||||
panel would otherwise be on screen at once — four `role="tabpanel"` regions for a screen reader
|
||||
to walk — and the tab buttons' `aria-controls` would point at nothing. `<x-tabs>` renders this
|
||||
slot before its own view, so it cannot hand the panel anything; the panel reads the attributes
|
||||
written on `<x-tabs>` off the component stack (Blade's `@aware` mechanism) and repeats the two
|
||||
lines tabs.blade.php runs on them. The two must agree, so change them together. --}}
|
||||
|
||||
@props([
|
||||
'name',
|
||||
])
|
||||
|
||||
@php
|
||||
$aware = fn (string $key, $default = null) => $__env->getConsumableComponentData($key, $default);
|
||||
|
||||
$tabs = $aware('tabs', []);
|
||||
// `wire:model` is a key, not a prop, and its modifiers are part of the key: the spellings that
|
||||
// make sense on a tab set. An exotic one only means the panel falls back to the first tab.
|
||||
$model = collect(['wire:model', 'wire:model.live', 'wire:model.blur', 'wire:model.change', 'wire:model.lazy'])
|
||||
->map(fn (string $key) => $aware($key))
|
||||
->first(fn ($value): bool => filled($value));
|
||||
|
||||
$tabsId = $aware('id') ?? 'tabs-'.substr(md5($model.'|'.implode('|', array_column($tabs, 'name'))), 0, 8);
|
||||
|
||||
$initial = $aware('selected') ?? (collect($tabs)->first(fn (array $tab): bool => ! ($tab['disabled'] ?? false))['name'] ?? null);
|
||||
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null && filled(data_get($component, $model))) {
|
||||
$initial = data_get($component, $model);
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div
|
||||
x-show="selected === @js($name)"
|
||||
role="tabpanel"
|
||||
x-bind:id="tabsId + '-' + @js($name) + '-panel'"
|
||||
x-bind:aria-labelledby="tabsId + '-' + @js($name)"
|
||||
id="{{ $tabsId }}-{{ $name }}-panel"
|
||||
aria-labelledby="{{ $tabsId }}-{{ $name }}"
|
||||
tabindex="0"
|
||||
@if ((string) $name !== (string) $initial) style="display: none" @endif
|
||||
{{ $attributes->class(['pt-6 outline-none']) }}
|
||||
>
|
||||
{{ $slot }}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
The bar is rendered on the server from `tabs`, a list of `['name' => …, 'label' => …]` with an
|
||||
optional `icon`, `badge` and `disabled` — so it arrives with the page, never a moment after it.
|
||||
The panels are `<x-tab name="…">` in the slot; every panel is rendered, so switching never waits
|
||||
on the server.
|
||||
on the server, and each arrives with its id and with every panel but the chosen one already
|
||||
hidden, so nothing flashes and no screen reader meets four panels at once.
|
||||
|
||||
The chosen tab is the Livewire property in `wire:model` (entangled; `wire:model.live` tells the
|
||||
server at once), or, without it, `selected` (the first tab by default) and `x-model`. The bar is
|
||||
@@ -24,6 +25,8 @@
|
||||
@php
|
||||
$model = $attributes->wire('model')->value() ?: null;
|
||||
$variant = $variant === 'secondary' ? 'secondary' : 'primary';
|
||||
// tab.blade.php repeats these two lines off the component stack, because a slot renders before
|
||||
// the component around it: change them together, or a panel's id stops matching aria-controls.
|
||||
$id = $attributes->get('id') ?? 'tabs-'.substr(md5($model.'|'.implode('|', array_column($tabs, 'name'))), 0, 8);
|
||||
|
||||
$initial = $selected ?? (collect($tabs)->first(fn (array $tab): bool => ! ($tab['disabled'] ?? false))['name'] ?? null);
|
||||
|
||||
@@ -6,76 +6,79 @@
|
||||
you — a sun while the page is dark, a moon while it is light — and it is a toggle button,
|
||||
"Dark theme", pressed while dark.
|
||||
- `cycle`: an icon button that steps light → dark → system, showing the choice it is on.
|
||||
- `picker`: M3's segmented buttons for the three choices, for a settings page.
|
||||
- `picker`: the three choices as a connected button group, for a settings page.
|
||||
- `contrast`: the same row for M3's three contrast levels — standard, medium (3:1) and high
|
||||
(7:1), the three the scheme is generated in. The row marks the level in force
|
||||
(`resolvedContrast`), so the operating system's setting shows while the choice is
|
||||
`system`, and choosing one is an explicit choice from then on.
|
||||
|
||||
The two rows are `<x-group>` — M3 Expressive's connected button group, the successor of the
|
||||
segmented button, which the expressive update deprecates. Its segments are native radios, so
|
||||
the browser supplies the radiogroup semantics, both arrow axes, the wrap and the roving tab
|
||||
stop; the store is bound through an `x-model` accessor on the wrapper, since the level a
|
||||
contrast row marks is the resolved one but the level it writes goes through `setContrast()`.
|
||||
`label` adds a visible legend (a settings page); without one the row is still named for a
|
||||
screen reader. `class` lands on the button or on the group.
|
||||
|
||||
The theme is the visitor's, known only in the browser, so the parts that depend on it wait for
|
||||
Alpine (`x-cloak`) rather than render a guess. `class` lands on the button or the group. --}}
|
||||
Alpine (`x-cloak`, an unchecked radio) rather than render a guess. --}}
|
||||
|
||||
@props([
|
||||
'mode' => 'toggle',
|
||||
'label' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$mode = in_array($mode, ['toggle', 'cycle', 'picker', 'contrast'], true) ? $mode : 'toggle';
|
||||
|
||||
$row = [
|
||||
'picker' => [
|
||||
'name' => __('Theme'),
|
||||
'field' => 'material-theme',
|
||||
'read' => '$store.theme.choice',
|
||||
'write' => '$store.theme.set(value)',
|
||||
'options' => [
|
||||
['id' => 'light', 'name' => __('Light'), 'icon' => 'light_mode'],
|
||||
['id' => 'dark', 'name' => __('Dark'), 'icon' => 'dark_mode'],
|
||||
['id' => 'system', 'name' => __('System'), 'icon' => 'brightness_auto'],
|
||||
],
|
||||
],
|
||||
'contrast' => [
|
||||
'name' => __('Contrast'),
|
||||
'field' => 'material-contrast',
|
||||
'read' => '$store.theme.resolvedContrast',
|
||||
'write' => '$store.theme.setContrast(value)',
|
||||
'options' => [
|
||||
['id' => 'standard', 'name' => __('Standard'), 'icon' => 'contrast'],
|
||||
['id' => 'medium', 'name' => __('Medium'), 'icon' => 'contrast_circle'],
|
||||
['id' => 'high', 'name' => __('High'), 'icon' => 'contrast_square'],
|
||||
],
|
||||
],
|
||||
][$mode] ?? null;
|
||||
@endphp
|
||||
|
||||
@if ($mode === 'contrast')
|
||||
@if ($row !== null)
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="{{ __('Contrast') }}"
|
||||
x-data
|
||||
data-theme-toggle="contrast"
|
||||
{{ $attributes->class(['inline-flex h-10 rounded-corner-full border border-outline']) }}
|
||||
role="group"
|
||||
aria-label="{{ $label ?? $row['name'] }}"
|
||||
data-theme-toggle="{{ $mode }}"
|
||||
x-data="{
|
||||
get chosen() {
|
||||
return this.{{ $row['read'] }}
|
||||
},
|
||||
set chosen(value) {
|
||||
this.{{ $row['write'] }}
|
||||
},
|
||||
}"
|
||||
>
|
||||
@foreach (['standard' => ['Standard', 'contrast'], 'medium' => ['Medium', 'contrast_circle'], 'high' => ['High', 'contrast_square']] as $level => [$text, $icon])
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked="false"
|
||||
x-bind:aria-checked="($store.theme.resolvedContrast === '{{ $level }}').toString()"
|
||||
x-bind:tabindex="$store.theme.resolvedContrast === '{{ $level }}' ? 0 : -1"
|
||||
x-on:click="$store.theme.setContrast('{{ $level }}')"
|
||||
x-on:keydown.arrow-right.prevent="$el.nextElementSibling?.click(); $el.nextElementSibling?.focus();"
|
||||
x-on:keydown.arrow-left.prevent="$el.previousElementSibling?.click(); $el.previousElementSibling?.focus();"
|
||||
data-contrast-option="{{ $level }}"
|
||||
class="state-layer focus-ring inline-flex min-w-0 flex-1 cursor-pointer items-center justify-center gap-2 border-outline px-3 type-label-lg text-on-surface not-first:border-s first:rounded-s-corner-full last:rounded-e-corner-full aria-checked:bg-secondary-container aria-checked:text-on-secondary-container"
|
||||
>
|
||||
<x-livewire-material::icon name="check" class="hidden size-4.5 in-aria-checked:block" />
|
||||
<x-livewire-material::icon :name="$icon" class="size-4.5 in-aria-checked:hidden" />
|
||||
{{ __($text) }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif ($mode === 'picker')
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="{{ __('Theme') }}"
|
||||
x-data
|
||||
data-theme-toggle="picker"
|
||||
{{ $attributes->class(['inline-flex h-10 rounded-corner-full border border-outline']) }}
|
||||
>
|
||||
@foreach (['light' => ['Light', 'light_mode'], 'dark' => ['Dark', 'dark_mode'], 'system' => ['System', 'brightness_auto']] as $choice => [$text, $icon])
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked="false"
|
||||
x-bind:aria-checked="($store.theme.choice === '{{ $choice }}').toString()"
|
||||
x-bind:tabindex="$store.theme.choice === '{{ $choice }}' ? 0 : -1"
|
||||
x-on:click="$store.theme.set('{{ $choice }}')"
|
||||
x-on:keydown.arrow-right.prevent="$el.nextElementSibling?.click(); $el.nextElementSibling?.focus();"
|
||||
x-on:keydown.arrow-left.prevent="$el.previousElementSibling?.click(); $el.previousElementSibling?.focus();"
|
||||
data-theme-option="{{ $choice }}"
|
||||
class="state-layer focus-ring inline-flex min-w-0 flex-1 cursor-pointer items-center justify-center gap-2 border-outline px-3 type-label-lg text-on-surface not-first:border-s first:rounded-s-corner-full last:rounded-e-corner-full aria-checked:bg-secondary-container aria-checked:text-on-secondary-container"
|
||||
>
|
||||
<x-livewire-material::icon name="check" class="hidden size-4.5 in-aria-checked:block" />
|
||||
<x-livewire-material::icon :name="$icon" class="size-4.5 in-aria-checked:hidden" />
|
||||
{{ __($text) }}
|
||||
</button>
|
||||
@endforeach
|
||||
<x-livewire-material::group
|
||||
inline
|
||||
x-model="chosen"
|
||||
:label="$label"
|
||||
:name="$row['field']"
|
||||
:options="$row['options']"
|
||||
{{ $attributes }}
|
||||
/>
|
||||
</div>
|
||||
@else
|
||||
<button
|
||||
@@ -93,7 +96,7 @@
|
||||
x-bind:aria-pressed="($store.theme.resolved === 'dark').toString()"
|
||||
x-on:click="$store.theme.toggle()"
|
||||
@endif
|
||||
{{ $attributes->class(['state-layer focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant transition-[border-radius] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast active:rounded-corner-sm']) }}
|
||||
{{ $attributes->class(['state-layer touch-target focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant transition-[border-radius] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast active:rounded-corner-sm']) }}
|
||||
>
|
||||
@if ($mode === 'cycle')
|
||||
<x-livewire-material::icon name="light_mode" x-cloak x-show="$store.theme.choice === 'light'" />
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
toolbar sits where it is written. A `fab` slot sets an `<x-fab>` beside a floating toolbar.
|
||||
`label` names it for screen readers.
|
||||
|
||||
A docked toolbar and a navigation bar occupy the same region of the screen and M3 says never to
|
||||
show both at once: the bar belongs on a primary page, the toolbar on a secondary or contextual
|
||||
one. Either way a toolbar placed at `bottom` clears `--material-bottom-bar`, so it is never
|
||||
buried under `<x-app-shell>`'s bar.
|
||||
|
||||
Put `<x-button icon="…" tooltip="…" />` controls in the slot (`:selected` for toggles). It is a
|
||||
`role="toolbar"`: the arrow keys move between its controls (resources/js/toolbar.js,
|
||||
resources/css/components/toolbar.css). --}}
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
fetched on first focus, and / or Ctrl+K (⌘K) reaches it from anywhere. Its names
|
||||
keep clear of <x-search>'s own (`results`, `open`), which the slot also sees. --}}
|
||||
<div
|
||||
class="mx-auto w-full max-w-2xl"
|
||||
class="w-full"
|
||||
x-data="{
|
||||
query: '',
|
||||
entries: null,
|
||||
|
||||
@@ -34,9 +34,9 @@
|
||||
'Collapsed and expanded rails' => <<<'BLADE'
|
||||
<div class="flex w-full flex-wrap items-start gap-6">
|
||||
<div class="flex h-[36rem] overflow-hidden rounded-corner-lg border border-outline-variant">
|
||||
<x-navigation-rail mode="collapsed" label="Collapsed example">
|
||||
<x-navigation-rail mode="collapsed" label="Collapsed example" divider>
|
||||
<x-slot:header>
|
||||
<x-fab icon="edit" tooltip-right="Compose" />
|
||||
<x-fab label="Compose" icon="edit" />
|
||||
</x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
|
||||
@@ -82,8 +82,7 @@
|
||||
</x-slot:brand>
|
||||
|
||||
<x-slot:header>
|
||||
<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>
|
||||
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>
|
||||
<x-fab label="Compose" icon="edit" />
|
||||
</x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
|
||||
@@ -92,7 +91,7 @@
|
||||
</x-navigation-rail>
|
||||
|
||||
<div class="min-w-0 flex-1 bg-surface-container-low p-6 type-body-md text-on-surface-variant">
|
||||
The menu button collapses and expands the rail. The choice is remembered and applied before the next page paints — every collapsible rail follows it, <code><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>
|
||||
BLADE,
|
||||
@@ -104,7 +103,7 @@
|
||||
|
||||
<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>,
|
||||
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.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -45,8 +45,7 @@
|
||||
</x-slot:brand>
|
||||
|
||||
<x-slot:rail-header>
|
||||
<span class="rail-collapsed:hidden"><x-livewire-material::fab label="Compose" icon="edit" x-on:click="materialToast('Compose opens here')" /></span>
|
||||
<span class="hidden rail-collapsed:inline-flex"><x-livewire-material::fab icon="edit" tooltip-right="Compose" x-on:click="materialToast('Compose opens here')" /></span>
|
||||
<x-livewire-material::fab label="Compose" icon="edit" x-on:click="materialToast('Compose opens here')" />
|
||||
</x-slot:rail-header>
|
||||
|
||||
<x-slot:rail-footer>
|
||||
|
||||
Reference in New Issue
Block a user