Size the loading indicator and publish the rail's value
Two gaps found rewriting the Boost skill for 2.0's plain CSS, each
closed the way M3 describes it.
`<x-loading size="96">`: M3 gives the loading indicator a responsive
size, 48dp by default and flexible from 24 to 240dp ("never exceed that
range"), with the container and the active shape in a fixed ratio. The
size had no prop, so an application wrote a width and height of its own;
`size` now takes a whole number of px in that range, written as
`--md-loading-size`, and the SVG keeps the 48:38 ratio as it scales. A
value outside the range is ignored, as `<x-icon>` and `<x-shape>` ignore
theirs, and an application's own width and height still win.
`--md-navigation-rail-value`: M3 Expressive's rail has two values,
collapsed and expanded (Compose's WideNavigationRailValue), and content
in a rail follows it. Without a hook an application copied the rail's
seven conditions — mode, `data-rail`, `data-rail-auto`, open, and the
window band — out of navigation-rail.css. The rail now publishes the
answer from the same branches that narrow it: `expanded` by default,
`collapsed` wherever it is drawn collapsed, so a style query in the
application's CSS switches at the first paint and at the same moment
as the rail's own items. A rail open over a scrim reads `expanded`, and
outside a rail the property is unset. A JS attribute would have missed
the first paint; a width container query would have lagged the collapse
animation.
Style queries on a custom property need Firefox 151, so the browser
floor moves from Firefox 147 to 151 (README, the CI note, UPGRADE's new
2.1.0 section); Chrome 125 and Safari 18.4 are unchanged.
Browser tests pin both in Chrome, Firefox and Safari: the indicator's
drawn box at 96 and 32px, and the rail's value — with a style query
acting on it — at the first paint for fixed modes, across the window
classes and the menu button for an adaptive rail, and open and closed
for a modal one. NavigationRailTest pins the value in each of the five
collapsed branches.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
24eb811f34
commit
f2a3c4d056
@@ -66,7 +66,7 @@ jobs:
|
|||||||
- name: Run feature tests
|
- name: Run feature tests
|
||||||
run: vendor/bin/pest --testsuite=Feature
|
run: vendor/bin/pest --testsuite=Feature
|
||||||
|
|
||||||
# One job per engine: the floor is Chrome 125, Firefox 147 and Safari 18.4, and the
|
# One job per engine: the floor is Chrome 125, Firefox 151 and Safari 18.4, and the
|
||||||
# components lean on platform features (dialog, popover, anchor positioning) that
|
# components lean on platform features (dialog, popover, anchor positioning) that
|
||||||
# differ between them. Pest calls the engines chrome, firefox and safari; Playwright
|
# differ between them. Pest calls the engines chrome, firefox and safari; Playwright
|
||||||
# installs them as chromium, firefox and webkit.
|
# installs them as chromium, firefox and webkit.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Material 3 Expressive components for Laravel and Livewire, in plain CSS.
|
|||||||
- Error pages and a Markdown mail theme in the same scheme.
|
- Error pages and a Markdown mail theme in the same scheme.
|
||||||
- A showcase of every component in the application's own scheme, a design guard for tests, and for AI agents two Laravel Boost guidelines and two skills: the library's own, and Material 3's rules and tables beside its utilities.
|
- A showcase of every component in the application's own scheme, a design guard for tests, and for AI agents two Laravel Boost guidelines and two skills: the library's own, and Material 3's rules and tables beside its utilities.
|
||||||
|
|
||||||
No JavaScript libraries beyond the Alpine that ships with Livewire. Browsers: Chrome 125+, Firefox 147+, Safari 18.4+.
|
No JavaScript libraries beyond the Alpine that ships with Livewire. Browsers: Chrome 125+, Firefox 151+, Safari 18.4+.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -1,5 +1,17 @@
|
|||||||
# Upgrading
|
# Upgrading
|
||||||
|
|
||||||
|
## From 2.0.0 to 2.1.0
|
||||||
|
|
||||||
|
- **Browsers:** Firefox 151 or later (was 147), for container style queries on a custom property;
|
||||||
|
Chrome 125 and Safari 18.4 are unchanged.
|
||||||
|
- **`<x-navigation-rail>`** publishes its value as `--md-navigation-rail-value`, `collapsed` or
|
||||||
|
`expanded` (M3's two rail values). Content an application puts in a rail reads it with
|
||||||
|
`@container style(--md-navigation-rail-value: collapsed)` instead of copying the rail's
|
||||||
|
conditions from `navigation-rail.css`.
|
||||||
|
- **`<x-loading size="96">`** sizes the loading indicator in px, 24 to 240 (M3's responsive range),
|
||||||
|
with the container and the shape in proportion. A width and height of the application's own
|
||||||
|
still work.
|
||||||
|
|
||||||
## From 1.x to 2.0.0
|
## From 1.x to 2.0.0
|
||||||
|
|
||||||
2.0.0 aligns the library with Material Design 3 Expressive as Google documents it
|
2.0.0 aligns the library with Material Design 3 Expressive as Google documents it
|
||||||
@@ -122,8 +134,9 @@ colour, use the role directly.
|
|||||||
- `<x-app-shell>` is renamed `<x-scaffold>`, with no alias; it is a column with a nested row
|
- `<x-app-shell>` is renamed `<x-scaffold>`, with no alias; it is a column with a nested row
|
||||||
and gains `banner` and `fab` slots. `<x-navigation-rail>`'s `header` slot takes one
|
and gains `banner` and `fab` slots. `<x-navigation-rail>`'s `header` slot takes one
|
||||||
`<x-fab label icon>` that morphs (replace the two-FAB `rail-collapsed:` swap); new `divider`
|
`<x-fab label icon>` that morphs (replace the two-FAB `rail-collapsed:` swap); new `divider`
|
||||||
and `fill` props. The `rail-collapsed:` variant is gone: style a rail's collapsed shape in the
|
and `fill` props. The `rail-collapsed:` variant is gone: style what the application puts in a
|
||||||
application's CSS with the conditions `resources/css/components/navigation-rail.css` lists. `data-app-shell`, `data-app-shell-bar`, `data-app-shell-actions` and
|
rail by the rail's value, `@container style(--md-navigation-rail-value: collapsed)` (2.1.0).
|
||||||
|
`data-app-shell`, `data-app-shell-bar`, `data-app-shell-actions` and
|
||||||
`data-app-shell-banner` are `data-md-scaffold`, `data-md-scaffold-bar`, `data-md-scaffold-actions`
|
`data-app-shell-banner` are `data-md-scaffold`, `data-md-scaffold-bar`, `data-md-scaffold-actions`
|
||||||
and `data-md-scaffold-banner`; the skip link is `data-md-skip-link`.
|
and `data-md-scaffold-banner`; the skip link is `data-md-skip-link`.
|
||||||
- `<x-icon optical="20">` selects the optical-size-20 cut; the components pass it for their own
|
- `<x-icon optical="20">` selects the optical-size-20 cut; the components pass it for their own
|
||||||
|
|||||||
@@ -400,11 +400,11 @@ Two to six items open above the FAB, which turns into a close button, rising int
|
|||||||
|
|
||||||
### `<x-loading>`
|
### `<x-loading>`
|
||||||
|
|
||||||
M3 Expressive's loading indicator — a shape morphing through seven Expressive shapes as it turns — for a wait of unknown length. 48px and `primary` unless your own CSS sizes or colours it (there is no `size` prop); `contained` sets it on a primary-container circle. A `progressbar` named by `label` ("Loading"); `:label="false"` makes it decorative. It rests under reduced motion.
|
M3 Expressive's loading indicator — a shape morphing through seven Expressive shapes as it turns — for a wait of unknown length. 48px and `primary` unless sized or coloured: `size` is its size in px, a whole number from 24 to 240 — M3's responsive range, which it asks you never to leave; scale it up on large and extra-large windows — and the container and the shape keep their ratio at every size (outside the range it is ignored, and your own CSS may size it). Colour it with your own CSS. `contained` sets it on a primary-container circle. A `progressbar` named by `label` ("Loading"); `:label="false"` makes it decorative. It rests under reduced motion.
|
||||||
|
|
||||||
```blade
|
```blade
|
||||||
<x-loading />
|
<x-loading />
|
||||||
<x-loading contained style="inline-size: 32px; block-size: 32px" label="Uploading" />
|
<x-loading contained size="32" label="Uploading" />
|
||||||
<div wire:loading.flex wire:target="upload"><x-loading /></div>
|
<div wire:loading.flex wire:target="upload"><x-loading /></div>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1034,7 +1034,16 @@ 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-scaffold>`'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-scaffold>`'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 — or the word `narrow` for M3's other *collapsed* width, 80px against the default 96, where the items are their icons alone; the labels stay in the accessibility tree and a narrow rail still expands to 16rem), `align` (`top` default, or `center` for M3's centred destinations — preferred on a tablet; the menu button, brand and FAB stay at the top and the footer at the foot), `hide-when-collapsed` (M3's immersive expanded behaviour, `collapsible` and `adaptive` only: collapsing the rail takes it out of the layout instead of narrowing it, and `$store.rail.show()` brings it back expanded over a scrim — so put a menu button in the app bar; the rail's own button then docks it again. Not below `medium` for a collapsible rail nor at `medium` for an adaptive one, where the window rather than the visitor collapses it and M3's collapsed rail may never hide), `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.
|
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px — or the word `narrow` for M3's other *collapsed* width, 80px against the default 96, where the items are their icons alone; the labels stay in the accessibility tree and a narrow rail still expands to 16rem), `align` (`top` default, or `center` for M3's centred destinations — preferred on a tablet; the menu button, brand and FAB stay at the top and the footer at the foot), `hide-when-collapsed` (M3's immersive expanded behaviour, `collapsible` and `adaptive` only: collapsing the rail takes it out of the layout instead of narrowing it, and `$store.rail.show()` brings it back expanded over a scrim — so put a menu button in the app bar; the rail's own button then docks it again. Not below `medium` for a collapsible rail nor at `medium` for an adaptive one, where the window rather than the visitor collapses it and M3's collapsed rail may never hide), `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 can take both shapes by matching the rail's own state directly — `data-md-navigation-rail`'s value, `:not([data-md-open])`, and the window band each mode collapses in, the same selectors `resources/css/components/navigation-rail.css` uses for every branch of "collapsed" (that file, not this one, is where the numbers live). There is no single hook or variant for "collapsed": write those conditions out in your own CSS. Nothing that shows while collapsed may be wider than 96px.
|
- Anything else inside a rail takes both shapes from the rail's value. The rail publishes M3's two values (Compose's `WideNavigationRailValue`) as `--md-navigation-rail-value`, `collapsed` or `expanded`, from the first paint and at the same moment as its own items change shape, whatever made it so (mode, window size class, the visitor's choice); a rail open over a scrim reads `expanded`. Every descendant inherits it, so your own CSS asks a style query rather than repeating the rail's conditions. Outside a rail it is unset and neither value matches. Style queries on a custom property need Chrome 111, Safari 18 or Firefox 151. Nothing that shows while collapsed may be wider than 96px.
|
||||||
|
|
||||||
|
```css
|
||||||
|
@container style(--md-navigation-rail-value: collapsed) {
|
||||||
|
.account-summary {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- 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 `<x-stack hide-below="medium">` if it must not show on a phone.
|
- 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 `<x-stack hide-below="medium">` 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`).
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
*
|
*
|
||||||
* The drawing is resources/svg/loading-indicator/, ported from androidx Compose Material 3's
|
* The drawing is resources/svg/loading-indicator/, ported from androidx Compose Material 3's
|
||||||
* LoadingIndicator in bin/loading-indicator.mjs (Apache-2.0): LoadingIndicatorTokens' 38px
|
* LoadingIndicator in bin/loading-indicator.mjs (Apache-2.0): LoadingIndicatorTokens' 38px
|
||||||
* indicator in a 48px container, which is the size here unless the caller's CSS sizes it. It is
|
* indicator in a 48px container, which is the size here unless `size` (`--md-loading-size`, 24 to
|
||||||
|
* 240px, M3's responsive range) or the caller's CSS sizes it; the SVG scales with its box, so the
|
||||||
|
* container and the shape keep their ratio at every size. It is
|
||||||
* drawn in the text colour, `primary` (ActiveIndicatorColor) unless the caller colours it;
|
* drawn in the text colour, `primary` (ActiveIndicatorColor) unless the caller colours it;
|
||||||
* `contained` puts it on a `primary-container` circle in `on-primary-container`
|
* `contained` puts it on a `primary-container` circle in `on-primary-container`
|
||||||
* (ContainedContainerColor, ContainedIndicatorColor), for a spinner over content.
|
* (ContainedContainerColor, ContainedIndicatorColor), for a spinner over content.
|
||||||
@@ -21,8 +23,8 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
inline-size: 48px;
|
inline-size: var(--md-loading-size, 48px);
|
||||||
block-size: 48px;
|
block-size: var(--md-loading-size, 48px);
|
||||||
color: var(--md-sys-color-primary);
|
color: var(--md-sys-color-primary);
|
||||||
|
|
||||||
& > svg {
|
& > svg {
|
||||||
|
|||||||
@@ -18,9 +18,8 @@
|
|||||||
* [data-md-navigation-rail-item] navigation-rail-item.css
|
* [data-md-navigation-rail-item] navigation-rail-item.css
|
||||||
* [data-md-navigation-rail-footer] never scrolls
|
* [data-md-navigation-rail-footer] never scrolls
|
||||||
*
|
*
|
||||||
* **Collapsed**, reproduced from the Tailwind-era custom variant named `rail-collapsed`: a rail (and
|
* **Collapsed**: a rail (and anything in it) is drawn collapsed while any of these hold, each its
|
||||||
* anything in it) is drawn collapsed while any of these hold, each its own rule below wherever a
|
* own rule below wherever a property differs by shape — a fixed `collapsed` mode; a `collapsible` rail the visitor collapsed
|
||||||
* property differs by shape — a fixed `collapsed` mode; a `collapsible` rail the visitor collapsed
|
|
||||||
* (`<html data-rail="collapsed">`, set before the first paint by `<x-theme-script>`) and not
|
* (`<html data-rail="collapsed">`, set before the first paint by `<x-theme-script>`) and not
|
||||||
* currently open over the page; a `modal` rail not open; below `medium` (600px) a `collapsible`
|
* currently open over the page; a `modal` rail not open; below `medium` (600px) a `collapsible`
|
||||||
* rail regardless of choice, M3's floor ("compact → use a navigation bar, not a standard rail");
|
* rail regardless of choice, M3's floor ("compact → use a navigation bar, not a standard rail");
|
||||||
@@ -34,6 +33,13 @@
|
|||||||
* `resources/js/navigation.js` reads the same numbers, so the menu button and the drawing agree at
|
* `resources/js/navigation.js` reads the same numbers, so the menu button and the drawing agree at
|
||||||
* every width.
|
* every width.
|
||||||
*
|
*
|
||||||
|
* The first set of branches also publishes the answer: `--md-navigation-rail-value` is `expanded`
|
||||||
|
* on every rail and `collapsed` wherever those branches hold — M3's two rail values, Compose's
|
||||||
|
* WideNavigationRailValue — so what an application puts in a rail reads it with
|
||||||
|
* `@container style(--md-navigation-rail-value: collapsed)` rather than copying the conditions. It
|
||||||
|
* is an unregistered custom property, so it inherits into every descendant and is unset outside a
|
||||||
|
* rail, where neither value matches.
|
||||||
|
*
|
||||||
* `data-md-width="narrow"` is M3's other collapsed width, `NarrowContainerWidth` 80px against the
|
* `data-md-width="narrow"` is M3's other collapsed width, `NarrowContainerWidth` 80px against the
|
||||||
* default 96 (`CollapsedContainerWidth`) — a variable, so every rule below that has its own reason
|
* default 96 (`CollapsedContainerWidth`) — a variable, so every rule below that has its own reason
|
||||||
* to name a collapsed width does not have to know which one applies.
|
* to name a collapsed width does not have to know which one applies.
|
||||||
@@ -66,6 +72,7 @@
|
|||||||
[data-md-navigation-rail] {
|
[data-md-navigation-rail] {
|
||||||
--navigation-rail-expanded-width: clamp(220px, var(--navigation-rail-width, 256px), 360px);
|
--navigation-rail-expanded-width: clamp(220px, var(--navigation-rail-width, 256px), 360px);
|
||||||
--navigation-rail-collapsed-width: 96px;
|
--navigation-rail-collapsed-width: 96px;
|
||||||
|
--md-navigation-rail-value: expanded;
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -81,17 +88,20 @@
|
|||||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||||
) {
|
) {
|
||||||
width: var(--navigation-rail-collapsed-width);
|
width: var(--navigation-rail-collapsed-width);
|
||||||
|
--md-navigation-rail-value: collapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width < 600px) {
|
@media (width < 600px) {
|
||||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||||
width: var(--navigation-rail-collapsed-width);
|
width: var(--navigation-rail-collapsed-width);
|
||||||
|
--md-navigation-rail-value: collapsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width < 840px) {
|
@media (width < 840px) {
|
||||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||||
width: var(--navigation-rail-collapsed-width);
|
width: var(--navigation-rail-collapsed-width);
|
||||||
|
--md-navigation-rail-value: collapsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +111,7 @@
|
|||||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||||
) {
|
) {
|
||||||
width: var(--navigation-rail-collapsed-width);
|
width: var(--navigation-rail-collapsed-width);
|
||||||
|
--md-navigation-rail-value: collapsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +121,7 @@
|
|||||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||||
) {
|
) {
|
||||||
width: var(--navigation-rail-collapsed-width);
|
width: var(--navigation-rail-collapsed-width);
|
||||||
|
--md-navigation-rail-value: collapsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
{{-- M3 Expressive's loading indicator: a shape that morphs through seven Expressive shapes while
|
{{-- M3 Expressive's loading indicator: a shape that morphs through seven Expressive shapes while
|
||||||
it turns, for a wait that has no known length.
|
it turns, for a wait that has no known length.
|
||||||
|
|
||||||
In the text colour — primary unless the caller colours it — and 48px unless the caller's CSS
|
In the text colour — primary unless the caller colours it — and 48px, M3's default. `size` is
|
||||||
sizes it. `contained` puts it on a primary-container circle, for a spinner over content. It
|
its size in px, any whole number from 24 to 240, M3's responsive range ("never exceed that
|
||||||
|
range"; scale it up for large and extra-large windows): the container and the shape keep
|
||||||
|
their 48:38 ratio at every size, because the drawing scales with its box. It is written as
|
||||||
|
`--md-loading-size`; anything outside the range is ignored, and without it the caller's CSS
|
||||||
|
may size it. `contained` puts it on a primary-container circle, for a spinner over content. It
|
||||||
is a `progressbar` named `label` ("Loading" by default); pass `:label="false"` where something
|
is a `progressbar` named `label` ("Loading" by default); pass `:label="false"` where something
|
||||||
else already says what is happening, as a button's own spinner does.
|
else already says what is happening, as a button's own spinner does.
|
||||||
|
|
||||||
@@ -16,15 +20,18 @@
|
|||||||
@props([
|
@props([
|
||||||
'contained' => false,
|
'contained' => false,
|
||||||
'label' => null,
|
'label' => null,
|
||||||
|
'size' => null,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
|
$size = filter_var($size, FILTER_VALIDATE_INT, ['options' => ['min_range' => 24, 'max_range' => 240]]) ?: null;
|
||||||
$decorative = $label === false;
|
$decorative = $label === false;
|
||||||
$label = $decorative ? null : ($label ?? __('Loading'));
|
$label = $decorative ? null : ($label ?? __('Loading'));
|
||||||
|
|
||||||
$attributes = $attributes->merge(array_filter([
|
$attributes = $attributes->merge(array_filter([
|
||||||
'data-md-loading' => true,
|
'data-md-loading' => true,
|
||||||
'data-md-contained' => $contained ? true : null,
|
'data-md-contained' => $contained ? true : null,
|
||||||
|
'style' => $size !== null ? "--md-loading-size: {$size}px" : null,
|
||||||
'role' => $decorative ? null : 'progressbar',
|
'role' => $decorative ? null : 'progressbar',
|
||||||
'aria-label' => $label,
|
'aria-label' => $label,
|
||||||
'aria-hidden' => $decorative ? 'true' : null,
|
'aria-hidden' => $decorative ? 'true' : null,
|
||||||
|
|||||||
@@ -45,11 +45,19 @@
|
|||||||
`footer`, pinned to the foot. Header and footer never scroll, so nothing in them is cut off by
|
`footer`, pinned to the foot. Header and footer never scroll, so nothing in them is cut off by
|
||||||
the scroller's edge.
|
the scroller's edge.
|
||||||
|
|
||||||
Anything else inside can take both shapes by matching the rail's own state directly, the same
|
Anything else inside takes both shapes from the rail's value: the rail publishes M3's two
|
||||||
selectors resources/css/components/navigation-rail.css uses for every branch of "collapsed" —
|
(Compose's WideNavigationRailValue) as `--md-navigation-rail-value`, `collapsed` or
|
||||||
`data-md-navigation-rail`'s value, `:not([data-md-open])`, and the window band each mode
|
`expanded`, from the first paint and in step with the rail's own items, and every descendant
|
||||||
collapses in (its header lists them). Every rule in the package that draws a collapsed shape
|
inherits it, so an application's CSS asks a style query instead of repeating the conditions:
|
||||||
writes them out, and tests/Feature/Components/NavigationRailTest.php keeps each copy to the same
|
|
||||||
|
@container style(--md-navigation-rail-value: collapsed) {
|
||||||
|
.account-summary { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
A rail open over a scrim reads `expanded`; outside a rail the property is unset and neither
|
||||||
|
value matches. Style queries on a custom property need Chrome 111, Safari 18 or Firefox 151.
|
||||||
|
The package's own rules write the conditions out (resources/css/components/navigation-rail.css
|
||||||
|
lists them), and tests/Feature/Components/NavigationRailTest.php keeps each copy to the same
|
||||||
conditions. Nothing that shows while collapsed may be wider than 96px.
|
conditions. Nothing that shows while collapsed may be wider than 96px.
|
||||||
|
|
||||||
Props: `label` names the landmark ("Main"); `width` is the expanded width (`256px`, held
|
Props: `label` names the landmark ("Main"); `width` is the expanded width (`256px`, held
|
||||||
|
|||||||
@@ -60,8 +60,8 @@
|
|||||||
'Loading indicator' => <<<'BLADE'
|
'Loading indicator' => <<<'BLADE'
|
||||||
<x-loading />
|
<x-loading />
|
||||||
<x-loading contained />
|
<x-loading contained />
|
||||||
<x-loading label="Uploading" style="width: 96px; height: 96px; color: var(--md-sys-color-tertiary);" />
|
<x-loading label="Uploading" size="96" style="color: var(--md-sys-color-tertiary);" />
|
||||||
<x-loading contained style="width: 32px; height: 32px;" />
|
<x-loading contained size="32" />
|
||||||
BLADE,
|
BLADE,
|
||||||
'Button groups: standard (press a button) and connected' => <<<'BLADE'
|
'Button groups: standard (press a button) and connected' => <<<'BLADE'
|
||||||
<x-button-group label="Standard" size="md">
|
<x-button-group label="Standard" size="md">
|
||||||
|
|||||||
@@ -272,6 +272,15 @@ it('animates the loading indicator in the browser', function () {
|
|||||||
showcase()->assertScript("{$clock} > 0.1");
|
showcase()->assertScript("{$clock} > 0.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('draws the loading indicator at the size it is given, container and shape in proportion', function () {
|
||||||
|
$box = fn (string $selector): string => "document.querySelector('#buttons {$selector}').getBoundingClientRect()";
|
||||||
|
|
||||||
|
showcase()
|
||||||
|
->assertScript("(({ width, height }) => width === 96 && height === 96)({$box('[aria-label=\"Uploading\"]')})")
|
||||||
|
->assertScript("(({ width, height }) => width === 96 && height === 96)({$box('[aria-label=\"Uploading\"] > [data-md-loading-animated]')})")
|
||||||
|
->assertScript("(({ width, height }) => width === 32 && height === 32)({$box('[data-md-loading][data-md-contained][style]')})");
|
||||||
|
});
|
||||||
|
|
||||||
/** A script giving the rects of a menu button (`control`) and of the menu it opens (`menu`). */
|
/** A script giving the rects of a menu button (`control`) and of the menu it opens (`menu`). */
|
||||||
function menuAgainst(string $test, string $label): string
|
function menuAgainst(string $test, string $label): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -431,3 +431,127 @@ it('takes an adaptive rail out of the layout across the expanded class when it h
|
|||||||
->assertScript("document.documentElement.hasAttribute('data-rail-auto')")
|
->assertScript("document.documentElement.hasAttribute('data-rail-auto')")
|
||||||
->assertNoJavaScriptErrors();
|
->assertNoJavaScriptErrors();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One rail in the given mode, whose footer holds two lines an application styles by the rail's
|
||||||
|
* value with style queries — the way `--md-navigation-rail-value` is meant to be read. An inline
|
||||||
|
* script after the rail records the value and the lines' display while the document is still being
|
||||||
|
* parsed, before Alpine exists: what the first paint shows.
|
||||||
|
*/
|
||||||
|
function railValueProbe(string $mode, int $width = 1280): mixed
|
||||||
|
{
|
||||||
|
Route::middleware('web')->get('/rail-value-probe/{mode}', fn (string $mode) => Blade::render(<<<'BLADE'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<x-theme-script />
|
||||||
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
|
@livewireStyles
|
||||||
|
<style>
|
||||||
|
@container style(--md-navigation-rail-value: collapsed) {
|
||||||
|
[data-test='expanded-only'] { display: none; }
|
||||||
|
}
|
||||||
|
@container style(--md-navigation-rail-value: expanded) {
|
||||||
|
[data-test='collapsed-only'] { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div style="display: flex; min-height: 100dvh">
|
||||||
|
<x-navigation-rail :mode="$mode" label="Probe">
|
||||||
|
<x-navigation-rail-item label="Inbox" icon="inbox" link="#a" no-wire-navigate active />
|
||||||
|
<x-navigation-rail-item label="Sent" icon="send" link="#b" no-wire-navigate />
|
||||||
|
<x-slot:footer>
|
||||||
|
<p data-test="expanded-only">Signed in as Ada</p>
|
||||||
|
<p data-test="collapsed-only">A</p>
|
||||||
|
</x-slot:footer>
|
||||||
|
</x-navigation-rail>
|
||||||
|
<script>
|
||||||
|
window.firstPaint = {
|
||||||
|
value: getComputedStyle(document.querySelector('[data-md-navigation-rail]')).getPropertyValue('--md-navigation-rail-value').trim(),
|
||||||
|
expandedOnly: getComputedStyle(document.querySelector('[data-test="expanded-only"]')).display,
|
||||||
|
collapsedOnly: getComputedStyle(document.querySelector('[data-test="collapsed-only"]')).display,
|
||||||
|
alpine: typeof window.Alpine,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<main>
|
||||||
|
<button type="button" id="open-rail" x-data x-on:click="$store.rail.show()">Open navigation</button>
|
||||||
|
<p data-test="outside">Outside the rail</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
BLADE, ['mode' => $mode]));
|
||||||
|
|
||||||
|
return navigationReady(visit("/rail-value-probe/{$mode}")->resize($width, 900));
|
||||||
|
}
|
||||||
|
|
||||||
|
const RAIL_VALUE = 'getComputedStyle('.RAIL.").getPropertyValue('--md-navigation-rail-value').trim()";
|
||||||
|
|
||||||
|
function railShows(string $line): string
|
||||||
|
{
|
||||||
|
return "getComputedStyle(document.querySelector('[data-test=\"{$line}\"]')).display !== 'none'";
|
||||||
|
}
|
||||||
|
|
||||||
|
it('publishes a fixed rail\'s value from the first paint, for style queries in its content', function () {
|
||||||
|
railValueProbe('collapsed')
|
||||||
|
->assertScript("window.firstPaint.alpine === 'undefined'")
|
||||||
|
->assertScript("window.firstPaint.value === 'collapsed'")
|
||||||
|
->assertScript("window.firstPaint.expandedOnly === 'none' && window.firstPaint.collapsedOnly !== 'none'")
|
||||||
|
->assertScript(RAIL_VALUE." === 'collapsed'")
|
||||||
|
->assertScript('! '.railShows('expanded-only'))
|
||||||
|
->assertScript(railShows('collapsed-only'))
|
||||||
|
// Outside a rail the property is unset, so neither value's query matches.
|
||||||
|
->assertScript("getComputedStyle(document.querySelector('[data-test=\"outside\"]')).getPropertyValue('--md-navigation-rail-value') === ''")
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
|
||||||
|
railValueProbe('expanded')
|
||||||
|
->assertScript("window.firstPaint.value === 'expanded'")
|
||||||
|
->assertScript("window.firstPaint.expandedOnly !== 'none' && window.firstPaint.collapsedOnly === 'none'")
|
||||||
|
->assertScript(RAIL_VALUE." === 'expanded'")
|
||||||
|
->assertScript(railShows('expanded-only'))
|
||||||
|
->assertScript('! '.railShows('collapsed-only'))
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an adaptive rail\'s value with the window and the menu button, in step with its width', function () {
|
||||||
|
$page = railValueProbe('adaptive', 839)
|
||||||
|
->assertScript(railWidth(96))
|
||||||
|
->assertScript(RAIL_VALUE." === 'collapsed'")
|
||||||
|
->assertScript('! '.railShows('expanded-only'));
|
||||||
|
|
||||||
|
// `large`: M3 starts an adaptive rail expanded while nothing is chosen.
|
||||||
|
$page->resize(1200, 900)
|
||||||
|
->assertScript(railWidth(256))
|
||||||
|
->assertScript(RAIL_VALUE." === 'expanded'")
|
||||||
|
->assertScript(railShows('expanded-only'))
|
||||||
|
->assertScript('! '.railShows('collapsed-only'));
|
||||||
|
|
||||||
|
// `expanded`: collapsed until the menu button widens it in place.
|
||||||
|
$page->resize(840, 900)
|
||||||
|
->assertScript(RAIL_VALUE." === 'collapsed'")
|
||||||
|
->click('[data-md-navigation-rail-menu]')
|
||||||
|
->assertScript(railWidth(256))
|
||||||
|
->assertScript(RAIL_VALUE." === 'expanded'")
|
||||||
|
->assertScript(railShows('expanded-only'))
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads expanded while a modal rail is open over the page, and collapsed once it closes', function () {
|
||||||
|
$page = railValueProbe('modal', 1000)
|
||||||
|
->assertScript(RAIL_VALUE." === 'collapsed'")
|
||||||
|
->assertScript('! '.railShows('expanded-only'));
|
||||||
|
|
||||||
|
$page->click('#open-rail')
|
||||||
|
->assertScript(RAIL.".hasAttribute('data-md-open')")
|
||||||
|
->assertScript(RAIL_VALUE." === 'expanded'")
|
||||||
|
->assertScript(railShows('expanded-only'))
|
||||||
|
->assertScript('! '.railShows('collapsed-only'));
|
||||||
|
|
||||||
|
$page->keys('[data-md-navigation-rail-panel]', 'Escape')
|
||||||
|
->assertScript('! '.RAIL.".hasAttribute('data-md-open')")
|
||||||
|
->assertScript(RAIL_VALUE." === 'collapsed'")
|
||||||
|
->assertScript('! '.railShows('expanded-only'))
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,13 +19,23 @@ it('is 48px in the primary colour unless the caller sizes or colours it', functi
|
|||||||
$css = ComponentStylesheet::read('loading');
|
$css = ComponentStylesheet::read('loading');
|
||||||
|
|
||||||
expect($css->declarations('[data-md-loading]'))
|
expect($css->declarations('[data-md-loading]'))
|
||||||
->toMatchArray(['inline-size' => '48px', 'block-size' => '48px', 'color' => 'var(--md-sys-color-primary)'])
|
->toMatchArray(['inline-size' => 'var(--md-loading-size, 48px)', 'block-size' => 'var(--md-loading-size, 48px)', 'color' => 'var(--md-sys-color-primary)'])
|
||||||
// A caller's class lands on the root untouched, and outranks the package's layer.
|
// A caller's class lands on the root untouched, and outranks the package's layer.
|
||||||
->and((string) $this->blade('<x-loading class="app-upload-spinner" label="Uploading" />'))
|
->and((string) $this->blade('<x-loading class="app-upload-spinner" label="Uploading" />'))
|
||||||
->toContain('class="app-upload-spinner"')
|
->toContain('class="app-upload-spinner"')
|
||||||
->toContain('aria-label="Uploading"');
|
->toContain('aria-label="Uploading"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('draws a size from M3\'s responsive range, 24 to 240px', function () {
|
||||||
|
$this->blade('<x-loading size="96" />')->assertSee('style="--md-loading-size: 96px"', false);
|
||||||
|
$this->blade('<x-loading size="24" />')->assertSee('style="--md-loading-size: 24px"', false);
|
||||||
|
$this->blade('<x-loading size="240" />')->assertSee('style="--md-loading-size: 240px"', false);
|
||||||
|
|
||||||
|
foreach (['16', '480', 'large'] as $outside) {
|
||||||
|
$this->blade("<x-loading size=\"{$outside}\" />")->assertDontSee('--md-loading-size', false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('sits on a primary-container circle when contained', function () {
|
it('sits on a primary-container circle when contained', function () {
|
||||||
expect((string) $this->blade('<x-loading contained />'))->toContain('data-md-contained')
|
expect((string) $this->blade('<x-loading contained />'))->toContain('data-md-contained')
|
||||||
->and((string) $this->blade('<x-loading />'))->not->toContain('data-md-contained')
|
->and((string) $this->blade('<x-loading />'))->not->toContain('data-md-contained')
|
||||||
|
|||||||
@@ -148,29 +148,33 @@ it('hides a collapsible or adaptive rail entirely when told to, and only those',
|
|||||||
* `X:where(A)` or `X:where(B)` or `X:where(C)`) and keeps the four media-gated ones separate, since
|
* `X:where(A)` or `X:where(B)` or `X:where(C)`) and keeps the four media-gated ones separate, since
|
||||||
* CSS cannot merge different `@media` queries into one rule. This pins all five physical branches
|
* CSS cannot merge different `@media` queries into one rule. This pins all five physical branches
|
||||||
* — and so every one of the seven original conditions — for the rail's own width, the property
|
* — and so every one of the seven original conditions — for the rail's own width, the property
|
||||||
* every branch sets.
|
* every branch sets, and for `--md-navigation-rail-value`, which each branch turns from `expanded`
|
||||||
|
* to `collapsed` so an application's style query answers exactly when the rail's own shape does.
|
||||||
*/
|
*/
|
||||||
it('reproduces every branch of the old rail-collapsed variant for the rail\'s own width', function () {
|
it('reproduces every branch of the old rail-collapsed variant for the rail\'s own width and its value', function () {
|
||||||
$css = ComponentStylesheet::read('navigation-rail');
|
$css = ComponentStylesheet::read('navigation-rail');
|
||||||
|
$collapsed = ['width' => 'var(--navigation-rail-collapsed-width)', '--md-navigation-rail-value' => 'collapsed'];
|
||||||
|
|
||||||
|
expect($css->declarations('[data-md-navigation-rail]'))->toMatchArray(['--md-navigation-rail-value' => 'expanded']);
|
||||||
|
|
||||||
// Branches 1-3: a fixed `collapsed` mode; a `collapsible` rail the visitor collapsed and not
|
// Branches 1-3: a fixed `collapsed` mode; a `collapsible` rail the visitor collapsed and not
|
||||||
// open; a `modal` rail not open.
|
// open; a `modal` rail not open.
|
||||||
expect($css->declarations("[data-md-navigation-rail]:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
|
expect($css->declarations("[data-md-navigation-rail]:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
|
||||||
->toBe(['width' => 'var(--navigation-rail-collapsed-width)'])
|
->toBe($collapsed)
|
||||||
// Branch 4: below `medium` (600px), a `collapsible` rail regardless of choice.
|
// Branch 4: below `medium` (600px), a `collapsible` rail regardless of choice.
|
||||||
->and($css->declarations("[data-md-navigation-rail]:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *)", ['@media (width < 600px)']))
|
->and($css->declarations("[data-md-navigation-rail]:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *)", ['@media (width < 600px)']))
|
||||||
->toBe(['width' => 'var(--navigation-rail-collapsed-width)'])
|
->toBe($collapsed)
|
||||||
// Branch 5: below `expanded` (840px), an `adaptive` rail not open.
|
// Branch 5: below `expanded` (840px), an `adaptive` rail not open.
|
||||||
->and($css->declarations("[data-md-navigation-rail]:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *)", ['@media (width < 840px)']))
|
->and($css->declarations("[data-md-navigation-rail]:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *)", ['@media (width < 840px)']))
|
||||||
->toBe(['width' => 'var(--navigation-rail-collapsed-width)'])
|
->toBe($collapsed)
|
||||||
// Branch 6: at `expanded` (840-1199), an `adaptive` rail not open, chosen collapsed or no
|
// Branch 6: at `expanded` (840-1199), an `adaptive` rail not open, chosen collapsed or no
|
||||||
// choice yet (`data-rail-auto`).
|
// choice yet (`data-rail-auto`).
|
||||||
->and($css->declarations("[data-md-navigation-rail]:where( :is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]), :is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) * )", ['@media (840px <= width < 1200px)']))
|
->and($css->declarations("[data-md-navigation-rail]:where( :is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]), :is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) * )", ['@media (840px <= width < 1200px)']))
|
||||||
->toBe(['width' => 'var(--navigation-rail-collapsed-width)'])
|
->toBe($collapsed)
|
||||||
// Branch 7: from `large` (1200px), an `adaptive` rail not open, only when chosen collapsed —
|
// Branch 7: from `large` (1200px), an `adaptive` rail not open, only when chosen collapsed —
|
||||||
// M3 starts it expanded there by default.
|
// M3 starts it expanded there by default.
|
||||||
->and($css->declarations("[data-md-navigation-rail]:where( [data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) * )", ['@media (width >= 1200px)']))
|
->and($css->declarations("[data-md-navigation-rail]:where( [data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) * )", ['@media (width >= 1200px)']))
|
||||||
->toBe(['width' => 'var(--navigation-rail-collapsed-width)']);
|
->toBe($collapsed);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flattens a FAB nested in the rail header and morphs its label', function () {
|
it('flattens a FAB nested in the rail header and morphs its label', function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user