16 Commits
Author SHA1 Message Date
Andreas Reinhold / reiniandClaude Opus 5 252c649c45 Keep a closing docked search above the page until its view has gone
tests / lint (push) Successful in 1m17s
tests / feature (8.4) (push) Failing after 1m56s
tests / feature (8.5) (push) Failing after 1m46s
tests / browser (chrome, chromium) (push) Successful in 7m14s
tests / browser (firefox, firefox) (push) Failing after 11m33s
tests / browser (safari, webkit) (push) Failing after 12m9s
The docked search raised itself to z-index 50 only while open, so the
moment it closed, positioned content later on the page could cover the
view while it faded out. The root now keeps the stacking for as long as
its view carries the `md-transition` class Alpine's x-transition sets
for the length of the exit. The browser test samples the view part-way
through its fade and counts it only while the root is still at 50; it
fails without the change and passes in Chrome, Firefox and Safari.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:02:48 +02:00
Andreas Reinhold / reiniandClaude Opus 5 1ccf0b639e Draw a standard side sheet's first state without motion
Giving a closing standard sheet its room back made the root's inline
size a transition, with an `@starting-style` for the entry, so a sheet
that starts open grew in on every page load: its open state arrives
through Alpine, after the first paint of the closed root. Nothing should
move while a page loads.

The view now marks the root `data-md-drawer-settled` two frames after
the first `settle()` — once the state Alpine starts with has been
painted — and drawer.css gives the standard sheet's root and sheet no
transitions until then. The browser test loads a page with slowed motion
tokens and a sheet that starts open, and finds it standing at its full
width, shown, with nothing animating; closing it afterwards still
animates. It fails without the change in Chrome and Firefox and passes
in all three engines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:02:48 +02:00
Andreas Reinhold / reiniandClaude Opus 5 99c18e01d8 Give a closing standard side sheet's room back as it leaves
From `expanded` a standard side sheet sits in the layout beside the
content, and the view took it out of the layout the moment it closed:
`data-md-drawer-collapsed` (`display: none` on the root) followed `! open`
at once, so its fade was never seen and the content beside it took the
sheet's width, and the gap, in one jump — in every engine. M3's side
sheets: opening a standard sheet shrinks the body beside it, and closing
gives the room back.

drawer.css: the standard root is a clipping flex box whose `inline-size`
springs between none and the sheet's width, with a negative margin as
wide as its flex parent's gap (`--md-drawer-gap`), on the sheet's own
exit and entry timings; the sheet keeps its width at the root's far edge,
so it is uncovered from its inner edge while it fades. drawer.blade.php:
`settle()`, from `x-effect`, measures the gap on each change and holds
the collapse (`closing`) for the root's and the sheet's closing
durations, read a frame on; reopening lets a pending end go by. The
binding reads `collapsed`, which only `settle()` and the window's width
write: a binding reading `open` ran before `settle()` in the same flush
and, already queued, did not run again once `closing` changed.

ContainmentTest closes the showcase's sheet at 1000px and samples, in the
page, for the root part-way to none while the column beside it has grown
part of the way, then checks the collapse, the column at the row's full
width, and a reopen part-way through the exit ending fully open; it fails
on the previous code in Chrome, Firefox and Safari. OverlayTest pins the
root's declarations and the view's bindings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:00:23 +02:00
Andreas Reinhold / reiniandClaude Opus 5 db6023bf80 Close a full-screen search back into its bar or icon, bar and view together
`fullScreen` followed `open`, so the moment a full-screen search closed,
`data-md-full-screen` went with it: the fixed header bar went back to its
resting pill — or to `display: none`, behind the search icon — and the
view, still fading out under Alpine's hold, dropped to the docked layout
for its exit. In every engine the bar vanished on the first frame while
the view went on fading somewhere else.

search.js: a close from full screen now `hold()`s the layout — `leaving`
keeps `fullScreen`, and with it `data-md-full-screen`, the back arrow and
the fixed bar, for the view's own closing duration, read from its
computed style a frame on (zero under reduced motion); reopening lets a
pending end go by. search.css fades the header bar out with the view on
the view's spring and keeps the root above the page while it leaves,
and the icon trigger's bar stays displayed until the layout settles. The
focus trap now binds to `open && fullScreen`, so it still lets go at the
close and its return of focus lands inside RETURN_GUARD_MS rather than
reopening the view at the end of the exit.

PickingTest samples both exits in the page — the icon trigger, and the
bar trigger on a compact window — for a moment where the root is still
full screen with a fixed bar and both bar and view part-way through their
fade, then checks the settled layout and focus back on the icon or the
field; both fail on the previous code in Chrome, Firefox and Safari. The
tests wait for the entry's own transitions first: Firefox reads a
transition's end value until its next refresh tick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:00:23 +02:00
Andreas Reinhold / reiniandClaude Opus 5 9f46630352 Ease a collapse open and shut in Firefox and Safari too
collapse.css animated `<details>`' `::details-content` from `block-size: 0`
to `auto`, which needs `interpolate-size: allow-keywords` (Chrome 129 and
nothing else) and a `content-visibility` held through the close with
`allow-discrete` (not Firefox), so in Firefox and Safari the section
snapped open and shut. No stylesheet can do it there.

resources/js/collapse.js animates the `<details>` itself, the same way
in every engine: a Web Animation of its `block-size` from the height it
is drawn at to the height it is going to, on the fast spatial spring's
tokens, with `overflow: clip` and a `min-block-size` that keeps the
summary whole through the spring's overshoot, all inside the keyframes,
so nothing is left in `style`. Content below moves with it. Opening sets
`open` at once and grows the height; closing keeps `open` while the
height goes, marks `data-md-collapse-closing` so the chevron turns back
at the start, and drops `open` (and fires `toggle`) when the height has.
A press mid-way turns it round from the height it has reached. The CSS
animation is gone, so Chrome takes the same path and never animates
twice.

Routes: a press on the summary (pointer, Enter, Space) is taken over;
the Alpine and Livewire bindings call `materialCollapse()` from the
view's `x-effect` (its first call, as Alpine starts, sets the state at
once); a `name` group's open member closes on the same spring, its
`name` lifted for the close so the browser's exclusivity does not shut
it first and put back once closed. Anything else that sets `open`
(find-in-page, an application's own script) stays instant, as the
browser draws it, and is followed. Under reduced motion every route is
the browser's own.

Browser tests in Chrome, Firefox and Safari catch the height part-way
open and shut (and what is under it moving) from a press and from the
Alpine binding, the chevron turning at the start of a close, the clip
while it moves, Enter on the summary, a reversal mid-way, a name group,
and reduced motion. The four motion tests fail on main in Firefox and
Safari.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:00:23 +02:00
Andreas Reinhold / reiniandClaude Opus 5 15e4fa7ed1 Slide the sheets and close the search view out in Firefox too
The side sheet, the bottom sheet's panel and the docked search's scrim
and view kept `display` alive through their exit with
`transition-behavior: allow-discrete`. Firefox does not transition
`display` (Chrome 117 and Safari 18 do), and `x-show` sets
`display: none` in the frame the exit starts, so in Firefox the sheets
vanished instead of sliding out and the search view and scrim vanished
instead of fading. The docked search had a second problem in every
engine: neither the view nor the scrim had a closed state to transition
to, so where `display` was held (Chrome, Safari) the view stood at full
opacity for its duration and then disappeared.

Each element now carries `x-transition:enter`/`:leave="md-transition"`,
the approach the two sheet scrims already took (renamed from
`md-scrim-transition` to one name for all of them). The class only
switches Alpine to CSS-transition mode, so `x-show` holds `display` for
the element's computed transition-duration before hiding it, in every
engine, and a reopen during the exit cancels the pending hide; nothing
styles it. `display` and `allow-discrete` leave the transitions so
Chrome and Safari do not hold a second time. Alpine reads the first
`transition-duration` listed, which is the closing slide or fade in each
list (the preset panel lists translate before height). The search view
now closes back into the bar (opacity 0, `scale: 1 0.9`, the reverse of
its `@starting-style` entry) and its scrim fades out on close and when
the search turns full screen. Under reduced motion the durations are
zero and every one of them closes at once.

Four browser tests sample each exit mid-way in the page, in the same
round trip as the close: the sheets part of the way to their closed
offset, the search scrim and view part of the way faded, each still
displayed, then `display: none`. All four fail on main in Firefox (the
two search tests in Chrome too) and pass in Chrome, Firefox and Safari.
OverlayTest pins the drawer's new transition and the view's markup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:26:45 +02:00
Andreas Reinhold / reiniandClaude Opus 5 92e560bef6 Slide the navigation rail and fade its scrim out in Firefox too
A rail that was open over the page left it on `transition: … display …
allow-discrete`: the compact adaptive rail's slide-out, the slide-out of
a rail that hides when collapsed, and every rail scrim's fade. Firefox
does not transition `display`, even with `allow-discrete` (Chrome 117
and Safari 18 do), so there the panel and the scrim vanished on the
first frame. Unlike the sheets' scrims these are drawn by attribute
rules on `data-md-open`, not `x-show`, so Alpine had nothing to hold.

navigation.js now marks a rail that closes `data-md-closing` — `sheet`
when the panel leaves the window, `scrim` when it stands in the layout
again — and drops it once the panel's `translate` and the scrim's
`opacity` transitions have finished (at once under reduced motion, or
when the rail opens again). navigation-rail.css keeps what is leaving
displayed, and a sliding panel in its open geometry, while the
attribute is set, and no longer transitions `display` anywhere, so
Chrome and Safari do not hold a second time. The view sets it from
`x-effect`, beside the `x-bind` that drops `data-md-open`, so both
attributes change in one flush: a `$watch` a microtask later let a
style read in between settle the scrim as already hidden. The collapsed
branches and `--md-navigation-rail-value` are untouched.

NavigationTest samples each exit part-way in the page (the compact
slide, the hide-when-collapsed slide, a modal rail's scrim) and checks
it ends hidden; all three fail on main in Firefox and pass in Chrome,
Firefox and Safari. They wait for the entry to finish first: Firefox
creates a transition on its next refresh tick, so straight after a
change its computed style already reads the end value. NavigationRailTest
pins the holds and that no rule transitions `display`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:24:43 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7db522826b Fade and shrink popovers out in every engine, Firefox included
Menus, submenus, tooltips, rich tooltips and the FAB menu held their
exit with `transition-behavior: allow-discrete` on `display` and
`overlay`. Firefox transitions neither (MDN browser-compat-data,
`display.is_transitionable`: Chrome 117, Safari 18, Firefox none), so
every one of them vanished on its first frame there. A script cannot
hold a popover open instead: `beforetoggle` is not cancellable on the
way out, and the browser's own light dismiss (Escape, a press outside)
never asks.

resources/js/popover-exit.js: a popover marked `data-md-popover-exit`
closes for real at once — focus, aria-expanded and toggle stay the
browser's — and a copy taken in `beforetoggle`, while it is still drawn,
stands in for the exit. The copy is decoration: a manual popover in the
top layer (closing no other popover), inert, aria-hidden, without ids or
nested popovers, `x-ignore`d so Alpine starts nothing in it, pinned to
the popover's box with its resolved colours. It is shown with its
transitions off, so `@starting-style` does not replay the entry, then
marked `data-md-popover-closing`, which each stylesheet turns into its
closed values (`:popover-open:not([data-md-popover-closing])`, and the
FAB menu's items' sink), so it moves on the component's own tokens. It
is removed once the longest of them has run, and opening the popover
again takes it away. Under reduced motion every duration is zero and no
copy is made. `display`, `overlay` and `allow-discrete` leave the
transitions, so Chrome and Safari take the same path.

Browser tests in Chrome, Firefox and Safari slow the motion tokens so a
round trip still finds the exit on screen: a menu after Escape and after
a press outside (the real menu closed and focus back on its button, the
copy inert, fading, with no Alpine state, and gone after), a reopen
part-way through, reduced motion, a submenu while its menu stays open, a
tooltip, the FAB menu's items part-way down their sink, and a persistent
rich tooltip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:24:23 +02:00
Andreas Reinhold / reiniandClaude Opus 5 684d60efc8 Close a persistent rich tooltip on a second press of its trigger
The bubble is a popover="auto" and the trigger is outside it, so the press
on the trigger light-dismissed the open bubble and the click that followed
opened it again: a second press never closed it, in any engine. Found by
the new exit test. A close the browser made within 250ms is now taken for
that press, as menu.js's REOPEN_GUARD_MS does, timed from beforetoggle
because toggle is queued past the click; the morph test waits past the
guard before pressing again, as the menu tests do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:24:13 +02:00
Andreas Reinhold / reiniandClaude Opus 5 6a133c2c67 Fade the side and bottom sheet scrims out in Firefox too
Both scrims are shown and hidden by Alpine's `x-show`, and faded by CSS:
closing set `display: none` inline in the same frame as the opacity
change, and `transition-behavior: allow-discrete` on `display` held the
element for the fade. Firefox does not transition `display`, so no
transition started there and the scrim vanished at once — what
ContainmentTest's two fade-out tests caught.

Alpine now holds `display` itself: `x-transition` with a class string
waits for the element's computed transition-duration (the effects token,
zero under reduced motion) before hiding it, the same in every engine,
and registering `enter` too means reopening during the fade cancels the
pending hide. `display` leaves the scrims' CSS transitions, so Chrome and
Safari do not hold it a second time after Alpine's hold.

The Alpine transition also holds `$nextTick` until the sheet is shown,
which menu.js's openSheet() relies on ("Alpine holds $nextTick until
then") and which had been lost when the bottom sheet moved off
x-transition. Without it `focusItem()` could run while the sheet was
still `display: none`, so ActionsTest's sheet-at-compact menu and its
filter field did not take the focus in Firefox and Safari; both pass
there again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:24:19 +02:00
Andreas Reinhold / reiniandClaude Opus 5 921c8cef04 Give a text-only dialog's scrolling body the first focus in every engine
With nothing focusable inside, showModal() focuses the <dialog> itself.
Chrome's scroll containers are keyboard-focusable, so there the scrolling
body became the focus delegate and modal.css drew its inset ring; Firefox
and WebKit focused the dialog, where the arrow keys scroll nothing, and
WebKit never lets Tab reach a scroll container, so a keyboard could not
read a long text-only dialog there at all.

<x-modal> now opens through materialShowModal() (resources/js/dialog.js):
showModal(), and when the dialog took the focus itself and its body
overflows, the body gets tabindex="0" and the focus, as in Chrome. The
tabindex goes when the dialog closes. The body is wire:ignore.self, like
the dialog: a Livewire render would otherwise morph the tabindex away and
WebKit dropped the focus out of the dialog. ContainmentTest now also
checks the focus stays on the body through a render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:23:15 +02:00
Andreas Reinhold / reiniandClaude Opus 5 b4a82a4dbe Focus what x-show reveals on the frame it shows
A sheet-at-compact menu focused its first item, or its filter field, in
$nextTick, and the icon entry point of <x-search> handed focus back to
its trigger the same way. Both targets are x-show'n, and Alpine shows an
element on the animation frame after its condition turns true, not in the
tick: the focus reached an element still display: none, which Firefox and
WebKit refuse. The sheet's focus trap then put the focus on the drag
handle, and a closed search left it on the page body; in Chrome the
frame or the trap's own return happened to cover for it. The comment in
openSheet() assumed Alpine holds $nextTick until the transition begins,
which it only does with x-transition, and the sheet uses CSS transitions.

Both now wait for that frame after the tick, running after Alpine's own
show in it, as search.js's expand() already did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:23:15 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d2245ad122 Measure the error shape's laid-out width, not its turning bounding rect
The error page's shape turns once a minute (material-error-turn), and
getBoundingClientRect() returns the axis-aligned box of what is drawn,
which is wider than the 240px art box as soon as the turn has begun. The
browser test compared that rect with 240: Chrome sampled it before the
first frame of the animation (currentTime 0) and passed, while Firefox
(33ms, 240.83px) and Safari (4ms, 240.20px) had already turned a fraction
of a degree and failed. The layout itself was right in all three: the
SVG's computed width is 240px.

The test now reads the SVG's computed width, which the rotation does not
touch. It still guards the regression it was written for: with the
inlined layout rules removed, the SVG computes to 1280px in every engine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:16:35 +02:00
Andreas Reinhold / reiniandClaude Opus 5 59a24f6c11 Open the M3 select picker only where the browser has base-select
The browser test asserted CSS.supports('appearance', 'base-select') as a
precondition, so it failed in Firefox, which has no customizable select.
menu.css styles the picker only inside @supports (appearance: base-select),
and the native picker is the intended fallback there; the test now checks
that fallback where the property is missing and the M3 menu where it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:13:32 +02:00
Andreas Reinhold / reiniandClaude Opus 5 f2a3c4d056 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>
2026-09-16 12:06:16 +02:00
Andreas Reinhold / reiniandClaude Opus 5 24eb811f34 Teach 2.0's plain CSS in the Boost guidelines and skills
2.0.0 took Tailwind out of the stack, but the texts Boost copies into
every application still taught its utilities: the core guideline said
"built on Tailwind CSS", the always-on material-3 guideline wrote every
rule as `bg-primary`, `type-*`, `rounded-corner-*`, `state-layer` and
`medium:`, and both skills' tables and examples did the same. None of
those classes exists in 2.0's stylesheets, so an agent following the
guideline wrote markup that compiled to nothing. Found moving ReStride
onto 2.0: its CLAUDE.md, and SealShare's, carry these texts.

The guidelines and skills now teach what 2.0 has: component and layout
component props (`gap="space200"`, `hide-from="medium"`), the `md-type-*`,
`md-ink-*` and interaction classes, and `--md-sys-*` tokens in the
application's own CSS, with breakpoints as range media queries.
UPGRADE.md §1, §2 and §4 describe the finished move instead of the
in-between state, and header comments that pointed at the removed
tokens/utilities.css and tailwind.css, or called a component "still
Tailwind", say what is true now.

BoostVocabularyTest runs DesignGuard over the code in every shipped
guideline and skill (fenced Blade and CSS, and each inline class list),
so a text that teaches a class the stylesheets do not define fails the
suite; it finds 268 violations in the texts as 2.0.0 shipped them. The
guard's own table of what it reports is exempt. BoostResourcesTest now
asks the design skill for each breakpoint's prop value and media query
instead of the removed `medium:` variants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:07:54 +02:00
68 changed files with 2389 additions and 473 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
- name: Run feature tests
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
# differ between them. Pest calls the engines chrome, firefox and safari; Playwright
# installs them as chromium, firefox and webkit.
+1 -1
View File
@@ -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.
- 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
+54 -29
View File
@@ -1,5 +1,17 @@
# 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
2.0.0 aligns the library with Material Design 3 Expressive as Google documents it
@@ -8,35 +20,43 @@ the change is inside the components. What reaches an application is below, in th
### 1. Breakpoints are M3's window size classes
Tailwind's `sm:`, `md:`, `lg:`, `xl:` and `2xl:` no longer compile. The variants are
`medium:` (600px), `expanded:` (840px), `large:` (1200px) and `extra-large:` (1600px), plus
`max-medium:` and friends for "below"; compact is everything below `medium`. Replace every
prefix in the application's views:
Tailwind's `sm:`, `md:`, `lg:`, `xl:` and `2xl:` no longer compile, and there are no responsive
variants in their place: the breakpoints are M3's window size classes — compact below 600px, then
medium (600px), expanded (840px), large (1200px) and extra-large (1600px) — and only those. A
layout component takes the class as a prop (`hide-below`, `hide-from`, `stack-below`, `<x-grid>`'s
`columns` map); the application's own CSS writes the width as a range media query:
| Was | Becomes |
|---|---|
| `sm:` / `max-sm:` (640) | `medium:` / `max-medium:` (600) |
| `md:` / `max-md:` (768) | `medium:` or `expanded:` — choose by what the layout means |
| `lg:` / `max-lg:` (1024) | `expanded:` / `max-expanded:` (840) |
| `xl:` / `max-xl:` (1280) | `large:` / `max-large:` (1200) |
| `2xl:` (1536) | `extra-large:` (1600) |
| `sm:` / `max-sm:` (640) | `medium` · `@media (width >= 600px)` / `(width < 600px)` |
| `md:` / `max-md:` (768) | `medium` or `expanded` — choose by what the layout means |
| `lg:` / `max-lg:` (1024) | `expanded` · `@media (width >= 840px)` / `(width < 840px)` |
| `xl:` / `max-xl:` (1280) | `large` · `@media (width >= 1200px)` / `(width < 1200px)` |
| `2xl:` (1536) | `extra-large` · `@media (width >= 1600px)` |
Scripts read the same numbers from `resources/js/breakpoints.js` (`from('expanded')`,
`upTo('medium')`). `DesignGuard` reports every leftover prefix with its replacement.
`<div class="hidden lg:block">` is `<x-stack hide-below="expanded">`; `flex flex-col sm:flex-row` is
`<x-row stack-below="medium">`; `grid-cols-1 lg:grid-cols-2` is
`<x-grid :columns="['compact' => 1, 'expanded' => 2]">`. Scripts read the same numbers from
`resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`). `DesignGuard` reports every
leftover prefix with its replacement.
### 2. Only M3's scales compile
### 2. Only M3's scales, as tokens
Tailwind's default radius, shadow, text-size, weight, leading, tracking and easing utilities are
cleared like its palette was:
Tailwind's radius, shadow, text-size, weight, leading, tracking and easing utilities compile to
nothing, and so do the 1.x utilities that stood for M3's scales. A text style is a class; the
rest is a token in the application's own CSS:
| Was | Becomes |
|---|---|
| `rounded-sm``rounded-4xl` | `rounded-corner-xs` `rounded-corner-xxl` (`rounded-corner-full`, `rounded-corner-none`) |
| `shadow-sm``shadow-2xl` | `shadow-elevation-1``shadow-elevation-5` |
| `text-xs``text-9xl`, `leading-*`, `tracking-*`, `font-medium``font-black` | one `type-*` style (`type-body-md`, `type-emphasized-title-md` …) |
| `ease-in`, `ease-out`, `ease-in-out`, `duration-300` | `ease-spatial-*` / `ease-effects-*` with `duration-(--md-sys-motion-…-duration)` |
| `rounded-sm``rounded-4xl`, `rounded-corner-*` (1.x) | `var(--md-sys-shape-corner-xs)` `var(--md-sys-shape-corner-xxl)` (`-full`, `-none`), or `<x-surface corner="xs">` |
| `shadow-sm``shadow-2xl`, `shadow-elevation-*` (1.x) | `var(--md-sys-elevation-1)``var(--md-sys-elevation-5)` |
| `text-xs``text-9xl`, `leading-*`, `tracking-*`, `font-medium``font-black`, `type-*` (1.x) | one `md-type-*` class (`md-type-body-md`, `md-type-emphasized-title-md` …), or `font: var(--md-sys-typescale-body-md)` with its `-tracking` |
| `ease-in`, `ease-out`, `ease-in-out`, `duration-300`, `ease-spatial-*` (1.x) | `var(--md-sys-motion-spatial-*)` / `var(--md-sys-motion-effects-*)` with its `-duration`, in a `transition` |
| `gap-4`, `p-4`, `space-y-2` | `gap="space200"`, `<x-surface padding="space200">`, `<x-stack gap="space100">`, or `var(--md-sys-measurement-space200)` |
| `state-layer`, `focus-ring`, `touch-target`, `link` (1.x) | `md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link` |
The 4px spacing scale is unchanged. `DesignGuard` names each one with its replacement.
The spacing tokens are the 4px grid Tailwind's scale was (`space200` is 16px). `DesignGuard` names
each one with its replacement.
### 3. Regenerate the colour scheme
@@ -56,10 +76,13 @@ php artisan material:scheme "#4f46e5" --variant=tonal-spot # the command in th
### 4. Inks are roles, not opacities
`text-meta`, `text-quiet`, `border-chrome` and `border-divider` keep their names but now resolve
to `on-surface-variant`, `outline`, `outline-variant` and `outline-variant`. Where the old
translucent grey was intended, nothing to do; where a template relied on the opacity to blend
over a colour, use the role directly.
1.x's `text-meta`, `text-body`, `text-quiet`, `border-chrome`, `border-structure` and
`border-divider` are gone with the rest of the utilities. Text takes the role's `md-ink-*` class —
`md-ink-variant` (on-surface-variant) for `text-meta` and `text-body`, `md-ink-quiet` (outline) for
`text-quiet` — and a line is `<x-divider>`, `<x-surface outlined>` or
`var(--md-sys-color-outline-variant)` in the application's CSS. Where the old translucent grey was
intended, the role is the same colour; where a template relied on the opacity to blend over a
colour, use the role directly.
### 5. Changed defaults and props
@@ -111,19 +134,20 @@ over a colour, use the role directly.
- `<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
`<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
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
and `fill` props. The `rail-collapsed:` variant is gone: style what the application puts in a
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`
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
small icons, applications pass it for icons drawn at 20px or less.
- A text field stops at 40rem wide from `medium:`, as M3 bounds fields on wider windows; a
`max-w-*` class on the field beats it, and `full` removes it.
- A text field stops at 40rem wide from medium (600px), as M3 bounds fields on wider windows; a
width rule of the application's own on the field beats it, and `full` removes it.
- Every `<x-modal>` draws a rule under its header and over its actions while its body scrolls,
and neither when the body fits. `separator` now means "always draw both rules" and no longer
renders two `<x-divider>` elements; the spacing between header, body and actions moved to
M3's split gaps, so a dialog that fits is a few pixels shorter.
- A snackbar with a `description` is 68px tall, and below `medium:` a two-line snackbar with an
- A snackbar with a `description` is 68px tall, and below medium (600px) a two-line snackbar with an
action puts the action under the text. Alt+G moves focus to an actioned snackbar.
- `<x-bottom-sheet>`'s drag now follows the pointer and settles on the nearest preset height when
`heights` or `snap` is set; without stops it behaves as before.
@@ -203,4 +227,5 @@ whatever the page renders.
### 9. For AI agents
`php artisan boost:update --discover` picks up the new `material-3` guideline and the
`material-3-design` skill, which state M3's rules and tables beside the library's utilities.
`material-3-design` skill, which state M3's rules and tables beside the library's classes, props
and tokens.
+3 -2
View File
@@ -1,10 +1,11 @@
@verbatim
## Livewire Material
This application uses `nonameweb/livewire-material`: Material 3 Expressive components for Laravel and Livewire, built on Tailwind CSS. It replaces UI kits such as maryUI, daisyUI and Flux in this application.
This application uses `nonameweb/livewire-material`: Material 3 Expressive components for Laravel and Livewire, in plain CSS. It replaces UI kits such as maryUI, daisyUI and Flux, and Tailwind CSS, in this application.
- Components are anonymous Blade components, unprefixed unless `config/livewire-material.php` sets a `prefix`. Before writing or changing a view that uses them, activate the `livewire-material-development` skill for the props, slots and traps of each component.
- Never write maryUI tags (`<x-mary-*>`) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`). They compile to nothing and fail silently.
- The CSS entry imports `foundation.css` first, then the stylesheet of each component the views render (or `all.css` for all of them). A component whose stylesheet is not imported renders unstyled; `DesignGuard::missingStylesheets()` names each missing `@import`.
- Never write a utility class Tailwind's, the library's 1.x ones or daisyUI's — or a maryUI tag. Nothing defines them, so they compile to nothing and fail silently. Layout is the layout components (`<x-row>`, `<x-stack>`, `<x-grid>`, `<x-surface>`, `<x-pane>`), text is `md-type-*` and `md-ink-*`, and everything else is the application's own CSS on `--md-sys-*` custom properties.
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` never edit `resources/css/material-scheme.css` by hand. With colour profiles (`livewire-material.profiles`), run it without a seed after changing them; the active profile comes from `Scheme::resolveProfileUsing()`.
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
+26 -21
View File
@@ -3,56 +3,61 @@
Every view in this application is Material 3 Expressive (m3.material.io), through `nonameweb/livewire-material`. These rules decide what to write; the `material-3-design` skill carries the tables, the numbers and Google's source pages behind each one — activate it before designing a screen.
The library is plain CSS on M3's tokens, and there are no utility classes: a Tailwind class, or one of the library's 1.x utilities (bg-primary, type-body-md, medium:hidden), compiles to nothing. A view is written three ways:
- Components and their props: `<x-button variant="filled">`, and the layout components `<x-row>`, `<x-stack>`, `<x-grid>`, `<x-feed>`, `<x-surface>` and `<x-pane>`, whose `gap` and `padding` take a spacing token (`space200`) and whose `hide-below`, `hide-from` and `stack-below` take a window size class.
- A fixed set of classes for text and interaction on plain elements: `md-type-*`, `md-ink-*`, `md-text-*`, `md-truncate`, `md-tabular`, `md-visually-hidden`, `md-state-layer`, `md-focus-ring`, `md-touch-target` and `md-link`.
- The application's own CSS, named by the application, whose values are `--md-sys-*` custom properties.
### Colour
- A colour is always a role: `bg-primary`, `text-on-surface-variant`, `border-outline-variant`. Never a hex, an arbitrary value, a palette tone or an opacity; Tailwind's palette does not compile.
- Pair a role only with its `on-` partner: `bg-primary text-on-primary`, `bg-secondary-container text-on-secondary-container`. That pair is the one whose contrast is guaranteed at every contrast level; mixing pairs (`bg-primary-container text-on-surface`) is not.
- A colour is always a role: `md-ink-variant` on text, `var(--md-sys-color-outline-variant)` in the application's CSS, `color="error"` on a component. Never a hex, a palette tone or an opacity.
- Pair a role only with its `on-` partner: a `primary` fill takes `on-primary` text, a `secondary-container` fill takes `on-secondary-container`. That pair is the one whose contrast is guaranteed at every contrast level; mixing pairs (`primary-container` under `on-surface`) is not.
- `primary` is the one key action on a screen (a filled button; the FAB in `primary-container`). `secondary-container` is the quiet fill (tonal buttons, selected navigation, selected chips). `tertiary` is a contrasting accent, used rarely. `error`, `success`, `warning`, `info` mean state and nothing else: the `-container` for a tinted panel, the role itself for its text and icon.
- Ink is `text-on-surface`; lower emphasis is `text-on-surface-variant` (`text-body`, `text-meta`); decoration is `text-outline` (`text-quiet`). Never dim ink with an opacity: 38% means disabled.
- `border-outline` is a boundary that must be read (a text field, the edge of a target). `border-outline-variant` (`border-divider`, `border-structure`, `border-chrome`) is a divider or a card edge. Never `outline` on a divider.
- Ink is `on-surface` (`md-ink`); lower emphasis is `on-surface-variant` (`md-ink-variant`); decoration is `outline` (`md-ink-quiet`). Never dim ink with an opacity: 38% means disabled.
- `outline` is a boundary that must be read (a text field, the edge of a target). `outline-variant` is a divider or a card edge (`<x-divider>`, `<x-surface outlined>`). Never `outline` on a divider.
- Fixed and dim roles (`primary-fixed`, `surface-dim`, …) are for a colour that must not change with the theme; if unsure, don't. Inverse roles only on an inverse surface (the snackbar).
- A link is `text-primary` and underlined (`link`); colour alone signals nothing.
- A link in running text is underlined (`md-link`, with `md-ink-primary`); colour alone signals nothing.
- Contrast: 4.5:1 for text, 3:1 for large text, icons and grouped controls; disabled is exempt. Three contrast levels exist (`<html data-contrast>`: standard, medium, high) and every role changes with them which is why only roles are allowed.
### Surfaces and elevation
- The page is `bg-surface`. Panels separate by tone first: `surface-container-lowest``surface-container-highest` is a hierarchy of emphasis, not of height. Navigation chrome is `surface-container`; a dialog, a menu, the search bar are `surface-container-high`; a modal sheet is `surface-container-low`; a filled card is `surface-container-highest`. A region keeps its role at every width.
- Shadows (`shadow-elevation-1…5`) are for what floats or lifts: 1 for elevated cards, buttons and modal sheets; 2 for menus, the navigation bar, a scrolled app bar; 3 for the FAB, dialogs, pickers and search; one level more on hover; nothing rests above 3. Fewer shadows carry more meaning.
- A scrim is `bg-scrim/32`.
- The page is `surface`. Panels separate by tone first: `surface-container-lowest` `surface-container-highest` is a hierarchy of emphasis, not of height (`<x-surface level="surface-container-high">`). Navigation chrome is `surface-container`; a dialog, a menu, the search bar are `surface-container-high`; a modal sheet is `surface-container-low`; a filled card is `surface-container-highest`. A region keeps its role at every width.
- Shadows (`var(--md-sys-elevation-1)` `-5`) are for what floats or lifts: 1 for elevated cards, buttons and modal sheets; 2 for menus, the navigation bar, a scrolled app bar; 3 for the FAB, dialogs, pickers and search; one level more on hover; nothing rests above 3. Fewer shadows carry more meaning.
- A scrim is `scrim` at 32%: `color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent)`.
### Shape
- Corners come from the scale `rounded-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full}`; Tailwind's `rounded-*` does not compile.
- Corners come from the scale `var(--md-sys-shape-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full})`, or `<x-surface corner="md">`; never a length of your own.
- By family: `full` buttons, icon buttons, chips' avatars, badges, switches, sliders, the search bar, navigation indicators; `xs` text fields, menus, snackbars, plain tooltips; `sm` chips; `md` cards, rich tooltips; `lg` the FAB and a side sheet's inner corners; `xl` dialogs, bottom sheets, the search view, pickers, carousel items; `xxl` large hero containers.
- Nested shapes: inner radius = outer radius padding; never the same radius inside and out.
- A press squares a round shape (the components do it; nothing morphs on hover). The 35 `<x-shape>`s are decoration, never meaning, used sparingly.
### Type
- Every text element carries one `type-*` style: `display` for hero figures and short marketing lines; `headline` for page and section titles; `title` for card, dialog and list-section titles; `body` for paragraphs (`body-lg` for reading); `label` inside components (buttons, chips, tabs, captions). Never `text-sm`, `font-medium`, `leading-*`, `tracking-*` they do not compile.
- `type-emphasized-*` is opt-in: a selected item, a primary action, a headline, a badge not decoration.
- 4060 characters per line; `tabular-nums` on figures that change; text must scale to 200% without loss (containers grow, rows wrap, no fixed heights on text, no ellipsis without a way to read the rest).
- Every text element carries one `md-type-*` class: `display` for hero figures and short marketing lines; `headline` for page and section titles; `title` for card, dialog and list-section titles; `body` for paragraphs (`md-type-body-lg` for reading); `label` inside components (buttons, chips, tabs, captions). In the application's CSS a style is `font: var(--md-sys-typescale-body-md)` with its `-tracking`; never a size, weight, line height or letter spacing of your own.
- `md-type-emphasized-*` is opt-in: a selected item, a primary action, a headline, a badge — not decoration.
- 4060 characters per line; `md-tabular` on figures that change; text must scale to 200% without loss (containers grow, rows wrap, no fixed heights on text, no ellipsis without a way to read the rest).
### Motion
- Position, size and shape move on the spatial springs (they overshoot): `transition-transform duration-(--md-sys-motion-spatial-default-duration) ease-spatial-default` `fast` for small elements, `slow` for large ones. Colour and opacity move on the effects springs (`ease-effects-*`), which never overshoot. Always pair an easing with its duration.
- Position, size and shape move on the spatial springs (they overshoot): `transition: transform var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default)` — `fast` for small elements, `slow` for large ones. Colour and opacity move on the effects springs (`--md-sys-motion-effects-*`), which never overshoot. Always pair an easing with its duration.
- Entering decelerates, a permanent exit accelerates, a temporary exit (a sheet, a drawer) takes the emphasized curve; exits are shorter than entrances.
- Everything that moves goes through these tokens, so reduced motion makes it instant; a literal duration is a bug.
### States and targets
- Interactive elements carry `state-layer focus-ring`: hover 8%, focus 10%, pressed 10%, dragged 16% (`data-dragged`) of the content colour; disabled is `disabled:text-on-surface/38 disabled:bg-on-surface/12` and has no hover. Every state shows two indicators: colour plus a shape, an outline, an icon or a word.
- Every target is at least 48×48px with 8px between targets (`touch-target` on anything drawn smaller); a denser layout is an opt-in prop, never a default.
- Interactive elements carry `md-state-layer md-focus-ring`: hover 8%, focus 10%, pressed 10%, dragged 16% (`data-md-dragged`) of the content colour. Disabled is content at 38% and a container at 12% of `on-surface` (`--md-sys-state-disabled-content-opacity`, `--md-sys-state-disabled-container-opacity`, through `color-mix()`), with no state layer. Every state shows two indicators: colour plus a shape, an outline, an icon or a word.
- Every target is at least 48×48px with 8px between targets (`md-touch-target` on anything drawn smaller); a denser layout is an opt-in prop, never a default.
- Keyboard: Tab between components, arrows within one, Enter and Space activate, Escape dismisses; a dialog takes focus and gives it back to what opened it.
### Layout and breakpoints
- Widths are M3's window size classes, the only variants that compile: compact below 600px (the default), `medium:` 600, `expanded:` 840, `large:` 1200, `extra-large:` 1600, and `max-medium:` … for "below". In scripts, `from()` and `upTo()` from `resources/js/breakpoints.js`.
- Widths are M3's window size classes and only those: compact below 600px (the default), medium 600, expanded 840, large 1200, extra-large 1600. A layout component takes them as props (`<x-row stack-below="medium">`, `<x-stack hide-from="expanded">`, `<x-grid :columns="['compact' => 1, 'expanded' => 2]">`); the application's CSS writes `@media (width >= 840px)`; a script asks `from()` and `upTo()` from `resources/js/breakpoints.js`.
- What changes per class: compact — navigation bar, one pane, full-screen dialogs, a bottom sheet for choices; medium — collapsed rail, one pane; expanded — rail (collapsible), two panes, menus and basic dialogs; large and extra-large — the rail expanded, two panes, a third only at extra-large as a side sheet. `<x-scaffold>` does this; content lives in panes (`<x-pane>`, `<x-list-detail>` for a list's second pane), never beside the rail by hand.
- Margins are 16px below `medium` and 24px from it; spacing sits on the 4px grid, as padding and gaps on the parent, with margins only between layout regions. A fixed pane is 360px (expanded) or 412px (large); a side sheet at most 400px.
- Write logical properties (`ps-*`, `me-*`, `start-*`, `text-start`); directional icons mirror in RTL; charts and media controls stay LTR. Keep controls inside the safe area (`--material-safe-*`).
- Margins are 16px below medium and 24px from it (`<x-pane>` draws them); spacing sits on the 4px grid as `space25` `space900`, as padding and gaps on the parent, with margins only between layout regions. A fixed pane is 360px (expanded) or 412px (large); a side sheet at most 400px.
- Write logical properties (`padding-inline-start`, `inset-inline-end`, `md-text-start`); directional icons mirror in RTL; charts and media controls stay LTR. Keep controls inside the safe area (`--material-safe-*`).
### Accessibility
- Native elements first (`<button>`, `<dialog>`, `<input>`), then ARIA. One `main`, one `banner`, one `contentinfo`; every repeated `nav` labelled, without the word "navigation".
- Headings in order from a single H1; the level is structure, the `type-*` style is appearance.
- Headings in order from a single H1; the level is structure, the `md-type-*` class is appearance.
- An icon-only control has an accessible name that does not include its role; decorative icons are hidden; an error is announced and tied to its field (`aria-describedby`); a toast uses a polite live region and never takes focus. A single-key shortcut needs a modifier or a focused component.
### Icons
- `<x-icon name="">` is a Material Symbol Rounded: `filled` means active or selected, `optical="20"` when drawn at 20px or less, one weight per group, the size and colour of the text beside it.
- `<x-icon name="home">` is a Material Symbol Rounded: `filled` means active or selected, `optical="20"` when drawn at 20px or less, one weight per group, the size and colour of the text beside it.
### Don'ts
- No icon in a snackbar; no disabled FAB (hide it); no horizontal radio rows; no hover morph on cards; no `outline` on dividers; no hex colours; no Tailwind breakpoints or scales; no segmented buttons, navigation drawer or bottom app bar use `<x-button-group connected>`, the expanded rail and `<x-toolbar>`.
- No icon in a snackbar; no disabled FAB (hide it); no horizontal radio rows; no hover morph on cards; no `outline` on dividers; no hex colours; no utility classes, and no breakpoint, radius, shadow, type size or duration off M3's scales; no segmented buttons, navigation drawer or bottom app bar use `<x-button-group connected>`, the expanded rail and `<x-toolbar>`.
@endverbatim
@@ -48,7 +48,7 @@ php artisan material:scheme "#4f46e5" --variant=tonal-spot
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (default, M3 Expressive) or `2021` (M3's original colour — keep it for a palette generated before Expressive; the library itself uses 2021 for variants 2025 does not define, and the header records the spec actually used). `--success`, `--warning` and `--info` set the source of the state colours; `--harmonize` pulls those three towards the seed (off by default: a state has to stay recognisable). The header of the stylesheet records the whole command, every option that differs from its default included. The command also writes `material-scheme.json` beside the stylesheet.
`success`, `warning` and `info` are built exactly as M3 builds `error` — dynamic colours on their own tonal palette — so they follow the scheme's spec, its dark tones and its contrast level like every other role. Use them as roles (`bg-success`, `text-on-warning-container`), never as a hex.
`success`, `warning` and `info` are built exactly as M3 builds `error` — dynamic colours on their own tonal palette — so they follow the scheme's spec, its dark tones and its contrast level like every other role. Use them as roles (`md-ink-success` on text, `var(--md-sys-color-on-warning-container)` in your own CSS, a component's `color="warning"`), never as a hex.
### Contrast levels
@@ -90,19 +90,42 @@ Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
## Tokens
2.0.0's vocabulary is plain CSS, without Tailwind. `resources/css/foundation.css` is the one required import, first in the entry, followed by the stylesheets of the components the views render (see Setup; 1.x's `material.css` shortcut is gone). It declares the layer order `material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility`, so an application's unlayered CSS beats every package rule, and `hidden` or `x-cloak` hides any element. It brings the reset, the page's `surface` and `on-surface` in the brand typeface smoothed in grayscale (drop Tailwind's `antialiased`), every `--md-sys-*` token (spacing included: `--md-sys-measurement-space25``space900`, 272px), and `md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link`. Text on plain elements takes the fixed text classes of `resources/css/text.css` and nothing else: `md-type-{display|headline|title|body|label}-{lg|md|sm}` and `md-type-emphasized-…`; `md-ink` (on-surface), `md-ink-variant`, `md-ink-quiet`, `md-ink-primary`, `md-ink-error`, `md-ink-success`, `md-ink-warning`, `md-ink-info`, `md-ink-inverse`; `md-text-start|center|end`, `md-truncate`, `md-line-clamp-2|3`, `md-nowrap`, `md-tabular`, `md-mono`, `md-visually-hidden`. Monospace is for a value read or typed character by character (a token, a recovery code, a file name, a key): put it in `<code>`, `<kbd>` or `<samp>`, which take `--md-ref-typeface-mono` already, or give another element `md-mono` after its `md-type-*` class; `<x-input mono>` for a field. `--md-ref-typeface-mono` is the package's addition, the platform's monospace faces, not an M3 token. Every component is plain CSS now; the Tailwind names below stay for the showcase and an application's own views, until Tailwind itself leaves the package.
2.0.0's vocabulary is plain CSS. There are no utility classes: a Tailwind class, or one of 1.x's own colour, type, shape, elevation, motion or breakpoint utilities, compiles to nothing, and the design guard names the replacement for each (see Testing the design). `resources/css/foundation.css` is the one required import, first in the entry, followed by the stylesheets of the components the views render (see Setup; 1.x's `material.css` shortcut is gone). It declares the layer order `material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility`, so an application's unlayered CSS beats every package rule, and `hidden` or `x-cloak` hides any element. It brings the reset, the page's `surface` and `on-surface` in the brand typeface smoothed in grayscale (drop Tailwind's `antialiased`), every `--md-sys-*` token, and the classes below. An application's views reach for four things, in this order:
Tailwind's default palette is cleared: every colour class names an M3 role. `text-red-600`, `bg-base-200` or `text-gray-500` compile to nothing. The rules behind the names below — which role, surface container, corner, type style, elevation level, motion spring, state and window size class to use, and M3's don'ts — are the `material-3` guideline (always on) and the `material-3-design` skill (the tables and Google's source pages); activate that skill before designing a screen.
- **A component's own props** — `color`, `variant`, `size`, `dense` — before anything else.
- **Layout components** for arrangement and spacing: `<x-row>`, `<x-stack>`, `<x-grid>`, `<x-feed>`, `<x-surface>` inside `<x-pane>`s (see Layout), with `gap` and `padding` as spacing tokens (`space25``space900`, 272px) and `hide-below`, `hide-from` and `stack-below` for the window size classes. Nothing arranges by class.
- **The fixed text and interaction classes** on plain elements, and nothing else:
- Type, from `resources/css/text.css`: `md-type-{display|headline|title|body|label}-{lg|md|sm}` and `md-type-emphasized-…` — M3's 15 styles and their emphasized twins, one weight step heavier, rounder, with their own tracking; each sets size, line height, weight and tracking together. Emphasis is deliberate and one element at a time (M3 asks for it on badges, primary buttons, selected rows, headlines), so a regular class inside an emphasized one goes back to plain.
- Ink, from the same file: `md-ink` (on-surface), `md-ink-variant` (on-surface-variant: body copy beside a title, metadata), `md-ink-quiet` (outline: decoration only), `md-ink-primary`, `md-ink-error`, `md-ink-success`, `md-ink-warning`, `md-ink-info`, `md-ink-inverse`. On a container the ink is the container's `on-` role, which the component sets.
- Text layout: `md-text-start|center|end`, `md-truncate`, `md-line-clamp-2|3`, `md-nowrap`, `md-tabular`, `md-mono`, `md-visually-hidden`. Monospace is for a value read or typed character by character (a token, a recovery code, a file name, a key): put it in `<code>`, `<kbd>` or `<samp>`, which take `--md-ref-typeface-mono` already, or give another element `md-mono` after its `md-type-*` class; `<x-input mono>` for a field. `--md-ref-typeface-mono` is the package's addition, the platform's monospace faces, not an M3 token.
- Interaction, from `foundation/interaction.css`: `md-state-layer` (M3's hover, focus, press and dragged overlay in the content colour; makes the element `position: relative` and `isolation: isolate`; `data-md-dragged` draws the dragged layer), `md-focus-ring` (the keyboard focus indicator), `md-touch-target` (a 48px target around a smaller drawing), `md-link` (a link in running text, underlined; add `md-ink-primary` where it should read as primary).
- **The application's own stylesheet** for the rest, on the tokens, never on a literal value:
- Colour roles, `var(--md-sys-color-…)`: `primary`, `on-primary`, `primary-container`, `on-primary-container`, `primary-dim`, `inverse-primary`, `primary-fixed`, `primary-fixed-dim`, `on-primary-fixed`, `on-primary-fixed-variant`; the same for `secondary` and `tertiary`; `error`, `on-error`, `error-container`, `on-error-container`, `error-dim`; `success`, `warning` and `info` with their `on-`, `-container` and `on-…-container`; `inverse-error|success|warning|info`; `surface`, `surface-dim`, `surface-bright`, `surface-container-lowest|low||high|highest`, `on-surface`, `on-surface-variant`, `inverse-surface`, `inverse-on-surface`, `outline` (a boundary that must be read), `outline-variant` (a divider, a card's edge), `scrim`, `shadow`. There is no white or black.
- Type: `font: var(--md-sys-typescale-body-md)` with `letter-spacing: var(--md-sys-typescale-body-md-tracking)`, never a size, weight or line height of your own. The typefaces are `--md-ref-typeface-brand` (Google Sans Flex), `--md-ref-typeface-plain` and `--md-ref-typeface-mono`; the weights `--md-ref-typeface-weight-regular|medium|bold`.
- Shape: `var(--md-sys-shape-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full})`.
- Elevation: `box-shadow: var(--md-sys-elevation-{1…5})` — for what floats over content, not for panels (a panel separates by its container tone).
- Motion: `var(--md-sys-motion-spatial-{fast|default|slow})` (position, size, shape; springs that overshoot) and `var(--md-sys-motion-effects-{fast|default|slow})` (colour, opacity, which never overshoot), each always with its own `-duration`: `transition: transform var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast)` — M3's published web durations, spatial 350/500/650 ms and effects 150/200/300 ms. `motion.scheme` in the config picks `expressive` (the default, with the bounce) or `standard` (minimal bounce), which the head script writes to `<html data-motion>` and which swaps the three spatial springs; a rule names a spring, never a scheme. Reduced motion zeroes every duration in both schemes.
- Spacing: `var(--md-sys-measurement-space25)``var(--md-sys-measurement-space900)`.
- States: `--md-sys-state-{hover|focus|pressed|dragged}-state-layer-opacity` and `--md-sys-state-disabled-{content|container}-opacity`, mixed into a role: `color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent)`. A scrim is `color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent)`.
- Colour roles (`bg-*`, `text-*`, `border-*`, …): `primary`, `on-primary`, `primary-container`, `on-primary-container`, `inverse-primary`, `primary-fixed`, `primary-fixed-dim`, `on-primary-fixed`, `on-primary-fixed-variant`; the same for `secondary` and `tertiary`; `error`, `on-error`, `error-container`, `on-error-container`; `success`, `warning` and `info` with their `on-`, `-container` and `on-…-container`; `inverse-error|success|warning|info`; `surface`, `surface-dim`, `surface-bright`, `surface-container-lowest|low||high|highest`, `on-surface`, `on-surface-variant`, `inverse-surface`, `inverse-on-surface`, `outline`, `outline-variant`, `scrim`, `shadow`; plus `white` and `black`.
- Ink and lines by meaning: `text-body` (body copy), `text-meta` (metadata), `text-quiet` (decoration only), `border-structure`, `border-chrome`, `border-divider` / `divide-divider`.
- Type: `type-{display|headline|title|body|label}-{lg|md|sm}` and `type-emphasized-…` — M3's 15 styles and their emphasized twins, one weight step heavier, rounder, with their own tracking. Never assemble `text-*`, `leading-*` and `tracking-*` by hand; a utility carries size, line height and tracking together. Emphasis is deliberate and one element at a time (M3 asks for it on badges, primary buttons, selected rows, headlines), so a regular utility inside an emphasized one goes back to plain. The font is Google Sans Flex (`font-sans`).
- Shape: `rounded-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full}`.
- Elevation: `shadow-elevation-{1…5}` — for what floats over content, not for panels (a panel separates by its container tone).
- Motion: `ease-spatial-{fast|default|slow}` (position, size, shape; springs that overshoot) and `ease-effects-{fast|default|slow}` (colour, opacity, which never overshoot). Always pair an easing with its duration: `duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast` — M3's published web durations, spatial 350/500/650 ms and effects 150/200/300 ms. `motion.scheme` in the config picks `expressive` (the default, with the bounce) or `standard` (minimal bounce), which the head script writes to `<html data-motion>` and which swaps the three spatial springs; a component names a spring, never a scheme. Reduced motion zeroes every duration in both schemes.
- States: `state-layer` (M3's hover/focus/press overlay; makes the element `relative` and `isolate`), `focus-ring` (keyboard focus indicator), `link` (a link in running text).
- Breakpoints are M3's window size classes, and only those: `medium:` 600px, `expanded:` 840px, `large:` 1200px, `extra-large:` 1600px, with `max-medium:` … for "below" (compact is below `medium`). Tailwind's `sm:``2xl:` are cleared — a `sm:` compiles to nothing — because 640px means nothing in M3. Scripts ask `resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`) so a stylesheet and a script never disagree at the boundary pixel; a component's *own* width is a container query (`@md:`), which is a different thing.
- `dark:` follows the page's theme (`data-theme`), not the operating system.
The rules behind these names — which role, surface container, corner, type style, elevation level, motion spring, state and window size class to use, and M3's don'ts — are the `material-3` guideline (always on) and the `material-3-design` skill (the tables and Google's source pages); activate that skill before designing a screen.
```css
/* resources/css/app.css, after the imports */
.quota-meter {
border-radius: var(--md-sys-shape-corner-md);
background-color: var(--md-sys-color-surface-container-high);
padding: var(--md-sys-measurement-space200);
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
@media (width >= 840px) {
padding: var(--md-sys-measurement-space300);
}
}
```
- Breakpoints are M3's window size classes, and only those: compact below 600px, medium 600px, expanded 840px, large 1200px, extra-large 1600px. A layout component takes them as props; your own CSS writes them as range media queries (`@media (width >= 840px)`, `@media (width < 600px)`), and the design guard rejects any other width; a script asks `resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`), so a stylesheet and a script never disagree at the boundary pixel. A component's *own* width is a container query (`@container (width >= 28rem)`), which is a different thing.
- The roles switch with the theme by themselves. A rule of your own that only applies in dark keys on `[data-theme='dark']` (the page's theme), never on `prefers-color-scheme` (the operating system's).
- `x-figure` on an element holding one number counts it up on first appearance and on change.
## Theme
@@ -169,7 +192,7 @@ Laravel's HTTP error pages — 403, 404, 419, 429, 500, 503, and the framework's
## Mail
Markdown mail (notifications and `markdown:` mailables) wears M3 once the application selects the theme:
Markdown mail (notifications, and mailables built with `->markdown()`) wears M3 once the application selects the theme:
```dotenv
MAIL_MARKDOWN_THEME=livewire-material::mail.theme
@@ -216,7 +239,7 @@ M3's optical size axis redraws a symbol so its strokes look equally heavy at eve
### `<x-shape>`
One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, sized by its caller: `<x-shape name="cookie-9" class="size-40 text-secondary-container" />`. Names: `circle`, `square`, `slanted`, `arch`, `fan`, `arrow`, `semi-circle`, `oval`, `pill`, `triangle`, `diamond`, `clam-shell`, `pentagon`, `gem`, `very-sunny`, `sunny`, `cookie-4`, `cookie-6`, `cookie-7`, `cookie-9`, `cookie-12`, `ghostish`, `clover-4`, `clover-8`, `burst`, `soft-burst`, `boom`, `soft-boom`, `flower`, `puffy`, `puffy-diamond`, `pixel-circle`, `pixel-triangle`, `bun`, `heart`.
One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`: `<x-shape name="cookie-9" size="160" style="color: var(--md-sys-color-secondary-container)" />`. `size` is its size in px (8 to 1024); without it your own CSS sizes it. Names: `circle`, `square`, `slanted`, `arch`, `fan`, `arrow`, `semi-circle`, `oval`, `pill`, `triangle`, `diamond`, `clam-shell`, `pentagon`, `gem`, `very-sunny`, `sunny`, `cookie-4`, `cookie-6`, `cookie-7`, `cookie-9`, `cookie-12`, `ghostish`, `clover-4`, `clover-8`, `burst`, `soft-burst`, `boom`, `soft-boom`, `flower`, `puffy`, `puffy-diamond`, `pixel-circle`, `pixel-triangle`, `bun`, `heart`.
### `<x-theme-script>`
@@ -269,7 +292,7 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
</x-menu>
```
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`, `filter`, `sheet-at-compact`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`, `submenu`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls.
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`, `filter`, `sheet-at-compact`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="sport-run"`, a class of your own), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`, `submenu`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls.
`submenu` makes an item a menu of its own — the slot holds the nested `<x-menu-item>`s instead of a label, and they open beside it, on its end, flipping to its start where the window has no room:
@@ -345,14 +368,14 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
### `<x-fab>`
`<x-fab icon="add" tooltip="New share" />``size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. The glyph is filled, as M3 requires of a FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself: put the page's FAB in `<x-scaffold>`'s `fab` slot, which places it at the bottom-end corner with M3's margin and clear of the navigation bar and a snackbar. `link`, `external`, `type`. There is no `disabled`: M3 says to remove a FAB whose action is unavailable, so hide it instead. `data-fab` on the root lets a place restyle a nested FAB (a rail flattens it to elevation 0).
`<x-fab icon="add" tooltip="New share" />``size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. The glyph is filled, as M3 requires of a FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself: put the page's FAB in `<x-scaffold>`'s `fab` slot, which places it at the bottom-end corner with M3's margin and clear of the navigation bar and a snackbar. `link`, `external`, `type`. There is no `disabled`: M3 says to remove a FAB whose action is unavailable, so hide it instead. `data-md-fab` on the root lets a place restyle a nested FAB (a rail flattens it to elevation 0).
`collapse-on-scroll` on an extended FAB with an `icon` (`<x-fab icon="edit" label="Compose" collapse-on-scroll />`) is M3's scroll behaviour: it shrinks to the FAB of its size while the window scrolls down and extends again on scroll-up or at the top of the page. The width morphs on the spatial spring and the label fades; under reduced motion it swaps outright. The label stays in the page, clipped, so the collapsed FAB keeps its accessible name. It watches the window, so it is for a FAB pinned over a scrolling page, not one inside a scrolling pane.
### `<x-fab-menu>`, `<x-fab-menu-item>`
```blade
<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">
<div class="new-share-menu">
<x-fab-menu label="New">
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
<x-fab-menu-item label="Paste text" icon="content_paste" link="{{ route('paste') }}" />
@@ -360,15 +383,28 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
</div>
```
Two to six items open above the FAB, which turns into a close button, rising into place as it opens and sinking back as it closes; a window too short for them scrolls the list while the FAB stays put. `<x-fab-menu>`: `icon` (`add`), `label` (defaults to "Toggle menu" — the trigger has no other accessible name), `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as `<x-menu>`. The wrapper keeps M3's margin from the window edge: 16dp, 24dp from `large`.
```css
.new-share-menu {
position: fixed;
inset-inline-end: var(--md-sys-measurement-space200);
inset-block-end: var(--md-sys-measurement-space200);
@media (width >= 1200px) {
inset-inline-end: var(--md-sys-measurement-space300);
inset-block-end: var(--md-sys-measurement-space300);
}
}
```
Two to six items open above the FAB, which turns into a close button, rising into place as it opens and sinking back as it closes; a window too short for them scrolls the list while the FAB stays put. `<x-fab-menu>`: `icon` (`add`), `label` (defaults to "Toggle menu" — the trigger has no other accessible name), `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as `<x-menu>`. It does not place itself: the wrapper keeps M3's margin from the window edge, 16dp, 24dp from `large`.
### `<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 sized or coloured by class; `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
<x-loading />
<x-loading contained class="size-8" label="Uploading" />
<x-loading contained size="32" label="Uploading" />
<div wire:loading.flex wire:target="upload"><x-loading /></div>
```
@@ -398,7 +434,7 @@ window.addEventListener('app:update', () => location.reload())
### `<x-progress>`
M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless a `size-*` class is passed), flat or `wavy`, determinate with a `value` or indeterminate without one.
M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless your own CSS sizes it), flat or `wavy`, determinate with a `value` or indeterminate without one.
| Prop | Default | |
|---|---|---|
@@ -421,15 +457,15 @@ M3 Expressive's progress indicator: linear (as wide as its container) or `circul
</div>
```
A value the server changes animates after a morph (the SVG is `wire:ignore`; only the root's attributes change). Under reduced motion values jump, the wave stands still and an indeterminate indicator holds one frame. A `w-*` class narrows a linear one; never pass a display or position class.
A value the server changes animates after a morph (the SVG is `wire:ignore`; only the root's attributes change). Under reduced motion values jump, the wave stands still and an indeterminate indicator holds one frame. A width of your own on its `class` narrows a linear one (`inline-size: 12rem`); leave its `display` and `position` to it.
### `<x-badge>`
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `position: relative` parent: `<span style="position: relative; display: inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Built in" color="primary" solid />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container, in the colour itself (`solid`, for a label that has to stand out), or a neutral `outline` edge (the role that has to be seen, not the decorative `outline-variant` dividers use). `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`, `neutral`, `plain`; an unknown colour is `error`.
- `color="neutral"` — neutral ink on every variant: a dot or count in on-surface-variant with surface text, `tonal` in surface-container-high with on-surface-variant text, `outline` in the outline edge with on-surface-variant text.
- `color="plain"` — no background, text or border colour in any variant (shape, size and type stay), so the classes you pass paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`. Pass both a background and a text class; an `outline` badge's edge takes the text colour unless you pass a `border-*` colour.
- The value is `value` or the slot; the slot renders as HTML: `<x-badge tonal><x-icon name="bolt" optical="20" class="size-3" /> Pro</x-badge>`. `value` is escaped. A slot that holds only whitespace or comments is still a dot.
- `color="plain"` — no background, text or border colour in any variant (shape, size and type stay), so your own CSS paints it: `<x-badge value="Run" tonal color="plain" class="sport-run-label" />` with `.sport-run-label { background-color: var(--md-sys-color-tertiary-container); color: var(--md-sys-color-on-tertiary-container); }`. Set both a background and a colour; an `outline` badge's edge takes the text colour unless you set a `border-color`.
- The value is `value` or the slot; the slot renders as HTML: `<x-badge tonal><x-icon name="bolt" size="12" /> Pro</x-badge>`. `value` is escaped. A slot that holds only whitespace or comments is still a dot.
### `<x-alert>`
@@ -459,30 +495,30 @@ Shows on hover and keyboard focus and goes 1.5s after the pointer or the focus l
### `<x-stat>`
`<x-stat title="Shares" value="1,204" icon="link" description="12 this week" />` — a figure on a surface-container panel; the value counts up on first appearance and when it changes. The slot goes under the description (e.g. a quota's `<x-progress>`). Do not pass a `bg-*` class; wrap it.
`<x-stat title="Shares" value="1,204" icon="link" description="12 this week" />` — a figure on a surface-container panel; the value counts up on first appearance and when it changes. The slot goes under the description (e.g. a quota's `<x-progress>`). Its fill is surface-container: do not repaint it with a class of your own; wrap it.
### `<x-empty-state>`
"Nothing here yet": `icon` on an Expressive `shape` (`cookie-9` by default), `title`, `description` or slot, and an `actions` slot. Use it for an empty collection, not for a filter that matched nothing.
The `illustration` slot draws the application's own artwork in place of the shape and icon (`icon` and `shape` are then unused). Size the artwork yourself; the slot's attributes go on the element around it, so its `class` sets the colour `currentColor` takes. Mark decorative SVG `aria-hidden="true"`. A slot holding only whitespace or comments leaves the shape and icon.
The `illustration` slot draws the application's own artwork in place of the shape and icon (`icon` and `shape` are then unused). Size the artwork yourself; the slot's attributes go on the element around it, so its `style` (or a class of your own) sets the colour `currentColor` takes. Mark decorative SVG `aria-hidden="true"`. A slot holding only whitespace or comments leaves the shape and icon.
```blade
<x-empty-state title="No routes yet" description="Draw one on the map.">
<x-slot:illustration class="text-primary"><svg class="size-32" viewBox="0 0 120 120" aria-hidden="true">…</svg></x-slot:illustration>
<x-slot:illustration style="color: var(--md-sys-color-primary)"><svg width="128" height="128" viewBox="0 0 120 120" aria-hidden="true">…</svg></x-slot:illustration>
<x-slot:actions><x-button label="Draw a route" variant="filled" /></x-slot:actions>
</x-empty-state>
```
### `<x-card>`
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`, `heading` (the title's element, `h3` by default: pass `h2` for a card straight under the page's `h1`); slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). A card holds content and actions about one subject (M3); a table of many rows or a group of settings is a headed section, not a card. Do not pass `bg-*`; use `variant`.
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`, `heading` (the title's element, `h3` by default: pass `h2` for a card straight under the page's `h1`); slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). A card holds content and actions about one subject (M3); a table of many rows or a group of settings is a headed section, not a card. Choose its fill with `variant`, not a background of your own.
A card or list item that opens something is a **row**: `data-md-list-row` on it and `data-md-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `<a>` or use a stretched link. A row answers with the state layer and one step of elevation; its corner does not move.
```blade
<x-card variant="outlined" data-md-list-row wire:key="share-{{ $share->id }}">
<a href="{{ route('shares.show', $share) }}" data-md-list-open wire:navigate class="type-title-md">{{ $share->name }}</a>
<a href="{{ route('shares.show', $share) }}" data-md-list-open wire:navigate class="md-type-title-md">{{ $share->name }}</a>
<x-slot:actions><x-button label="Copy link" wire:click="copy({{ $share->id }})" /></x-slot:actions>
</x-card>
```
@@ -545,7 +581,7 @@ Props: `title`, `subtitle`, `icon` (centred hero icon), `separator` (draw the di
### `<x-drawer>`
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`; mirrored in RTL), `width` (`400px`), `with-close-button` (**default true** — M3 requires a close affordance; `:with-close-button="false"` is ignored when Escape or the scrim is off, or on a `standard` sheet), `close-on-escape` (default true), `without-backdrop-close`, `actions` slot (**left**-aligned in a 72dp row, which is what the side-sheet spec says; a dialog's are trailing-aligned). For the second pane of a list-detail layout use `<x-list-detail>` instead — `<x-drawer>` no longer has a `pane` prop. Its body is a size container — lay out inside with `@md:` (a *container* query), never a window class.
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`; mirrored in RTL), `width` (`400px`), `with-close-button` (**default true** — M3 requires a close affordance; `:with-close-button="false"` is ignored when Escape or the scrim is off, or on a `standard` sheet), `close-on-escape` (default true), `without-backdrop-close`, `actions` slot (**left**-aligned in a 72dp row, which is what the side-sheet spec says; a dialog's are trailing-aligned). For the second pane of a list-detail layout use `<x-list-detail>` instead — `<x-drawer>` no longer has a `pane` prop. Its body is a size container — lay out inside it with a container query in your own CSS (`@container (width >= 28rem)`), never a window size class.
`standard` is M3's other side-sheet variant: supplementary content beside the primary content — filters, details, a list of actions — co-planar from `expanded`, flat on `surface` with 0dp elevation and no corner, the window's full height, an outline-variant rule down its inner edge instead of a scrim, nothing inert and no focus trap. Below `expanded` it is the modal sheet. Capped at M3's 400dp whatever `width` says, and it always draws the close button. Render it beside its content in a row that only lays out side by side from `expanded` (`<x-supporting-pane>`, or a caller's own row).
@@ -567,7 +603,7 @@ An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page,
</x-carousel>
```
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `multi-aspect`, `full-screen` — one edge-to-edge item at a time scrolled **vertically**, which M3 gives to compact and medium windows in portrait only, and never to landscape), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, **16** — M3's specs table; leading only for `uncontained`, none for `full-screen`), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element sized `size-full`; `label` overlays a line of text; `aspect` is its ratio in a `multi-aspect` carousel. A `region` of `slide` groups named "n of m", each item a tab stop and the row itself not one, as M3 asks; from a focused item the arrow keys move one item, Home/End go to the ends and Space/Enter opens one that is not fully in view. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `multi-aspect`, `full-screen` — one edge-to-edge item at a time scrolled **vertically**, which M3 gives to compact and medium windows in portrait only, and never to landscape), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, **16** — M3's specs table; leading only for `uncontained`, none for `full-screen`), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element your own CSS sizes to fill it (`inline-size: 100%; block-size: 100%`); `label` overlays a line of text; `aspect` is its ratio in a `multi-aspect` carousel. A `region` of `slide` groups named "n of m", each item a tab stop and the row itself not one, as M3 asks; from a focused item the arrow keys move one item, Home/End go to the ends and Space/Enter opens one that is not fully in view. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
`layout="multi-aspect"` is M3's uncontained multi-aspect-ratio layout (November 2025): each `<x-carousel-item aspect="16/9">` keeps its own ratio at the row's `height`, held inside M3's 9:16-to-16:9 range, so the widths come from the art. Only use it when the items really do have various widths. It is a plain flex row with uncontained scrolling — no keylines and no masks, since an arrangement of one item size cannot describe it — while the buttons, the arrow keys, Home/End and bring-into-view still work, from resting positions measured off the DOM.
@@ -626,12 +662,12 @@ A one-column grid of fields with an `actions` slot at the foot, end-aligned (the
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant` (and all but `<x-file>` a `hint-class`, classes added to the hint: `hint-class="text-warning"`), and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]``photos`, `address[city]``address.city`); the error replaces the hint, sets `aria-invalid`, and puts an `error` icon at the end of the row as M3's second indicator (not on `size="xs"`, and not when the field already trails something — `icon-right`, `clearable`, `copyable`). `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant` (and all but `<x-file>` a `hint-class`, classes added to the hint: `hint-class="md-ink-warning"`), and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]``photos`, `address[city]``address.city`); the error replaces the hint, sets `aria-invalid`, and puts an `error` icon at the end of the row as M3's second indicator (not on `size="xs"`, and not when the field already trails something — `icon-right`, `clearable`, `copyable`). `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `counter`, `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
- `<x-password>`: a reveal button; `icon`, `size`.
- `<x-textarea>`: grows from `rows` (3) to `max-rows`, then scrolls; `:autogrow="false"` for a fixed, hand-resizable one; `counter`.
- Width: M3 asks that a text field never span the full width of a large screen, so from `medium` (600px) every field stops at **40rem**; below that it fills its pane. A `max-w-*` class on the component narrows or widens it, and `full` (on `<x-field>`, `<x-input>`, `<x-textarea>`) takes the bound off for a field that really is the width of its pane — a search row, an editor. `<x-search>`'s bar carries M3's own bound, 720px.
- Width: M3 asks that a text field never span the full width of a large screen, so from `medium` (600px) every field stops at **40rem**; below that it fills its pane. A width rule of your own on its `class` (`max-inline-size: 24rem`) narrows or widens it, because your unlayered CSS outranks the package's, and `full` (on `<x-field>`, `<x-input>`, `<x-textarea>`) takes the bound off for a field that really is the width of its pane — a search row, an editor. `<x-search>`'s bar carries M3's own bound, 720px.
- `counter` (on `<x-input>` and `<x-textarea>`) puts M3's character counter at the end of the supporting-text row, beside the hint or the error: `n/max`, counted on every keystroke against the field's own `maxlength`, and in the error colour once the value is past it. It needs `maxlength` — without one there is nothing to count against and nothing is drawn. It is said as "Character count, 5/20" from a polite region a second after typing stops.
- `<x-select>`: native `<select>` (M3 menu where the browser supports customizable selects). `options` as `['id' => …, 'name' => …, 'disabled' => bool]`, `option-value`, `option-label`, `placeholder` + `placeholder-value`, or `<option>`s in the slot; `icon`, `size`.
- `<x-file>`: native file input; errors from `photos` and `photos.*`. Show previews of what was chosen yourself.
@@ -641,7 +677,7 @@ M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire
M3 selection controls on native inputs; the whole row is the label.
- `<x-checkbox label hint right indeterminate />``indeterminate` for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render). Grouping is yours: from `expanded` (840px) M3 wants a set of related checkboxes gathered into a contained region rather than one long column, so wrap the set in `<div class="grid gap-4 expanded:grid-cols-2">` (or a card or side sheet) under a heading that names what the group asks.
- `<x-checkbox label hint right indeterminate />``indeterminate` for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render). Grouping is yours: from `expanded` (840px) M3 wants a set of related checkboxes gathered into a contained region rather than one long column, so wrap the set in `<x-grid :columns="['compact' => 1, 'expanded' => 2]" gap="space200">` (or a card or side sheet) under a heading that names what the group asks.
- `<x-radio label wire:model :options inline />` — options `['id', 'name', 'hint', 'disabled']` (`option-value`, `option-label`, `option-hint`); `value` checks an option without `wire:model`; `name` names an unbound group. M3 stacks radios and cautions against a row at any width, so reach for `inline` only for two or three short labels; it also wants five options or fewer and one of them chosen when the page loads.
- `<x-toggle label hint right icons />` — M3 switch (`role="switch"`); `icons` puts a check and a cross on the handle, `icons="selected"` only the check. Without `label`, pass `aria-label`.
@@ -676,12 +712,12 @@ M3 Expressive's slider on native `<input type="range">`s (one per handle), so th
| `orientation` | `horizontal` | `vertical` stands it up: the value grows upwards, the value label sits beside the handle, Up and Down move it. Ignored with `range` — M3 keeps range sliders horizontal |
| `disabled` | `false` | |
Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless a `w-*` class is passed.
Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless your own CSS narrows it.
A vertical slider is as wide as a horizontal one is tall and as long as the wrapper it is in, so **give it a height**`class="h-64"`, which the label and hint share. Without one it is 192px long.
A vertical slider is as wide as a horizontal one is tall and as long as the wrapper it is in, so **give it a height**a `block-size` rule of your own on its `class`, which the label and hint share. Without one it is 192px long.
```blade
<x-slider label="Volume" orientation="vertical" wire:model.live="volume" class="h-64" />
<x-slider label="Volume" orientation="vertical" wire:model.live="volume" style="block-size: 16rem" />
```
### `<x-datepicker>`
@@ -758,7 +794,7 @@ M3 search bar that opens into a search view: docked under the bar from `medium`
</x-search>
```
The docked view overlaps what is under it; never place a search inside an element with `overflow-hidden` (a card), which clips it. The bar is never wider than M3's 720px and grows to that width while it is focused; for M3's 360px resting bar, wrap it in an element carrying `style="--search-width: 22.5rem"`.
The docked view overlaps what is under it; never place a search inside an element with `overflow: hidden` (a card), which clips it. The bar is never wider than M3's 720px and grows to that width while it is focused; for M3's 360px resting bar, wrap it in an element carrying `style="--search-width: 22.5rem"`.
- `trigger="icon"` is M3's other entry point — search as a secondary action: one 48px search icon button that expands into the full-screen view at any width (so `docked` does not apply) and gives the button its focus back on close. Put it in a toolbar or an app bar row where a bar would not fit.
- The `suggestions` slot is shown in the view until the first keystroke — recent or popular searches — and the results slot takes over once something is typed. The live region counts whichever list is on screen and names suggestions as such.
@@ -808,13 +844,13 @@ A visitor who has pressed the menu button keeps that choice in both standard ban
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
]">
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="md-type-title-lg">SealShare</a></x-slot:brand>
<x-slot:rail-footer>
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
</x-slot:rail-footer>
<x-slot:top>
{{-- the page's app bar; its menu button opens the modal rail on a phone --}}
<span class="medium:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>
<x-stack as="span" hide-from="medium"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></x-stack>
</x-slot:top>
<x-slot:fab>
<x-fab icon="add" tooltip="New share" link="{{ route('upload') }}" />
@@ -829,9 +865,9 @@ A visitor who has pressed the menu button keeps that choice in both standard ban
- `fab` places an `<x-fab>` as Compose's Scaffold does: fixed at the bottom-end corner, 16px from the window's edges on a compact window and 24px from `medium`, above the navigation bar and the bottom safe area, and lifted above a snackbar while one shows (M3: a snackbar appears above a FAB, never in front of or behind it). In focus order it comes after the page's bar and before the page. Use it or a FAB in `rail-header`, never both: M3 allows one FAB on a screen.
- `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-scaffold>`), 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 scaffold writes no gutters of its own, and a pane or canonical layout inside draws none; something that must reach the window's edges opts out with `-mx-(--material-margin)`.
- `--material-margin` is M3's window margin (16px compact, 24px from `medium`) and the content region already carries it, so a page inside the scaffold writes no gutters of its own, and a pane or canonical layout inside draws none; something that must reach the window's edges opts out with `margin-inline: calc(var(--material-margin) * -1)` in your own CSS.
- On a compact window the scaffold sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, the FAB and the page's bottom padding clear the bar; pad anything else you pin to the bottom with it. See Safe areas.
- The content region is `max-expanded:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
- Below `expanded` the content region clips what overflows sideways (`overflow-x: clip`). Never give a page wrapper `overflow-x: hidden`: it turns the region into a scroll container and breaks every `position: sticky` inside.
#### `<x-pane>`
@@ -950,15 +986,24 @@ Children in columns inside a pane.
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`<x-scaffold>` does):
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; pin it in a wrapper and hide it where a rail takes over (`<x-scaffold>` does both):
```blade
<div class="fixed inset-x-0 bottom-0 z-30 medium:hidden">
<x-stack class="bottom-navigation" hide-from="medium">
<x-navigation-bar>
<x-navigation-bar-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" :active="request()->routeIs('shares.*')" badge="3" />
<x-navigation-bar-item label="Upload" icon="upload" link="{{ route('upload') }}" />
</x-navigation-bar>
</div>
</x-stack>
```
```css
.bottom-navigation {
position: fixed;
inset-inline: 0;
inset-block-end: 0;
z-index: 30;
}
```
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"), `tall` (M3's 80px container, which keeps the icon over the label at every width — `<x-scaffold tall-bar>` picks it, and the bottom offset grows with it), `hide-on-scroll` (M3's scrolling behaviour: the bar slides out on a scroll down and springs back on a scroll up, never before the first screenful and never while a snackbar, bottom sheet or drawer is on screen; focus reaching it brings it back. `<x-scaffold hide-bar-on-scroll>` picks it, and `--material-bottom-bar` goes down and comes back with the bar, so a `fab` button and the snackbar keep their distance from it). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
@@ -968,9 +1013,9 @@ M3 Expressive's flexible navigation bar, for three to five destinations. It does
M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (a 56px full-width pill, icon beside label, count at the end).
```blade
<div class="flex min-h-dvh">
<x-row align="stretch" style="min-block-size: 100dvh">
<x-navigation-rail mode="collapsible">
<x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
<x-slot:brand><span class="md-type-title-lg">SealShare</span></x-slot:brand>
<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" />
@@ -983,14 +1028,23 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
</x-slot:footer>
</x-navigation-rail>
<main class="min-w-0 flex-1">…</main>
</div>
<main style="flex: 1; min-inline-size: 0">…</main>
</x-row>
```
- `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.
- 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). The Tailwind `rail-collapsed:` variant this used to teach no longer exists: the package's own CSS is plain now. 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.
- 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.
- `<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`).
@@ -1015,7 +1069,7 @@ M3 Expressive top app bar, sticky by default (`:sticky="false"` to scroll away),
]" />
```
A collapsing bar needs the window to scroll: no ancestor with `overflow-hidden`/`overflow-auto` (`overflow-x-clip` is fine).
A collapsing bar needs the window to scroll: no ancestor with `overflow: hidden` or `overflow: auto` (`overflow-x: clip` is fine).
### `<x-toolbar>`
@@ -1087,19 +1141,19 @@ A choice of colour profile (see Colour profiles): a swatch per generated profile
### `<x-table>`, `<x-sort-header>`
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>`; cell utilities (`text-end`, `whitespace-nowrap`) always win. Rows are 52px — a target a finger can hit. `dense` tightens them to 36px and `size="xs"` is for a table inside a panel inside a panel (32px rows); M3 says density is always an opt-in, so neither is a default and both are yours to justify. Scrolling is yours: wrap it in `<div class="overflow-x-auto">`. A row that opens something is `data-md-list-row` with one `data-md-list-open` control; a selected row is `aria-selected="true"`.
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>`; the text classes on a cell (`md-text-end`, `md-nowrap`, `md-tabular`) always win over the table's own alignment. Rows are 52px — a target a finger can hit. `dense` tightens them to 36px and `size="xs"` is for a table inside a panel inside a panel (32px rows); M3 says density is always an opt-in, so neither is a default and both are yours to justify. Scrolling is yours: wrap it in an element that scrolls sideways (`overflow-x: auto` in your own CSS). A row that opens something is `data-md-list-row` with one `data-md-list-open` control; a selected row is `aria-selected="true"`.
`<x-sort-header column="size" :sort-by="$sortBy">Size</x-sort-header>` sorts through the Livewire property `sortBy` (`['column' => …, 'direction' => 'asc'|'desc']`; `model` names another), with `aria-sort`.
```blade
<div class="overflow-x-auto">
<div style="overflow-x: auto">
<x-table>
<thead><tr><x-sort-header column="name" :sort-by="$sortBy">Name</x-sort-header><th class="text-end">Size</th></tr></thead>
<thead><tr><x-sort-header column="name" :sort-by="$sortBy">Name</x-sort-header><th class="md-text-end">Size</th></tr></thead>
<tbody>
@foreach ($shares as $share)
<tr data-md-list-row wire:key="share-{{ $share->id }}">
<td><a href="{{ route('shares.show', $share) }}" data-md-list-open wire:navigate>{{ $share->name }}</a></td>
<td class="text-end tabular-nums">{{ $share->size }}</td>
<td class="md-text-end md-tabular">{{ $share->size }}</td>
</tr>
@endforeach
</tbody>
@@ -1157,12 +1211,12 @@ In the `.css` files it is given (`material-scheme.css` skipped) it fails on a li
## Conventions
- Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}::name>` when `config('livewire-material.prefix')` is set; `<x-livewire-material::name>` always works.
- Write class names out whole. Tailwind cannot compile `'text-'.$tone` or `type-{{ $size }}`, and the design guard cannot read them.
- Write class names out whole: the design guard reads a line at a time and cannot see `'md-ink-'.$tone` or `md-type-{{ $size }}`, so a class built at runtime goes unchecked. Where the choice is data, put it in a `data-*` attribute your own CSS keys on.
- The showcase at `/material` (local only, `MATERIAL_SHOWCASE=true` to force it) renders every token and component.
## Livewire traps
- Blade directives do not compile inside a component tag's attributes: `<x-foo x-show="ok(@js($value))">` reaches the browser as literal text. On a component tag use `{{ }}` and `:prop` bindings — `@js($value)` is `{{ \Illuminate\Support\Js::from($value) }}`, `@class([...])` is `:class="\Illuminate\Support\Arr::toCssClasses([...])"` — or put the Alpine on a plain element inside the slot. `DesignGuard` names the form for each directive.
- Never pass `hidden`, a display utility or a position (`absolute`, `relative`) to a component: it is merged beside the component's own and whichever Tailwind emits last wins. Wrap the component in an element that carries it. A variant that only hides (`max-medium:hidden`) is safe.
- Blade directives do not compile inside a component tag's attributes: `x-show="ok(@js($value))"` on a component tag reaches the browser as literal text. On a component tag use `{{ }}` and `:prop` bindings — `@js($value)` is `{{ \Illuminate\Support\Js::from($value) }}`, `@class([...])` is `:class="\Illuminate\Support\Arr::toCssClasses([...])"` — or put the Alpine on a plain element inside the slot. `DesignGuard` names the form for each directive.
- To hide a component, give it the `hidden` attribute (it wins over any `display`, foundation/hidden.css), `x-show`, or a layout component's `hide-below`/`hide-from` around it. A `display` or `position` of your own on a component's `class` wins too — your unlayered CSS outranks every package rule — but it replaces the one the component draws itself with, so put it on a wrapper instead.
- `$attributes->wire('model')->value()` is `false`, not `null`, when there is no `wire:model`, and `filled(false)` is true. Normalise with `?: null`.
- End every statement in a multi-line Alpine attribute with `;`: an inline `@if … @endif` inside it swallows the newline after it.
+122 -118
View File
@@ -1,6 +1,6 @@
---
name: material-3-design
description: Material 3 Expressive's design system as Livewire Material implements it — colour roles and surface containers, elevation, shape, type, motion, states and targets, window size classes, spacing, icons, accessibility — each M3 name beside the utility that draws it and Google's source page, for deciding how a screen should look and behave before writing it.
description: Material 3 Expressive's design system as Livewire Material implements it — colour roles and surface containers, elevation, shape, type, motion, states and targets, window size classes, spacing, icons, accessibility — each M3 name beside the class, prop or token that draws it and Google's source page, for deciding how a screen should look and behave before writing it.
---
# Material 3 design
@@ -9,57 +9,59 @@ description: Material 3 Expressive's design system as Livewire Material implemen
Use this skill when deciding how a screen, panel or control should look or behave — which colour, container, corner, type style, motion, breakpoint or spacing — in an application that requires `nonameweb/livewire-material`, and when reviewing a view against Material 3. The props and slots of each component are in the `livewire-material-development` skill; this one is the design language they implement. The rules an agent must always follow are in the `material-3` guideline; the tables here are what those rules compress.
Every table pairs the M3 name with the library's utility or attribute. The tokens are CSS variables (`--md-sys-color-*`, `--md-sys-typescale-*`, `--md-sys-shape-*`, `--md-sys-elevation-*`, `--md-sys-motion-*`, `--md-sys-state-*`, `--md-sys-measurement-*`, `--md-ref-typeface-*`) and the utilities read them, so a template names a utility and never a variable or a value.
Every table pairs the M3 name with what the library gives for it. The library is plain CSS with no utility classes, so that is one of three things: a component or layout component prop (`color="error"`, `<x-surface level="surface-container">`, `gap="space200"`), one of the fixed text and interaction classes (`md-type-*`, `md-ink-*`, `md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link`), or a token the application's own CSS reads with `var()`. The tokens are CSS custom properties (`--md-sys-color-*`, `--md-sys-typescale-*`, `--md-sys-shape-*`, `--md-sys-elevation-*`, `--md-sys-motion-*`, `--md-sys-state-*`, `--md-sys-measurement-*`, `--md-ref-typeface-*`), so a stylesheet names a token and never a value.
## Colour
A colour scheme is generated from one seed by Google's colour science (`php artisan material:scheme`); every role below is a slot in that scheme, light and dark, at three contrast levels. A template names a role and nothing else — never a hex, a palette tone, an arbitrary value or an opacity — because only a role follows the theme, the contrast level and a colour profile.
A colour scheme is generated from one seed by Google's colour science (`php artisan material:scheme`); every role below is a slot in that scheme, light and dark, at three contrast levels. A view names a role and nothing else — never a hex, a palette tone or an opacity — because only a role follows the theme, the contrast level and a colour profile.
### Roles
| Role | Purpose | Its `on-` pair | Utility |
| Role | Purpose | Its `on-` pair | In this library |
| --- | --- | --- | --- |
| primary | High-emphasis fills, text and icons: the key action on a screen | on-primary | `bg-primary text-on-primary`, `text-primary` |
| primary-container | A standout fill for key components (FAB, an emphasised panel) | on-primary-container | `bg-primary-container text-on-primary-container` |
| primary-dim | A darker primary for a pressed or contrasting fill (2025 spec) | on-primary | `bg-primary-dim` |
| secondary | Less prominent fills, text and icons | on-secondary | `bg-secondary text-on-secondary` |
| secondary-container | The recessive fill: tonal buttons, selected navigation, selected chips | on-secondary-container | `bg-secondary-container text-on-secondary-container` |
| tertiary | A complementary accent, used sparingly for contrast | on-tertiary | `bg-tertiary text-on-tertiary` |
| tertiary-container | The complementary fill | on-tertiary-container | `bg-tertiary-container text-on-tertiary-container` |
| error | Urgency and errors; static, does not follow dynamic colour | on-error | `text-error`, `bg-error text-on-error` |
| error-container | An error panel | on-error-container | `bg-error-container text-on-error-container` |
| success, warning, info | This library's custom state colours, built like error on the 2025 spec, with `-container` and `on-` pairs | on-success … | `text-success`, `bg-warning-container text-on-warning-container` |
| surface | The page background | on-surface | `bg-surface text-on-surface` |
| on-surface-variant | Lower-emphasis text and icons on any surface | — | `text-on-surface-variant`, `text-body`, `text-meta` |
| outline | A boundary that must be read: a text field, a target's edge (3:1 against surface) | — | `border-outline`, `text-outline`, `text-quiet` |
| outline-variant | Decorative lines: dividers, card edges | — | `border-outline-variant`, `border-divider`, `border-structure`, `border-chrome`, `divide-divider` |
| inverse-surface | A surface that contrasts with its surroundings (the snackbar) | inverse-on-surface | `bg-inverse-surface text-inverse-on-surface` |
| inverse-primary | An action on an inverse surface (the snackbar's action) | — | `text-inverse-primary` |
| scrim | Behind a modal, at 32% | — | `bg-scrim/32` |
| shadow | The shadow colour, inside `shadow-elevation-*` | — | — |
| surface-dim, surface-bright | Add-on surfaces that keep their relative brightness in both themes | on-surface | `bg-surface-dim`, `bg-surface-bright` |
| primary-fixed, primary-fixed-dim, on-primary-fixed, on-primary-fixed-variant (and secondary, tertiary) | Add-on roles with the same tone in light and dark; for a colour that must not change with the theme; never where contrast matters | — | `bg-primary-fixed text-on-primary-fixed` |
| primary | High-emphasis fills, text and icons: the key action on a screen | on-primary | `var(--md-sys-color-primary)`, `md-ink-primary`, `<x-button variant="filled">` |
| primary-container | A standout fill for key components (FAB, an emphasised panel) | on-primary-container | `var(--md-sys-color-primary-container)` |
| primary-dim | A darker primary for a pressed or contrasting fill (2025 spec) | on-primary | `var(--md-sys-color-primary-dim)` |
| secondary | Less prominent fills, text and icons | on-secondary | `var(--md-sys-color-secondary)` |
| secondary-container | The recessive fill: tonal buttons, selected navigation, selected chips | on-secondary-container | `var(--md-sys-color-secondary-container)`, `<x-button variant="tonal">` |
| tertiary | A complementary accent, used sparingly for contrast | on-tertiary | `var(--md-sys-color-tertiary)`, `color="tertiary"` |
| tertiary-container | The complementary fill | on-tertiary-container | `var(--md-sys-color-tertiary-container)` |
| error | Urgency and errors; static, does not follow dynamic colour | on-error | `md-ink-error`, `var(--md-sys-color-error)`, `color="error"` |
| error-container | An error panel | on-error-container | `var(--md-sys-color-error-container)`, `<x-alert color="error">` |
| success, warning, info | This library's custom state colours, built like error on the 2025 spec, with `-container` and `on-` pairs | on-success … | `md-ink-success`, `var(--md-sys-color-warning-container)`, `color="info"` |
| surface | The page background | on-surface | the page itself (the foundation paints it), `<x-surface level="surface">` |
| on-surface-variant | Lower-emphasis text and icons on any surface | — | `md-ink-variant` |
| outline | A boundary that must be read: a text field, a target's edge (3:1 against surface) | — | `md-ink-quiet`, `var(--md-sys-color-outline)` |
| outline-variant | Decorative lines: dividers, card edges | — | `<x-divider>`, `<x-surface outlined>`, `var(--md-sys-color-outline-variant)` |
| inverse-surface | A surface that contrasts with its surroundings (the snackbar) | inverse-on-surface | `var(--md-sys-color-inverse-surface)` with `md-ink-inverse` |
| inverse-primary | An action on an inverse surface (the snackbar's action) | — | `var(--md-sys-color-inverse-primary)` |
| scrim | Behind a modal, at 32% | — | `color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent)` |
| shadow | The shadow colour, inside every `--md-sys-elevation-*` | — | — |
| surface-dim, surface-bright | Add-on surfaces that keep their relative brightness in both themes | on-surface | `<x-surface level="surface-dim">`, `<x-surface level="surface-bright">` |
| primary-fixed, primary-fixed-dim, on-primary-fixed, on-primary-fixed-variant (and secondary, tertiary) | Add-on roles with the same tone in light and dark; for a colour that must not change with the theme; never where contrast matters | — | `var(--md-sys-color-primary-fixed)` with `var(--md-sys-color-on-primary-fixed)` |
Pairing: a role's `on-` pair is the only combination whose contrast is guaranteed at every contrast level. `bg-primary text-on-primary` and `bg-secondary-container text-on-secondary-container` are right; `bg-primary-container text-on-surface` or `bg-secondary-container text-primary` are not, and break as the contrast level rises. Google: "Pair and layer color roles only as intended … Don't mix roles improperly."
Pairing: a role's `on-` pair is the only combination whose contrast is guaranteed at every contrast level. A `primary` fill under `on-primary` text and a `secondary-container` fill under `on-secondary-container` are right; `primary-container` under `on-surface`, or `secondary-container` under `primary`, are not, and break as the contrast level rises. A component sets its own pair; the application's CSS writes both halves (`background-color: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary)`). Google: "Pair and layer color roles only as intended … Don't mix roles improperly."
### Surface containers
A hierarchy of emphasis, not of height: the tone separates panels before any shadow does, and a region keeps its role at every breakpoint (body always `surface`, navigation always `surface-container`).
| Role | Use | Utility |
| Role | Use | In this library |
| --- | --- | --- |
| surface | The page | `bg-surface` |
| surface-container-lowest | The most recessed panel; an elevated card's body in dark themes | `bg-surface-container-lowest` |
| surface-container-low | An elevated card, a modal bottom or side sheet, the full-screen search view | `bg-surface-container-low` |
| surface-container | Navigation bar and rail, docked and floating toolbars, menus, the segmented list | `bg-surface-container` |
| surface-container-high | Dialogs, the search bar, date and time pickers, a rich tooltip | `bg-surface-container-high` |
| surface-container-highest | A filled card, a filled text field, a filled chip's selected state | `bg-surface-container-highest` |
| surface | The page | the page itself, `<x-surface level="surface">` |
| surface-container-lowest | The most recessed panel; an elevated card's body in dark themes | `<x-surface level="surface-container-lowest">` |
| surface-container-low | An elevated card, a modal bottom or side sheet, the full-screen search view | `<x-surface level="surface-container-low">` |
| surface-container | Navigation bar and rail, docked and floating toolbars, menus, the segmented list | `<x-surface>` (the default level) |
| surface-container-high | Dialogs, the search bar, date and time pickers, a rich tooltip | `<x-surface level="surface-container-high">` |
| surface-container-highest | A filled card, a filled text field, a filled chip's selected state | `<x-surface level="surface-container-highest">` |
In the application's CSS each is `var(--md-sys-color-surface-container-low)` and so on; the ink on every one of them is `on-surface`.
### Emphasis and lines
- Default ink is `on-surface`; lower emphasis is `on-surface-variant`; decoration is `outline`. Emphasis is never an opacity: M3 reserves 38% (`/38`) for disabled content and 12% (`/12`) for a disabled container.
- Default ink is `on-surface` (`md-ink`); lower emphasis is `on-surface-variant` (`md-ink-variant`); decoration is `outline` (`md-ink-quiet`). Emphasis is never an opacity: M3 reserves 38% (`--md-sys-state-disabled-content-opacity`) for disabled content and 12% (`--md-sys-state-disabled-container-opacity`) for a disabled container.
- `outline` for a boundary that has to be perceived (a text field's edge, a target's edge — 3:1 against the surface); `outline-variant` for dividers and the edge of a card or any component holding several elements. Google: "Don't use the outline color for dividers … use outline variant instead." `outline-variant` may edge a chip or a button only because the content inside already carries the contrast.
- A hyperlink in running text is `primary` (or `tertiary` for a quieter link) **and** underlined: the `link` utility.
- A hyperlink in running text is `primary` (or `tertiary` for a quieter link) **and** underlined: `md-link` with `md-ink-primary`.
### Contrast
@@ -69,7 +71,7 @@ A hierarchy of emphasis, not of height: the tone separates panels before any sha
| Medium | 3:1 minimum everywhere, without halation | `<html data-contrast="medium">` |
| High | 7:1 | `<html data-contrast="high">`, or the visitor's OS setting (`theme.contrast.default` = `system`) |
Every role changes with the level automatically; a component built from roles needs nothing else. Disabled states are exempt from contrast. A colour outside the roles (a hex, `white`, `black`) does not change and is the one thing that breaks a contrast level.
Every role changes with the level automatically; a component built from roles needs nothing else. Disabled states are exempt from contrast. A colour outside the roles (a hex, white, black) does not change and is the one thing that breaks a contrast level.
Sources: https://m3.material.io/styles/color/roles · https://m3.material.io/styles/color/system/how-the-system-works · https://m3.material.io/styles/color/advanced/apply-colors · https://m3.material.io/foundations/designing/color-contrast
@@ -77,18 +79,18 @@ Sources: https://m3.material.io/styles/color/roles · https://m3.material.io/sty
M3 separates surfaces by tone first; a shadow says that something floats over the content or is being interacted with. "When it comes to applying shadows, less is more."
| Level | Shadow | Rests here | Utility |
| Level | Shadow | Rests here | In this library |
| --- | --- | --- | --- |
| 0 | none | The page, cards (filled, outlined), buttons (filled, tonal, outlined), button groups, icon buttons, lists, chips, tabs, sliders, the rail, a docked side sheet, a carousel, a full-screen dialog, a FAB inside the rail, an app bar at rest | — |
| 1 | 1dp | Elevated cards, elevated buttons and chips, modal bottom and side sheets, a banner | `shadow-elevation-1` |
| 2 | 3dp | Menus, the navigation bar, a scrolled app bar, toolbars, rich tooltips | `shadow-elevation-2` |
| 3 | 6dp | FAB and extended FAB, the FAB menu's close button, dialogs, date and time pickers, the search bar | `shadow-elevation-3` |
| 4 | 8dp | Interaction only: a level-3 element on hover or while dragged | `shadow-elevation-4` |
| 5 | 12dp | Interaction only | `shadow-elevation-5` |
| 1 | 1dp | Elevated cards, elevated buttons and chips, modal bottom and side sheets, a banner | `box-shadow: var(--md-sys-elevation-1)` |
| 2 | 3dp | Menus, the navigation bar, a scrolled app bar, toolbars, rich tooltips | `var(--md-sys-elevation-2)` |
| 3 | 6dp | FAB and extended FAB, the FAB menu's close button, dialogs, date and time pickers, the search bar | `var(--md-sys-elevation-3)` |
| 4 | 8dp | Interaction only: a level-3 element on hover or while dragged | `var(--md-sys-elevation-4)` |
| 5 | 12dp | Interaction only | `var(--md-sys-elevation-5)` |
- Hover lifts an element one level (a FAB 3 → 4, an elevated card 1 → 2); focus and selection may too; a raised element lowers when something higher appears.
- Overlapping panels take different surface-container roles to show separation; the roles are not tied to the levels.
- A scrim (`bg-scrim/32`) brings focus to a modal over a large surface; it is never a substitute for a shadow on a small floating element.
- A scrim (`scrim` at 32%) brings focus to a modal over a large surface; it is never a substitute for a shadow on a small floating element.
- On a dark surface a shadow is nearly invisible, so the tone does the work there.
Sources: https://m3.material.io/styles/elevation/overview · https://m3.material.io/styles/elevation/applying-elevation · https://m3.material.io/styles/elevation/tokens
@@ -97,20 +99,20 @@ Sources: https://m3.material.io/styles/elevation/overview · https://m3.material
### The corner scale
| Style | Value | Utility |
| Style | Value | In this library |
| --- | --- | --- |
| None | 0 | `rounded-corner-none` |
| Extra small | 4px | `rounded-corner-xs` |
| Small | 8px | `rounded-corner-sm` |
| Medium | 12px | `rounded-corner-md` |
| Large | 16px | `rounded-corner-lg` |
| Large increased | 20px | `rounded-corner-lg-increased` |
| Extra large | 28px | `rounded-corner-xl` |
| Extra large increased | 32px | `rounded-corner-xl-increased` |
| Extra extra large | 48px | `rounded-corner-xxl` |
| Full | a stadium or circle | `rounded-corner-full` |
| None | 0 | `var(--md-sys-shape-corner-none)`, `corner="none"` |
| Extra small | 4px | `var(--md-sys-shape-corner-xs)`, `corner="xs"` |
| Small | 8px | `var(--md-sys-shape-corner-sm)`, `corner="sm"` |
| Medium | 12px | `var(--md-sys-shape-corner-md)`, `corner="md"` |
| Large | 16px | `var(--md-sys-shape-corner-lg)`, `corner="lg"` |
| Large increased | 20px | `var(--md-sys-shape-corner-lg-increased)`, `corner="lg-increased"` |
| Extra large | 28px | `var(--md-sys-shape-corner-xl)`, `corner="xl"` |
| Extra large increased | 32px | `var(--md-sys-shape-corner-xl-increased)`, `corner="xl-increased"` |
| Extra extra large | 48px | `var(--md-sys-shape-corner-xxl)`, `corner="xxl"` |
| Full | a stadium or circle | `var(--md-sys-shape-corner-full)`, `corner="full"` |
Tailwind's `rounded-sm`, `rounded-lg`, `rounded-full`, … do not compile. The `-t`, `-s`, `-e` sides work (`rounded-t-corner-xl` for a bottom sheet).
`corner` is `<x-surface>`'s prop. In the application's CSS a corner is `border-radius` on a token, and one side at a time a logical longhand (`border-start-start-radius` and `border-start-end-radius` for a bottom sheet's top); a length of your own is off the scale.
### Corner by component
@@ -139,26 +141,26 @@ Tailwind's `rounded-sm`, `rounded-lg`, `rounded-full`, … do not compile. The `
- Optical roundness: a shape nested in a rounded container takes inner radius = outer radius padding (48 14 = 34), never the container's own radius.
- Large and full corners do not belong on information-dense containers (cards, tables, text fields).
- A press squares a round shape and rounds a square one (the components carry the morph on the fast spatial spring); nothing morphs on hover.
- The 35 Expressive shapes (`<x-shape name="">`, also the loading indicator and the standard button group's press shape) are decoration for emphasis and delight — never a carrier of meaning, never behind text-heavy content, and used sparingly.
- The 35 Expressive shapes (`<x-shape name="cookie-9">`, also the loading indicator and the standard button group's press shape) are decoration for emphasis and delight — never a carrier of meaning, never behind text-heavy content, and used sparingly.
Sources: https://m3.material.io/styles/shape/corner-radius-scale · https://m3.material.io/styles/shape/shape-morph · https://m3.material.io/styles/shape/overview-principles
## Type
The typeface is Google Sans Flex for brand and plain styles (`font-sans`, `--md-ref-typeface-brand`); an application may replace it after importing the stylesheet. Each style is one utility that sets size, line height, weight, family and tracking together; `text-sm`, `font-medium`, `leading-*` and `tracking-*` do not compile.
The typeface is Google Sans Flex for brand and plain styles (`--md-ref-typeface-brand`, `--md-ref-typeface-plain`); an application may replace it after importing the stylesheet. Each style is one class that sets size, line height, weight, family and tracking together — or, in the application's CSS, `font: var(--md-sys-typescale-body-md)` with `letter-spacing: var(--md-sys-typescale-body-md-tracking)`. A size, weight, line height or letter spacing of your own is off the scale.
| Role | Style | Size / line | Weight | Utility | Use for |
| Role | Style | Size / line | Weight | In this library | Use for |
| --- | --- | --- | --- | --- | --- |
| Display | large / medium / small | 57/64 · 45/52 · 36/44 | 400 | `type-display-lg` … | hero figures, one short marketing line; never running text |
| Headline | large / medium / small | 32/40 · 28/36 · 24/32 | 400 | `type-headline-lg` … | page titles, section titles, a dialog's headline (`headline-sm`) |
| Title | large / medium / small | 22/28 · 16/24 · 14/20 | 400 / 500 / 500 | `type-title-lg` … | app bar title (`lg`), card and list-section titles (`md`), dense headers (`sm`) |
| Body | large / medium / small | 16/24 · 14/20 · 12/16 | 400 | `type-body-lg` … | paragraphs (`lg` for reading, `md` in components), supporting text (`sm`) |
| Label | large / medium / small | 14/20 · 12/16 · 11/16 | 500 | `type-label-lg` … | buttons and tabs (`lg`), chips and navigation (`md`), captions and badges (`sm`) |
| Display | large / medium / small | 57/64 · 45/52 · 36/44 | 400 | `md-type-display-lg` … | hero figures, one short marketing line; never running text |
| Headline | large / medium / small | 32/40 · 28/36 · 24/32 | 400 | `md-type-headline-lg` … | page titles, section titles, a dialog's headline (`headline-sm`) |
| Title | large / medium / small | 22/28 · 16/24 · 14/20 | 400 / 500 / 500 | `md-type-title-lg` … | app bar title (`lg`), card and list-section titles (`md`), dense headers (`sm`) |
| Body | large / medium / small | 16/24 · 14/20 · 12/16 | 400 | `md-type-body-lg` … | paragraphs (`lg` for reading, `md` in components), supporting text (`sm`) |
| Label | large / medium / small | 14/20 · 12/16 · 11/16 | 500 | `md-type-label-lg` … | buttons and tabs (`lg`), chips and navigation (`md`), captions and badges (`sm`) |
- `type-emphasized-*` is the same size and line height one weight step heavier (400 → 500, 500 → 700), fully rounded in Google Sans Flex, with its own tracking. M3 uses it deliberately, never by default: a selected list or menu item, a button's label on a primary action, an extended FAB, a badge, a headline given editorial weight.
- `md-type-emphasized-*` (`--md-sys-typescale-emphasized-*`) is the same size and line height one weight step heavier (400 → 500, 500 → 700), fully rounded in Google Sans Flex, with its own tracking. M3 uses it deliberately, never by default: a selected list or menu item, a button's label on a primary action, an extended FAB, a badge, a headline given editorial weight.
- Tracking follows Compose's `TypeScaleTokens`: display-large 0.2, title-medium 0.2, title-small 0.1, body-large 0.5, body-medium 0.2, body-small 0.4, label-large 0.1, label-medium and small 0.5 (sp; rem = sp/16); the emphasized set tightens a few (display-large 0, title-medium 0.15, body-large 0.15, body-medium 0.25).
- Line length 4060 characters (`max-w-prose` is close; `max-w-[60ch]` is exact). Figures that change take `tabular-nums`.
- Text must scale to 200%: containers grow, side-by-side controls stack, padding stays; components without text (progress, checkboxes) do not scale. Truncate to an ellipsis only when the full text is one tooltip or link away.
- Line length 4060 characters (`max-inline-size: 60ch` in the application's CSS). Figures that change take `md-tabular`.
- Text must scale to 200%: containers grow, side-by-side controls stack, padding stays; components without text (progress, checkboxes) do not scale. Truncate to an ellipsis (`md-truncate`) only when the full text is one tooltip or link away.
- When customising, change the typeface or tracking, never the sizes: component layout depends on them.
Sources: https://m3.material.io/styles/typography/type-scale-tokens · https://m3.material.io/styles/typography/applying-type · https://m3.material.io/styles/typography/fonts · https://m3.material.io/foundations/writing/text-resizing · https://m3.material.io/foundations/writing/text-truncation
@@ -167,82 +169,84 @@ Sources: https://m3.material.io/styles/typography/type-scale-tokens · https://m
M3 Expressive moves on physics: every transition is a spring, and the library samples each spring into a CSS `linear()` easing paired with a duration. Use the pair together, or the curve is stretched over the wrong time.
| Spring | Damping / stiffness | Duration | Utility | For |
| Spring | Damping / stiffness | Duration | In this library | For |
| --- | --- | --- | --- | --- |
| Spatial fast | 0.6 / 800 | 350ms | `ease-spatial-fast duration-(--md-sys-motion-spatial-fast-duration)` | small elements: a button's press morph, a switch, a chip |
| Spatial default | 0.8 / 380 | 500ms | `ease-spatial-default duration-(--md-sys-motion-spatial-default-duration)` | most position, size and shape changes |
| Spatial slow | 0.8 / 200 | 650ms | `ease-spatial-slow duration-(--md-sys-motion-spatial-slow-duration)` | large surfaces: a sheet, a pane, a full-screen transition |
| Effects fast | 1.0 / 3800 | 150ms | `ease-effects-fast duration-(--md-sys-motion-effects-fast-duration)` | state layers, small fades |
| Effects default | 1.0 / 1600 | 200ms | `ease-effects-default duration-(--md-sys-motion-effects-default-duration)` | most colour and opacity changes |
| Effects slow | 1.0 / 800 | 300ms | `ease-effects-slow duration-(--md-sys-motion-effects-slow-duration)` | large fades, a scrim |
| Spatial fast | 0.6 / 800 | 350ms | `var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast)` | small elements: a button's press morph, a switch, a chip |
| Spatial default | 0.8 / 380 | 500ms | `var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default)` | most position, size and shape changes |
| Spatial slow | 0.8 / 200 | 650ms | `var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow)` | large surfaces: a sheet, a pane, a full-screen transition |
| Effects fast | 1.0 / 3800 | 150ms | `var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast)` | state layers, small fades |
| Effects default | 1.0 / 1600 | 200ms | `var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default)` | most colour and opacity changes |
| Effects slow | 1.0 / 800 | 300ms | `var(--md-sys-motion-effects-slow-duration) var(--md-sys-motion-effects-slow)` | large fades, a scrim |
- Spatial springs are underdamped and overshoot — that bounce is what reads as Expressive — so they carry only position, size and shape. Effects springs are critically damped and carry colour and opacity, which must never overshoot. A `transition-all` mixes the two and is wrong.
A transition names the property, then the pair: `transition: transform var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default), opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast)`.
- Spatial springs are underdamped and overshoot — that bounce is what reads as Expressive — so they carry only position, size and shape. Effects springs are critically damped and carry colour and opacity, which must never overshoot. A transition on `all` mixes the two and is wrong.
- The Standard motion scheme (`<html data-motion="standard">`, config `motion.scheme`) swaps the spatial springs for stiffer ones with almost no bounce (0.9 / 1400, 700, 300; 350, 500, 750ms) for utilitarian products; effects are shared.
- Direction: something entering decelerates (`ease-emphasized-decelerate`, or a spatial spring from off-screen), a permanent exit accelerates (`ease-emphasized-accelerate`), a temporary exit that can be recalled (a drawer, a sheet) takes `ease-emphasized`; exits are shorter than entrances, and larger areas move longer.
- The cubic-bezier set (`ease-standard`, `ease-emphasized`, `ease-emphasized-decelerate`, `ease-emphasized-accelerate`, `--md-sys-motion-duration-short|medium|long`) is for the few transitions whose duration is fixed from outside: a view transition, an animated scroll.
- Reduced motion zeroes every duration token, so anything animated through them turns instant; a literal `duration-300` or a keyframe with its own timing ignores the visitor's setting and is a bug. Container transforms, parallax and expansions are removed, not slowed.
- Direction: something entering decelerates (`--md-sys-motion-easing-emphasized-decelerate`, or a spatial spring from off-screen), a permanent exit accelerates (`--md-sys-motion-easing-emphasized-accelerate`), a temporary exit that can be recalled (a drawer, a sheet) takes `--md-sys-motion-easing-emphasized`; exits are shorter than entrances, and larger areas move longer.
- The cubic-bezier set (`--md-sys-motion-easing-standard`, `-emphasized`, `-emphasized-decelerate`, `-emphasized-accelerate`, with `--md-sys-motion-duration-short|medium|long`) is for the few transitions whose duration is fixed from outside: a view transition, an animated scroll.
- Reduced motion zeroes every duration token, so anything animated through them turns instant; a literal `300ms`, or a keyframe animation with its own timing, ignores the visitor's setting and is a bug. Container transforms, parallax and expansions are removed, not slowed.
Sources: https://m3.material.io/styles/motion/overview · https://m3.material.io/styles/motion/overview/specs · https://m3.material.io/styles/motion/easing-and-duration/tokens-specs · https://m3.material.io/styles/motion/transitions/transition-patterns
## States and targets
| State | Layer | Utility or hook | Also |
| State | Layer | Class or hook | Also |
| --- | --- | --- | --- |
| Enabled | none | — | |
| Hover | 8% of the content colour | `state-layer` (pointer devices only) | one level of elevation on floating elements |
| Focused | 10% | `state-layer focus-ring` (keyboard focus: a 3px `secondary` ring, 2px out) | only one focused element at a time |
| Pressed | 10% | `state-layer` (`:active`) | the shape morph on buttons |
| Dragged | 16% | `state-layer` with `data-dragged` | one level of elevation |
| Disabled | content 38%, container 12%, no state layer, not focusable | `disabled:text-on-surface/38 disabled:bg-on-surface/12` | exempt from contrast; a FAB is hidden rather than disabled |
| Hover | 8% of the content colour | `md-state-layer` (pointer devices only) | one level of elevation on floating elements |
| Focused | 10% | `md-state-layer md-focus-ring` (keyboard focus: a 3px `secondary` ring, 2px out) | only one focused element at a time |
| Pressed | 10% | `md-state-layer` (`:active`) | the shape morph on buttons |
| Dragged | 16% | `md-state-layer` with `data-md-dragged` | one level of elevation |
| Disabled | content 38%, container 12%, no state layer, not focusable | `color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent)`, and the container likewise with `--md-sys-state-disabled-container-opacity` | exempt from contrast; a FAB is hidden rather than disabled |
| Selected | the `secondary-container` pair, a filled icon, the emphasized style | component props (`selected`, `aria-selected`, `aria-pressed`) | combines with hover, focus and press |
- The state layer takes the content's `on-` colour (on `secondary-container` it is `on-secondary-container`), is 40px on a 48px target, and only one shows at a time.
- The state layer takes the content's `on-` colour (on `secondary-container` it is `on-secondary-container`), is 40px on a 48px target, and only one shows at a time. `md-state-layer` draws it in `currentColor` as a `::before`, so the element becomes `position: relative`.
- Every state shows two indicators, so a colour change alone is never a state: add a shape, an outline, an icon, a weight or a word (`aria-selected` plus the container, an error colour plus an icon and a message).
- Targets: 48×48px minimum, 8px between targets, on every device; `touch-target` extends a smaller drawing to 48px. Density is an opt-in prop (`dense`) that steps padding by 4px and never applies to menus, snackbars, dialogs or settings controls, and never takes a target below 48px.
- Targets: 48×48px minimum, 8px between targets, on every device; `md-touch-target` extends a smaller drawing to 48px. Density is an opt-in prop (`dense`) that steps padding by 4px and never applies to menus, snackbars, dialogs or settings controls, and never takes a target below 48px.
- Keyboard: Tab and Shift+Tab between components in DOM order, arrows within a component (menu, tabs, grid, radio group), Enter and Space activate, Escape dismisses; a dialog moves focus in on open and back to its opener on close.
Sources: https://m3.material.io/foundations/interaction/states/state-layers · https://m3.material.io/foundations/interaction/states/applying-states · https://m3.material.io/foundations/designing/structure · https://m3.material.io/foundations/layout/grids-spacing/density
## Layout and breakpoints
Layout keys on the width of the window, in M3's five window size classes; they are the only responsive variants that compile, and `resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`) gives scripts the same numbers.
Layout keys on the width of the window, in M3's five window size classes and only those. A layout component names the class in a prop (`hide-below`, `hide-from`, `stack-below`, `<x-grid>`'s `columns` map); the application's CSS writes the width as a range media query; a script asks `resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`) for the same numbers.
| Class | Width | Variant | Navigation | Panes | Dialogs and choices | Margins |
| Class | Width | Prop value · CSS | Navigation | Panes | Dialogs and choices | Margins |
| --- | --- | --- | --- | --- | --- | --- |
| Compact | below 600px | the default; `max-medium:` for "only here" | navigation bar; the rail opens as a modal | 1 | full-screen or basic dialog; a bottom sheet for choices | 16px |
| Medium | 600839px | `medium:` | collapsed rail (96px) | 1, or 2 for low-density content at 50% each | basic dialog; a menu for choices | 24px |
| Expanded | 8401199px | `expanded:` | rail, collapsed or expanded, collapsible | 2 recommended; a fixed pane 360px | basic dialog; menu | 24px |
| Large | 12001599px | `large:` | rail expanded | 2; a fixed pane 412px | basic dialog; menu | 24px |
| Extra-large | 1600px and up | `extra-large:` | rail expanded | 2, or 3 with a standard side sheet (at most 400px) | basic dialog; menu | 24px |
| Compact | below 600px | the default; `hide-from="medium"` for "only here" · `@media (width < 600px)` | navigation bar; the rail opens as a modal | 1 | full-screen or basic dialog; a bottom sheet for choices | 16px |
| Medium | 600839px | `medium` · `@media (width >= 600px)` | collapsed rail (96px) | 1, or 2 for low-density content at 50% each | basic dialog; a menu for choices | 24px |
| Expanded | 8401199px | `expanded` · `@media (width >= 840px)` | rail, collapsed or expanded, collapsible | 2 recommended; a fixed pane 360px | basic dialog; menu | 24px |
| Large | 12001599px | `large` · `@media (width >= 1200px)` | rail expanded | 2; a fixed pane 412px | basic dialog; menu | 24px |
| Extra-large | 1600px and up | `extra-large` · `@media (width >= 1600px)` | rail expanded | 2, or 3 with a standard side sheet (at most 400px) | basic dialog; menu | 24px |
- `<x-scaffold>` implements the navigation column; `<x-list-detail>` is the second pane of a list-detail layout from `expanded:`, `<x-supporting-pane>` puts a supporting pane (360px, beside the focus pane) from `expanded:` and below it before that. Moving up a class, ask what to reveal, divide into panes, resize, reposition or swap — never swap a component for one that does not do the same job.
- `<x-scaffold>` implements the navigation column; `<x-pane>` is a content region with the margins above; `<x-list-detail>` is the second pane of a list-detail layout from expanded, `<x-supporting-pane>` puts a supporting pane (360px, beside the focus pane) from expanded and below it before that. Moving up a class, ask what to reveal, divide into panes, resize, reposition or swap — never swap a component for one that does not do the same job.
- Scaffold: bars (app bar at the top, navigation bar at the bottom: 35 destinations), rails (the navigation rail, toolbars, the FAB, on the leading edge), panes (all content), around a safety region that stays clear of the device's own chrome (`--material-safe-top|bottom|left|right`).
- Canonical layouts: feed (a grid of cards that gains columns per class), list-detail (one pane on compact, two from expanded; a back button only in single-pane mode, a selected row only in two-pane mode), supporting pane (two thirds focus, one third support).
- Bidirectionality: write logical properties (`ps-4`, `me-2`, `start-0`, `text-start`, `border-s`); leading and trailing icons swap, directional icons (back, send) mirror, the rail moves to the right; charts, media controls, clocks and Hebrew progress bars stay left-to-right.
- Canonical layouts: feed (`<x-feed>`, a grid of cards that gains columns as the room grows), list-detail (one pane on compact, two from expanded; a back button only in single-pane mode, a selected row only in two-pane mode), supporting pane (two thirds focus, one third support).
- Bidirectionality: write logical properties (`padding-inline-start`, `margin-inline-end`, `inset-inline-start`, `border-inline-start`, `md-text-start`); `<x-row>` runs in the inline direction and mirrors by itself; leading and trailing icons swap, directional icons (back, send) mirror, the rail moves to the right; charts, media controls, clocks and Hebrew progress bars stay left-to-right.
Sources: https://m3.material.io/foundations/layout/breakpoints/overview · https://m3.material.io/foundations/layout/breakpoints/compact (medium, expanded, large-extra-large) · https://m3.material.io/foundations/layout/scaffold/overview · https://m3.material.io/foundations/layout/canonical-examples/overview · https://m3.material.io/foundations/layout/bidirectionality-rtl
## Spacing
M3's spacing tokens are multiples of an 8px base on a 4px grid; Tailwind's spacing scale is that grid, so `p-4` is `space200`.
M3's spacing tokens are multiples of an 8px base on a 4px grid. A layout component takes the token's name (`gap="space200"`, `<x-surface padding="space300">`); the application's CSS reads it (`var(--md-sys-measurement-space200)`).
| Token | Value | Utility |
| Token | Value | In this library |
| --- | --- | --- |
| space25 | 2px | `p-0.5`, `gap-0.5` |
| space50 | 4px | `p-1` |
| space75 | 6px | `p-1.5` |
| space100 | 8px (the base) | `p-2`, `gap-2` |
| space125 | 10px | `p-2.5` |
| space200 | 16px | `p-4` — a component's padding, compact margins |
| space300 | 24px | `p-6` — a dialog's padding, margins from `medium:` |
| space400 | 32px | `p-8` |
| space500 | 40px | `p-10` |
| space600 | 48px | `p-12` — a target |
| space700 | 56px | `p-14` |
| space800 | 64px | `p-16` |
| space900 | 72px | `p-18` |
| space25 | 2px | `space25` |
| space50 | 4px | `space50` |
| space75 | 6px | `space75` |
| space100 | 8px (the base) | `space100` |
| space125 | 10px | `space125` |
| space200 | 16px | `space200` — a component's padding, compact margins |
| space300 | 24px | `space300` — a dialog's padding, margins from medium |
| space400 | 32px | `space400` |
| space500 | 40px | `space500` |
| space600 | 48px | `space600` — a target |
| space700 | 56px | `space700` |
| space800 | 64px | `space800` |
| space900 | 72px | `space900` |
- Padding and gaps live on the parent (`p-4 gap-2` on the container), never as margins on children; a margin is for space beyond a container's padding or between layout regions.
- Padding and gaps live on the parent (`<x-surface padding="space200">` around `<x-stack gap="space100">`), never as margins on children; a margin is for space beyond a container's padding or between layout regions.
- Spacing does not scale with text: at 200% text size the same padding and gaps stay.
- Name a gap by what it separates when a component has several (iconlabel 8px, labelsupporting text 4px).
@@ -250,16 +254,16 @@ Sources: https://m3.material.io/styles/spacing/overview · https://m3.material.i
## Icons
`<x-icon name="">` draws a Material Symbol Rounded (weight 400, grade 0), outlined or `filled`, at optical size 24 or `optical="20"`.
`<x-icon name="lock">` draws a Material Symbol Rounded (weight 400, grade 0), outlined or `filled`, at optical size 24 or 20.
| Axis | Values | In this library |
| --- | --- | --- |
| Fill | 0 outlined, 1 filled | `filled` — active, selected or on state (a selected navigation item, a FAB's icon, a checked filter chip) |
| Weight | 100700; never below 200 at 24px | 400 for every icon; one weight per group |
| Grade | 25 on dark backgrounds, 0 otherwise, positive for emphasis | 0 |
| Optical size | 20 dense, 24 standard, 4048 with display type | `optical="20"` when drawn at 20px or less (small buttons, chips, dense lists), 24 otherwise |
| Optical size | 20 dense, 24 standard, 4048 with display type | `size="20"` and below pick the 20 cut (small buttons, chips, dense lists); `optical="20"` for an icon sized by the application's own CSS |
- An icon beside text takes the text's size and colour (`size-5` beside `type-label-lg`, `size-6` beside body) and the same optical weight; its baseline sits about 11.5% of the text size below the text's.
- An icon beside text takes the text's size and colour (`size="20"` beside `md-type-label-lg`, 24 beside body) and the same optical weight; its baseline sits about 11.5% of the text size below the text's.
- Icons stay flat and forward-facing, on the pixel grid, inside their 20px live area of the 24px canvas.
- An icon-only control has an accessible name (`aria-label`, or a tooltip that names it); a decorative icon is `aria-hidden`; a complex icon drawn below 20px needs a label beside it.
@@ -269,8 +273,8 @@ Sources: https://m3.material.io/styles/icons/overview · https://m3.material.io/
- Native elements before ARIA: `<button>`, `<a href>`, `<dialog>`, `<input>`, `<select>`; a styled `div` that fakes one needs everything re-implemented and tested.
- Landmarks: one `main`, one `banner`, one `contentinfo` per page; `nav`, `search`, `complementary`, `form`, `region` labelled when they repeat, never with their own role in the label ("Primary", not "Primary navigation").
- Headings: one H1 for the page, then H2H6 in order without skipping; the level is the document's structure, `type-*` is the appearance, and they need not match.
- Names: an interactive icon, image or ambiguous button ("Save", "Learn more") has a name that says what it does, without the word "button"; decorative images are `alt=""` or `aria-hidden`.
- Headings: one H1 for the page, then H2H6 in order without skipping; the level is the document's structure, the `md-type-*` class is the appearance, and they need not match.
- Names: an interactive icon, image or ambiguous button ("Save", "Learn more") has a name that says what it does, without the word "button"; decorative images are `alt=""` or `aria-hidden`; text only a screen reader needs is `md-visually-hidden`.
- Focus: DOM order is reading order; a dialog moves focus to its first meaningful control and returns it to the opener; grouped controls are one Tab stop with arrows inside; a keyboard shortcut is two keys, or a single key only while its component is focused.
- Announcements: an error is tied to its field (`aria-invalid`, `aria-describedby`) and announced; a snackbar is a polite live region that never steals focus and stays while it carries an action; a loading state has a name.
- Contrast and states: 4.5:1 text, 3:1 large text and icons and grouped controls, disabled exempt; every state has two indicators; targets 48px with 8px between; text scales to 200%; motion honours reduced motion.
@@ -287,9 +291,9 @@ What Google's pages say not to do, and this library follows:
| Disable a FAB | "if the action is unavailable, the FAB shouldn't appear" | hide it |
| Lay radio buttons in a row | a row reads as one control | a vertical group; chips or a connected button group for a horizontal choice |
| Morph a card's corners on hover | shape morphs mark a press or a selection | the state layer and one level of elevation |
| Use `outline` on a divider | dividers carry no contrast requirement and read too heavy | `border-divider` (`outline-variant`) |
| Use a hex, `white`, `black` or an opacity for ink | it ignores theme, contrast level and profile | a role |
| Use Tailwind's breakpoints or scales | they are not M3's values | `medium:` … and `type-*`, `rounded-corner-*`, `shadow-elevation-*`, `ease-*` |
| Use `outline` on a divider | dividers carry no contrast requirement and read too heavy | `<x-divider>` (`outline-variant`) |
| Use a hex, white, black or an opacity for ink | it ignores theme, contrast level and profile | a role |
| Write a utility class, or a breakpoint, radius, shadow, type size or easing of your own | nothing defines utility classes, and other values are not M3's | the layout components' props, `md-type-*` and `md-ink-*`, and `--md-sys-*` tokens at 600/840/1200/1600px in your own CSS |
| Use segmented buttons, a navigation drawer or a bottom app bar | deprecated in M3 Expressive | `<x-button-group connected>`, the expanded rail, `<x-toolbar>` |
| Truncate without a way to read the rest | an ellipsis alone is not accessible | wrap, grow the container, or a tooltip |
| Animate with a literal duration | it ignores reduced motion | the paired tokens |
+11 -9
View File
@@ -31,21 +31,24 @@
*
* The scrim fades out as it fades in, and the state rules match the root's own scrim and panel,
* so a sheet nested in an open one (a menu's sheet at compact inside a sheet) keeps its own state.
* Both keep `x-show`, and the view's `x-transition` holds their `display` through the exit rather
* than `allow-discrete`, which Firefox does not honour for `display` (see drawer.css); Alpine holds
* for the first `transition-duration` listed, so the closing slide is listed first.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
/* The scrim fades out while the view's `x-transition` holds its `display` (see drawer.css). */
[data-md-bottom-sheet-scrim] {
position: fixed;
inset: 0;
z-index: 40;
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
opacity: 0;
transition-property: opacity, display;
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-effects-default);
transition-behavior: allow-discrete;
}
[data-md-bottom-sheet][data-md-open] > [data-md-bottom-sheet-scrim] {
@@ -85,10 +88,9 @@
box-shadow: var(--md-sys-elevation-1);
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
translate: 0 100%;
transition-property: translate, display;
transition-property: translate;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
transition-behavior: allow-discrete;
}
[data-md-bottom-sheet][data-md-open] > [data-md-bottom-sheet-panel] {
@@ -107,14 +109,14 @@
spring, independent of the translate that opens and closes the sheet. */
[data-md-bottom-sheet-panel][data-md-preset] {
height: var(--sheet-max-height);
transition-property: translate, height, display;
transition-duration: var(--md-sys-motion-effects-default-duration), var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate), var(--md-sys-motion-spatial-default), var(--md-sys-motion-easing-emphasized-accelerate);
transition-property: translate, height;
transition-duration: var(--md-sys-motion-effects-default-duration), var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate), var(--md-sys-motion-spatial-default);
}
[data-md-bottom-sheet][data-md-open] > [data-md-bottom-sheet-panel][data-md-preset] {
transition-duration: var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate), var(--md-sys-motion-spatial-default), var(--md-sys-motion-easing-emphasized-decelerate);
transition-duration: var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate), var(--md-sys-motion-spatial-default);
}
[data-md-bottom-sheet-handle] {
+8 -23
View File
@@ -4,12 +4,13 @@
* state through a Livewire morph, and is announced as a disclosure by the browser. Judged against
* foundations rather than any M3 component spec (docs/audits/m3-alignment/containment.md, C-24).
*
* `interpolate-size: allow-keywords` makes `<details>`'s `0 -> auto` block-size an animatable pair,
* so `::details-content` (the pseudo-element that holds everything after the summary) can transition
* `block-size` on the fast spatial spring, the same one that turns the chevron; `content-visibility`
* goes with it, `allow-discrete`, so the content stays rendered while it closes rather than
* vanishing on the first frame. Under reduced motion the duration token is zero
* (tokens/motion.css), so the section snaps open with nothing else to do.
* The height eases open and shut on the fast spatial spring, the same one that turns the chevron,
* from resources/js/collapse.js: it animates the `<details>`' own `block-size`. This file cannot
* `interpolate-size` (for `0 -> auto`) is Chrome's alone, and Firefox does not hold the content's
* `content-visibility` through a close so Firefox and Safari used to snap open and shut. While a
* close runs, `open` is still set and `data-md-collapse-closing` turns the chevron back at its
* start. Under reduced motion the duration token is zero (tokens/motion.css), and the section
* snaps, as the browser draws it.
*
* `data-md-variant="filled"` is a surface-container tile with a large corner; the summary inherits
* it (`border-radius: inherit`) so the shared `md-state-layer`'s own `::before`, which also
@@ -27,27 +28,11 @@
@import './icon.css';
@layer material.components {
[data-md-collapse] {
interpolate-size: allow-keywords;
}
[data-md-collapse][data-md-variant='filled'] {
border-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-sys-color-surface-container);
}
[data-md-collapse]::details-content {
block-size: 0;
overflow: hidden;
transition:
block-size var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast),
content-visibility var(--md-sys-motion-spatial-fast-duration) allow-discrete;
}
[data-md-collapse][open]::details-content {
block-size: auto;
}
[data-md-collapse-summary] {
display: flex;
min-block-size: var(--md-sys-measurement-space600);
@@ -82,7 +67,7 @@
transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
}
[data-md-collapse][open] > [data-md-collapse-summary] > [data-md-collapse-chevron] {
[data-md-collapse][open]:not([data-md-collapse-closing]) > [data-md-collapse-summary] > [data-md-collapse-chevron] {
rotate: 180deg;
}
+64 -12
View File
@@ -18,9 +18,12 @@
* 400px cap, 24dp start/end padding, a large corner on the inner edge only from the specs
* table. It enters on the emphasized-decelerate easing rather than a spring, because a sheet
* anchored to an edge that overshot would open a gap, and leaves on emphasized accelerate: the
* view keeps `x-show`, so `@starting-style` gives the entry its start and `allow-discrete` holds
* `display` through the exit, whose timing comes from the closed state's own rule (a transition
* takes the after-change style's). Reduced motion zeroes both duration tokens (tokens/motion.css).
* view keeps `x-show`, so `@starting-style` gives the entry its start and the view's `x-transition`
* holds `display` through the exit, whose timing comes from the closed state's own rule (a
* transition takes the after-change style's). Not `allow-discrete` on `display`: Firefox does not
* transition `display`, and in the other engines it would hold a second time after Alpine's hold.
* Alpine holds for the first `transition-duration` listed, so each exit's longest transition is
* listed first. Reduced motion zeroes both duration tokens (tokens/motion.css).
* Below `expanded` it slides, from whichever edge `data-md-side` names in the document's direction;
* once `standard` makes it co-planar it only fades (an in-flow box does not need to travel). Every
* state rule matches the sheet and scrim as the root's children, so a sheet nested in an open one
@@ -30,9 +33,15 @@
* for `surface` with an outline-variant rule down its inner edge, in place of the scrim
* (its anatomy's "Divider (optional)"). Below `expanded` (840px) it is the modal sheet M3 caps
* a side sheet at 400dp, and a 600px window has too little room left beside one, so the switch
* sits at `expanded` rather than `medium`. `data-md-drawer-collapsed` (the view, from the
* `wide` window 840px Alpine state) takes the standard sheet out of the layout entirely
* while it is closed there, so the content beside it fills the space.
* sits at `expanded` rather than `medium`. Opening it shrinks the body beside it and closing it
* gives the room back (M3's side sheets, "Adaptive"): the root's `inline-size` springs from none to
* the sheet's width — the sheet itself keeps its width, sits at the root's far edge and is clipped,
* so it is uncovered from its inner edge together with a negative margin as wide as the flex
* parent's gap (`--md-drawer-gap`, which the view measures), so the content beside does not jump by
* the gap when the root leaves the layout. `data-md-drawer-collapsed` (the view, while the window is
* 840px and the sheet is closed) then takes the root out of the layout entirely, but only once the
* exit has run: the view's `closing` holds it, where it once cut the fade and the resize off on
* their first frame.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@@ -41,17 +50,19 @@
@import './divider.css';
@layer material.components {
/* The scrim fades in and, while `display` is held by `allow-discrete`, back out. */
/* The scrim fades in from `@starting-style` and back out while the view's `x-transition` holds
its `display` for this duration. Not `allow-discrete` on `display`: Firefox does not transition
`display`, so the scrim vanished there, and in the other engines it would hold a second time
after Alpine's hold. */
[data-md-drawer-scrim] {
position: fixed;
inset: 0;
z-index: 40;
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
opacity: 0;
transition-property: opacity, display;
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-effects-default);
transition-behavior: allow-discrete;
}
[data-md-drawer][data-md-open] > [data-md-drawer-scrim] {
@@ -82,10 +93,9 @@
color: var(--md-sys-color-on-surface);
box-shadow: var(--md-sys-elevation-1);
translate: var(--sheet-offset) 0;
transition-property: translate, display;
transition-property: translate;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
transition-behavior: allow-discrete;
}
[data-md-drawer-sheet][data-md-side='end'] {
@@ -142,21 +152,63 @@
[data-md-drawer][data-md-standard] {
position: sticky;
top: 0;
display: flex;
justify-content: flex-end;
height: 100dvh;
flex-shrink: 0;
align-self: flex-start;
overflow: clip;
inline-size: 0;
margin-inline-start: calc(-1 * var(--md-drawer-gap, 0px));
transition-property: inline-size, margin-inline;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
}
/* A start sheet sits at the start edge, with the gap after it. */
[data-md-drawer][data-md-standard]:where(:has(> [data-md-drawer-sheet][data-md-side='start'])) {
justify-content: flex-start;
margin-inline-start: 0;
margin-inline-end: calc(-1 * var(--md-drawer-gap, 0px));
}
[data-md-drawer][data-md-standard][data-md-open] {
inline-size: min(var(--sheet-width), calc(100vw - 64px));
margin-inline: 0;
transition-duration: var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate);
}
@starting-style {
[data-md-drawer][data-md-standard][data-md-open] {
inline-size: 0;
margin-inline-start: calc(-1 * var(--md-drawer-gap, 0px));
}
[data-md-drawer][data-md-standard][data-md-open]:where(:has(> [data-md-drawer-sheet][data-md-side='start'])) {
margin-inline-start: 0;
margin-inline-end: calc(-1 * var(--md-drawer-gap, 0px));
}
}
[data-md-drawer][data-md-standard][data-md-drawer-collapsed] {
display: none;
}
/* Until the view settles (`data-md-drawer-settled`, two frames after Alpine starts), the
state the page loads with is drawn at once: a sheet that starts open does not grow in. */
[data-md-drawer][data-md-standard]:not([data-md-drawer-settled]),
[data-md-drawer][data-md-standard]:not([data-md-drawer-settled]) > [data-md-drawer-sheet] {
transition: none;
}
[data-md-drawer][data-md-standard] > [data-md-drawer-scrim] {
display: none;
}
[data-md-drawer][data-md-standard] > [data-md-drawer-sheet] {
position: relative;
flex-shrink: 0;
top: 0;
z-index: auto;
height: 100%;
@@ -165,7 +217,7 @@
background-color: var(--md-sys-color-surface);
box-shadow: none;
opacity: 0;
transition-property: opacity, display;
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
}
+6 -11
View File
@@ -8,10 +8,9 @@
* (foundation/interaction.css); its 56px already meets M3's target, so it needs no
* `md-touch-target`.
*
* The entry is `@starting-style` on the item itself; the exit needs the state to be readable
* while the popover is on its way out, which `[data-md-fab-menu-popover]:not(:popover-open) > *`
* (fab-menu.css) drives from the popover's own open state the popover keeps `display: flex` for
* the length of its own discrete transition, so the item has somewhere to animate to (ACT-33).
* The entry is `@starting-style` on the item itself; the exit is the popover's exit copy
* (resources/js/popover-exit.js), whose items fab-menu.css sets to the closed values, so the item
* has somewhere to animate to (ACT-33).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@@ -42,13 +41,9 @@
user-select: none;
translate: 0 0;
opacity: 1;
transition-property: translate, opacity, display, overlay;
transition-duration:
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-effects-default-duration),
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function:
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-effects-default), linear, linear;
transition-behavior: allow-discrete;
transition-property: translate, opacity;
transition-duration: var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast), var(--md-sys-motion-effects-default);
@starting-style {
translate: 0 var(--md-sys-measurement-space100);
+9 -11
View File
@@ -12,13 +12,13 @@
* button, which stays fixed (ACT-32).
*
* The items (fab-menu-item.css) rise into place as the list opens and sink back as it closes: the
* entry is `@starting-style` on the item itself; the exit needs the state to be readable while the
* popover is on its way out, which `[data-md-fab-menu-popover]:not(:popover-open) > *` is the
* popover keeps `display: flex` for the length of its own discrete transition, so the items have
* somewhere to animate to (ACT-33). The Tailwind-era rule sat outside the layers to beat the
* item's utilities; inside the layer it still beats fab-menu-item.css's resting `translate: 0 0`
* and `opacity: 1`, on specificity an attribute and a pseudo-class against the item's one
* attribute whichever order the two files are bundled in.
* entry is `@starting-style` on the item itself; the exit is the popover's exit copy
* (`data-md-popover-exit`, resources/js/popover-exit.js), which stays on screen after the popover
* has closed, so the items have somewhere to animate to (ACT-33) in every engine, Firefox included.
* `[data-md-fab-menu-popover]:not(:popover-open) > *` and the copy's `[data-md-popover-closing] > *`
* are the closed values; inside the layer they beat fab-menu-item.css's resting `translate: 0 0`
* and `opacity: 1` on specificity two attributes, or an attribute and a pseudo-class, against the
* item's one attribute whichever order the two files are bundled in.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@@ -112,9 +112,6 @@
border-width: 0;
background-color: transparent;
padding: var(--md-sys-measurement-space100);
transition-property: display, overlay;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-behavior: allow-discrete;
&:popover-open {
display: flex;
@@ -149,7 +146,8 @@
position-area: bottom span-right;
}
[data-md-fab-menu-popover]:not(:popover-open) > * {
[data-md-fab-menu-popover]:not(:popover-open) > *,
[data-md-fab-menu-popover][data-md-popover-closing] > * {
translate: 0 var(--md-sys-measurement-space100);
opacity: 0;
}
+3 -3
View File
@@ -62,9 +62,9 @@
* under half the `large` breakpoint's 1200px, a little over half the `expanded` one's, and holds
* about the 70 characters body-large reads best at a measure of text, so it is in rem and grows
* with the text. It is a ceiling, not a width a narrower pane still gets a narrower field. A
* width rule from the call site beats it, because an application's CSS and a utility both
* outrank this layer, and `full` takes it off for a field that really is the width of its pane (a
* search-and-filter row, an editor). Below `medium` nothing is bounded. */
* width rule from the call site beats it, because an application's CSS outranks this layer, and
* `full` takes it off for a field that really is the width of its pane (a search-and-filter row,
* an editor). Below `medium` nothing is bounded. */
@media (width >= 600px) {
[data-md-field]:not([data-md-full]) {
max-width: 40rem;
+5 -3
View File
@@ -4,7 +4,9 @@
*
* 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
* 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;
* `contained` puts it on a `primary-container` circle in `on-primary-container`
* (ContainedContainerColor, ContainedIndicatorColor), for a spinner over content.
@@ -21,8 +23,8 @@
flex-shrink: 0;
align-items: center;
justify-content: center;
inline-size: 48px;
block-size: 48px;
inline-size: var(--md-loading-size, 48px);
block-size: var(--md-loading-size, 48px);
color: var(--md-sys-color-primary);
& > svg {
+4 -8
View File
@@ -165,15 +165,11 @@
position-try-fallbacks: flip-inline;
opacity: 0;
scale: 0.95;
transition-property: opacity, scale, display, overlay;
transition-duration:
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function:
var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast), linear, linear;
transition-behavior: allow-discrete;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast);
&:popover-open {
&:popover-open:not([data-md-popover-closing]) {
opacity: 1;
scale: 1;
}
+5 -9
View File
@@ -26,7 +26,7 @@
* `--material-menu-surface`/`--material-menu-ink`, which the sheet's own copy of the list falls
* back to instead (it is not a descendant of the popover).
*
* `sheet-at-compact`'s bottom sheet is `<x-bottom-sheet>` (still Tailwind): `[data-md-menu-sheet]`
* `sheet-at-compact`'s bottom sheet is `<x-bottom-sheet>` (bottom-sheet.css): `[data-md-menu-sheet]`
* cancels its 24px padding and restyles a submenu to open in place under its item instead of
* beside it (M3 calls submenus "best suited to large screens").
*
@@ -70,15 +70,11 @@
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
opacity: 0;
scale: 0.95;
transition-property: opacity, scale, display, overlay;
transition-duration:
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function:
var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast), linear, linear;
transition-behavior: allow-discrete;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast);
&:popover-open {
&:popover-open:not([data-md-popover-closing]) {
opacity: 1;
scale: 1;
}
+5 -3
View File
@@ -30,9 +30,11 @@
* `> [data-md-modal-box] >`, as the divider marks do: a basic dialog opened from inside a
* full-screen dialog's body is a descendant of it, and must keep its own title and padding.
*
* `[data-md-modal-body]:focus-visible` is Chrome's own focusable scroll container catching the
* dialog's first focus when nothing inside can take it — M3's 3px secondary indicator, drawn
* inside the edge because the box's rounded, overflow-hidden corner would clip one drawn outside.
* `[data-md-modal-body]:focus-visible` is the scrolling body catching the dialog's first focus when
* nothing inside can take it natively in Chrome, whose scroll containers are focusable, and
* through `materialShowModal()` (resources/js/dialog.js) in Firefox and WebKit M3's 3px secondary
* indicator, drawn inside the edge because the box's rounded, overflow-hidden corner would clip one
* drawn outside.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
+61 -27
View File
@@ -6,7 +6,8 @@
* docs/reference/m3/components-navigation-selection-inputs.md § Navigation Rail).
*
* [data-md-navigation-rail="collapsed|expanded|collapsible|modal|adaptive"] data-md-open
* [data-md-navigation-rail-scrim] modal and adaptive rails, open only
* data-md-closing="sheet|scrim", while it exits
* [data-md-navigation-rail-scrim] modal and adaptive rails, open or closing only
* [data-md-navigation-rail-panel] the <nav>
* [data-md-navigation-rail-header] never scrolls
* [data-md-navigation-rail-menu-row] the menu button and the brand
@@ -18,9 +19,8 @@
* [data-md-navigation-rail-item] navigation-rail-item.css
* [data-md-navigation-rail-footer] never scrolls
*
* **Collapsed**, reproduced from the Tailwind-era custom variant named `rail-collapsed`: a rail (and
* anything in it) is drawn collapsed while any of these hold, each its own rule below wherever a
* property differs by shape a fixed `collapsed` mode; a `collapsible` rail the visitor collapsed
* **Collapsed**: a rail (and anything in it) is drawn collapsed while any of these hold, each its
* own rule below wherever a 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
* 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");
@@ -34,6 +34,22 @@
* `resources/js/navigation.js` reads the same numbers, so the menu button and the drawing agree at
* every width.
*
* **Closing**: nothing here transitions `display`, which Firefox cannot do even with
* `allow-discrete`, so a panel sliding off the window and a fading scrim were cut to nothing there.
* `resources/js/navigation.js` sets `data-md-closing` on a rail that was open over the page until
* its exit transitions have run `sheet` when the panel leaves the window (a compact adaptive
* rail, or one that hides when collapsed), `scrim` when the panel stands in the layout again and
* the rules near the end of the file keep what is leaving displayed meanwhile, in every engine.
* The collapsed branches do not read it: a closing rail is already collapsed, which is what
* `--md-navigation-rail-value` says the moment it starts to close.
*
* 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
* 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.
@@ -66,6 +82,7 @@
[data-md-navigation-rail] {
--navigation-rail-expanded-width: clamp(220px, var(--navigation-rail-width, 256px), 360px);
--navigation-rail-collapsed-width: 96px;
--md-navigation-rail-value: expanded;
position: relative;
flex-shrink: 0;
@@ -81,17 +98,20 @@
[data-md-navigation-rail='modal']:not([data-md-open]) *
) {
width: var(--navigation-rail-collapsed-width);
--md-navigation-rail-value: collapsed;
}
@media (width < 600px) {
&: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);
--md-navigation-rail-value: collapsed;
}
}
@media (width < 840px) {
&: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);
--md-navigation-rail-value: collapsed;
}
}
@@ -101,6 +121,7 @@
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
width: var(--navigation-rail-collapsed-width);
--md-navigation-rail-value: collapsed;
}
}
@@ -110,6 +131,7 @@
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
) {
width: var(--navigation-rail-collapsed-width);
--md-navigation-rail-value: collapsed;
}
}
}
@@ -294,16 +316,17 @@
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" (N-22). Neither applies to a rail open over a scrim, a surface over the page. */
[data-md-navigation-rail][data-md-divider]:not([data-md-open]) > [data-md-navigation-rail-panel] {
[data-md-navigation-rail][data-md-divider]:not([data-md-open], [data-md-closing='sheet']) > [data-md-navigation-rail-panel] {
border-inline-end: 1px solid var(--md-sys-color-outline-variant);
}
[data-md-navigation-rail][data-md-fill='false']:not([data-md-open]) > [data-md-navigation-rail-panel] {
[data-md-navigation-rail][data-md-fill='false']:not([data-md-open], [data-md-closing='sheet']) > [data-md-navigation-rail-panel] {
background-color: transparent;
}
/* Open: expanded over a scrim, in surface-container with a large corner at its inner edge. */
[data-md-navigation-rail][data-md-open] > [data-md-navigation-rail-panel] {
/* Open: expanded over a scrim, in surface-container with a large corner at its inner edge and
still, while a panel that is leaving the window slides off it (`data-md-closing="sheet"`). */
[data-md-navigation-rail]:is([data-md-open], [data-md-closing='sheet']) > [data-md-navigation-rail-panel] {
position: fixed;
inset-block: 0;
inset-inline-start: 0;
@@ -338,8 +361,7 @@
box-shadow: var(--md-sys-elevation-2);
translate: -100% 0;
transition:
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
display var(--md-sys-motion-effects-default-duration) allow-discrete;
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate);
&:is([dir='rtl'], [dir='rtl'] *) {
translate: 100% 0;
@@ -350,8 +372,7 @@
display: flex;
translate: 0 0;
transition:
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate),
display var(--md-sys-motion-spatial-default-duration) allow-discrete;
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate);
@starting-style {
translate: -100% 0;
@@ -383,8 +404,7 @@
display: none;
translate: -100% 0;
transition:
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
display var(--md-sys-motion-effects-default-duration) allow-discrete;
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate);
&:is([dir='rtl'], [dir='rtl'] *) {
translate: 100% 0;
@@ -396,8 +416,7 @@
display: none;
translate: -100% 0;
transition:
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
display var(--md-sys-motion-effects-default-duration) allow-discrete;
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate);
&:is([dir='rtl'], [dir='rtl'] *) {
translate: 100% 0;
@@ -410,8 +429,7 @@
display: none;
translate: -100% 0;
transition:
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
display var(--md-sys-motion-effects-default-duration) allow-discrete;
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate);
&:is([dir='rtl'], [dir='rtl'] *) {
translate: 100% 0;
@@ -427,8 +445,7 @@
display: none;
translate: -100% 0;
transition:
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
display var(--md-sys-motion-effects-default-duration) allow-discrete;
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate);
&:is([dir='rtl'], [dir='rtl'] *) {
translate: 100% 0;
@@ -444,8 +461,7 @@
display: none;
translate: -100% 0;
transition:
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
display var(--md-sys-motion-effects-default-duration) allow-discrete;
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate);
&:is([dir='rtl'], [dir='rtl'] *) {
translate: 100% 0;
@@ -458,8 +474,7 @@
display: flex;
translate: 0 0;
transition:
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate),
display var(--md-sys-motion-spatial-default-duration) allow-discrete;
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate);
@starting-style {
translate: -100% 0;
@@ -474,6 +489,23 @@
}
}
/* Closing: what leaves stays drawn while it animates out. The exits above transition `translate`
and the scrim's `opacity` only not `display`, which Firefox cannot transition even with
`allow-discrete`, so the slide and the fade were cut to nothing there and
resources/js/navigation.js sets `data-md-closing` on the rail until those transitions have
run: `sheet` when the panel leaves the window, `scrim` when only the scrim was over the page.
One attribute more than every collapsed branch, so a hold outranks the `display: none` those
branches draw. */
[data-md-navigation-rail][data-md-hide-when-collapsed][data-md-closing='sheet'] > [data-md-navigation-rail-panel] {
display: flex;
}
@media (width < 600px) {
[data-md-navigation-rail='adaptive'][data-md-closing='sheet'] > [data-md-navigation-rail-panel] {
display: flex;
}
}
/* The two bands the configuration above does not reach, because there it is the window and not
the visitor that collapses the rail: below `medium` for a collapsible rail already held at
its collapsed width above and `medium` itself for the adaptive one, which M3 gives a
@@ -508,9 +540,11 @@
display: none;
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
opacity: 0;
transition:
opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default),
display var(--md-sys-motion-effects-default-duration) allow-discrete;
transition: opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
}
[data-md-navigation-rail][data-md-closing] > [data-md-navigation-rail-scrim] {
display: block;
}
[data-md-navigation-rail][data-md-open] > [data-md-navigation-rail-scrim] {
+2 -3
View File
@@ -11,9 +11,8 @@
*
* Linear mirrors in a right-to-left document unconditionally M3's own rule for this indicator,
* not the `mirror-rtl` a caller opts an icon into. The sizing here is a default only: an
* application's own class, unlayered or in Tailwind's `utilities` layer, always outranks
* `material.components`, so `w-*`/`size-*` still narrows or grows the indicator exactly as it did
* before this file existed.
* application's own unlayered class always outranks `material.components`, so an `inline-size` or
* `block-size` of its own narrows or grows the indicator.
*
* resources/js/progress.js reads and writes `data-md-value`, `data-md-max`, `data-md-circular`,
* `data-md-wavy` and `data-md-thick` ProgressIndicator.kt, WavyProgressIndicator.kt and its
+6 -6
View File
@@ -10,9 +10,10 @@
*
* Placed by CSS anchor positioning on `data-md-side`, corner-to-corner so the 312px bubble has
* room to spread past a narrow trigger; `position-try-fallbacks` flips it when the window has no
* room, same shape as tooltip.css's plain tooltip. It fades in and out on the fast effects spring,
* `allow-discrete` keeping `display` and `overlay` alive for the fade out and `@starting-style`
* giving the fade in a start.
* room, same shape as tooltip.css's plain tooltip. It fades in and out on the fast effects spring:
* `@starting-style` gives the fade in a start, and the fade out is its exit copy's
* (`data-md-popover-exit`, resources/js/popover-exit.js), which `data-md-popover-closing` turns
* back to the closed opacity.
*
* resources/js/rich-tooltip.js shows and hides the bubble and keeps the trigger described with
* `aria-describedby` and, while `persistent`, `aria-haspopup`/`aria-expanded` (ACT-22) none of
@@ -41,12 +42,11 @@
text-align: start;
white-space: normal;
opacity: 0;
transition-property: opacity, display, overlay;
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
transition-behavior: allow-discrete;
&:popover-open {
&:popover-open:not([data-md-popover-closing]) {
opacity: 1;
@starting-style {
+31 -6
View File
@@ -45,7 +45,10 @@
position: relative;
}
[data-md-search][data-md-open] {
/* Above the page while open, and while the view leaves: full screen through `data-md-full-screen`,
docked through the `md-transition` class Alpine's `x-transition` carries for the length of the
view's exit, so later positioned content on the page never covers the fading view. */
[data-md-search]:is([data-md-open], [data-md-full-screen], :has(> [data-md-search-view].md-transition)) {
z-index: 50;
}
@@ -56,10 +59,9 @@
inset: 0;
z-index: -1;
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
transition-property: opacity, display;
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
transition-behavior: allow-discrete;
}
@starting-style {
@@ -68,6 +70,12 @@
}
}
/* Closing (or turning full screen), the scrim fades back out while the view's `x-transition`
holds its `display`: not `allow-discrete`, which Firefox does not honour for `display`. */
[data-md-search]:is(:not([data-md-open]), [data-md-full-screen]) > [data-md-search-scrim] {
opacity: 0;
}
[data-md-search-bar] {
position: relative;
z-index: 1;
@@ -201,10 +209,9 @@
background-color: var(--md-sys-color-surface-container-high);
box-shadow: var(--md-sys-elevation-3);
transform-origin: top;
transition-property: opacity, scale, display;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
transition-behavior: allow-discrete;
}
@starting-style {
@@ -214,6 +221,13 @@
}
}
/* Closing, the view goes back the way it came, into the bar, while its `x-transition` holds
`display` for the same duration. */
[data-md-search]:not([data-md-open]) > [data-md-search-view] {
opacity: 0;
scale: 1 0.9;
}
/* M3: the docked container is at least 240px tall once there is something in it to be tall
about; a search with nothing to show stays the height of its bar. */
[data-md-search-view]:has([data-md-search-results], [data-md-search-suggestions]) {
@@ -268,7 +282,7 @@
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), transparent);
}
[data-md-search][data-md-trigger='icon']:not([data-md-open]) [data-md-search-bar] {
[data-md-search][data-md-trigger='icon']:not([data-md-open], [data-md-full-screen]) [data-md-search-bar] {
display: none;
}
@@ -283,6 +297,17 @@
border-radius: 0;
}
/* Leaving full screen (`data-md-full-screen` outlives `data-md-open` by the view's exit,
resources/js/search.js): the header bar fades with the view, on the view's own spring, rather
than going back to its resting pill or to nothing, behind the icon on the first frame. */
[data-md-search][data-md-full-screen]:not([data-md-open]) [data-md-search-bar] {
opacity: 0;
background-color: transparent;
transition-property: opacity;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-md-search][data-md-full-screen] [data-md-search-view] {
position: fixed;
inset: 0;
+6 -6
View File
@@ -8,9 +8,10 @@
* text in `inverse-on-surface`, 8px either side and 4px above and below, 200px wide at most, 4px
* from its anchor. It never takes the pointer, so it can stand over the control it names.
*
* It fades in and out on the fast effects spring; `allow-discrete` keeps `display` and `overlay`
* alive until the fade out has been seen, and `@starting-style` gives the fade in a start.
* resources/js/tooltip.js shows and hides it.
* It fades in and out on the fast effects spring: `@starting-style` gives the fade in a start, and
* the fade out is its exit copy's (`data-md-popover-exit`, resources/js/popover-exit.js), which
* `data-md-popover-closing` turns back to the closed opacity. resources/js/tooltip.js shows and
* hides it.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@@ -38,12 +39,11 @@
white-space: normal;
pointer-events: none;
opacity: 0;
transition-property: opacity, display, overlay;
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
transition-behavior: allow-discrete;
&:popover-open {
&:popover-open:not([data-md-popover-closing]) {
opacity: 1;
@starting-style {
+1 -1
View File
@@ -26,7 +26,7 @@
*
* An application still building Tailwind keeps it in a separate entry and opens both entries with
* `@layer properties, theme, base, material, components, utilities;`, so the `material` layers sit
* above Tailwind's preflight and below its utilities (resources/css/tailwind.css's header).
* above Tailwind's preflight and below its utilities.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
+4 -5
View File
@@ -22,11 +22,10 @@
* M3 never lets colour alone mark a link (docs/reference/m3/styles.md § Typography); give it
* `md-ink-primary` where it should also read as primary.
*
* The same declarations as the `state-layer`, `focus-ring`, `touch-target` and `link` utilities
* (tokens/utilities.css) the components still written in Tailwind use, but for two: the dragged
* hook is `data-md-dragged` where the utility reads `data-dragged`, and `md-touch-target` is px
* where the utility reads 3rem. In `material.base`, so a component's own rules, the text classes
* and an application's rules all outrank them.
* They replace 1.x's `state-layer`, `focus-ring`, `touch-target` and `link` utilities, but for two
* changes: the dragged hook is `data-md-dragged` where the utility read `data-dragged`, and
* `md-touch-target` is px where the utility read 3rem. In `material.base`, so a component's own
* rules, the text classes and an application's rules all outrank them.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
+2 -3
View File
@@ -1,7 +1,7 @@
/*
* The text classes: a fixed, documented set for text on plain elements. A component sets its own
* type and ink; these are for the headings, paragraphs and captions an application writes around
* them, and the set the package's own views use for text once they leave Tailwind.
* them, and the set the package's own views use for their own text.
*
* Type one per text element, the whole style at once (size, line height, weight, tracking;
* tokens/type.css), never assembled by hand. Roles from docs/reference/m3/styles.md § Typography:
@@ -49,8 +49,7 @@
* gives the eye
*
* In `material.text`, above every component: a text class on a component's root or on an element
* inside it wins over the component's own type and ink. The `md-type-*` declarations are the
* `type-*` utilities' (tokens/utilities.css), which the components still written in Tailwind use.
* inside it wins over the component's own type and ink.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
+1 -1
View File
@@ -6,7 +6,7 @@
* and a disabled container at 12%.
*
* The classes that draw them are foundation/interaction.css (`md-state-layer`, `md-focus-ring`,
* `md-touch-target`, `md-link`), and utilities.css for the components still written in Tailwind.
* `md-touch-target`, `md-link`).
* The tokens sit in `material.tokens`, so an application's own unlayered declaration wins.
*/
+2 -2
View File
@@ -10,8 +10,8 @@
* <h2 class="md-type-title-lg"></h2>
* <p class="md-type-emphasized-headline-md md-tabular"></p>
*
* The `md-type-*` classes are text.css; the `type-*` utilities of the components still written in
* Tailwind are utilities.css, with the same declarations.
* The `md-type-*` classes are text.css. An application's own CSS sets a style whole the same way,
* `font: var(--md-sys-typescale-title-lg)` with its `-tracking` as `letter-spacing`.
*
* The brand typeface is Google Sans Flex (font.css). Emphasized styles are one weight step
* heavier and fully rounded ("ROND" 100) the axis that typeface exists for, verified by
+4 -4
View File
@@ -1,10 +1,10 @@
/**
* M3's window size classes, for scripts that ask the viewport width.
*
* The same four numbers as the `medium:`, `expanded:`, `large:` and `extra-large:` variants in
* tokens/theme.css; compact is everything below `medium`. `from('expanded')` and `upTo('medium')`
* return MediaQueryLists on the range syntax Tailwind compiles, so a script and a stylesheet
* never disagree at the boundary pixel.
* The same four numbers as the stylesheets' range media queries (`@media (width >= 840px)`) and the
* layout components' `hide-below`, `hide-from` and `stack-below` props; compact is everything below
* `medium`. `from('expanded')` and `upTo('medium')` return MediaQueryLists on that same range
* syntax, so a script and a stylesheet never disagree at the boundary pixel.
*/
export const breakpoints = Object.freeze({
medium: 600,
+212
View File
@@ -0,0 +1,212 @@
/**
* `<x-collapse>`'s height, eased open and shut on the fast spatial spring in every engine.
*
* collapse.css used to animate `<details>`' `::details-content` from `block-size: 0` to `auto`:
* that takes `interpolate-size: allow-keywords` (Chrome only) and a `content-visibility` that holds
* through the close (not Firefox), so Firefox and Safari snapped open and shut. A script can do what
* the stylesheet cannot, the same way everywhere: the `<details>` itself runs a Web Animation of its
* `block-size`, from the height it is drawn at to the height it is going to, with `overflow: clip`
* for as long as it runs. Content below it moves with it; nothing is written into its `style`.
*
* - Open: `open` is set at once the content is there, `toggle` fires, the chevron turns and the
* height grows from where it was to the section's natural height.
* - Close: `open` stays set while the height shrinks to the summary's, so the content is still there
* to be clipped; `data-md-collapse-closing` turns the chevron back at the start, and `open` goes
* (and `toggle` fires) when the height has. `min-block-size` holds the summary whole while the
* spring overshoots.
* - A press mid-way turns it round from the height it has reached.
* - A `name` group closes its open member with the same animation: that member's `name` is lifted
* for the close, so the browser's own exclusivity does not shut it on the first frame, and put
* back once it has closed.
*
* Routes: a press on the summary (a pointer, Enter or Space, all a `click`) is taken over here; the
* Alpine and Livewire bindings call `materialCollapse(details, open)` from the view's `x-effect`.
* Anything else that sets `open` find-in-page revealing a match, an application's own script
* opens or closes it at once, as the browser does, and this follows along. Under reduced motion the
* duration token is zero and every route is the browser's own, instant.
*/
const COLLAPSE = 'details[data-md-collapse]'
const CLOSING = 'data-md-collapse-closing'
/** Where each collapse is headed (`true` open), its running animation, and a `name` lifted for its close. */
const targets = new WeakMap()
const animations = new WeakMap()
const lifted = new WeakMap()
const milliseconds = (value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0
/** The height the section is drawn at, closed: its summary, and its own padding and border. */
const closedHeight = (details) => {
const style = getComputedStyle(details)
const summary = details.querySelector(':scope > summary')
return (
(summary?.getBoundingClientRect().height ?? 0) +
parseFloat(style.paddingBlockStart) +
parseFloat(style.paddingBlockEnd) +
parseFloat(style.borderBlockStartWidth) +
parseFloat(style.borderBlockEndWidth)
)
}
const restoreName = (details) => {
if (lifted.has(details)) {
details.setAttribute('name', lifted.get(details))
lifted.delete(details)
}
}
/** Moves the height from where it is drawn now to `to`, then `done`. Returns false under reduced motion. */
const animate = (details, from, to, done) => {
const style = getComputedStyle(details)
const duration = milliseconds(style.getPropertyValue('--md-sys-motion-spatial-fast-duration'))
if (duration === 0) {
return false
}
const floor = `${closedHeight(details)}px`
const animation = details.animate(
[
{ blockSize: `${from}px`, minBlockSize: floor, overflow: 'clip' },
{ blockSize: `${to}px`, minBlockSize: floor, overflow: 'clip' },
],
{ duration, easing: style.getPropertyValue('--md-sys-motion-spatial-fast').trim() || 'ease', fill: 'forwards' },
)
animations.set(details, animation)
animation.onfinish = () => {
if (animations.get(details) !== animation) {
return
}
animations.delete(details)
done()
animation.cancel()
}
return true
}
/** The height the section is drawn at this moment, then without whatever animation was running. */
const settle = (details) => {
const height = details.getBoundingClientRect().height
animations.get(details)?.cancel()
animations.delete(details)
return height
}
const open = (details) => {
targets.set(details, true)
const from = settle(details)
details.removeAttribute(CLOSING)
// The group's open member closes on the same spring, with its `name` lifted so the browser's
// exclusivity does not shut it the moment this one opens; this one's own name comes back
// after, when no other member of the group is open to be closed by it.
const name = details.getAttribute('name') ?? lifted.get(details)
if (name) {
details
.getRootNode()
.querySelectorAll(`${COLLAPSE}[open]`)
.forEach((other) => {
if (other !== details && (other.getAttribute('name') ?? lifted.get(other)) === name && targets.get(other) !== false) {
close(other)
}
})
}
restoreName(details)
details.open = true
animate(details, from, details.getBoundingClientRect().height, () => {})
}
const close = (details) => {
targets.set(details, false)
const from = settle(details)
const to = closedHeight(details)
const shut = () => {
details.removeAttribute(CLOSING)
details.open = false
restoreName(details)
}
if (details.hasAttribute('name')) {
lifted.set(details, details.getAttribute('name'))
details.removeAttribute('name')
}
details.setAttribute(CLOSING, '')
if (!animate(details, from, to, shut)) {
shut()
}
}
/**
* Opens (`true`) or closes a collapse on its spring; what the view's bindings call. The first call
* for a collapse, when Alpine starts and applies the bound value, sets it at once: nothing should
* move on page load.
*/
window.materialCollapse = (details, shouldOpen) => {
shouldOpen = Boolean(shouldOpen)
if (!targets.has(details)) {
targets.set(details, details.open)
if (details.open !== shouldOpen) {
details.open = shouldOpen
targets.set(details, shouldOpen)
}
return
}
if (targets.get(details) === shouldOpen) {
return
}
shouldOpen ? open(details) : close(details)
}
document.addEventListener('click', (event) => {
const summary = event.target instanceof Element ? event.target.closest('summary') : null
const details = summary?.parentElement
if (event.defaultPrevented || !details?.matches(COLLAPSE) || summary !== details.querySelector(':scope > summary')) {
return
}
if (milliseconds(getComputedStyle(details).getPropertyValue('--md-sys-motion-spatial-fast-duration')) === 0) {
return
}
event.preventDefault()
const headedOpen = targets.has(details) ? targets.get(details) : details.open
headedOpen ? close(details) : open(details)
})
// Whatever else sets `open` (find-in-page, a `name` group this script did not close, an
// application's own script) is followed, unless this script is the one mid-way through a change.
document.addEventListener(
'toggle',
(event) => {
const details = event.target
if (details instanceof HTMLDetailsElement && details.matches(COLLAPSE) && !animations.has(details)) {
targets.set(details, details.open)
details.removeAttribute(CLOSING)
}
},
true,
)
+27 -3
View File
@@ -5,15 +5,39 @@
* those as the dividers under the pinned header and over the pinned actions; a body that fits
* marks neither.
*
* The marks go on the `<dialog>` rather than the body because the dialog is `wire:ignore.self`: a
* Livewire morph hands the body back the server's attributes, which would wipe a mark until the
* next scroll, but it never touches the dialog's own.
* The marks go on the `<dialog>`, which is `wire:ignore.self`, so a Livewire morph never wipes one
* until the next scroll; the body is `wire:ignore.self` too, for `materialShowModal()`'s
* `tabindex`, but the dividers are the dialog's frame and modal.css reads them there.
*
* Measured on scroll, and whenever the body or what is in it changes size: the body when the window
* or the dialog does (opening, too a closed dialog has no size, and the observer reports the
* one it opens to), and the element the component wraps around the slot when a morph, an image or
* a disclosure makes the content taller or shorter while the body stays at its cap.
*/
/**
* `materialShowModal(dialog)`, how `<x-modal>` opens: `showModal()`, and then the first focus Chrome
* gives a text-only dialog and Firefox and WebKit do not. With nothing focusable inside,
* `showModal()` focuses the `<dialog>` itself, the HTML fallback; Chrome's scroll containers are
* keyboard-focusable, so there the scrolling body is the focus delegate instead. A focused dialog
* leaves a long body unscrollable from the keyboard the arrow keys scroll what has the focus, and
* WebKit never lets Tab reach a scroll container so when the dialog took the focus itself and its
* body overflows, the body is made focusable and takes it, as in Chrome, where modal.css draws the
* inset ring. The `tabindex` goes when the dialog closes, so each opening decides afresh.
*/
window.materialShowModal = (dialog) => {
dialog.showModal()
const body = dialog.querySelector(':scope > [data-md-modal-box] > [data-md-modal-body]')
if (document.activeElement !== dialog || !body || body.scrollHeight - body.clientHeight < 1) {
return
}
body.tabIndex = 0
body.focus()
dialog.addEventListener('close', () => body.removeAttribute('tabindex'), { once: true })
}
document.addEventListener('alpine:init', () => {
window.Alpine.directive('dialog-dividers', (el, _, { cleanup }) => {
const dialog = el.closest('dialog')
+2
View File
@@ -9,6 +9,7 @@
*/
import './theme.js'
import './figure.js'
import './popover-exit.js'
import './tooltip.js'
import './menu.js'
import './fab.js'
@@ -18,6 +19,7 @@ import './progress.js'
import './list-rows.js'
import './bottom-sheet.js'
import './dialog.js'
import './collapse.js'
import './carousel.js'
import './chips.js'
import './field.js'
+14 -9
View File
@@ -317,8 +317,11 @@ const menu = () => ({
},
/**
* The sheet shows a frame or two after `open` turns true, once its transition has begun, and
* Alpine holds `$nextTick` until then. Its focus trap starts on a timer of its own and keeps a
* The sheet's panel is `x-show`n, and Alpine shows an element on the animation frame after
* `open` turns true, not in the tick: an item focused in `$nextTick` is still `display: none`,
* which Firefox and WebKit refuse to focus (Chrome only got there because its frame came
* first). So the focus waits for that frame too, and runs after Alpine's own show in it, as
* search.js's `expand()` does. The sheet's focus trap starts on a timer of its own and keeps a
* focus already inside it, so the item (or the field) M3 asks to be focused first wins over
* the drag handle the trap would otherwise pick.
*/
@@ -329,15 +332,17 @@ const menu = () => ({
this.control()?.setAttribute('aria-expanded', 'true')
this.label()
this.$nextTick(() => {
if (this.field()) {
this.lookUp()
this.$nextTick(() =>
requestAnimationFrame(() => {
if (this.field()) {
this.lookUp()
return
}
return
}
this.focusItem(focus)
})
this.focusItem(focus)
}),
)
},
/** Focus in the field, its text selected, and the first row it leaves highlighted. */
+57 -1
View File
@@ -15,7 +15,8 @@
* (`hide()`). It is never remembered.
*
* `materialNavigationRail` is one rail's view of the store for its `mode` and whether it hides
* when collapsed see resources/views/components/navigation-rail.blade.php.
* when collapsed see resources/views/components/navigation-rail.blade.php. It also holds a
* closing rail on screen for its exit (`closing`, below).
*
* `materialNavigationBar` is `<x-navigation-bar hide-on-scroll>` see the same file's sibling.
*/
@@ -104,6 +105,20 @@ document.addEventListener('alpine:init', () => {
queries: [],
listeners: [],
/**
* `data-md-closing` while a rail that was open over the page leaves it: `sheet` when the
* panel slides away (a compact adaptive rail, or one that hides when collapsed), `scrim`
* when only the scrim fades and the panel stands in the layout again. navigation-rail.css
* keeps what is leaving displayed while it is set, because the exit cannot hold `display`
* itself: Firefox does not transition `display`, even with `allow-discrete`, so the slide
* and the fade were cut to nothing there. It is dropped once the exit's own transitions
* have finished none under reduced motion, whose durations are zero or at once when
* the rail opens again.
*/
closing: null,
closings: 0,
wasOpen: false,
init() {
if (mode !== 'adaptive') {
// Below `medium` a collapsible rail is held at its collapsed width whatever the
@@ -152,6 +167,47 @@ document.addEventListener('alpine:init', () => {
this.queries.forEach((query, index) => query.removeEventListener('change', this.listeners[index]))
},
/**
* Holds a rail that has just closed on screen until its exit has run; see `closing`. The
* view calls it from `x-effect`, beside the `x-bind` that drops `data-md-open`, so both
* attributes change in the same flush: a `$watch` would set `data-md-closing` a microtask
* later, and a style read in between would settle what is leaving as already hidden.
*/
settle(open) {
if (open === this.wasOpen) {
return
}
this.wasOpen = open
const closing = ++this.closings
if (open) {
this.closing = null
return
}
// Whether the panel leaves the window, rather than returning to the layout: a compact
// adaptive rail has no place in the layout, and a rail that hides when collapsed has
// left it. Decided from the state, not measured: reading the panel's style between the
// two attribute changes would settle it as hidden, and nothing would transition.
this.closing = this.away || (mode === 'adaptive' && upTo('medium').matches) ? 'sheet' : 'scrim'
// A frame on, the attributes have met the style, and the exit's transitions exist.
requestAnimationFrame(() => {
const exits = [...this.$root.querySelectorAll(':scope > :is([data-md-navigation-rail-panel], [data-md-navigation-rail-scrim])')]
.flatMap((element) => element.getAnimations())
.filter((animation) => ['translate', 'opacity'].includes(animation.transitionProperty))
Promise.allSettled(exits.map((animation) => animation.finished)).then(() => {
if (closing === this.closings) {
this.closing = null
}
})
})
},
/** What this rail's mode and the visitor's choice make of it, before anything opens it. */
get standing() {
if (mode === 'expanded') {
+141
View File
@@ -0,0 +1,141 @@
/**
* Popover exits in every engine: a popover marked `data-md-popover-exit` fades or shrinks out on
* its own transitions, however it closes `hidePopover()` from a component's script, Escape, a
* press outside it, or another popover opening.
*
* A closed popover leaves the top layer and takes `display: none` at once. Chrome and Safari could
* hold both for a transition (`transition-behavior: allow-discrete` on `display` and `overlay`);
* Firefox transitions neither (MDN browser-compat-data, `display.is_transitionable`), so there every
* exit vanished on its first frame. Nor can a script hold the popover open: `beforetoggle` cannot be
* cancelled on the way out, and the browser's own light dismiss never asks.
*
* So the popover closes for real, at once its focus, `aria-expanded` and `toggle` event are the
* browser's as before and a copy of it, taken in `beforetoggle` while it is still drawn, stands
* in its place for the exit. The copy is decoration: `popover="manual"` (the top layer, above what
* the popover was above, without closing any other popover), inert, hidden from assistive
* technology, without ids or nested popovers, and `x-ignore`, so Alpine does not start the
* components it holds. It is pinned to the popover's box, shown with its transitions off (its
* `@starting-style` would otherwise replay the entry), and then marked `data-md-popover-closing`,
* which the component's stylesheet turns into its closed values (`:popover-open:not(…)`), so the
* copy moves on the component's own durations and springs. It is removed when the longest of them
* has run; under reduced motion they are zero, and there is no copy at all.
*/
const EXIT = '[data-md-popover-exit]'
const GHOST = 'data-md-popover-ghost'
const CLOSING = 'data-md-popover-closing'
/** The copy each popover's exit is showing, so opening the popover again takes it away. */
const ghosts = new WeakMap()
/** The longest `transition-duration` + `transition-delay` pair on an element, in ms. */
const longestTransition = (element) => {
const style = getComputedStyle(element)
const durations = style.transitionDuration.split(',').map((value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000))
const delays = style.transitionDelay.split(',').map((value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000))
return Math.max(0, ...durations.map((duration, index) => duration + (delays[index % delays.length] || 0)))
}
/** Out of the DOM, which takes it off the top layer without a `beforetoggle` or `toggle` of its own. */
const remove = (ghost) => ghost.remove()
/**
* A still copy of the popover, pinned where it is drawn. Colours come from custom properties an
* ancestor may set (a vibrant menu's `--material-menu-surface`), which the copy loses at the end of
* `<body>`, so the root's resolved colours travel with it.
*/
const copyOf = (popover) => {
const box = popover.getBoundingClientRect()
const style = getComputedStyle(popover)
const ghost = popover.cloneNode(true)
ghost.querySelectorAll('[popover]').forEach((nested) => nested.remove())
ghost.querySelectorAll('[id]').forEach((element) => element.removeAttribute('id'))
ghost.removeAttribute('id')
ghost.removeAttribute(EXIT.slice(1, -1))
ghost.setAttribute('popover', 'manual')
ghost.setAttribute(GHOST, '')
ghost.setAttribute('x-ignore', '')
ghost.setAttribute('aria-hidden', 'true')
ghost.inert = true
Object.assign(ghost.style, {
position: 'fixed',
inset: 'auto',
left: `${box.left}px`,
top: `${box.top}px`,
width: `${box.width}px`,
height: `${box.height}px`,
margin: '0',
positionAnchor: 'none',
positionArea: 'none',
positionTryFallbacks: 'none',
pointerEvents: 'none',
backgroundColor: style.backgroundColor,
color: style.color,
})
return { ghost, scrollTop: popover.scrollTop }
}
const exit = ({ ghost, scrollTop }, popover) => {
const still = [ghost, ...ghost.querySelectorAll('*')]
const transitions = still.map((element) => element.style.transition)
still.forEach((element) => (element.style.transition = 'none'))
document.body.append(ghost)
ghost.showPopover()
ghost.scrollTop = scrollTop
// Resolve the open values with no transition, then give the transitions back and close.
still.forEach((element) => getComputedStyle(element).opacity)
still.forEach((element, index) => (element.style.transition = transitions[index]))
ghost.setAttribute(CLOSING, '')
const duration = Math.max(...still.map(longestTransition))
ghosts.set(popover, ghost)
setTimeout(() => {
remove(ghost)
if (ghosts.get(popover) === ghost) {
ghosts.delete(popover)
}
}, duration)
}
document.addEventListener(
'beforetoggle',
(event) => {
const popover = event.target
if (!(popover instanceof HTMLElement) || !popover.matches(EXIT)) {
return
}
const previous = ghosts.get(popover)
if (previous) {
ghosts.delete(popover)
remove(previous)
}
if (event.newState !== 'closed') {
return
}
// Under reduced motion every duration token is zero: nothing to show on the way out.
if (Math.max(...[popover, ...popover.querySelectorAll('*')].map(longestTransition)) === 0) {
return
}
// Taken now, while the popover is still drawn; shown in the next frame, before it is
// painted without the popover, so neither `showPopover()` runs inside the browser's own
// hiding nor a frame goes by with nothing on screen.
const copy = copyOf(popover)
requestAnimationFrame(() => exit(copy, popover))
},
true,
)
+23 -1
View File
@@ -18,6 +18,12 @@
const HOVER_DELAY_MS = 500
const LEAVE_GRACE_MS = 1500
// A persistent bubble is a `popover="auto"`, and its trigger is outside it: the press on the
// trigger light-dismisses the open bubble, and the click that follows would open it again, so a
// second press never closed it. A close the browser made this recently is taken as that press, as
// menu.js's REOPEN_GUARD_MS does; `beforetoggle` times it, because `toggle` is queued past the click.
const REOPEN_GUARD_MS = 250
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialRichTooltip', (persistent = false) => ({
timer: null,
@@ -50,12 +56,28 @@ document.addEventListener('alpine:init', () => {
wrapper.addEventListener('pointerenter', describe)
if (persistent) {
let dismissedAt = -Infinity
let closingExplicitly = false
bubble.addEventListener('beforetoggle', (event) => {
if (event.newState === 'closed' && !closingExplicitly) {
dismissedAt = performance.now()
}
closingExplicitly = false
})
wrapper.addEventListener('click', (event) => {
if (bubble.contains(event.target)) {
return
}
open() ? bubble.hidePopover() : bubble.showPopover()
if (open()) {
closingExplicitly = true
bubble.hidePopover()
} else if (performance.now() - dismissedAt > REOPEN_GUARD_MS) {
bubble.showPopover()
}
})
return
+55 -4
View File
@@ -17,6 +17,14 @@
* `trigger` is M3's entry point: the bar itself, or `icon` a single search icon button that
* expands into the full-screen view wherever the window is wide enough to dock, because an icon
* button has nowhere to dock under.
*
* A full-screen view closes back into the bar or the icon it came from (M3's search view), so the
* full-screen layout outlives `open` by the view's own exit: `leaving` holds `fullScreen` and with
* it `data-md-full-screen`, the fixed header bar and the back arrow until the view's closing
* transition has run, while the header bar fades out with it (search.css). Without it the bar went
* back to its resting form, or to nothing behind the icon, on the first frame of the exit, and the
* fading view dropped to the docked layout. The focus trap lets go at the close itself, not at the
* end of the exit, so its return of focus still lands inside RETURN_GUARD_MS.
*/
import { upTo } from './breakpoints.js'
@@ -33,9 +41,21 @@ const RETURN_GUARD_MS = 250
// counting, so the live region speaks once.
const SETTLE_MS = 120
/** The longest `transition-duration` + `transition-delay` on an element, in ms: zero under reduced motion. */
const longestTransition = (element) => {
const style = getComputedStyle(element)
const ms = (value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000)
const delays = style.transitionDelay.split(',').map(ms)
return Math.max(0, ...style.transitionDuration.split(',').map((duration, index) => ms(duration) + (delays[index % delays.length] || 0)))
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialSearch', (docked = false, announce = {}, trigger = 'bar') => ({
open: false,
// A full-screen view on its way out; see the file's header.
leaving: false,
leavings: 0,
compact: false,
closedAt: -Infinity,
announcement: '',
@@ -93,10 +113,12 @@ document.addEventListener('alpine:init', () => {
get fullScreen() {
// An icon button has nothing to dock under, so M3's icon entry point always expands.
return this.open && (trigger === 'icon' || (this.compact && !docked))
return (this.open || this.leaving) && (trigger === 'icon' || (this.compact && !docked))
},
show() {
this.leavings++
this.leaving = false
this.open = true
},
@@ -119,18 +141,47 @@ document.addEventListener('alpine:init', () => {
},
close(refocus = false) {
const fromFullScreen = this.open && this.fullScreen
this.open = false
this.closedAt = performance.now()
if (fromFullScreen) {
this.hold()
}
if (refocus) {
// Back to whatever opened the view: the icon button, or the field itself. A tick
// later, because the icon button is only on screen again once the view has closed.
// Back to whatever opened the view: the icon button, or the field itself. A frame
// after the tick, as `expand()` waits: the icon button is `x-show`n, and Alpine only
// shows it on that frame, so a focus in the tick reaches a hidden button, which
// Firefox and WebKit refuse (in Chrome the trap's own return had covered for it).
const back = this.$refs.trigger ?? this.$refs.input
this.$nextTick(() => back.focus())
this.$nextTick(() => requestAnimationFrame(() => back.focus()))
}
},
/**
* Keeps the full-screen layout for the length of the view's exit. The closed state's
* durations are read a frame on, once they are the ones computed; reopening, which counts
* `leavings` up, lets a pending end go by.
*/
hold() {
const leaving = ++this.leavings
this.leaving = true
requestAnimationFrame(() => {
const duration = this.$refs.view ? longestTransition(this.$refs.view) : 0
setTimeout(() => {
if (leaving === this.leavings) {
this.leaving = false
}
}, duration)
})
},
clear() {
const input = this.$refs.input
@@ -104,7 +104,9 @@
@if ($standard) data-md-standard @endif
>
@unless ($standard)
<div data-md-bottom-sheet-scrim x-cloak x-show="open" x-on:click="close()" aria-hidden="true"></div>
{{-- `md-transition` turns on Alpine's CSS transition, which holds the scrim and the panel
displayed through their exit, as in drawer.blade.php. --}}
<div data-md-bottom-sheet-scrim x-cloak x-show="open" x-transition:enter="md-transition" x-transition:leave="md-transition" x-on:click="close()" aria-hidden="true"></div>
@endunless
@if ($stops !== [])
@@ -116,6 +118,8 @@
<section
x-cloak
x-show="open"
x-transition:enter="md-transition"
x-transition:leave="md-transition"
x-ref="sheet"
@unless ($standard) x-trap.inert.noscroll="open" @endunless
x-bind:style="sheetStyle"
@@ -4,8 +4,10 @@
state through a Livewire morph (`wire:ignore.self` stops the server's HTML from closing it),
and is announced as a disclosure. Drawn in M3's terms: a title-medium `title` (or a
`heading` slot), an optional leading `icon`, a chevron that turns over on the spatial spring,
and a height that eases open on the same spring
(`data-md-collapse`, resources/css/components/collapse.css). `open` starts it open.
and a height that eases open and shut on the same spring in every engine
(`data-md-collapse`, resources/css/components/collapse.css, and resources/js/collapse.js,
which animates the `<details>` itself because only Chrome can animate its content's height
from CSS). `open` starts it open.
`variant`: `plain` (the default, on the surface around it) or `filled` (a surface-container
tile with a large corner).
@@ -48,7 +50,7 @@
@if ($bound)
x-data="{ collapseOpen: @if ($model !== null) @entangle($attributes->wire('model')) @else @js($expanded) @endif }"
@if ($model === null) x-modelable="collapseOpen" @endif
x-effect="$el.open = collapseOpen"
x-effect="materialCollapse($el, collapseOpen)"
x-on:toggle="collapseOpen = $el.open"
@endif
@if ($expanded) open @endif
+76 -5
View File
@@ -23,7 +23,16 @@
modal sheet M3 calls the standard sheet "supplementary surfaces mainly for medium to
expanded breakpoints" and the modal one "preferred at compact breakpoints", and the switch
sits at `expanded` rather than `medium` because M3 also caps a side sheet at 400dp and a
600px window has too little left beside one.
600px window has too little left beside one. Opening one shrinks the body beside it and closing
one gives the room back (M3's side sheets, "Adaptive"): the root's inline size, and a negative
margin as wide as its flex parent's gap, spring between none and the sheet's width while the
sheet fades, and `data-md-drawer-collapsed` which takes the closed sheet out of the layout
waits for that exit (`closing`, `settle()`) instead of cutting it off on its first frame. It
binds `collapsed`, which only `settle()` and the window's width write: a binding that read
`open` itself would run before `settle()` in the same flush and, already queued, not run again
once `closing` changed. Nothing moves while the page loads: `data-md-drawer-settled` comes two
frames after the state Alpine starts with, and the standard sheet has no transitions until
then, so a sheet that starts open stands open rather than growing in.
For the second pane of a list-detail layout use `<x-list-detail>` instead (step 35's canonical
layout); the two are not the same thing a pane shows what the list beside it selected, a
@@ -78,26 +87,89 @@
x-data="{
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
wide: false,
settled: false,
collapsed: false,
closing: false,
closings: 0,
wasOpen: null,
close() { this.open = typeof this.open === 'boolean' ? false : null; },
settle(open) {
open = Boolean(open);
if (this.wasOpen === null) {
this.wasOpen = open;
// The state Alpine starts with is drawn, not animated: two frames on, once it has
// been painted, the sheet may move.
requestAnimationFrame(() => requestAnimationFrame(() => this.settled = true));
return;
}
if (open === this.wasOpen) {
return;
}
this.wasOpen = open;
const closing = ++this.closings;
const gap = parseFloat(getComputedStyle(this.$el.parentElement).columnGap);
this.$el.style.setProperty('--md-drawer-gap', Number.isNaN(gap) ? '0px' : gap + 'px');
this.closing = ! open;
if (open) {
this.collapsed = false;
return;
}
requestAnimationFrame(() => {
const ms = (value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000);
const longest = (element) => Math.max(0, ...getComputedStyle(element).transitionDuration.split(',').map(ms));
const duration = Math.max(longest(this.$el), ...[...this.$el.children].map(longest));
setTimeout(() => {
if (closing === this.closings) {
this.closing = false;
this.collapsed = this.wide;
}
}, duration);
});
},
@if ($standard)
init() {
const query = window.matchMedia('(width >= 840px)');
this.wide = query.matches;
query.addEventListener('change', (event) => this.wide = event.matches);
this.collapsed = ! this.open && this.wide;
query.addEventListener('change', (event) => {
this.wide = event.matches;
this.collapsed = ! this.open && this.wide && ! this.closing;
});
},
@endif
}"
@if ($closeOnEscape) x-on:keydown.window.escape="if (open && ! wide) close()" @endif
x-bind:data-md-open="open ? '' : null"
x-bind:data-md-drawer-collapsed="(! open && wide) ? '' : null"
x-bind:data-md-drawer-collapsed="collapsed ? '' : null"
x-bind:data-md-drawer-settled="settled ? '' : null"
x-effect="settle(open)"
data-md-drawer
@if ($standard) data-md-standard @endif
style="--sheet-width: {{ $sheetWidth }}"
>
<div data-md-drawer-scrim x-cloak x-show="open" @if (! $withoutBackdropClose) x-on:click="close()" @endif aria-hidden="true"></div>
{{-- `md-transition` is a class name only to turn on Alpine's CSS transition: `x-show` then keeps
the scrim and the sheet displayed for their computed transition-duration (drawer.css's
tokens) before hiding them, so they fade and slide out rather than vanish Firefox does not
transition `display`, even with `allow-discrete`. Both stages, so reopening during the exit
cancels the pending hide. Nothing styles the class. --}}
<div data-md-drawer-scrim x-cloak x-show="open" x-transition:enter="md-transition" x-transition:leave="md-transition" @if (! $withoutBackdropClose) x-on:click="close()" @endif aria-hidden="true"></div>
<aside
x-cloak
x-show="open"
x-transition:enter="md-transition"
x-transition:leave="md-transition"
x-trap.inert.noscroll="open && ! wide"
x-bind:role="wide ? 'region' : 'dialog'"
x-bind:aria-modal="wide ? null : 'true'"
@@ -107,7 +179,6 @@
id="{{ $id }}"
data-md-drawer-sheet
data-md-side="{{ $side }}"
style="--sheet-width: {{ $sheetWidth }}"
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id']) }}
>
@if (filled($title) || $closeButton)
@@ -66,6 +66,7 @@
popover="auto"
role="menu"
data-md-fab-menu-popover
data-md-popover-exit
data-md-position="{{ $position }}"
aria-label="{{ $label }}"
tabindex="-1"
+9 -2
View File
@@ -1,8 +1,12 @@
{{-- M3 Expressive's loading indicator: a shape that morphs through seven Expressive shapes while
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
sizes it. `contained` puts it on a primary-container circle, for a spinner over content. It
In the text colour primary unless the caller colours it and 48px, M3's default. `size` is
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
else already says what is happening, as a button's own spinner does.
@@ -16,15 +20,18 @@
@props([
'contained' => false,
'label' => null,
'size' => null,
])
@php
$size = filter_var($size, FILTER_VALIDATE_INT, ['options' => ['min_range' => 24, 'max_range' => 240]]) ?: null;
$decorative = $label === false;
$label = $decorative ? null : ($label ?? __('Loading'));
$attributes = $attributes->merge(array_filter([
'data-md-loading' => true,
'data-md-contained' => $contained ? true : null,
'style' => $size !== null ? "--md-loading-size: {$size}px" : null,
'role' => $decorative ? null : 'progressbar',
'aria-label' => $label,
'aria-hidden' => $decorative ? 'true' : null,
@@ -140,6 +140,7 @@
popover="auto"
role="menu"
data-md-submenu
data-md-popover-exit
aria-label="{{ $label }}"
tabindex="-1"
style="position-anchor: {{ $anchor }}"
@@ -120,6 +120,7 @@
popover="auto"
@unless ($filtering) role="menu" @endunless
data-md-menu-popover
data-md-popover-exit
data-md-position="{{ $position }}"
@if ($vibrant) data-md-vibrant @endif
@if ($label && ! $filtering) aria-label="{{ $label }}" @endif
+6 -3
View File
@@ -13,7 +13,10 @@
`wire:ignore.self`, because `showModal()` sets the `open` attribute, which the server's HTML
does not have: without it the next Livewire render morphs the attribute away and the dialog
shuts under the person using it. The contents still morph.
shuts under the person using it. The contents still morph. The body is `wire:ignore.self` for
the same reason: `materialShowModal()` (resources/js/dialog.js) gives a text-only body the
`tabindex` that lets it hold the first focus, and a render that took it away would drop that
focus out of the dialog in WebKit.
M3's basic dialog (DialogTokens, androidx Compose Material 3, Apache-2.0): surface-container-
high, extra-large corner, elevation 3, a headline-small `title`, body-medium `subtitle` in
@@ -93,7 +96,7 @@
@else
x-data="{ close() { this.open = false } }"
@endif
x-effect="open ? ($el.open || $el.showModal()) : ($el.open && $el.close())"
x-effect="open ? ($el.open || materialShowModal($el)) : ($el.open && $el.close())"
x-on:cancel.prevent="{{ $persistent ? '' : 'close()' }}"
x-on:close="if (open) close()"
@if (! $persistent) x-on:click.self="close()" @endif
@@ -141,7 +144,7 @@
@endif
@if ($body)
<div id="{{ $id }}-body" data-md-modal-body x-dialog-dividers>
<div id="{{ $id }}-body" wire:ignore.self data-md-modal-body x-dialog-dividers>
<div data-md-modal-content>{{ $slot }}</div>
</div>
@endif
@@ -28,8 +28,8 @@
`--material-bottom-bar` follows the bar down and up, so a `fab` button, the snackbar and the
page's bottom padding keep their distance from it rather than from where it was.
It does not position itself: wrap it in the element that pins it (`fixed inset-x-0 bottom-0`)
and hides it where a rail takes over. `<x-scaffold>` does both, and lifts the snackbar and a
It does not position itself: wrap it in the element that pins it (`position: fixed` to the
bottom edge, in the application's own CSS) and hides it where a rail takes over. `<x-scaffold>` does both, and lifts the snackbar and a
`fab` button above it through `--material-bottom-bar`.
`label` names the landmark ("Main" by default); `tall` picks the 80px container;
@@ -45,11 +45,19 @@
`footer`, pinned to the foot. Header and footer never scroll, so nothing in them is cut off by
the scroller's edge.
Anything else inside can take both shapes by matching the rail's own state directly, the same
selectors resources/css/components/navigation-rail.css uses for every branch of "collapsed"
`data-md-navigation-rail`'s value, `:not([data-md-open])`, and the window band each mode
collapses in (its header lists them). Every rule in the package that draws a collapsed shape
writes them out, and tests/Feature/Components/NavigationRailTest.php keeps each copy to the same
Anything else inside takes both shapes from the rail's value: the rail publishes M3's two
(Compose's WideNavigationRailValue) as `--md-navigation-rail-value`, `collapsed` or
`expanded`, from the first paint and in step with the rail's own items, and every descendant
inherits it, so an application's CSS asks a style query instead of repeating the conditions:
@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.
Props: `label` names the landmark ("Main"); `width` is the expanded width (`256px`, held
@@ -73,8 +81,11 @@
(`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.
over the page then. As it closes it keeps `data-md-closing` (`sheet` while the panel slides off
the window, `scrim` while only the scrim fades) until the exit has run, which is what holds it
on screen in Firefox, where `display` cannot transition (resources/js/navigation.js). 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,
@@ -120,6 +131,8 @@
@if ($interactive)
x-data="materialNavigationRail('{{ $mode }}', {{ $hideWhenCollapsed ? 'true' : 'false' }})"
x-bind:data-md-open="open"
x-bind:data-md-closing="closing"
x-effect="settle(open)"
@endif
{{ $attributes->merge(['style' => "--navigation-rail-width: {$width}"]) }}
>
@@ -7,9 +7,9 @@
moves the same way after a Livewire morph.
Linear by default, as wide as its container, unless the caller's own class narrows it an
application's unlayered CSS always outranks the package's default, `w-*` Tailwind utilities
included. `circular` is 40px, 48px wavy, the same way. `wavy` draws Expressive's wave. `thick`
makes the track and indicator 8px instead of 4px and keeps the wave as high as it was, which
application's unlayered CSS always outranks the package's default. `circular` is 40px, 48px
wavy, the same way. `wavy` draws Expressive's wave. `thick` makes the track and indicator 8px
instead of 4px and keeps the wave as high as it was, which
is what Compose's thick samples do: the container grows by the extra stroke (14px linear
wavy, 44px circular, 52px circular wavy). `color` is the active indicator and the stop:
`primary` (the default), `secondary`, `tertiary`, `error`, `success`, `warning`, `info`; the
@@ -55,6 +55,7 @@
role="{{ $persistent ? 'dialog' : 'tooltip' }}"
@if ($title) aria-label="{{ $title }}" @endif
data-md-rich-tooltip-bubble
data-md-popover-exit
data-md-side="{{ $side }}"
style="position-anchor: {{ $anchor }}"
>
+8 -3
View File
@@ -28,7 +28,8 @@
for both (docs/reference/m3/components-navigation-selection-inputs.md § Search).
The root renders `data-md-search` with `data-md-trigger`, and `data-md-open` and
`data-md-full-screen` while they hold; the parts are `data-md-search-*`, drawn by
`data-md-full-screen` while they hold `data-md-full-screen` a little longer, through a
full-screen view's exit back into the bar or the icon (resources/js/search.js); the parts are `data-md-search-*`, drawn by
resources/css/components/search.css. `class`, `style` and `wire:key` land on the root. --}}
@props([
@@ -59,14 +60,16 @@
x-on:keydown.escape="if (open) { $event.stopPropagation(); close(true); }"
x-on:focusout="leave($event)"
x-on:pointerdown.outside="close()"
x-trap.noscroll="fullScreen"
x-trap.noscroll="open && fullScreen"
x-bind:data-md-open="open ? '' : null"
x-bind:data-md-full-screen="fullScreen ? '' : null"
data-md-search
data-md-trigger="{{ $trigger }}"
{{ $attributes->only(['class', 'style', 'wire:key']) }}
>
<div data-md-search-scrim x-cloak x-show="open && ! fullScreen" x-on:pointerdown="close()" aria-hidden="true"></div>
{{-- `md-transition` turns on Alpine's CSS transition, which holds the scrim and the view
displayed while they fade out (search.css): Firefox does not transition `display`. --}}
<div data-md-search-scrim x-cloak x-show="open && ! fullScreen" x-transition:enter="md-transition" x-transition:leave="md-transition" x-on:pointerdown="close()" aria-hidden="true"></div>
@if ($trigger === 'icon')
<button
@@ -134,6 +137,8 @@
data-md-search-view
x-cloak
x-show="open"
x-transition:enter="md-transition"
x-transition:leave="md-transition"
x-on:keydown.arrow-down.prevent="step(1)"
x-on:keydown.arrow-up.prevent="step(-1)"
x-on:click="choose($event)"
@@ -45,6 +45,7 @@
aria-hidden="true"
x-data="materialTooltip"
data-md-tooltip
data-md-popover-exit
data-md-side="{{ $side }}"
style="position-anchor: {{ $anchor }}"
>{{ $text }}</span>
@@ -60,8 +60,8 @@
'Loading indicator' => <<<'BLADE'
<x-loading />
<x-loading contained />
<x-loading label="Uploading" style="width: 96px; height: 96px; color: var(--md-sys-color-tertiary);" />
<x-loading contained style="width: 32px; height: 32px;" />
<x-loading label="Uploading" size="96" style="color: var(--md-sys-color-tertiary);" />
<x-loading contained size="32" />
BLADE,
'Button groups: standard (press a button) and connected' => <<<'BLADE'
<x-button-group label="Standard" size="md">
+129
View File
@@ -272,6 +272,15 @@ it('animates the loading indicator in the browser', function () {
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`). */
function menuAgainst(string $test, string $label): string
{
@@ -922,3 +931,123 @@ it('scrolls the FAB menu\'s items on a short window, behind the close button, wh
// The close button is not inside the scrolling list, so scrolling it never moves the button.
$page->assertScript("{$trigger}.getBoundingClientRect().top === window.__fabTop");
});
/**
* Slows the page's motion tokens, so a popover's exit copy (resources/js/popover-exit.js) is
* still on screen after a separate round trip to the browser: a key press or a click already
* costs about as long as a 150350ms exit, and the copy moves on the tokens as the popover would.
*/
function slowMotion(mixed $page, string $duration = '1500ms'): mixed
{
$page->script(<<<JS
document.head.insertAdjacentHTML('beforeend', '<style>:root { --md-sys-motion-effects-fast-duration: {$duration}; --md-sys-motion-effects-default-duration: {$duration}; --md-sys-motion-spatial-fast-duration: {$duration}; --md-sys-motion-spatial-default-duration: {$duration}; }</style>')
JS);
return $page;
}
/** The exit copy on screen: shown, inert, hidden from assistive technology, with no ids of its own. */
const EXIT_COPY = "(() => { const copy = document.querySelector('[data-md-popover-ghost]'); return copy !== null && copy.matches(':popover-open') && copy.inert && copy.getAttribute('aria-hidden') === 'true' && ! copy.hasAttribute('id') && copy.querySelector('[id], [popover]:not([data-md-popover-ghost])') === null; })()";
/** Whether the exit copy is caught part-way to its closed opacity. */
const EXIT_COPY_FADING = "(() => { const copy = document.querySelector('[data-md-popover-ghost]'); const opacity = copy && parseFloat(getComputedStyle(copy).opacity); return opacity > 0.02 && opacity < 0.98; })()";
it('fades a menu out after the browser has closed it, on Escape or a press outside, in every engine', function () {
$menu = 'document.getElementById(document.querySelector(\''.MORE.'\').getAttribute(\'aria-controls\'))';
$page = slowMotion(showcase('menus'))
->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true');
// Escape: the browser's own light dismiss, which no script can hold open.
$page->keys(':focus', 'Escape')
->assertAttribute(MORE, 'aria-expanded', 'false')
->assertScript("! {$menu}.matches(':popover-open')")
->assertScript(focused("getAttribute('aria-label') === 'More'"))
->assertScript(EXIT_COPY)
->assertScript(EXIT_COPY_FADING)
// The copy is decoration: Alpine starts nothing inside it.
->assertScript("window.eval(\"[...document.querySelectorAll('[data-md-popover-ghost], [data-md-popover-ghost] *')].every((element) => element._x_dataStack === undefined)\")")
// Gone once the slowest of its transitions has run.
->assertScript('new Promise((resolve) => setTimeout(() => resolve(document.querySelector("[data-md-popover-ghost]") === null), 1800))');
// A press outside it.
$page->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true')
->click('#menus')
->assertAttribute(MORE, 'aria-expanded', 'false')
->assertScript(EXIT_COPY)
->assertScript(EXIT_COPY_FADING)
->assertNoJavaScriptErrors();
});
it('takes a menu\'s exit copy away when the menu opens again part-way through it', function () {
$page = slowMotion(showcase('menus'))
->click(MORE);
$page->keys(':focus', 'Escape')
->assertScript(EXIT_COPY)
// Past menu.js's reopen guard (250ms), which takes a press this soon after a light dismiss
// for the press that dismissed it; the copy is still fading.
->wait(0.3)
->assertScript(EXIT_COPY);
$page->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true')
->assertScript("document.querySelector('[data-md-popover-ghost]') === null")
->assertScript('document.getElementById(document.querySelector(\''.MORE.'\').getAttribute(\'aria-controls\')).matches(\':popover-open\')');
});
it('leaves no exit copy under reduced motion, where every duration token is zero', function () {
$page = slowMotion(showcase('menus'), '0ms')
->click(MORE);
$page->keys(':focus', 'Escape')
->assertAttribute(MORE, 'aria-expanded', 'false')
->assertScript('new Promise((resolve) => { let seen = false; const look = () => { seen ||= document.querySelector("[data-md-popover-ghost]") !== null; }; const timer = setInterval(look, 5); setTimeout(() => { clearInterval(timer); resolve(! seen); }, 300); })');
});
it('fades a submenu out on its own, while its menu stays open', function () {
$trigger = '#menus button:has-text("Share")';
$sendTo = '#menus [role="menuitem"]:has-text("Send to")';
$page = slowMotion(showcase('menus'))
->click($trigger);
$page->keys(':focus', 'ArrowDown');
$page->keys(':focus', 'ArrowRight')
->assertAttribute($sendTo, 'aria-expanded', 'true');
$page->keys(':focus', 'Escape')
->assertAttribute($sendTo, 'aria-expanded', 'false')
->assertAttribute($trigger, 'aria-expanded', 'true')
->assertScript(EXIT_COPY)
->assertScript("document.querySelectorAll('[data-md-popover-ghost]').length === 1 && document.querySelector('[data-md-popover-ghost]').hasAttribute('data-md-submenu')")
->assertScript(EXIT_COPY_FADING);
});
it('fades a tooltip out after Escape hides it', function () {
$page = slowMotion(showcase());
$page->keys('#content', 'Tab');
$page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()");
$page->assertScript("document.querySelector('#buttons [aria-label=\"Tonal\"] [popover]').matches(':popover-open')");
$page->keys(':focus', 'Escape')
->assertScript("! document.querySelector('#buttons [aria-label=\"Tonal\"] [popover]').matches(':popover-open')")
->assertScript(EXIT_COPY)
->assertScript("document.querySelector('[data-md-popover-ghost]').hasAttribute('data-md-tooltip')")
->assertScript(EXIT_COPY_FADING);
});
it('sinks a FAB menu\'s items back after the menu has closed', function () {
$page = slowMotion(showcase())
->click('button[aria-label="New"]')
->assertScript("document.querySelector('button[aria-label=\"New\"]').getAttribute('aria-expanded') === 'true'");
$page->keys(':focus', 'Escape')
->assertScript("document.querySelector('button[aria-label=\"New\"]').getAttribute('aria-expanded') === 'false'")
->assertScript(EXIT_COPY)
// The first item part-way down its 8px sink and its fade.
->assertScript("(() => { const item = document.querySelector('[data-md-popover-ghost] [data-md-fab-menu-item]'); const style = getComputedStyle(item); const drop = parseFloat(style.translate.split(' ')[1] ?? '0'); const opacity = parseFloat(style.opacity); return drop > 0.1 && drop < 7.9 && opacity > 0.02 && opacity < 0.98; })()");
});
+20 -1
View File
@@ -282,8 +282,11 @@ it('keeps a rich tooltip open while its action renders the component, and opens
->assertSeeIn('#renders', '1')
->assertScript("{$persistent}.matches(':popover-open')");
// Past the reopen guard (rich-tooltip.js's REOPEN_GUARD_MS), which takes a press this soon after
// a light dismiss for the press that dismissed it, as menu.js's does.
$page->click('#outside')
->assertScript("! {$persistent}.matches(':popover-open')");
->assertScript("! {$persistent}.matches(':popover-open')")
->wait(0.3);
$page->click('@details')
->assertScript("{$persistent}.matches(':popover-open')");
@@ -293,3 +296,19 @@ it('keeps a rich tooltip open while its action renders the component, and opens
->assertScript("{$transient}.matches(':popover-open')")
->assertNoJavaScriptErrors();
});
it('fades a persistent rich tooltip out when a second press closes it', function () {
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
// Slow tokens, so the exit copy (resources/js/popover-exit.js) is still on screen after the round trip.
$page->script("document.head.insertAdjacentHTML('beforeend', '<style>:root { --md-sys-motion-effects-fast-duration: 1500ms; }</style>')");
$page->click('#communication button:has-text("Press for details")')
->assertScript("{$bubble}.matches(':popover-open')")
->click('#communication button:has-text("Press for details")')
->assertScript("! {$bubble}.matches(':popover-open')")
->assertScript("(() => { const copy = document.querySelector('[data-md-popover-ghost]'); const opacity = copy && parseFloat(getComputedStyle(copy).opacity); return copy !== null && copy.hasAttribute('data-md-rich-tooltip-bubble') && copy.inert && opacity > 0.02 && opacity < 0.98; })()");
});
+419
View File
@@ -422,6 +422,13 @@ it('gives the scrolling body an inset focus ring when a text-only dialog opens f
->assertScript("getComputedStyle({$body}).outlineStyle === 'solid'")
->assertScript("getComputedStyle({$body}).outlineWidth === '3px'")
->assertScript("getComputedStyle({$body}).outlineOffset === '-3px'");
// Outside Chrome the body holds the focus through a tabindex materialShowModal() gives it; a
// Livewire render keeps it (the body is wire:ignore.self), so the focus stays in the dialog.
$page->script('window.eval("Livewire.first().$refresh()")');
$page->assertScript("document.querySelector('dialog[open]') !== null")
->assertScript("document.activeElement.matches('[data-md-modal-body]')");
});
it('cycles a bottom sheet\'s preset heights from its handle, announcing each, and closes from the last', function () {
@@ -502,6 +509,98 @@ it('is a standard side sheet from 840px, without a scrim or a focus trap, and th
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') !== null");
});
it('gives a closing standard side sheet\'s room back to the content beside it as it leaves, from 840px', function () {
$sheet = "document.querySelector('#containment aside[data-md-side]')";
$root = "{$sheet}.parentElement";
$column = "{$root}.previousElementSibling";
$toggle = "Array.from(document.querySelectorAll('#containment button')).find((button) => button.textContent.trim() === 'Show or hide the filters')";
// The example starts open, and stands open from the first frame Alpine draws: full 400px, fully
// shown, nothing on it animating.
$page = containment()->resize(1000, 800)
->assertScript("{$root}.hasAttribute('data-md-open') && Math.round({$root}.getBoundingClientRect().width) === 400 && getComputedStyle({$sheet}).opacity === '1' && {$root}.getAnimations({ subtree: true }).length === 0");
// Closed and sampled in one round trip: the sheet's root caught part-way between its width
// and none, still in the layout, while the column beside it has grown part of the way — not
// the whole sheet and its gap handed back in one jump at the end.
$midExit = $page->script(<<<JS
(async () => {
const root = {$root}
const column = {$column}
const open = { root: root.getBoundingClientRect().width, column: column.getBoundingClientRect().width, row: root.parentElement.getBoundingClientRect().width }
{$toggle}.click()
for (let i = 0; i < 80; i++) {
const width = root.getBoundingClientRect().width
const columnWidth = column.getBoundingClientRect().width
if (! root.hasAttribute('data-md-drawer-collapsed') && getComputedStyle(root).display !== 'none' && width > 1 && width < open.root - 1 && columnWidth > open.column + 1 && columnWidth < open.row - 1) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
JS);
expect($midExit)->toBeTrue();
// Settled: out of the layout, and the column is the whole row, gap included.
$page->assertScript("{$root}.hasAttribute('data-md-drawer-collapsed') && getComputedStyle({$root}).display === 'none'")
->assertScript("Math.abs({$column}.getBoundingClientRect().width - {$root}.parentElement.getBoundingClientRect().width) < 1");
// Reopened part-way through its exit, it ends open, not collapsed. Each script stays well under
// the browser plugin's one-second call timeout, past which it runs the script a second time.
$page->script("{$toggle}.click()");
$page->wait(0.6)
->assertScript("{$root}.hasAttribute('data-md-open') && {$root}.getAnimations({ subtree: true }).length === 0");
$page->script(<<<JS
(async () => {
{$toggle}.click()
await new Promise((resolve) => setTimeout(resolve, 60))
{$toggle}.click()
})()
JS);
$page->wait(0.9)
->assertScript("{$root}.hasAttribute('data-md-open') && ! {$root}.hasAttribute('data-md-drawer-collapsed') && Math.abs({$root}.getBoundingClientRect().width - 400) < 1 && getComputedStyle({$sheet}).opacity === '1'");
});
it('draws a standard side sheet that starts open standing open, without growing it in on load', function () {
// Slow tokens, so a load-time entry would still be running when the page is first read.
Route::middleware('web')->get('/standard-sheet-load-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
<style>:root { --md-sys-motion-spatial-default-duration: 3000ms; --md-sys-motion-effects-default-duration: 3000ms; }</style>
</head>
<body>
<x-row align="stretch" gap="space300" x-data="{ open: true }">
<x-stack style="flex: 1 1 0%; min-width: 0;">
<button type="button" id="toggle-sheet" x-on:click="open = ! open">Show or hide the filters</button>
</x-stack>
<x-drawer standard title="Filters">Filter the list</x-drawer>
</x-row>
@livewireScripts
</body>
</html>
BLADE));
$root = "document.querySelector('[data-md-drawer]')";
$page = visit('/standard-sheet-load-probe')->resize(1000, 800)->waitForEvent('networkidle')
->assertScript("typeof window.Alpine !== 'undefined' && {$root}.hasAttribute('data-md-drawer-settled')")
->assertScript("{$root}.hasAttribute('data-md-open') && Math.abs({$root}.getBoundingClientRect().width - 400) < 1")
->assertScript("{$root}.getAnimations({ subtree: true }).length === 0")
->assertScript("getComputedStyle({$root}.querySelector('[data-md-drawer-sheet]')).opacity === '1'");
// Settled, it still moves when someone closes it.
$page->script("document.getElementById('toggle-sheet').click()");
$page->assertScript("{$root}.getAnimations({ subtree: true }).length > 0");
});
it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () {
$page = containment();
@@ -628,6 +727,68 @@ it('fades the bottom sheet\'s scrim out on close, rather than making it vanish',
$page->assertScript("getComputedStyle({$scrim}).display === 'none'");
});
it('slides the side sheet out on close, rather than making it vanish', function () {
$sheet = "document.querySelector('#containment aside[role=\"dialog\"]')";
$scrim = "{$sheet}.parentElement.querySelector(':scope > [data-md-drawer-scrim]')";
$page = containment()
->click('#containment button:has-text("Side sheet")')
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("Math.abs(parseFloat(getComputedStyle({$sheet}).translate)) < 0.5");
// Sampled in the page, in the same round trip as the close, as the scrim's fade is above: the
// sheet must be caught still displayed and part of the way to its closed offset (one sheet
// width past its edge), not gone at once — which is what Firefox showed while the exit leaned
// on `allow-discrete` holding `display`.
$midSlide = $page->script(<<<JS
(async () => {
const width = {$sheet}.getBoundingClientRect().width
{$scrim}.click()
for (let i = 0; i < 60; i++) {
const style = getComputedStyle({$sheet})
const offset = Math.abs(parseFloat(style.translate) || 0)
if (style.display !== 'none' && offset > 1 && offset < width - 1) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
JS);
expect($midSlide)->toBeTrue();
$page->assertScript("getComputedStyle({$sheet}).display === 'none'");
});
it('slides the bottom sheet down on close, rather than making it vanish', function () {
$sheet = "document.querySelector('#containment section[role=\"dialog\"]')";
$scrim = "{$sheet}.parentElement.querySelector(':scope > [data-md-bottom-sheet-scrim]')";
$offset = "(parseFloat(getComputedStyle({$sheet}).translate.split(' ')[1]) || 0)";
$page = containment()
->click('#containment button:has(> span:text-is("Bottom sheet"))')
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("Math.abs({$offset}) < 0.5");
$midSlide = $page->script(<<<JS
(async () => {
const height = {$sheet}.getBoundingClientRect().height
{$scrim}.click()
for (let i = 0; i < 60; i++) {
const offset = {$offset}
if (getComputedStyle({$sheet}).display !== 'none' && offset > 1 && offset < height - 1) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
JS);
expect($midSlide)->toBeTrue();
$page->assertScript("getComputedStyle({$sheet}).display === 'none'");
});
it('slides the side sheet in from its own edge in a right-to-left page', function () {
Route::middleware('web')->get('/drawer-rtl-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
@@ -804,3 +965,261 @@ it('keeps a selected segmented row\'s fill under the hover and focus tint', func
expect($selectedFocusBackground)->not->toBe($restBackground)
->and($selectedFocusBackground)->not->toBe($selectedHoverBackground);
});
/**
* Collapses to watch move: one on its own with a paragraph under it, one bound to Alpine, and a
* `name` group of two with the first open. Each body is tall enough for a height caught part-way
* to be told from both ends.
*/
function collapseMotionProbe(string $durations = ''): mixed
{
Route::middleware('web')->get('/collapse-motion-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<x-collapse id="plain" title="How long do links last?">
<p style="block-size: 240px">Until the expiry the sender chose, at most thirty days.</p>
</x-collapse>
<p id="below">Under the collapse.</p>
<div x-data="{ advanced: false }">
<x-collapse id="bound" title="Advanced" x-model="advanced">
<p style="block-size: 240px">Everything else.</p>
</x-collapse>
<button type="button" id="open-bound" x-on:click="advanced = true">Open from Alpine</button>
<button type="button" id="close-bound" x-on:click="advanced = false">Close from Alpine</button>
<output id="advanced" x-text="advanced"></output>
</div>
<x-collapse id="first" title="First" name="faq" open>
<p style="block-size: 240px">The first answer.</p>
</x-collapse>
<x-collapse id="second" title="Second" name="faq">
<p style="block-size: 240px">The second answer.</p>
</x-collapse>
@livewireScripts
</body>
</html>
BLADE));
$page = visit('/collapse-motion-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
if ($durations !== '') {
$page->script("document.head.insertAdjacentHTML('beforeend', '<style>:root { --md-sys-motion-spatial-fast-duration: {$durations}; }</style>')");
}
return $page;
}
/**
* Runs `$act` in the page and samples `$details` every frame until its animation has run: whether
* its height was ever caught strictly between where it started and where it ended, what `open` and
* the closing mark said as it began, whether the chevron was caught turning, and how it ended. In one round trip, because a separate read
* costs about as long as the 350ms spring.
*/
function collapseMotion(mixed $page, string $act, string $details): array
{
return $page->script(<<<JS
(async () => {
const details = document.querySelector('{$details}')
const chevron = details.querySelector('[data-md-collapse-chevron]')
const below = details.nextElementSibling
const start = details.getBoundingClientRect().height
const belowStart = below?.getBoundingClientRect().top ?? 0
const heights = []
const belows = []
{$act}
// A binding reaches the collapse from Alpine's own flush, a microtask on; a task later
// both routes have begun, and the close's attribute and `open` hold for its whole run.
await new Promise((resolve) => setTimeout(resolve, 0))
const first = { open: details.open, closing: details.hasAttribute('data-md-collapse-closing') }
let chevronTurning = false
for (let i = 0; i < 120; i++) {
await new Promise((resolve) => requestAnimationFrame(resolve))
heights.push(details.getBoundingClientRect().height)
belows.push(below?.getBoundingClientRect().top ?? 0)
const rotate = getComputedStyle(chevron).rotate
chevronTurning ||= rotate !== 'none' && rotate !== '180deg' && rotate !== '0deg'
if (i > 5 && details.getAnimations().length === 0) break
}
const end = details.getBoundingClientRect().height
const low = Math.min(start, end)
const high = Math.max(start, end)
return {
between: heights.some((height) => height > low + 2 && height < high - 2),
belowMoved: belows.some((top) => top > Math.min(belowStart, belows.at(-1)) + 2 && top < Math.max(belowStart, belows.at(-1)) - 2),
first,
chevronTurning,
start,
end,
open: details.open,
closing: details.hasAttribute('data-md-collapse-closing'),
style: details.getAttribute('style'),
animations: details.getAnimations().length,
}
})()
JS);
}
it('eases a collapse open and shut in every engine, moving what is under it', function () {
$page = collapseMotionProbe();
$opened = collapseMotion($page, "details.querySelector('summary').click()", '#plain');
expect($opened['first']['open'])->toBeTrue()
->and($opened['between'])->toBeTrue()
->and($opened['belowMoved'])->toBeTrue()
->and($opened['open'])->toBeTrue()
->and($opened['end'])->toBeGreaterThan($opened['start'] + 200)
->and($opened['style'])->toBeNull()
->and($opened['animations'])->toBe(0);
$closed = collapseMotion($page, "details.querySelector('summary').click()", '#plain');
// Still open while the height goes, so there is content to clip; the chevron turns back at once.
expect($closed['first'])->toBe(['open' => true, 'closing' => true])
->and($closed['chevronTurning'])->toBeTrue()
->and($closed['between'])->toBeTrue()
->and($closed['belowMoved'])->toBeTrue()
->and($closed['open'])->toBeFalse()
->and($closed['closing'])->toBeFalse()
->and($closed['end'])->toBe($opened['start'])
->and($closed['style'])->toBeNull()
->and($closed['animations'])->toBe(0);
// Clipped while it moves, so the content never shows past the edge that is moving.
$clipped = $page->script(<<<'JS'
(async () => {
const details = document.querySelector('#plain')
details.querySelector('summary').click()
await new Promise((resolve) => requestAnimationFrame(resolve))
const overflow = getComputedStyle(details).overflowY
await Promise.all(details.getAnimations().map((animation) => animation.finished))
return overflow
})()
JS);
expect($clipped)->toBe('clip');
// From the keyboard: Enter on the summary is the same press, taken over the same way.
$page->script("document.querySelector('#plain summary').focus()");
$page->keys('#plain summary', 'Enter')
->assertScript("document.querySelector('#plain').open === false && document.querySelector('#plain').getAnimations().length === 0");
});
it('eases a collapse bound to Alpine open and shut, keeping the binding in step', function () {
$page = collapseMotionProbe();
$opened = collapseMotion($page, "document.querySelector('#open-bound').click()", '#bound');
expect($opened['between'])->toBeTrue()
->and($opened['open'])->toBeTrue();
$page->assertSeeIn('#advanced', 'true');
$closed = collapseMotion($page, "document.querySelector('#close-bound').click()", '#bound');
expect($closed['first']['closing'])->toBeTrue()
->and($closed['between'])->toBeTrue()
->and($closed['open'])->toBeFalse()
->and($closed['end'])->toBe($opened['start']);
$page->assertSeeIn('#advanced', 'false');
// A press on the summary tells the binding when the section has closed, and never loops.
$page->click('#bound summary')
->assertSeeIn('#advanced', 'true')
->assertScript("document.querySelector('#bound').open === true");
});
it('turns a collapse round from the height it has reached, ending as the last press asked', function () {
$page = collapseMotionProbe('1500ms');
$result = $page->script(<<<'JS'
(async () => {
const details = document.querySelector('#plain')
const summary = details.querySelector('summary')
const closed = details.getBoundingClientRect().height
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
summary.click()
await wait(300)
const midway = details.getBoundingClientRect().height
summary.click()
await new Promise((resolve) => requestAnimationFrame(resolve))
const turned = details.getBoundingClientRect().height
await wait(1700)
return { closed, midway, turned, end: details.getBoundingClientRect().height, open: details.open, style: details.getAttribute('style') }
})()
JS);
expect($result['midway'])->toBeGreaterThan($result['closed'] + 2)
// Turned round from where it was, not from either end.
->and(abs($result['turned'] - $result['midway']))->toBeLessThan(40)
->and($result['open'])->toBeFalse()
->and($result['end'])->toBe($result['closed'])
->and($result['style'])->toBeNull();
});
it('closes the open member of a name group on the same spring as the one opening', function () {
$page = collapseMotionProbe();
$first = "document.querySelector('#first')";
$result = $page->script(<<<'JS'
(async () => {
const first = document.querySelector('#first')
const second = document.querySelector('#second')
const start = first.getBoundingClientRect().height
let caught = false
second.querySelector('summary').click()
const stillOpen = first.open
for (let i = 0; i < 120; i++) {
await new Promise((resolve) => requestAnimationFrame(resolve))
const height = first.getBoundingClientRect().height
caught ||= first.open && height > 60 && height < start - 2
if (i > 5 && first.getAnimations().length === 0 && second.getAnimations().length === 0) break
}
return { stillOpen, caught, first: first.open, second: second.open, name: first.getAttribute('name') }
})()
JS);
expect($result)->toBe(['stillOpen' => true, 'caught' => true, 'first' => false, 'second' => true, 'name' => 'faq']);
// The group still keeps one open: the browser's own exclusivity, with the name back.
$page->script("document.querySelector('#first').open = true");
$page->assertScript("{$first}.open === true && document.querySelector('#second').open === false");
});
it('opens and closes a collapse at once under reduced motion, leaving nothing behind', function () {
$page = collapseMotionProbe('0ms');
$result = $page->script(<<<'JS'
(() => {
const details = document.querySelector('#plain')
details.querySelector('summary').click()
const opened = { open: details.open, animations: details.getAnimations().length }
details.querySelector('summary').click()
return { opened, open: details.open, animations: details.getAnimations().length, closing: details.hasAttribute('data-md-collapse-closing'), style: details.getAttribute('style') }
})()
JS);
expect($result)->toBe(['opened' => ['open' => true, 'animations' => 0], 'open' => false, 'animations' => 0, 'closing' => false, 'style' => null]);
});
+3 -1
View File
@@ -136,7 +136,9 @@ it('draws the layout from its own inlined rules under a build that never imports
// The layout: a centred column, the art at its 240px from medium, not a shape filling the window.
->assertScript("getComputedStyle(document.querySelector('[data-md-error-page]')).display === 'flex'")
->assertScript("Math.round(document.querySelector('[data-md-error-art]').getBoundingClientRect().width) === 240")
->assertScript("document.querySelector('[data-md-error-shape] svg').getBoundingClientRect().width <= 240")
// The shape's laid-out width, not its bounding rect: the shape turns once a minute, and a
// rotated box's axis-aligned rect is wider than the box as soon as the turn has begun.
->assertScript("parseFloat(getComputedStyle(document.querySelector('[data-md-error-shape] svg')).width) <= 240")
->assertScript("(() => { const r = document.querySelector('[data-md-error-headline]').getBoundingClientRect(); return Math.abs((r.left + r.right) / 2 - window.innerWidth / 2) <= 2; })()")
// The button and shape rules the layout imports, and the scheme from the application's build.
->assertScript("getComputedStyle(document.querySelector('[data-md-button]')).borderRadius !== '0px'")
+11 -5
View File
@@ -148,11 +148,17 @@ it('opens the customizable select picker as M3\'s menu, where the browser suppor
$select = "document.querySelector('#hours-field')";
$option = "{$select}.querySelector('option')";
$page = fieldProbe()
// menu.css only styles the picker inside @supports (appearance: base-select), so this
// also confirms the browser running the suite actually has it.
->assertScript("CSS.supports('appearance', 'base-select') === true")
->assertScript("getComputedStyle({$select}).appearance === 'base-select'");
$page = fieldProbe();
// menu.css only styles the picker inside @supports (appearance: base-select). A browser without
// it (Firefox) keeps its own native picker, which is the fallback, and there is no M3 menu to open.
if ($page->script("CSS.supports('appearance', 'base-select')") !== true) {
$page->assertScript("getComputedStyle({$select}).appearance !== 'base-select'");
return;
}
$page->assertScript("getComputedStyle({$select}).appearance === 'base-select'");
$page->click('#hours-field')
->assertScript("{$select}.matches(':open')")
+211
View File
@@ -431,3 +431,214 @@ it('takes an adaptive rail out of the layout across the expanded class when it h
->assertScript("document.documentElement.hasAttribute('data-rail-auto')")
->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();
});
/**
* Closes a rail with the given script and reports whether, sampling every few ms in the page
* itself, it was ever caught part-way: the whole close in one round trip, since a separate
* `script` and `assertScript` apiece already take as long as the 200ms exit (see ContainmentTest's
* scrim fades).
*/
function caughtMidClose(mixed $page, string $close, string $partWay): bool
{
return $page->script(<<<JS
(async () => {
{$close}
for (let i = 0; i < 60; i++) {
if ({$partWay}) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
JS) === true;
}
/**
* Opened and done opening: no transition left on the element. Firefox creates a transition on its
* next refresh tick, so straight after the change its computed style already reads the end value
* while the entry has not begun, and a close then would start from where the entry did.
*/
function settled(string $element): string
{
return "{$element}.getAnimations().length === 0";
}
/** Still drawn, and strictly between resting at the window's edge and gone past it. */
function slidingOut(string $panel): string
{
return "(getComputedStyle({$panel}).display !== 'none' && {$panel}.getBoundingClientRect().left < -2 && {$panel}.getBoundingClientRect().right > 2)";
}
it('slides the compact rail out on close, rather than making it vanish', function () {
$page = shellPage(599, 860)
->click('@shell-menu')
->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript(settled(RAIL_PANEL).' && Math.round('.RAIL_PANEL.'.getBoundingClientRect().left) === 0');
expect(caughtMidClose($page, "document.querySelector('[data-md-navigation-rail-scrim]').click()", slidingOut(RAIL_PANEL)))->toBeTrue();
$page->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'")
->assertScript('! '.RAIL.".hasAttribute('data-md-closing')")
->assertNoJavaScriptErrors();
});
it('slides a rail that hides when collapsed out on close, rather than making it vanish', function () {
$page = navigationExtrasProbe();
$rail = "document.querySelector('#hiding-rail [data-md-navigation-rail]')";
$panel = "{$rail}.querySelector(':scope > [data-md-navigation-rail-panel]')";
$page->click('#hiding-rail [data-md-navigation-rail-menu]')
->assertScript("Math.round({$rail}.getBoundingClientRect().width) === 0")
->click('#open-hiding-rail')
->assertScript("{$rail}.hasAttribute('data-md-open')")
->assertScript(settled($panel)." && Math.round({$panel}.getBoundingClientRect().left) === 0 && Math.round({$panel}.getBoundingClientRect().width) === 256");
expect(caughtMidClose($page, "{$rail}.querySelector(':scope > [data-md-navigation-rail-scrim]').click()", slidingOut($panel)))->toBeTrue();
$page->assertScript("getComputedStyle({$panel}).display === 'none'")
->assertScript("Math.round({$rail}.getBoundingClientRect().width) === 0")
->assertNoJavaScriptErrors();
});
it('fades a modal rail\'s scrim out on close while the rail stands collapsed in the layout', function () {
$scrim = RAIL.".querySelector(':scope > [data-md-navigation-rail-scrim]')";
$page = railValueProbe('modal', 1000)
->click('#open-rail')
->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript(settled($scrim)." && getComputedStyle({$scrim}).opacity === '1'");
$fading = "(getComputedStyle({$scrim}).display !== 'none' && parseFloat(getComputedStyle({$scrim}).opacity) > 0.02 && parseFloat(getComputedStyle({$scrim}).opacity) < 0.98)";
expect(caughtMidClose($page, "{$scrim}.click()", $fading))->toBeTrue();
// The panel goes straight back into the layout: only the scrim was over the page.
$page->assertScript("getComputedStyle({$scrim}).display === 'none'")
->assertScript('getComputedStyle('.RAIL_PANEL.").position === 'sticky'")
->assertScript(railWidth(96))
->assertNoJavaScriptErrors();
});
+127
View File
@@ -175,6 +175,68 @@ it('says so when nothing matches, and closes on a press outside or a chosen resu
->assertScript("getComputedStyle({$view}).display === 'none'");
});
/**
* Presses the open docked search's scrim and samples, in the page and in the same round trip,
* whether `$expression` (a number) was ever caught strictly between `$from` and `$to` while the
* element was still displayed: the exit ran, rather than the element vanishing at once.
*/
function searchCloseSample(string $element, string $expression): string
{
return <<<JS
(async () => {
const root = document.querySelector('[data-md-search]:has(#find)')
root.querySelector('[data-md-search-scrim]').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
for (let i = 0; i < 80; i++) {
const element = root.querySelector('{$element}')
const value = {$expression}
if (getComputedStyle(element).display !== 'none' && value > 0.02 && value < 0.98) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
JS;
}
it('fades the docked search\'s scrim out on close, rather than making it vanish', function () {
$root = "document.querySelector('[data-md-search]:has(#find)')";
$page = pickProbe()
->click('#find')
// Displayed as well as opaque: until the first frame of its entry the scrim is still
// `display: none` at the open state's full opacity.
->assertScript("(({ display, opacity }) => display !== 'none' && opacity === '1')(getComputedStyle({$root}.querySelector('[data-md-search-scrim]')))");
expect($page->script(searchCloseSample('[data-md-search-scrim]', 'parseFloat(getComputedStyle(element).opacity)')))->toBeTrue();
$page->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-scrim]')).display === 'none'");
});
it('keeps the docked search above the page while its view closes', function () {
$view = "document.querySelector('#find-view')";
$page = pickProbe()
->click('#find')
->assertScript("getComputedStyle({$view}).display !== 'none' && getComputedStyle({$view}).opacity === '1'");
// The view caught part-way through its fade only counts while the root is still at z-index 50.
expect($page->script(searchCloseSample('[data-md-search-view]', "getComputedStyle(root).zIndex === '50' ? parseFloat(getComputedStyle(element).opacity) : 0")))->toBeTrue();
$page->assertScript("getComputedStyle({$view}).display === 'none' && getComputedStyle(document.querySelector('[data-md-search]:has(#find)')).zIndex !== '50'");
});
it('closes the docked search view back into its bar, rather than making it vanish', function () {
$view = "document.querySelector('#find-view')";
$page = pickProbe()
->click('#find')
->assertScript("getComputedStyle({$view}).display !== 'none' && getComputedStyle({$view}).opacity === '1'");
expect($page->script(searchCloseSample('[data-md-search-view]', 'parseFloat(getComputedStyle(element).opacity)')))->toBeTrue();
$page->assertScript("getComputedStyle({$view}).display === 'none'");
});
it('takes the whole screen on a compact window, with a back arrow', function () {
$page = pickProbe()->resize(400, 800);
@@ -235,6 +297,71 @@ it('expands the search icon into the full-screen view, and hands focus back to t
->assertScript("getComputedStyle({$trigger}).display !== 'none'");
});
/**
* Closes a full-screen search from its back arrow and samples, in the page, for a moment in its
* exit where the layout is still full screen the root marked, the header bar fixed at the top
* with both the bar and the view part-way through their fade, rather than the bar gone back to
* its resting form (or to nothing, behind the icon) on the first frame.
*/
function fullScreenCloseSample(string $input): string
{
return <<<JS
(async () => {
const root = document.querySelector('[data-md-search]:has(#{$input})')
root.querySelector('[data-md-search-back]').click()
for (let i = 0; i < 80; i++) {
const bar = getComputedStyle(root.querySelector('[data-md-search-bar]'))
const view = getComputedStyle(root.querySelector('[data-md-search-view]'))
const fading = (style) => style.display !== 'none' && parseFloat(style.opacity) > 0.02 && parseFloat(style.opacity) < 0.98
if (! root.hasAttribute('data-md-open') && root.hasAttribute('data-md-full-screen') && bar.position === 'fixed' && fading(bar) && fading(view)) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
JS;
}
it('closes the full-screen view and its bar together, back into the search icon', function () {
$root = "document.querySelector('[data-md-search]:has(#find-icon)')";
$trigger = "{$root}.querySelector('[data-md-search-trigger]')";
$page = pickProbe()
->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]')
->assertScript("{$root}.hasAttribute('data-md-full-screen')")
// Its entry run: Firefox reads a transition's end value until its next refresh tick, so an
// opacity of 1 alone does not say the view has finished fading in.
->assertScript("{$root}.getAnimations({ subtree: true }).length === 0");
expect($page->script(fullScreenCloseSample('find-icon')))->toBeTrue();
$page->assertScript("! {$root}.hasAttribute('data-md-full-screen')")
->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-bar]')).display === 'none'")
->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-view]')).display === 'none'")
->assertScript("document.activeElement === {$trigger}")
// The view does not open again when the trap's return of focus lands on the icon.
->assertScript("! {$root}.hasAttribute('data-md-open')");
});
it('closes a compact window\'s full-screen view and its bar together, back into the bar', function () {
$root = "document.querySelector('[data-md-search]:has(#find)')";
$page = pickProbe()->resize(400, 800);
$page->click('#find')
->assertScript("{$root}.hasAttribute('data-md-full-screen')")
->assertScript("{$root}.getAnimations({ subtree: true }).length === 0");
expect($page->script(fullScreenCloseSample('find')))->toBeTrue();
$page->assertScript("! {$root}.hasAttribute('data-md-full-screen')")
->assertScript("(({ position, display, opacity }) => position === 'relative' && display !== 'none' && opacity === '1')(getComputedStyle({$root}.querySelector('[data-md-search-bar]')))")
->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-view]')).display === 'none'")
->assertScript("document.activeElement.id === 'find'")
->assertScript("! {$root}.hasAttribute('data-md-open')");
});
it('shows the suggestions until the first key, then the results, and counts whichever is on screen', function () {
$root = "document.querySelector('[data-md-search]:has(#find-icon)')";
$shown = fn (string $list): string => "{$root}.querySelector('[data-md-search-{$list}]').checkVisibility()";
+5 -2
View File
@@ -2,6 +2,7 @@
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Support\Layout;
const SKILL_PATH = __DIR__.'/../../resources/boost/skills/livewire-material-development/SKILL.md';
@@ -79,8 +80,10 @@ it('names every token family and breakpoint in the design skill', function () {
expect($skill)->toContain($family.'*');
}
foreach (['medium', 'expanded', 'large', 'extra-large'] as $breakpoint) {
expect($skill)->toContain("`{$breakpoint}:`");
foreach (Layout::BREAKPOINTS as $breakpoint => $width) {
if ($width > 0) {
expect($skill)->toContain("`{$breakpoint}` · `@media (width >= {$width}px)`");
}
}
});
+137
View File
@@ -0,0 +1,137 @@
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
/**
* The texts Boost copies into an application: the two guidelines every session reads, and the two
* skills. Each teaches the vocabulary an agent then writes, so none may name a class or variant
* the package's stylesheets do not define.
*/
const BOOST_TEXTS = [
'resources/boost/guidelines/core.blade.php',
'resources/boost/guidelines/material-3.blade.php',
'resources/boost/skills/livewire-material-development/SKILL.md',
'resources/boost/skills/material-3-design/SKILL.md',
];
/**
* The one section that names Tailwind's utilities on purpose: the design guard's table of what it
* reports, each beside its replacement.
*/
const BOOST_SECTIONS_NAMING_WHAT_FAILS = ['Testing the design'];
beforeEach(fn () => Stylesheets::resetCache());
/**
* A text's code, line for line, as the design guard reads an application: fenced Blade, HTML, PHP
* and JS into a view, fenced CSS into a stylesheet, and every inline span that reads as a class
* list (`bg-primary`, `medium:hidden`, `md-ink-variant`) as a `class` attribute on its own line.
* Prose, props, attributes and custom properties are left out.
*
* @return array{view: string, css: string}
*/
function boostTextAsCode(string $text): array
{
$view = [];
$css = [];
$fence = null;
$skipping = false;
foreach (explode("\n", $text) as $line) {
$viewLine = '';
$cssLine = '';
if (preg_match('/^```\s*([a-z]*)/', $line, $open) === 1) {
$fence = $fence === null ? ($open[1] ?: 'text') : null;
} elseif ($fence !== null) {
if (! $skipping && $fence === 'css') {
$cssLine = $line;
} elseif (! $skipping && in_array($fence, ['blade', 'html', 'php', 'js'], true)) {
$viewLine = $line;
}
} else {
if (preg_match('/^## (.+)$/', $line, $heading) === 1) {
$skipping = in_array($heading[1], BOOST_SECTIONS_NAMING_WHAT_FAILS, true);
}
if (! $skipping) {
preg_match_all('/`([^`]+)`/', $line, $spans);
$viewLine = collect($spans[1])
->map(fn (string $span): ?string => match (true) {
str_contains($span, '<') => $span,
boostSpanIsClassList($span) => '<div class="'.$span.'"></div>',
default => null,
})
->filter()
->implode(' ');
}
}
$view[] = $viewLine;
$css[] = $cssLine;
}
return ['view' => implode("\n", $view), 'css' => implode("\n", $css)];
}
/**
* Whether an inline span reads as a class list. Not: a CSS declaration (`font: var(…)`), a
* command (`php artisan …`), or a single bare word, which is as often a role, prop or value
* (`outline`, `link`, `hidden`) as a utility.
*/
function boostSpanIsClassList(string $span): bool
{
$tokens = preg_split('/\s+/', trim($span));
if (count($tokens) === 1 && preg_match('/[-:]/', $tokens[0]) !== 1) {
return false;
}
if (preg_match('/^(?:php|npm|npx|composer|git)\b|\b(?:var|calc|color-mix)\(|^[a-z-]+:\s/', $span) === 1) {
return false;
}
foreach ($tokens as $token) {
if (preg_match('/^(?:--|data-|aria-|wire:|x-|@|\$)/', $token) === 1 || str_contains($token, '::')) {
return false;
}
if (preg_match('/^[a-z0-9][a-z0-9:\/.\[\]()#%_-]*$/', $token) !== 1) {
return false;
}
}
return true;
}
it('teaches only classes and variants the stylesheets define', function () {
$directory = sys_get_temp_dir().'/livewire-material-boost-'.bin2hex(random_bytes(4));
$files = [];
foreach (BOOST_TEXTS as $index => $path) {
$code = boostTextAsCode(File::get(__DIR__.'/../../'.$path));
File::ensureDirectoryExists($directory);
File::put($files["{$directory}/text-{$index}.blade.php"] = "{$directory}/text-{$index}.blade.php", $code['view']);
File::put($files["{$directory}/text-{$index}.css"] = "{$directory}/text-{$index}.css", $code['css']);
}
try {
$violations = DesignGuard::scan(realpath($directory))->violations();
} finally {
File::deleteDirectory($directory);
}
$named = array_map(function (string $violation) use ($directory): string {
return (string) preg_replace_callback(
'#^'.preg_quote(realpath(dirname($directory)) ?: dirname($directory), '#').'/[^/]+/text-(\d+)\.(?:blade\.php|css)#',
fn (array $match): string => BOOST_TEXTS[(int) $match[1]],
$violation,
);
}, $violations);
expect($named)->toBe([]);
});
+1 -1
View File
@@ -75,7 +75,7 @@ it('keeps two trailing icon buttons below medium and four from it, the rest in a
->toMatch('/<button[^>]*aria-label="Star" aria-pressed="true"/')
// One overflow button per width, named and tooltipped.
->and(preg_match_all('/<button[^>]*data-md-size="sm"[^>]*aria-label="More options"/', $html))->toBe(2)
->and(preg_match_all('/role="menu"\s+data-md-menu-popover\s+data-md-position="bottom-end"\s+aria-label="More options"/', $html))->toBe(2)
->and(preg_match_all('/role="menu"\s+data-md-menu-popover\s+data-md-popover-exit\s+data-md-position="bottom-end"\s+aria-label="More options"/', $html))->toBe(2)
// Below medium: all but the first.
->and($compact)
->toMatch('/<a class="md-state-layer md-focus-ring" data-md-menu-item[^>]*role="menuitem" tabindex="-1" href="\/shares\/1\/share"/')
+2 -2
View File
@@ -39,7 +39,7 @@ it('binds its open state through x-model, drawing the open prop until Alpine sta
->toMatch('/x-data="{ collapseOpen:\s*true\s*}"/')
->toContain('x-modelable="collapseOpen"')
->toContain('x-model="advanced"')
->toContain('x-effect="$el.open = collapseOpen"')
->toContain('x-effect="materialCollapse($el, collapseOpen)"')
->toContain('x-on:toggle="collapseOpen = $el.open"')
->toMatch('/\sopen\s/');
});
@@ -72,6 +72,6 @@ it('draws a filled collapse\'s padding and an open one\'s chevron on its own par
expect($css->declarations("[data-md-collapse][data-md-variant='filled'] > [data-md-collapse-summary]"))->toBe(['padding-inline' => 'var(--md-sys-measurement-space200)'])
->and($css->declarations("[data-md-collapse][data-md-variant='filled'] > [data-md-collapse-body]"))->toBe(['padding-inline' => 'var(--md-sys-measurement-space200)'])
->and($css->declarations('[data-md-collapse][open] > [data-md-collapse-summary] > [data-md-collapse-chevron]'))->toBe(['rotate' => '180deg'])
->and($css->declarations('[data-md-collapse][open]:not([data-md-collapse-closing]) > [data-md-collapse-summary] > [data-md-collapse-chevron]'))->toBe(['rotate' => '180deg'])
->and($css->declarations('[data-md-collapse-summary]'))->toHaveKey('gap', '12px');
});
+6 -5
View File
@@ -79,11 +79,12 @@ it('opens at the corner asked for, secondary and tertiary in their own colours',
]);
});
it('sinks the items back while the list closes, from the popover\'s own open state', function () {
expect(ComponentStylesheet::read('fab-menu')->declarations('[data-md-fab-menu-popover]:not(:popover-open) > *'))->toBe([
'translate' => '0 var(--md-sys-measurement-space100)',
'opacity' => '0',
]);
it('sinks the items back while the list closes, in the popover and in its exit copy', function () {
expect((string) $this->blade('<x-fab-menu icon="add" label="New"><x-fab-menu-item label="Upload" icon="upload" /></x-fab-menu>'))->toContain('data-md-popover-exit')
->and(ComponentStylesheet::read('fab-menu')->declarations('[data-md-fab-menu-popover]:not(:popover-open) > *, [data-md-fab-menu-popover][data-md-popover-closing] > *'))->toBe([
'translate' => '0 var(--md-sys-measurement-space100)',
'opacity' => '0',
]);
});
it('gives an item its icon, colour and the exit fab-menu.css drives from the popover', function () {
+11 -1
View File
@@ -19,13 +19,23 @@ it('is 48px in the primary colour unless the caller sizes or colours it', functi
$css = ComponentStylesheet::read('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.
->and((string) $this->blade('<x-loading class="app-upload-spinner" label="Uploading" />'))
->toContain('class="app-upload-spinner"')
->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 () {
expect((string) $this->blade('<x-loading contained />'))->toContain('data-md-contained')
->and((string) $this->blade('<x-loading />'))->not->toContain('data-md-contained')
+40 -10
View File
@@ -63,10 +63,11 @@ it('takes M3\'s optional divider and turns the container fill off', function ()
$css = ComponentStylesheet::read('navigation-rail');
// Neither reaches a rail open over a scrim, which is a surface over the page (N-22).
expect($css->declarations('[data-md-navigation-rail][data-md-divider]:not([data-md-open]) > [data-md-navigation-rail-panel]'))
// Neither reaches a rail open over a scrim, which is a surface over the page (N-22), nor one
// sliding off the window as it closes.
expect($css->declarations('[data-md-navigation-rail][data-md-divider]:not([data-md-open], [data-md-closing=\'sheet\']) > [data-md-navigation-rail-panel]'))
->toBe(['border-inline-end' => '1px solid var(--md-sys-color-outline-variant)'])
->and($css->declarations('[data-md-navigation-rail][data-md-fill=\'false\']:not([data-md-open]) > [data-md-navigation-rail-panel]'))
->and($css->declarations('[data-md-navigation-rail][data-md-fill=\'false\']:not([data-md-open], [data-md-closing=\'sheet\']) > [data-md-navigation-rail-panel]'))
->toBe(['background-color' => 'transparent'])
// A collapsible rail is held to its collapsed width where M3 asks for a bar instead (N-24).
->and($css->declarations('[data-md-navigation-rail=\'collapsible\']', ['@media (width < 600px)']))
@@ -148,29 +149,58 @@ 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
* 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
* 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('holds a closing rail on screen for its exit rather than transitioning display', function () {
$css = ComponentStylesheet::read('navigation-rail');
// Firefox cannot transition `display`, so no rule tries: the view sets `data-md-closing` beside
// `data-md-open`, in the same flush, and navigation.js drops it once the exit has run.
$discrete = collect($css->rules())
->flatMap(fn (array $rule): array => array_values($rule['declarations']))
->filter(fn (string $value): bool => str_contains($value, 'allow-discrete'));
expect($discrete)->toBeEmpty()
->and((string) $this->blade('<x-navigation-rail mode="modal" />'))
->toContain('x-bind:data-md-closing="closing"')
->toContain('x-effect="settle(open)"')
// The panel that leaves the window stays drawn, and in its open geometry, while it slides.
->and($css->declarations("[data-md-navigation-rail][data-md-hide-when-collapsed][data-md-closing='sheet'] > [data-md-navigation-rail-panel]"))
->toBe(['display' => 'flex'])
->and($css->declarations("[data-md-navigation-rail='adaptive'][data-md-closing='sheet'] > [data-md-navigation-rail-panel]", ['@media (width < 600px)']))
->toBe(['display' => 'flex'])
->and($css->declarations("[data-md-navigation-rail]:is([data-md-open], [data-md-closing='sheet']) > [data-md-navigation-rail-panel]"))
->toMatchArray(['position' => 'fixed', 'width' => 'var(--navigation-rail-expanded-width)'])
// The scrim stays drawn for its fade whichever way the rail closes.
->and($css->declarations('[data-md-navigation-rail][data-md-closing] > [data-md-navigation-rail-scrim]'))
->toBe(['display' => 'block']);
});
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');
$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
// 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]) * )"))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)'])
->toBe($collapsed)
// 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)']))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)'])
->toBe($collapsed)
// 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)']))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)'])
->toBe($collapsed)
// Branch 6: at `expanded` (840-1199), an `adaptive` rail not open, chosen collapsed or no
// 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)']))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)'])
->toBe($collapsed)
// Branch 7: from `large` (1200px), an `adaptive` rail not open, only when chosen collapsed —
// 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)']))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)']);
->toBe($collapsed);
});
it('flattens a FAB nested in the rail header and morphs its label', function () {
+25 -3
View File
@@ -75,7 +75,7 @@ it('pins a dialog\'s headline and actions and scrolls only the body between them
expect($html)
->toContain('data-md-modal-box')
->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
->toMatch('/<div id="[^"]+-body" data-md-modal-body x-dialog-dividers>\s*<div data-md-modal-content>Body<\/div>\s*<\/div>/')
->toMatch('/<div id="[^"]+-body" wire:ignore.self data-md-modal-body x-dialog-dividers>\s*<div data-md-modal-content>Body<\/div>\s*<\/div>/')
->toMatch('/<div\s+data-md-modal-actions\s+data-md-modal-divider\s*>/');
});
@@ -194,6 +194,8 @@ it('puts a side sheet\'s actions on the left, in M3\'s 72dp row', function () {
it('slides a side sheet in from either edge', function () {
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
->toContain('x-trap.inert.noscroll="open && ! wide"')
->toContain('x-bind:data-md-drawer-collapsed="collapsed ? \'\' : null"')
->toContain('x-effect="settle(open)"')
->toContain('data-md-side="end"')
->toContain('data-md-drawer-sheet')
->toContain('role="dialog"')
@@ -229,6 +231,8 @@ it('is M3\'s standard side sheet from expanded, and the modal one below', functi
->toContain('--sheet-width: min(400px, 400px)')
->toContain("matchMedia('(width >= 840px)')")
->toContain('x-trap.inert.noscroll="open && ! wide"')
->toContain('x-bind:data-md-drawer-collapsed="collapsed ? \'\' : null"')
->toContain('x-effect="settle(open)"')
->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer standard side="start" width="30rem">Body</x-drawer>'))
->toContain('data-md-side="start"')
@@ -241,6 +245,18 @@ it('is M3\'s standard side sheet from expanded, and the modal one below', functi
expect($css->has('[data-md-drawer][data-md-standard]', $expanded))->toBeTrue()
->and($css->declarations('[data-md-drawer][data-md-standard][data-md-drawer-collapsed]', $expanded))->toBe(['display' => 'none'])
// Closing gives the room back as the sheet leaves (M3's side sheets, "Adaptive"): the root's
// inline size and the parent's gap spring to none, and the collapse waits for that exit.
->and($css->declarations('[data-md-drawer][data-md-standard]', $expanded))->toMatchArray([
'overflow' => 'clip',
'inline-size' => '0',
'margin-inline-start' => 'calc(-1 * var(--md-drawer-gap, 0px))',
'transition-property' => 'inline-size, margin-inline',
])
->and($css->declarations('[data-md-drawer][data-md-standard][data-md-open]', $expanded))->toMatchArray([
'inline-size' => 'min(var(--sheet-width), calc(100vw - 64px))',
'margin-inline' => '0',
])
// The old view's `expanded:hidden` on a standard sheet's scrim.
->and($css->declarations('[data-md-drawer][data-md-standard] > [data-md-drawer-scrim]', $expanded))->toBe(['display' => 'none'])
->and($css->declarations('[data-md-drawer][data-md-standard] > [data-md-drawer-sheet]', $expanded))->toHaveKey('box-shadow', 'none')
@@ -251,15 +267,21 @@ it('is M3\'s standard side sheet from expanded, and the modal one below', functi
it('slides a side sheet out as it slid in, from its own edge in either direction, and fades its scrim both ways', function () {
$css = ComponentStylesheet::read('drawer');
// Alpine's CSS transition holds `display` through the exit (the view's `x-transition`), not
// `allow-discrete`, which Firefox does not honour for `display`.
expect($css->declarations('[data-md-drawer-sheet]'))
->toHaveKey('translate', 'var(--sheet-offset) 0')
->toHaveKey('transition-behavior', 'allow-discrete')
->toHaveKey('transition-property', 'translate')
->not->toHaveKey('transition-behavior')
->not->toHaveKey('opacity')
->and($css->declarations("[data-md-drawer-sheet][data-md-side='end']:is([dir='rtl'], [dir='rtl'] *)"))->toBe(['--sheet-offset' => '-100%'])
->and($css->declarations("[data-md-drawer-sheet][data-md-side='start']:is([dir='rtl'], [dir='rtl'] *)"))->toBe(['--sheet-offset' => '100%'])
->and($css->declarations('[data-md-drawer][data-md-open] > [data-md-drawer-sheet]', ['@starting-style']))->toBe(['translate' => 'var(--sheet-offset) 0'])
->and($css->declarations('[data-md-drawer-scrim]'))->toHaveKey('opacity', '0')
->and($css->declarations('[data-md-drawer][data-md-open] > [data-md-drawer-scrim]'))->toBe(['opacity' => '1']);
->and($css->declarations('[data-md-drawer][data-md-open] > [data-md-drawer-scrim]'))->toBe(['opacity' => '1'])
->and((string) $this->blade('<x-drawer title="Filters">Body</x-drawer>'))
->toMatch('/<div data-md-drawer-scrim x-cloak x-show="open" x-transition:enter="md-transition" x-transition:leave="md-transition"/')
->toMatch('/<aside\s+x-cloak\s+x-show="open"\s+x-transition:enter="md-transition"\s+x-transition:leave="md-transition"/');
});
it('draws the drawer from a stylesheet imported from the containment block, with the pane prop gone', function () {
+3 -1
View File
@@ -114,7 +114,9 @@ it('draws the bar and the view from its stylesheet, at M3\'s widths', function (
expect($css)->toContain('@layer material.components')
->toMatch('/\\[data-md-search\\] \\{\\s*--search-height: 56px;[^}]*--search-open-width: 720px;/')
->toContain('[data-md-search][data-md-full-screen] [data-md-search-view] {')
->toContain("[data-md-search][data-md-trigger='icon']:not([data-md-open]) [data-md-search-bar] {")
->toContain("[data-md-search][data-md-trigger='icon']:not([data-md-open], [data-md-full-screen]) [data-md-search-bar] {")
// Leaving full screen, the header bar fades with the view instead of going on the first frame.
->toContain('[data-md-search][data-md-full-screen]:not([data-md-open]) [data-md-search-bar] {')
->not->toMatch('/\\d(?:\\.\\d+)?rem/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/search.css';");
});
+6 -4
View File
@@ -10,7 +10,7 @@ it('wraps a trigger and anchors the bubble to it', function () {
expect($anchor)->not->toBeEmpty()
->and($html)
->toMatch('/<span data-md-tooltip-anchor style="anchor-name: --material-tooltip-[a-z0-9]+">\s*<button>Copy<\/button>/')
->toMatch('/<span\s+popover="manual"\s+aria-hidden="true"\s+x-data="materialTooltip"\s+data-md-tooltip\s+data-md-side="top"\s+style="position-anchor: '.preg_quote($anchor[1], '/').'"\s*>Copy link<\/span>/')
->toMatch('/<span\s+popover="manual"\s+aria-hidden="true"\s+x-data="materialTooltip"\s+data-md-tooltip\s+data-md-popover-exit\s+data-md-side="top"\s+style="position-anchor: '.preg_quote($anchor[1], '/').'"\s*>Copy link<\/span>/')
->not->toContain('class=');
});
@@ -44,12 +44,14 @@ it('draws M3\'s plain tooltip in the inverse surface, and fades on the effects s
'font' => 'var(--md-sys-typescale-body-sm)',
'pointer-events' => 'none',
'opacity' => '0',
'transition-property' => 'opacity',
'transition-duration' => 'var(--md-sys-motion-effects-fast-duration)',
'transition-timing-function' => 'var(--md-sys-motion-effects-fast)',
'transition-behavior' => 'allow-discrete',
])
->and($css->declarations('[data-md-tooltip]:popover-open'))->toBe(['opacity' => '1'])
->and($css->declarations('[data-md-tooltip]:popover-open', ['@starting-style']))->toBe(['opacity' => '0']);
// No `display`/`overlay` transition: the fade out is the exit copy's (popover-exit.js).
->not->toHaveKey('transition-behavior')
->and($css->declarations('[data-md-tooltip]:popover-open:not([data-md-popover-closing])'))->toBe(['opacity' => '1'])
->and($css->declarations('[data-md-tooltip]:popover-open:not([data-md-popover-closing])', ['@starting-style']))->toBe(['opacity' => '0']);
});
it('takes the caller\'s class and style on the standalone wrapper, its root', function () {