22 Commits
Author SHA1 Message Date
Andreas Reinhold / reiniandClaude Opus 5 b1fc0c9cfa Keep a connected group's inner corners round
tests / feature (8.4) (push) Successful in 1m17s
tests / feature (8.5) (push) Successful in 1m21s
tests / browser (chrome, chromium) (push) Successful in 3m55s
tests / browser (firefox, firefox) (push) Successful in 5m34s
tests / lint (push) Successful in 1m5s
tests / browser (safari, webkit) (push) Successful in 5m54s
Connected segments and split halves wrote their full corners as
--md-sys-shape-corner-full, 9999px, beside 8px inner corners on the same
element. A box whose radii add up to more than its side has every radius
scaled by one factor, so the inner corners shrank to a hundredth of a pixel
and drew square. Full is now half the size's height (--group-full), which no
side can overflow. A browser test fails on any radius in a group or split
button larger than half its height.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 22:46:10 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a980f5b0d0 Expect the section picker to mark its page, not a checked item
The browser test still looked for the menuitemcheckbox the picker drew before
it marked the current section with aria-current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 22:41:51 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a4bc046d1d Speak a shell destination's badge in words, and stop a null offset on PHP 8.5
<x-app-shell> passed no badge label to its rail and bar items, so a badge
like 0/3 reached a screen reader bare; a destination's badgeLabel now does.

The head script indexed the profiles with a null scheme when none are
configured, which PHP 8.5 reports as a deprecated null array offset on every
page with theme.meta on. It reads the default scheme without the lookup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 22:20:37 +02:00
Andreas Reinhold / reiniandClaude Opus 5 433ddb2baa Keep aria-pressed off a selected link button
ARIA defines aria-pressed for buttons, not links, so a selected <x-button
link> keeps the selected look without announcing itself as a toggle; the
caller marks the page with aria-current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 21:36:35 +02:00
Andreas Reinhold / reiniandClaude Opus 5 e6f4357a2a Add a solid status label to the badge
A status label came tonal (the colour's container) or outlined; a label that
has to stand out in the colour itself fell back to the filled count, which is
16px and hidden from screen readers. solid draws the label shape in the
colour's filled pair and is spoken like the others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 21:08:55 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ea529bf785 Let a list-detail pane close on Escape when asked
A drawer that is a pane from xl ignored Escape, whatever close-on-escape
said: the page beside a pane stays in use, so that is the right default. An
application whose pane is a transient detail (ReStride's activity and
workout panes) can now pass pane-close-on-escape and have Escape close it at
every width.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 20:31:33 +02:00
Andreas Reinhold / reiniandClaude Opus 5 869ccdf447 Mark a section picker's current page as the page, with its badge
The phone picker of <x-section-nav> drew the current section as a checked
menuitemcheckbox in tertiary-container, which a screen reader announces as a
choice rather than where you are, and it dropped the sections' badges, so an
open support count showed on the tabs but not on a phone. <x-menu-item> gains
current (aria-current="page", the selected shape in secondary-container,
the navigation indicator's colour) and badge (a dot or a count at the end of
the row), and the picker uses both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 19:56:54 +02:00
Andreas Reinhold / reiniandClaude Opus 5 65788d0c61 Keep a date picker's and an app bar's settings on their own component
materialDatepicker set its first day, format, min, max and year range in
init() without declaring them, and materialAppBar did the same with its
resize observer and scroll handler. Alpine writes an undeclared property to
the outermost x-data scope, so two pickers inside one page scope (ReStride's
plan setup wraps its form in x-data) shared the last one's min and first day,
and a second app bar would have taken the first one's observer. Both now
declare them; a browser test puts two pickers in an outer scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 19:17:11 +02:00
Andreas Reinhold / reiniandClaude Opus 5 8640d815c8 Keep open menus and rich tooltips open through a Livewire render
<x-menu>, <x-fab-menu> and <x-rich-tooltip> gave their popover an id
that is new with every render, and Livewire's morph matches an element
without a wire:key by its id. So every render of the component around
them swapped the popover for a closed copy: an open menu closed, its
listeners stayed behind on the old element (Escape or a press outside
then left aria-expanded="true" on the trigger and focus unreturned), a
keep-open item's wire:click closed its menu when the response came, and
a rich tooltip went on showing the detached bubble, so it never opened
again. <x-carousel>'s row had the same kind of id: after a render it
scrolled without its listeners, and its items stopped re-masking.

The popover, the bubble and the row now carry a wire:key, so the morph
patches them in place and changes the id and anchor name together with
the trigger's, as it already did for everything else. The key goes
through an attribute bag: Livewire compiles a wire:key written in a
template into the key of the loop iteration around it, which would have
given every child component after the menu in a row the same key.

A morph also removes the menu button's ARIA attributes, which only
script writes. menu.js now writes them again after every morph, so the
button of a menu that stays open, and a FAB menu's close look, still
say it is open, and aria-controls names the popover's new id.

A second press on an open menu's button opened it again, render or not:
the popover closes on the press, and the guard against the click that
follows was timed from the toggle event, which is queued and arrives
after that click. It is timed from beforetoggle now.

Browser tests with Livewire probes in Chromium, Firefox and WebKit, and
a render test for the keys and the keys of the child components after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:50:14 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ab7317faae Add hint-class to the group and icon-class to menu items
<x-group hint-class> adds classes to the hint, as the text fields'
hint-class does, and a validation message still replaces the hint.
<x-menu-item icon-class> adds classes to the leading icon, for an icon
whose colour means something of its own, such as a sport's glyph.

A colour class there has to win over the component's own colour, and
which of two colour utilities wins depends on the order Tailwind emits
them (text-error comes before text-on-surface-variant). So when either
prop is given, the component's own colour is written with a :where()
variant that carries no specificity, as the fields' hint colour sits
in the components layer. A disabled item's icon stays disabled. Without
the props the markup is unchanged. The skill now also lists the fields'
hint-class, which it had left out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:18:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 a83d7f62ea Hang a menu on its menu button rather than the trigger's wrapper
<x-menu> named its CSS anchor on the <span> around the trigger slot.
A trigger taken out of the flow, such as <x-button fab> fixed to the
bottom corner of a phone's window, left that span behind as an empty
box where the page put it, and the menu opened there. menu.js now moves
the name onto the menu button, beside any name the button already
carries for its tooltip, and moves it again whenever a Livewire morph
puts the server's attributes and a fresh name back. The wrapper keeps
the name until Alpine starts, or when there is no menu button.

A menu that fits neither below nor above its start edge now also tries
the opposite side and end together, so a default-position menu on a
FAB in the bottom-right corner opens above it, end-aligned. Before, it
fell back to its base position and overflowed the window.

This also applies to <x-fab-menu>, <x-account-menu>, <x-split-button>
and the <x-section-nav> picker, which share menu.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:18:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 fb29169d44 Let the date picker take a first day of the week and a format
An application that lets each person choose when their week starts and
how dates are written needs the picker to follow that choice rather
than the locale. <x-datepicker> takes week-start, 0 (Sunday) to 6
(Saturday), and format, dd, MM and yyyy each once around one delimiter
(dd.MM.yyyy, dd/MM/yyyy, MM/dd/yyyy, yyyy-MM-dd). They replace what
Intl derives for firstDayOfWeek() and inputFormat(), so the field, the
server-rendered value, the typed-date reader (year first too), the
calendar's columns and weekday header, Home and End, the dialog's text
fields and ranges all follow them. Month and weekday names stay the
locale's, and wire:model still stores Y-m-d. Values that are neither
are ignored, and without the props nothing changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:18:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 fdb13313f7 Keep the current section lit while a Livewire component updates
<x-section-nav> and <x-app-shell> mark an item current when its URL is
the request's. While a Livewire component on the page re-renders, the
request is Livewire's update endpoint, so no section or destination
stayed lit after the morph. Both now compare with the page's URL from
Livewire::originalUrl() during a Livewire update request; a normal
request compares exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:18:39 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d77ecf5e2a Add an illustration slot to the empty state
An application with its own artwork for an empty collection puts it in
the `illustration` slot, which is drawn in place of the shape and icon.
The slot's attributes go on the element around it, so its class sets the
colour an SVG's currentColor takes. A slot holding only whitespace or
comments counts as empty (hasActualContent), and without the slot the
empty state renders as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:11:00 +02:00
Andreas Reinhold / reiniandClaude Opus 5 e63c0f684c Bind the collapse's open state through wire:model and x-model
An application keeps a disclosure's state in a Livewire property
(`<x-collapse wire:model="fineTuning">`) or an Alpine one. Either binding
now works both ways: toggling the details writes the property, and the
property changing, in an action or in Alpine, opens or closes it.

`wire:model` is entangled as the dialog and sheets entangle theirs, and
taken off the element. The server renders `open` from the property, so
the first paint matches it. `x-model` binds through `x-modelable`, with
`open` as the first paint until Alpine starts. The state is named
`collapseOpen` so it never hides an `open` that a dialog in the slot reads
from a scope around it.

Without a binding the element has no Alpine and renders as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:10:59 +02:00
Andreas Reinhold / reiniandClaude Opus 5 973cc0b68c Render badge slots as HTML and add neutral and plain badge colours
The slot was cast to a string and escaped, so an icon beside a badge's
word showed as literal markup. It renders as HTML now; `value` stays
escaped, and a slot holding only whitespace or comments is still a dot
(hasActualContent rather than isNotEmpty).

`color="neutral"` draws neutral ink on every variant: a dot or count in
on-surface-variant with surface text (the ink the outline badge already
writes in), a tonal label in surface-container-high with
on-surface-variant text, and an outline in outline-variant.

`color="plain"` emits no background, text or border colour in any
variant, keeping shape, size and type, so an application's own colour
classes paint it without racing the badge's.

Every existing colour renders the same classes as before, and an unknown
colour still falls back to error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:10:59 +02:00
Andreas Reinhold / reiniandClaude Opus 5 07e008d942 Add sticky toasts, action events and toast hooks
A sticky toast stays until it is dismissed or its action pressed, for a
question that must be answered ("A new version is ready" with Reload).
It is kept aside rather than queued, so it never holds up ordinary
toasts: one that arrives while it shows takes its place, and the sticky
toast comes back once the queue is empty. One is kept at a time; a newer
sticky toast replaces it. Every other toast keeps today's queue.

An action's `event` names a window event dispatched when it is pressed,
beside `handler`, for toasts whose detail cannot carry a function. The
snackbar now closes before either runs, so a toast they show is not the
one dismissed.

`data-toast` and `data-toast-action` give applications stable hooks for
their tests.

The queue now forgets a cleared timer in next(): a toast dismissed by a
click that neither hovered nor focused it first, as a screen reader
activates a button, left its timer running and cut the next toast short.

The showcase's snackbar buttons had no Alpine scope and did nothing; they
are wrapped in one, with a sticky example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:10:59 +02:00
Andreas Reinhold / reiniandClaude Opus 5 53bb000425 Let an application replace the safe-area insets and dock on the bottom bar
Components read the device's safe-area insets straight from env(), which a
browser test cannot fake and an application drawing its own status strip
cannot extend. Every inset the package reads, in its CSS and its views, is
now var(--material-safe-top|bottom|left|right, env(safe-area-inset-…)):
unchanged while the variables are unset.

The app shell's --material-bottom-bar also adds
var(--material-bottom-extra, 0px), so an application that docks something on
the phone's navigation bar (an offline banner) sets its height once and the
snackbar, a fab button and the page's bottom padding clear it.

A guard test fails on any env(safe-area-inset-*) outside such a variable, and
AppShellTest's assertion on the bar height's class follows the new value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:03:20 +02:00
Andreas Reinhold / reiniandClaude Opus 5 bcf0d4f4e3 Keep the theme-color meta in step with the theme
An app installed as a PWA, and a mobile browser, colour their bar from
<meta name="theme-color">, which the package left alone: the bar kept the
server's one colour whatever theme the visitor chose.

With the new `theme.meta` option (off by default), <x-theme-script> sets the
content of every theme-color meta without a `media` attribute to the
resolved theme's surface, from the scheme data and for the profile in
<html data-scheme>, adding one to <head> when the page has none. A
MutationObserver on <html> follows every later change of data-theme or
data-scheme: $store.theme's set() and toggle(), an OS change while
`system`, a profile preview, an application's own script. wire:navigate's
head merge puts the next page's server-rendered meta in place, so it is
painted again as the page is swapped in and on livewire:navigated. Turned
off, the script is byte-for-byte what it was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 17:59:34 +02:00
Andreas Reinhold / reiniandClaude Opus 5 3a7aa96ec7 Add a colour spec choice to material:scheme
The scheme script always generated with material-color-utilities' 2025 spec.
An application whose palette was generated with M3's original 2021 colour
(ReStride) could not reproduce it. The script now takes `spec` ('2025' by
default, or '2021') and refuses anything else, and the command takes
`--spec`, validated before Node runs.

Colour profiles may set their own `spec`, `success`, `warning` and `info`;
without them the command's options apply. The stylesheet header writes out
every option that differs from its default, so the recorded command
regenerates the file, and a profile whose spec differs from the header's
names it. Default output is byte-for-byte unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 17:52:50 +02:00
Andreas Reinhold / reiniandClaude Opus 5 db179e6b4e Compile the showcase's views with the showcase off
tests / lint (push) Successful in 1m6s
tests / feature (8.4) (push) Successful in 1m18s
tests / feature (8.5) (push) Successful in 1m17s
tests / browser (chrome, chromium) (push) Successful in 3m27s
tests / browser (firefox, firefox) (push) Successful in 4m57s
tests / browser (safari, webkit) (push) Successful in 5m29s
php artisan view:cache compiles every view in the package's namespace,
the showcase's pages included, but the showcase:: component prefix was
only registered while the showcase was enabled. In production, where it
is off, view:cache failed with "Unable to locate a class or view for
component [showcase::example]" and an application caching its views on
start would not boot. The prefix is now always registered; the routes
still are not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 17:40:33 +02:00
Andreas Reinhold / reiniandClaude Opus 5 8afe9bac6a Keep planning notes out of the repository
tests / lint (push) Successful in 1m1s
tests / feature (8.4) (push) Successful in 1m17s
tests / feature (8.5) (push) Successful in 1m19s
tests / browser (chrome, chromium) (push) Successful in 3m30s
tests / browser (firefox, firefox) (push) Successful in 4m57s
tests / browser (safari, webkit) (push) Successful in 5m23s
docs/plans is ignored from now on; the notes stay on the machine that
wrote them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 16:25:35 +02:00
66 changed files with 2138 additions and 829 deletions
+3
View File
@@ -7,3 +7,6 @@
/workbench/public/hot
/tests/Browser/Screenshots
.DS_Store
# Planning notes stay local
/docs/plans
+3 -2
View File
@@ -72,11 +72,11 @@ Generate the scheme from a seed colour. It writes `resources/css/material-scheme
php artisan material:scheme "#4f46e5" --variant=tonal-spot
```
Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--contrast` runs from -1 to 1; `--success`, `--warning` and `--info` seed the state colours. Regenerate instead of editing the file.
Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (M3 Expressive, the default) or `2021` (M3 as it first shipped, for a palette generated before Expressive). `--contrast` runs from -1 to 1; `--success`, `--warning` and `--info` seed the state colours. The stylesheet's header records the command that regenerates it; regenerate instead of editing the file.
#### Colour profiles
To let an installation switch between several schemes, list them as `profiles` in the config (name ⇒ `label`, `seed`, `variant`) and run `php artisan material:scheme` without a seed: every profile lands in the same stylesheet under `<html data-scheme>`. Tell the package which one is active — `Scheme::resolveProfileUsing(fn () => Setting::get('color_profile'))` in a service provider — and the head script, mails and error pages follow it. `<x-scheme-picker wire:model="colorProfile" />` lets someone choose, previewing each profile on the page.
To let an installation switch between several schemes, list them as `profiles` in the config (name ⇒ `label`, `seed`, `variant`, and optionally `contrast`, `spec`, `success`, `warning`, `info`, which otherwise come from the command's options) and run `php artisan material:scheme` without a seed: every profile lands in the same stylesheet under `<html data-scheme>`. Tell the package which one is active — `Scheme::resolveProfileUsing(fn () => Setting::get('color_profile'))` in a service provider — and the head script, mails and error pages follow it. `<x-scheme-picker wire:model="colorProfile" />` lets someone choose, previewing each profile on the page.
### Configuration
@@ -86,6 +86,7 @@ php artisan vendor:publish --tag=livewire-material-config
- `prefix` — components are `<x-button>`, `<x-card>`… Set `'m'` when a name clashes with the application's own components, and they become `<x-m::button>`. `<x-livewire-material::button>` always works.
- `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys` (an earlier toggle's localStorage keys, adopted once).
- `theme.meta` — keep `<meta name="theme-color">` (an installed web app's or a mobile browser's bar) on the resolved theme's `surface` and the active colour profile, before the first paint and after every change, `wire:navigate` included; one is added when the page has none (default `false`).
- `profiles`, `profile` — colour profiles and the default one (see Colour profiles).
- `fields.variant` — text fields `outlined` (default) or `filled`.
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
+8 -3
View File
@@ -6,7 +6,8 @@
* nothing but `node`. (The published library imports without file extensions, which
* plain Node refuses, so it cannot be run unbundled anyway.)
*
* Input: one JSON argument — {seed, variant, contrast, success, warning, info}.
* Input: one JSON argument — {seed, variant, spec, contrast, success, warning, info}. `spec` is
* the colour spec, '2025' (M3 Expressive, the default) or '2021' (M3 as it first shipped).
* Output: JSON on stdout — {seed, variant, spec, contrast, light: {role: hex}, dark: {role: hex}}.
*/
import {
@@ -56,9 +57,12 @@ try {
const hex = /^#[0-9a-f]{6}$/i
const Scheme = VARIANTS[input.variant]
const SPECS = ['2021', '2025']
const spec = input.spec ?? '2025'
if (!hex.test(input.seed ?? '')) fail(`The seed must be a #rrggbb colour, "${input.seed}" given.`)
if (!Scheme) fail(`Unknown variant "${input.variant}". Use one of: ${Object.keys(VARIANTS).join(', ')}.`)
if (!SPECS.includes(spec)) fail(`Unknown spec "${spec}". Use one of: ${SPECS.join(', ')}.`)
for (const state of ['success', 'warning', 'info']) {
if (!hex.test(input[state] ?? '')) fail(`The ${state} colour must be a #rrggbb colour, "${input[state]}" given.`)
@@ -73,8 +77,9 @@ const colors = new MaterialDynamicColors()
function roles(isDark) {
// The 2025 spec is M3 Expressive's colour; the library falls back to 2021 for the
// variants the new spec does not define (fidelity, content, monochrome, …).
const scheme = new Scheme(source, isDark, contrast, '2025')
// variants the new spec does not define (fidelity, content, monochrome, …). 2021 is
// M3's original colour, for an application whose palette was generated with it.
const scheme = new Scheme(source, isDark, contrast, spec)
const out = {}
for (const color of colors.allColors) {
+11 -1
View File
@@ -28,12 +28,20 @@ return [
| localStorage under 'storage_key'; values found under 'legacy_keys' (an
| earlier theme toggle's key) are adopted once and then removed.
|
| 'meta' keeps <meta name="theme-color"> (the colour an installed web app
| or a mobile browser gives its bar) on the resolved theme's surface, and
| the active colour profile's: the head script sets it before the first
| paint, adds one when the page has none, and follows every later change,
| wire:navigate included. A theme-color meta with a `media` attribute is
| left alone.
|
*/
'theme' => [
'default' => 'system',
'storage_key' => 'material-theme',
'legacy_keys' => [],
'meta' => false,
],
/*
@@ -113,7 +121,9 @@ return [
|--------------------------------------------------------------------------
|
| Named schemes an installation can switch between. Each one is a 'label',
| a 'seed' (#rrggbb), a 'variant' and an optional 'contrast'. Without a
| a 'seed' (#rrggbb), a 'variant' and an optional 'contrast'; 'spec'
| ('2025' or '2021') and the 'success', 'warning' and 'info' sources are
| optional too, taken from the command's options when left out. Without a
| seed, `php artisan material:scheme` generates every profile into one
| stylesheet keyed by <html data-scheme>. 'profile' names the default one
| (else the first); the application says which is active with
-658
View File
@@ -1,658 +0,0 @@
# Livewire Material: a shared Material 3 Expressive component library
> Split on 2026-09-13 from SealShare's `docs/plans/livewire-material.md`, where the plan was
> made. SealShare's adoption (Phase 11) stays in SealShare's copy.
## Goal
ReStride has just left maryUI and daisyUI for **Material 3 Expressive on its own Blade
components** (`ReStride/docs/plans/material-expressive.md`, done 2026-09-10). Doing that
again, by hand, in every Laravel + Livewire app is what this package avoids.
**`nonameweb/livewire-material`** carries the whole current M3 Expressive component catalogue
as Blade components for Livewire, together with everything around them — colour scheme
generation, tokens, the theme script, Material Symbols, Google Sans Flex, motion, error pages,
a mail theme, a showcase, test helpers and AI guidelines. SealShare converts to it once it is
complete (`1.0.0`); ReStride adopts it later, in a plan of its own.
## Context
**Stacks.** SealShare: Laravel 13.31, Livewire 4.4, maryUI 2.9.10 (no prefix), daisyUI 5.7,
Tailwind 4.3, Pest 5.1, Octane on FrankenPHP, PHP 8.5; public on GitHub under MIT, image
published to `ghcr.io/surtic86/sealshare`. ReStride: same Laravel / Livewire / Tailwind / Pest,
private on `gitea.nonameweb.ch`, CI through Gitea act_runner.
**What ReStride already solved** (and the package generalises):
- 45 anonymous components in `resources/views/components/`, maryUI's names and props, M3
styling; class components only where PHP earns it.
- `resources/css/material/{color,shape,type,motion,elevation,field,menu,table,list}.css`;
`@theme` (never `inline`) mapping M3 roles to utilities; `--color-*: initial` so only tokens
compile; `:root` dark + `[data-theme=light]`, each with its own `color-scheme`.
- Material Symbols Rounded (400/0/24) as local SVGs registered as `blade-icons` sets
(`ms`/`msf`), `config/blade-icons.php` turning off blade-icons' own `<x-icon>`.
- Google Sans Flex, a 63 KB Latin subset (weight 400700, ROND 0100).
- Spring motion as CSS `linear()` curves; press shape-morph; Expressive shapes generated by
formula into `resources/svg/shapes`.
- `App\Livewire\Concerns\Toasts` (protected `success|error|warning|info`, dispatching a window
event) and a snackbar `<x-toast>`.
- ~30 component tests (`$this->blade()`), browser tests (Pest browser plugin), and guard tests
(`DesignLanguageTest`, `MaterialTokensTest`, `MaterialSymbolsTest`).
- The Livewire traps, recorded in `../ReStride/.ai/rules/ui.md`: `wire:ignore.self` on a
`showModal()` dialog; never pass `hidden` or a position to a component (wrap it); Blade
directives do not compile inside a component tag's attributes; `wire('model')->value()` is
`false`, not `null`, without `wire:model`; a field's states are CSS selectors (`:has()`),
marked with `data-invalid` / `data-floated` / `data-readonly` where the control cannot carry
them; the customizable select's traps (`../ReStride/.ai/rules/components-views-components.md`).
- Mail on M3 (`../ReStride/docs/plans/material-mail.md`): one theme CSS inlined onto bare
tags, light only, because `CssToInlineStyles` strips `@media`.
**What in ReStride is app-specific** and does not move: the doctrine (two button weights, no
tertiary / primary-container — enforced by its build), `restride-theme`, `$store.install` /
`$store.connection` in the shell, `--bottom-bar` set by its layout, sport / zone / route
tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`,
`share-button`, the unDraw repaint, the Ace code editor.
**Constraints found.**
- `gitea.nonameweb.ch` requires sign-in to view anything (`/explore/repos` → login, API 403).
A "public" repo there cannot be installed anonymously until that changes.
*(Resolved in step 1.)*
- Laravel's Markdown mail accepts a namespaced view as theme
(`Illuminate/Mail/Markdown.php:114`), so a package can render the theme CSS from data.
- Laravel **replaces** the `errors` view namespace at render time with
`config('view.paths')` + `/errors` and the framework's own
(`Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php`), so error views a package
adds with `addNamespace('errors', …)` are wiped; only a path in `view.paths` survives.
- M3 Expressive deprecates bottom app bar, navigation drawer, the original navigation bar,
segmented button, small FAB and medium/large top app bar (material-components-android docs).
- CSS anchor positioning is in Chrome 125+, Firefox 147+, Safari 18.4+ (flip via
`@position-try` from Safari 18.4).
- Boost loads a package's `resources/boost/guidelines/core.blade.php` and its skills on
`boost:install` / `boost:update --discover`.
## Decisions
- **A shared Composer package, SealShare its first consumer; ReStride adopts later in its own
plan** — SealShare's small surface proves the API; ReStride's three-day-old migration is not
put at risk.
- **Public repo on `gitea.nonameweb.ch`, MIT** — SealShare is public; its CI, Docker build,
manual install and fork PRs must install the package without credentials. SealShare
requires it through a `vcs` repository entry. Material Symbols (Apache-2.0), Google Sans
Flex (OFL) and material-web token values (Apache-2.0) are compatible; attributions ship in
`NOTICE`.
- **Prerequisite: Gitea allows anonymous reads** (`[service] REQUIRE_SIGNIN_VIEW = false`, or
`expensive` on 1.23+) — otherwise "public" is not installable.
- **Name `nonameweb/livewire-material`, namespace `NoNameWeb\LivewireMaterial`,
`config/livewire-material.php`, views `livewire-material::`, commands `material:*`** — says
what it is and what it is for.
- **Components unprefixed by default, prefix configurable** (maryUI's model, via
`Blade::anonymousComponentPath($path, $prefix)`) — ReStride's call sites keep their names;
SealShare's maryUI tags keep theirs. The provider sets `blade-icons.components.default` to
`null`, or blade-icons' class `<x-icon>` beats ours.
- **CSS and JS imported from `vendor/`** (`@import`, `@source`, `import`) — one dependency, one
version; consuming Dockerfiles install Composer packages before the Vite build.
`@source` covers the package's views and PHP, never its SVG folders.
- **The full Material Symbols Rounded set** (400 / 0 / 24, outlined and filled, 4,135 symbols,
5.3 MB), fetched by a maintenance script in the package repo, never at runtime — any name works
in any app. **`<x-icon>` reads the SVG files itself; blade-icons is not used for them** (decided
after Phase 1): blade-icons registers one Blade component per icon whenever the view factory
resolves, ~8,000 registrations per request here. blade-icons stays a dev dependency, only to
test that the provider still keeps its `<x-icon>` from shadowing ours in apps that have it.
- **Google Sans Flex bundled** (ReStride's subset) via `@font-face` in the package CSS; an app
overrides `--font-sans`.
- **Full M3, not ReStride's doctrine** — every variant, colour role (tertiary and
primary-container included) and container is available; each app enforces its own rules
through a configurable guard helper the package ships.
- **`variant` + `color` props** — `variant="filled|tonal|outlined|text|elevated"`,
`color="primary|secondary|tertiary|error|success|warning|info"`, default `text` in primary;
both validated against fixed lists, unknown values fall back to the default. Shorthands
kept: `primary` = filled primary, `danger` = filled error, `caution` = filled warning. The
same pattern for badge, alert, chip, icon button and FAB (`tone` stays an alias of `color`
where ReStride used it).
- **`php artisan material:scheme {seed} --variant=`** — runs Google's
`material-color-utilities` from a single prebundled Node script inside the package; writes
the app's `resources/css/material-scheme.css` (dark and light `--md-sys-color-*`, plus
`success`/`warning`/`info` custom colours, harmonisation off) and
`resources/css/material-scheme.json` (the light hexes, for the mail theme). Output is
committed.
- **Theme: a head script component, `light | dark | system`** — sets `data-theme` before paint;
CSS keys only on the attribute and never asks `prefers-color-scheme`; only the script reads
the OS (and follows its changes while `system`). `$store.theme` is the one state every
toggle shares. Config: `theme.default`, `theme.storage_key`, `theme.legacy_keys` (adopted
once, then removed).
- **The whole M3 Expressive catalogue, current components only** — the six deprecated ones are
skipped; their names alias where free (`<x-group>` renders a connected button group, FAB
`size="sm"` renders medium).
- **Plus the non-M3 pieces apps need** — data table, sort header, pagination views, file input,
password, stat, alert, empty state, collapse, section nav, account menu, theme toggle, an
adaptive app-shell composition, error pages, mail theme.
- **Modern browser floor, no third-party JS** — Chrome 125+, Firefox 147+, Safari 18.4+:
native `<dialog>`, Popover API, CSS anchor positioning, customizable `<select>` as a
progressive enhancement; Alpine (bundled with Livewire) for behaviour. Date and time
pickers, carousel and search are our own.
- **PHP ^8.4, Laravel ^13, Livewire ^4.** No request state in singletons (Octane).
- **Strings through `__()` with an `en` file**, publishable and overridable.
- **Accessibility: WAI-ARIA Authoring Practices patterns, WCAG 2.2 AA contrast, full keyboard.**
- **Showcase in the package, mounted twice** — by the Workbench for development and browser
tests, and opt-in inside an app (`showcase.enabled`, default only when `APP_ENV=local`) so
an app sees every component in its own scheme. Every variant, colour, size and state, a
light/dark/system switch, and the Blade snippet beside each example.
- **Tests: render tests for every component, browser tests against the Workbench showcase in
Chromium, Firefox and WebKit; no screenshot diffs.**
- **Error pages and a mail theme in the package** — the mail theme is the namespaced view
`livewire-material::mail.theme`, rendering CSS from the app's `material-scheme.json`, so
mail colours cannot drift from the app.
- **Boost guideline + `livewire-material-development` skill** in the package, and a test that
fails when a component is missing from the skill.
- **No version tags until the whole catalogue is done; then `1.0.0`** — the waves land on `main` untagged (decided 2026-09-13; replaces "`0.x` per wave").
- **Semantic ink and line utilities carried over from ReStride** (`text-body`, `text-meta`,
`text-quiet`, `border-structure`, `border-chrome`, `border-divider`) — cheap `@theme` names
ReStride's templates already use, derived from M3 roles.
## Out of scope
- ReStride adopting the package — its own plan, after `1.0.0`.
- A brand colour chosen at runtime (e.g. in SealShare's admin), dynamic colour, a PHP port of
the colour maths.
- M3 Expressive's deprecated components (bottom app bar, navigation drawer, original
navigation bar, segmented button, small FAB, medium/large top app bar).
- Screenshot / visual regression tests.
- Blaze optimisation of the components — a follow-up if rendering gets slow.
- ReStride-specific components and tokens (training row, maps, product shot, star rating, share
button, sport/zone/route tokens, unDraw repaint, Ace code editor).
- Publishing on Packagist — the `vcs` repository is enough; revisit if others adopt it.
- Translations beyond `en`.
- Browsers below the floor.
- Non-Livewire stacks (Inertia, React, Vue).
## Implementation steps
### Phase 0 — Prerequisites
1. **Gitea.** *Done 2026-09-13.* `GITEA__service__REQUIRE_SIGNIN_VIEW=false` in the Gitea
compose service (Gitea 1.26.4); `/explore/repos` and the API answer anonymously, and no
other repo or org is public. The package lives in the public org **`noNameWEB`**:
`https://gitea.nonameweb.ch/noNameWEB/livewire-material.git` (public, empty, `main`,
Actions on). SealShare's `vcs` repository entry uses that URL.
2. **Split the plan.** *Done 2026-09-13.* This file; SealShare keeps its adoption steps.
### Phase 1 — Package skeleton
3. **Repository.** `composer.json` (`nonameweb/livewire-material`, PSR-4
`NoNameWeb\LivewireMaterial\`, requires `php ^8.4`, `laravel/framework ^13`,
`livewire/livewire ^4`, `blade-ui-kit/blade-icons ^1.10`; dev: `orchestra/testbench`,
`pestphp/pest`, `pestphp/pest-plugin-laravel`, `pestphp/pest-plugin-browser`,
`laravel/pint`), `LICENSE` (MIT), `NOTICE`, `pint.json`, `phpunit.xml`, `.gitattributes`
excluding `workbench/`, `tests/`, `bin/`, `docs/` and the Node/Vite dev files from dist
archives.
4. **Service provider** `LivewireMaterialServiceProvider`: merge config; register the
anonymous component path with the configured prefix; `loadViewsFrom` (`livewire-material`),
`loadTranslationsFrom`; set `blade-icons.components.default` to `null` in `register()`
(before blade-icons boots and registers its component); register the icon and shape sets;
append the package's error-view root (a directory holding only `errors/`) to
`view.paths` after the app's own, so Laravel's namespace replacement keeps it and an app's
`resources/views/errors` still wins; publishable config, lang, error views; commands; the
showcase routes when enabled. No request state anywhere in the container.
5. **Config** `config/livewire-material.php`: `prefix`, `theme.default`, `theme.storage_key`,
`theme.legacy_keys`, `showcase.enabled` (`env('MATERIAL_SHOWCASE', app()->isLocal())`),
`showcase.path` (`material`), `showcase.middleware` (`['web']`).
6. **Workbench** (`orchestra/workbench`): a Laravel app with Livewire, Vite and the package
CSS/JS built, serving the showcase at `/`; `composer serve` starts it.
7. **CI on Gitea act_runner:** Pint, Pest render tests, browser tests in Chromium, Firefox and
WebKit (Playwright installed with deps), on pushes and pull requests.
8. **Boost resources skeleton:** `resources/boost/guidelines/core.blade.php` and
`resources/boost/skills/livewire-material-development/SKILL.md`; the drift test (every
component file under `resources/views/components` is named in the skill).
**Phase 1 is done (2026-09-13).** What changed from the steps above:
- **Testbench 11.2** runs the Workbench (`testbench.yaml`: providers listed, `start: /material`,
`MATERIAL_SHOWCASE=true`). Vite builds into `workbench/public/build`; `composer serve` links
that into the skeleton's `public/build` through the Workbench `sync` option, and
`tests/TestCase.php` points `public_path()` at `workbench/public` so browser tests find the
manifest without the command. Verified: `composer serve` answers `/material` and its CSS.
- **The showcase is a plain view with `@extends`**, not a component: `<x-livewire-material::…>`
resolves under `components/`, which would have made the layout a public component.
- **blade-icons' `components.default` is set in a `booting` callback**, not in `register()`:
`mergeConfigFrom` is shallow, so a nested key written before blade-icons merges its defaults
wipes the rest of its `components` array. The test fails with the call removed.
- **The showcase switch is tested by rebooting** with `MATERIAL_SHOWCASE` in the environment:
Testbench's per-test attributes do not reach Pest closures.
- **The Boost guideline is wrapped in `@verbatim`**: it is rendered as Blade, and a
`<x-mary-*>` in its prose would compile as a component tag.
- **`phpunit.xml` trap:** a comment containing a double hyphen (`--browser`) is invalid XML and
PHPUnit refuses the file; Pest then reports an unrelated `Pest\Plugins\Tia` error on shutdown.
- **CI** (`.github/workflows/tests.yml`): Pint, feature tests on PHP 8.4 and 8.5, browser tests
per engine — Pest's `--browser chrome|firefox|safari`, Playwright's `chromium|firefox|webkit`.
Locally only Chromium was run; Firefox and WebKit first run on CI.
- **`NOTICE` moves to Phase 2**, when the first third-party assets (Symbols, font, tokens,
colour utilities) arrive — a notice with nothing to attribute would be wrong.
- **The scheme script is not in `bin/`:** `bin/` is export-ignored (maintenance scripts only),
but applications run the scheme script, so it lives at `resources/node/scheme.mjs`.
- **Found for Phase 2:** blade-icons calls `Factory::registerComponents()` whenever the view
factory resolves, registering one Blade component per icon unless `components.disabled`
and without `icons:cache` it scans the folders first. With ~7,800 symbols that is per
request under PHP-FPM. Decided before step 13.
### Phase 2 — Foundation
9. **Scheme command.** `resources/node/scheme.mjs` built once in the package repo (esbuild bundle of
`@material/material-color-utilities`, committed, Apache-2.0 header); `material:scheme {seed}
{--variant=tonal-spot|vibrant|expressive|fidelity|content|neutral|monochrome}
{--success=} {--warning=} {--info=} {--output=}` runs it through `node` and writes
`resources/css/material-scheme.css` and `resources/css/material-scheme.json`. Fails with a
clear message when `node` is missing. A default scheme (M3 baseline `#6750A4`) ships inside
the package so a fresh install renders before the command is run.
10. **Tokens.** `resources/css/material.css` as the single entry, importing `tokens/shape.css`,
`type.css` (typescale utilities incl. emphasized), `motion.css` (six spring `linear()`
curves — spatial/effects × fast/default/slow — and reduced-motion overrides),
`elevation.css`, `state.css` (state layers, focus ring), and the `@theme` block mapping
**every** M3 role (primary/secondary/tertiary and their containers, surfaces, outlines,
inverse, error, custom success/warning/info with `inverse-*`) plus the semantic ink and
line utilities; `--color-*: initial` with white and black re-added. Each theme block
declares `color-scheme`. Values from material-web's token files, attributed.
11. **Font.** `resources/fonts/google-sans-flex/` (woff2 + OFL), `@font-face` and `--font-sans`
in the entry CSS.
12. **Theme.** `<x-theme-script />` (inline, before `@vite`): reads `storage_key`, adopts
`legacy_keys` (their values may be JSON-encoded — maryUI's `$persist` stores `"dark"`
with quotes), resolves `system` through `matchMedia`, writes `data-theme`; `$store.theme`
in `resources/js/material.js` with `set()` and a `matchMedia` listener while `system`.
13. **Icons.** `bin/fetch-symbols` (maintenance only) downloads Material Symbols Rounded
400/0/24 outlined and filled from `google/material-design-icons` into
`resources/svg/symbols/{outlined,filled}` with `fill="currentColor"` and no width/height;
`<x-icon name="…" filled>` resolves `ms`/`msf`; throws on an unknown name.
14. **Shapes.** The Expressive shape set generated by formula (ReStride's
`components/shape.blade.php` method) into `resources/svg/shapes`; `<x-shape>`.
15. **JS entry** `resources/js/material.js`: theme store, snackbar listener, `data-list-row`
rows, `x-figure` count-up directive, shared keyboard helpers. Imported by an app's `app.js`.
16. **`Toasts` concern** `NoNameWeb\LivewireMaterial\Concerns\Toasts`: protected
`success|error|warning|info(string $title, ?string $description = null, ?int $timeout = null, ?string $redirectTo = null)`,
dispatching a browser event and flashing across `redirectTo`.
17. **Guard helpers** (`NoNameWeb\LivewireMaterial\Testing\DesignGuard`, Pest-friendly):
scan given paths for maryUI tags, daisyUI component classes, colour utilities not declared
by the compiled tokens, unknown icon names, and app-configured banned roles/variants.
18. **Showcase shell**: layout, section navigation, theme switch, snippet renderer; a section
per foundation piece (colour roles in both themes, type scale, shapes, motion, icons
search).
**Phase 2 is done (2026-09-13).** What changed from the steps above:
- **Icons without blade-icons** (see Decisions): `NoNameWeb\LivewireMaterial\Support\SvgFile`
reads a symbol or shape file on first use and keeps it for the worker. An unknown name throws,
and a Heroicon-style name (`o-home`) throws with a pointer to the catalogue. `bin/fetch-symbols`
sparse-checks-out google/material-design-icons (5 GB, cloned without blobs) for the
`_24px` and `_fill1_24px` files: 4,135 symbols, byte-identical to ReStride's hand-picked ones.
The `@material-symbols/svg-400` npm package was rejected: its "rounded" SVGs are optical size 48.
- **All 35 M3 Expressive shapes**, ported from androidx `MaterialShapes.kt` and graphics-shapes
into `bin/shapes.mjs` (commit in its header), each fitted to 298 of a 100-unit box; eight had
control points outside the box and are split into more segments along the same outline.
- **The scheme script** lives at `resources/node/scheme.mjs` (93 KB, esbuild bundle of
material-color-utilities 0.4.0, spec 2025 — M3 Expressive's colour; variants the 2025 spec
does not define fall back to 2021). The library's extensionless imports mean it cannot run
unbundled. 65 roles per theme plus `inverse-{error,success,warning,info}`. Default state
sources: success `#22a06b`, warning `#e2a400`, info `#1d7afc`. `config('livewire-material.node')`
names the binary. The package's own default is `#6750A4`, tonal-spot, in `tokens/scheme.css`.
- **Colour utilities are `@theme inline`** — contrary to ReStride's rule that `inline` breaks
theme switching. Verified in Chromium: without `inline`, `--color-primary` resolves once on
`:root`, so a `data-theme="dark"` section inside a light page keeps light colours; with it the
utility reads `var(--md-sys-color-primary)` on the element and both the page toggle and nested
themes work. Only blocks whose values are variables are inline. A browser test fails without it.
- **Tailwind emits only the theme variables that are used**, and cannot see a class assembled in
Blade (`type-{{ $style }}`): the showcase lists every class literally.
- **`dark:`** is keyed on `data-theme`, so it follows the page's theme rather than the OS.
- **Theme script** writes nothing for a visitor who never chose (a later change of
`theme.default` still reaches them); only an adopted legacy value is stored. `data-theme-choice`
and `data-theme-key` on `<html>` hand the state to `$store.theme` (`choice`, `resolved`, `set`,
`toggle`, `value`).
- **Moved out of Phase 2:** the snackbar listener goes to Phase 4 with `<x-toast>`; `data-list-row`
rows (JS and CSS) and the list keyboard go to Phase 5 with `list` and `card`, which they style.
`Toasts` dispatches `toast` with a 4 s default (M3's snackbar range is 410 s; ReStride used 3 s).
- **`DesignGuard`**: `secondary` is a valid role now that the package exposes full M3, so it is no
longer flagged as a daisyUI colour; icon attributes are checked per component tag (`icon` and
`icon-right` on the same tag).
- **Pest browser trap:** a bare `html` selector is taken as text to search for and times out;
assert on `document.documentElement` through `assertScript`. `@name` targets `data-test`.
- **Showcase**: colour roles in both themes side by side, type, corners and shapes, elevation,
springs and `x-figure`, and an icon search drawing matches as CSS masks from a showcase-only
symbol route (relative URLs — an absolute one carries `APP_URL` and misses the dev server's port).
### Phase 3 — Actions
19. Primitives the actions need: `loading` (M3 Expressive loading indicator, contained and
not), plain `tooltip` (from a fine pointer only, not laid out while hidden), `menu` /
`menu-item` / `menu-separator` (Popover API + anchor positioning, Expressive vertical menu,
APG menu keyboard).
20. `button` — five variants × colours × sizes `xs|sm|md|lg|xl`, `icon`, `icon-right`,
`label`, `link` (+ `wire:navigate` unless `external` / `no-wire-navigate`), `spinner`,
`responsive`, `tooltip*`, `disabled` on links (`aria-disabled`), press shape-morph.
21. `icon-button` behaviour inside `button` (icon, no label): standard/filled/tonal/outlined,
`selected` toggle (`aria-pressed`), widths.
22. `button-group` (standard and connected; `<x-group>` alias with `wire:model` options),
`split-button`, `fab` (56px default — M3's deprecated small FAB is gone, so `sm` is the baseline FAB — 80px `md`, 96px `lg`), extended FAB, and the
responsive `fab` prop (extended FAB below `sm`, filled header button above — one element),
`fab-menu`.
**Phase 3 is done (2026-09-13).** What changed from the steps above:
- **Values from androidx Compose Material 3's generated tokens** (`Button*Tokens`,
`*IconButtonTokens`, `Fab*Tokens`, `ExtendedFab*Tokens`, `FabMenuBaselineTokens`,
`ButtonGroupSmallTokens`, `ConnectedButtonGroupSmallTokens`, `SplitButton*Tokens`,
`StandardMenuTokens`, `VibrantMenuTokens`, `SegmentedMenuTokens`, `PlainTooltipTokens`,
`LoadingIndicatorTokens`), cited in each component's header. Two tokens are wrong and Compose
overrides them in code, so the package does too: the text button's label is `primary`, not
`on-surface-variant`, and the extra-small button's padding is 12px, not 16px.
- **`<x-button>` is label button, icon button and toggle in one**, with `data-icon-button` on the
icon-only form. A selected round button squares off; a selected square icon button rounds. A
`corners` prop lets composite components (the split button) draw the corners themselves.
- **Group and split corners are unlayered CSS** (`resources/css/components/groups.css`): a
child button's corners are utilities, and anything in a `@layer` loses to a utility. Inner
corners ride a `--group-corner` variable so pressing and selecting change one value while the
rounded outer corners stay put. The standard group's press expansion is padding moved from the
neighbours to the pressed button (a fixed step per size); icon buttons keep their width.
- **`<x-group>` stays ReStride's native-radio design** (checkboxes with `multiple`), restyled as a
connected button group — `wire:model`, `x-model` and the arrow keys need no script.
- **Tooltips and menus are popovers placed by CSS anchor positioning**, with per-render anchor
names generated in Blade (a morph updates the trigger and the popover together). `popover`
elements must never get a `display` utility (`flex`), which beats the UA's `display: none` for
a closed popover; use `open:flex`.
- **Menu keyboard is WAI-ARIA's menu button**; `aria-expanded` and the first item's focus are set
synchronously in `open()`, because the popover `toggle` event is queued and a test (or a screen
reader) reading in between saw a shut menu. **Bug found by the browser tests:** the guard
against a light-dismiss press reopening the menu compared against `closedAt = 0`, so every click
in the first 250ms after page load was swallowed; it starts at `-Infinity` now, with a test.
- **Anonymous component trap:** every prop is a local variable, so a helper variable in `@php`
must not reuse a prop's name — a local `$corners` array silently replaced the `corners` prop.
- **The loading indicator is androidx's own geometry, in SVG + SMIL** (`bin/loading-indicator.mjs`,
`resources/svg/loading-indicator/`): `Morph` is ported, so each of the seven morphs is a path
whose control points SMIL can interpolate in every engine (CSS `d:` has no WebKit support).
The spring is two keySplines (<1% error), each morph's path shrinks under its successor at the
hand-over, and the per-morph quarter turn runs on its own 2.6s cycle so the 630° per shape
cycle never needs a reset. 21.7 KB. Two deliberate differences from Compose: the spring settles
inside the 650ms instead of snapping back from 9% past, and frames centre on exact curve bounds,
so there is no 0.10.18 unit jump at three hand-overs. Reduced motion shows `static.svg`.
- **Showcase examples are Blade strings rendered with `Blade::render()` beside their source**
(`<x-showcase::example>`, an anonymous component path registered only when the showcase is
enabled), so the snippet can never disagree with what is drawn.
- **Browser tests in three engines, locally too** (Playwright's Firefox and WebKit are installed):
- WebKit, like Safari on macOS, leaves buttons out of the Tab order; focus them directly.
- Firefox counts a scripted focus as `:focus-visible` only after a key press.
- Firefox flaked ~3 runs in 4 with **HTTP 431 from Pest's in-process Amp server** on
`livewire.js`, so Alpine never started. It appeared once the showcase inlined a 60 KB list of
symbol names; the icon search now fetches `symbols.json` on `x-intersect.once`, and the suite
passed 4 of 4. Keep showcase pages lean.
- A click that lands before Alpine starts does nothing; tests wait for `networkidle`, and the
showcase's theme switch is `x-cloak` so Playwright's click waits for it.
### Phase 4 — Communication
23. `badge` (dot, count, label; variant/colour), `progress` (linear, circular, **wavy**,
determinate and indeterminate), `toast` (M3 snackbar, action, timeout, stacked), rich
`tooltip`, `alert` (tinted container, icon, actions slot), `stat` (figure with
`x-figure`), `empty-state`.
**Phase 4 is done (2026-09-13).** What changed from the steps above:
- **`<x-progress>` was built by a separate agent** from Compose's `ProgressIndicator.kt` and
`WavyProgressIndicator.kt`: server-rendered SVG first frame, then an Alpine component that ports
the drawing and keyframes and animates only while something moves and it is on screen; the SVG
is `wire:ignore` and a MutationObserver on the root's `data-value` turns a morph or a `bind`
expression into motion. Flat circular indeterminate has no track, as in Compose.
- **`<x-toast>` listens from the moment `snackbar.js` loads**, not on its Alpine component, and holds
toasts until the host registers — a toast dispatched before Alpine starts is shown, not lost.
`@persist` keeps the host across `wire:navigate`.
- **`<x-badge>` is M3's dot and count** (`floating` pins it to an icon) **plus a status label**
(`tonal`, `outline`), which M3 lacks and every app needs. `<x-alert>`, `<x-stat>` and
`<x-empty-state>` are built from M3's parts; `<x-rich-tooltip>` is transient or `persistent`.
- **A commit went out broken and was fixed forward:** `648ad8e` staged `material.js` and the
showcase index while they held the progress agent's temporary lines, without its files.
**With agents in the same tree, stage by explicit path and read the diff of shared files first.**
- **Browser test lessons (all three engines, locally):**
- `waitForEvent('networkidle')` can return before a repeated visit has loaded in Firefox (an
empty document, then a page still streaming in). Every helper now also asserts
`document.readyState === 'complete'` and that Alpine and Livewire exist; assertions retry.
- Firefox runs Playwright's evaluate in a sandbox: an event built there has a `detail` the page
cannot read, and assignments to Alpine's reactive proxies do not trigger. Go through the page's
realm with `window.eval("…")` (or `Livewire.dispatch`).
- Pest retries a failing `assertScript`; a script that changes state must not be written so
that a retry starts from the changed state.
### Phase 5 — Containment
24. `card` (elevated, filled, outlined; `title`, `subtitle`, `actions` slot; clickable row
contract), `divider`, `list` / `list-item` (one-, two-, three-line; leading/trailing;
selectable), `modal` (native `<dialog>`, `showModal()`, `wire:ignore.self`, writes back
`false`/`null`, `fullscreen` below `sm`, basic dialog with icon/headline/actions),
`bottom-sheet` (modal and standard, drag handle), `drawer` (side sheet: modal and
standard; `pane` for list-detail from `xl`; `width` prop), `carousel` (multi-browse, uncontained, hero,
full-screen on CSS scroll-snap), `collapse`.
**Phase 5 is done (2026-09-13).** What changed from the steps above:
- **`<x-carousel>` was built by a separate agent in its own worktree**, porting Compose's keyline
maths (`Arrangement`, `Keylines`, `Strategy`, `KeylineSnapPosition`, androidx
`7ac433e44e797de53af85226797862687f37735f`; checked against 43 values from androidx's unit tests).
Items are laid out at the large size on a native scroll-snap row and masked each frame with a
`clip-path` inset; snap positions are `scroll-margin-inline-start`. Full-screen follows
material-components-android, which Compose lacks; one item per swipe is `scroll-snap-stop`, not
fling physics. Hooks are `data-material-carousel*`: the design guard rejects `carousel*` classes
as daisyUI's.
- **The drawer is the side sheet**, with `pane` for list-detail from `xl`, and the bottom sheet is its
own component (modal or `standard`) with drag-to-dismiss.
- **`<x-list-item>`'s trailing slot is `end`**: a slot named like the `trailing` prop replaced it.
- **The design guard also rejects Blade directives inside component tags** (`@class` on
`<x-icon>`), which reach the browser as text.
- **`x-trap.inert` hides the page with `aria-hidden`**, not `inert`; tests assert what it sets.
- **WebKit returns focus from a dialog only to an element that had focus**, and a click does not
focus a button there: tests open dialogs from the keyboard.
### Phase 6 — Text inputs and selection
25. `form`, `field` (the shared shell: **outlined and filled**, floating label via `:has()`,
notch, `hint` replaced by error, `aria-invalid` / `aria-describedby`, `data-*` state
marks), `input` (prefix/suffix, icons, `copyable` trailing button with a snackbar),
`password` (reveal toggle), `textarea` (auto-grow), `select` (native, customizable-select
enhancement with ReStride's traps), `checkbox` (incl. indeterminate), `radio`, `toggle`
(M3 switch, icons), `file` (native input inside the field, batch and per-file errors).
26. `chip` (assist, filter, input, suggestion), `choices` (filter chips, or a searchable
combobox with a menu when `searchable`), `slider` (standard, centered, range; native range
inputs, value label), `search` (search bar and search view, results through a Livewire
property).
**Phase 6 is done (2026-09-13).** What changed from the steps above:
- **The field family is ReStride's, extended with M3's filled text field** (`variant`, default from
`config('livewire-material.fields.variant')`): a surface-container-highest box, extra-small top
corners, the outline turned into an indicator line and the label floating inside. A textarea's
top offset is half margin, so text scrolled up in a full textarea disappears under the edge
instead of running through the label; it grows with `field-sizing: content` from `rows` to
`max-rows`, with a script fallback (`field.js`) that no current engine needs.
- **Checkbox, radio and switch are CSS on native inputs** (`components/selection.css`), hooked by
`data-checkbox`, `data-radio`, `data-switch`: `checkbox`, `radio` and `toggle` are daisyUI class
names the design guard rejects. The indeterminate checkbox is `data-indeterminate`, kept in step
with the property by a MutationObserver, since HTML has no attribute for it.
- **`<x-chip>` and `<x-slider>` were built by separate agents in their own worktrees.** Chips: one
component for four types; a filter chip is a native checkbox when bound and a toggle button
otherwise; removable input chips hand focus on by `wire:key` after a morph. Slider: native range
inputs under a drawing ported from Compose (`wire:ignore`); `.number` is added to the binding,
because a string sent and an integer returned made Livewire move the handle back mid-drag.
- **`<x-choices>` keeps typed values in Alpine** (filter chips as toggle buttons, or a searchable
combobox whose list is a `popover="manual"` placed by CSS anchor positioning, so a card's
`overflow-hidden` never clips it). Selecting the text on focus has to happen on the `click` that
follows the press: Chromium collapses a selection made in the focus handler.
- **`<x-search>` is the search bar and view**: docked from `sm`, full screen and trapped below it.
It closes on `pointerdown` outside, not `click` (the bar moves under the pointer as it goes full
screen, and the click then lands outside), and focus returned to the input within 250ms of a close
does not reopen it (Escape, and the trap letting go).
- **A slot rendered by a `@foreach` with no items is not empty** to `isNotEmpty()`; use
`hasActualContent()`.
### Phase 7 — Pickers
27. `datepicker` (docked, modal, modal input; `Intl` month/day names and week start from the
app locale; `min`/`max`; single and range; `wire:model` stores `Y-m-d`), `timepicker`
(dial and input; 12/24h from locale; stores `H:i`). APG grid keyboard for the calendar.
**Phase 7 is done (2026-09-13).** Both pickers were built by separate agents in their own
worktrees. What changed from the step above:
- **`<x-datepicker>` is one `<dialog wire:ignore>` for every mode**: docked opens it as a
`popover="manual"` under the field (CSS anchor positioning, flipping above), modal and input
with `showModal()`, and docked becomes modal below `sm`. Dates are `Y-m-d` strings computed in
UTC; only "today" reads the browser's zone. The typed format is androidx's
`datePatternAsInputFormat` from `Intl` (`de``dd.MM.yyyy`). **A range binds one array**,
`['start' => …, 'end' => …]`, not two models: one update, so `after_or_equal:trip.start`
validates against the matching end, and all its errors land on one field. Docked values come from
material-web's docked tokens, which Compose lacks.
- **`<x-timepicker>` follows current Compose, not older Android**: a 24-hour dial puts 0011 outside
and 1223 inside, and the period selector is two separate toggles. The selector angle is a
registered custom property, so one transition turns the line and moves the handle.
- **Alpine's `x-show` reveals an element on the next animation frame**: focusing into it from
`$nextTick` failed in Firefox and Safari. Wait a frame, or switch views with an attribute and CSS.
- **A Livewire public property named `$slot` renders empty** in the component's view.
### Phase 8 — Navigation
28. `app-bar` (small, center-aligned, medium flexible, large flexible, search app bar; sticky,
scroll-elevation), `navigation-bar` (flexible), `navigation-rail` (collapsed, expanded,
modal; badges), `tabs` / `tab` (primary and secondary; server-rendered tablist, roving
tabindex), `toolbar` (docked and floating), `section-nav` (secondary tabs from `sm`, menu
picker below), `account-menu` (avatar trigger, slot for items, theme row), `theme-toggle`
(cycles or picks light/dark/system through `$store.theme`).
29. `app-shell` — a slot-based adaptive composition: app bar + navigation bar below `sm`, rail
`sm``lg`, expanded collapsible rail from `lg` (state in the store, applied before paint by
the theme script), content region with `wire:transition.navigate`, snackbar host. Nothing
app-specific inside; apps pass destinations and extra chrome as slots.
**Phase 8 is done (2026-09-13).** What changed from the steps above:
- **App bars, toolbars, tabs, section nav, account menu and theme toggle were built in main; the
navigation bar, rail and app shell by an agent in its own worktree.**
- **A medium or large app bar collapses without script moving anything**: the bar is sticky at a
negative top (its measured height less the 64px row, so a wrapped title still fits) and its row
is sticky at 0 inside it; script only reports `scrolled` and `collapsed`.
- **The tab indicator moves in a view transition** (`view-transition-name` from a per-tablist
custom property, `view-transition-class` for the timing); every tab draws its own indicator, so it
is right before Alpine starts.
- **Livewire 4.4's `wire:navigate` gives `<html>` the next page's attributes and removes the rest**,
which dropped `data-theme` on every in-app navigation. The head script saves the theme and rail
attributes on `livewire:navigating` and puts them back in `onSwap`, before anything paints.
- **The rail's collapsed state is applied before first paint** by the head script
(`<html data-rail>`, `rail.default`, `rail.storage_key`) and read by a `rail-collapsed:` variant;
`$store.rail` changes it. The shell draws no phone menu button: the app bar in `top` calls
`$store.rail.show()`.
- **Playwright's locators are strict**: `assertAttribute` on a selector matching two elements fails.
- **Tailwind only compiles classes it can see**: a class written only in a test probe (`h-[200vh]`)
does not exist in the Workbench build; use inline styles there.
- **A Tailwind `@variant` nested under a pseudo-element or a `* +` selector compiles to broken CSS.**
### Phase 9 — Data, pages, mail
30. `table` (`.data-table`, descendant selectors, fine-pointer density, `position: relative`),
`sort-header` (`sortBy` array shape, `aria-sort`), Livewire and Laravel pagination views
(current page in `secondary-container`, "Page 2 of 7" on a phone).
31. Error pages: a layout and `403, 404, 419, 429, 500, 503` in the package's error-view root
(wired through `view.paths` in step 4); publishable for per-app wording.
32. Mail theme: `livewire-material::mail.theme` renders CSS from the app's
`material-scheme.json` (falling back to the default scheme); `html/header` and
`html/message` overrides; typescale on bare tags; filled primary button.
**Steps 3032 are done (2026-09-13).** Tables, sort headers and pagination were built in main; error
pages and the mail theme by an agent in its own worktree. What changed from the steps above:
- **The paginators are prepended to the `pagination` and `livewire` view namespaces** rather than
set as the default view, because Livewire sets its own default on every render; published
`vendor/pagination` and `vendor/livewire` views still win (`livewire-material.pagination`).
- **The error-view root is `resources/views/error-pages`** (inside `views`, so an application's
`@source` line already covers its classes), appended to `view.paths` in `register()`, before the
view finder is built. The layout is `errors::minimal`, so the framework's 401 and 402 use it too.
Without a Vite build the pages fall back to an inline stylesheet from the scheme JSON. Testbench's
skeleton ships its own `errors/503`, which wins in the Workbench.
- **Mail is light only**: the CSS inliner strips `@media`. The header and message components are
opt-in (`livewire-material.mail.components`) or published, because they change every Markdown mail.
### Phase 10 — `1.0.0`
33. Showcase complete (every component, variant, colour, size and state, both themes); the
in-app mount verified inside a fresh Laravel app; Boost guideline and skill complete
(drift test green); README (install, CSS/JS wiring, scheme, theme, prefix, showcase,
guard, Docker ordering note); tag `1.0.0`.
**Phase 10 is done (2026-09-13).** What changed from the step above:
- **The package's own views write `<x-livewire-material::name>`** for the components they use. Blade
spells a configured prefix `<x-m::button>` (not `<x-m-button>`, as the config first claimed), and
an unqualified `<x-button>` inside a package view would have broken under a prefix and been
shadowed by an application's own `components/button.blade.php`. A test scans every package view
(outside comments and the showcase's example heredocs) for unqualified tags, and the showcase
rewrites its examples to the configured prefix. The component path string stays
`__DIR__.'/../resources/views/components'`: Blade names the view namespace after its hash, and
compiled views keep that name.
- **A test checks that every component appears in the showcase.**
- **The showcase is the package's own app shell**: an overview at `/material` and a page per section
(`/material/{section}`, `Showcase\Sections`), grouped in the navigation rail, moved between with
wire:navigate. It turned up a WebKit bug in `<x-menu>`: inside a focusable region (the shell's
`<main tabindex="-1">`), WebKit hands focus back to that region as the popover closes, so the
menu now reads whether focus was inside on `beforetoggle` before returning it to the trigger.
- **The showcase has a search** in a search app bar (`<x-search>`, `/` or Ctrl+K): an index of every
section, every example (by its anchor) and every component, read from the section views and fetched
as `/material/search.json` on first focus. Names in a slot's Alpine scope must keep clear of the
component's own: `results` and `open` in `<x-search>`'s scope hid the page's. wire:navigate keeps a
URL's hash but scrolls to the top, so the layout lands on the hash after the swap settles.
- **Verified in a fresh Laravel 13.31 application** (`composer create-project`, the package from a
path repository, the README's CSS, JS and layout, `material:scheme "#4f46e5"`, `npm run build`):
a Livewire page with an app bar, tabs, a card, a form with validation, a date picker, a dialog and
a toast works without console errors; `/material` and the 404 page render in its scheme.
- **`<x-datepicker clearable>`** empties a date, or both ends of a range, as `<x-timepicker clearable>`
does.
### Phase 11 — SealShare 2.0.0
Tracked in SealShare's `docs/plans/livewire-material.md`, after `1.0.0`.
## Testing
- **Render tests** (`tests/Feature/Components/*Test.php`, Testbench, `$this->blade()`), per
component: each `variant` × `color` renders its classes and falls back on an unknown value;
shorthands (`primary`, `danger`, `caution`, `tone`) map correctly; sizes; ARIA
(`aria-pressed`, `aria-expanded`, `aria-current`, `aria-invalid` + `aria-describedby`,
labelled dialogs, `aria-sort`); `link` adds `wire:navigate` unless `external`; `wire:model`
normalisation (`false``null`); prefix config renames the tags.
- **Foundation tests:** every `--md-sys-color-*` role defined in both theme blocks and
different between them; each block declares `color-scheme`; no `prefers-color-scheme` in
the CSS; colour blocks are `@theme inline`; the scheme command writes both files for a known seed
(snapshot of a few roles) and fails cleanly without Node; the theme script lands before
`@vite` and resolves `system`; `<x-icon>` throws on an unknown name; every shape fills the
100-unit box with `currentColor` only; `Toasts` dispatches and survives `redirectTo`;
`DesignGuard` catches each forbidden pattern on fixtures; the skill drift test; the mail
theme renders the JSON's hexes; a 404 renders the package's error view and an app's own
`errors/404.blade.php` still wins; the showcase route is 404 when disabled and 200 when
enabled; blade-icons' own `<x-icon>` is not registered.
- **Browser tests** against the Workbench showcase in Chromium, Firefox and WebKit: dialog
(open, Esc, morph survival, write-back), bottom and side sheets and the pane from `xl`,
menu and select (keyboard, anchoring, flip), tabs (arrows, Home/End), date and time pickers
(keyboard grid, locale, `wire:model` value), carousel (snap, keyboard), sliders, chips and
choices, search, snackbar timing and action, theme (light/dark/system, legacy adoption, OS
change followed), app shell at 393 / 768 / 1024 / 1512px, focus rings on keyboard focus,
and reduced motion leaving no running animations.
## Risks and open questions
- **Scope and time.** The whole catalogue (~45 components plus extras) comes before any app
uses it. Mitigation: each wave is reviewed in the showcase and green on CI before the next.
- **Accessibility is entirely ours** — menus, pickers, carousel, sheets. Mitigation: APG
patterns, native elements first, ARIA in render tests, keyboard in browser tests across
three engines.
- **Date and time pickers without a library** are the largest single components. Mitigation:
`Intl` for all locale data; the modal-input variant as the accessible baseline.
- **Full M3 invites inconsistency per app.** Mitigation: the configurable `DesignGuard`; each
app records its own rules.
- **Livewire morphing against Alpine state** inside components. Mitigation: ReStride's
recorded traps become package rules in the skill, with tests pinning each.
- **~8,300 SVGs** make dist archives and `vendor/` larger (5.3 MB). Mitigation: each file is read
only when drawn and kept per worker; `.gitattributes` keeps dev files out; `@source`
never scans the SVG folders.
- **Maintenance of a public package** is one person's job; breaking changes need semver
discipline once ReStride also depends on it.
- **Runner scope.** The package's CI needs an act_runner registered for the instance or the
`noNameWEB` org, not only for ReStride — check before the first push in Phase 1.
@@ -44,7 +44,7 @@ The scheme is generated, never hand-edited. Regenerate it with the seed and vari
php artisan material:scheme "#4f46e5" --variant=tonal-spot
```
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The command also writes `material-scheme.json` beside the stylesheet.
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; `--contrast` goes from -1 to 1. 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.
### Colour profiles
@@ -62,6 +62,7 @@ An installation that switches between several schemes lists them in `config/live
php artisan material:scheme
```
- Each profile: `seed`, and optionally `label` (default: the name as a headline), `variant` (default `tonal-spot`), `contrast` (default 0), `spec`, `success`, `warning`, `info` (for these four, without the key the command's `--spec`, `--success`, `--warning`, `--info` or their defaults apply).
- Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet.
- The application says which profile is active, once, in a service provider. The closure runs every time a colour is drawn (head script, mail, error page), so it may read the database; a name that is not a generated profile, or a closure that throws, falls back to the default:
@@ -90,7 +91,15 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
## Theme
`config/livewire-material.php``theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything.
`config/livewire-material.php``theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`, `theme.meta`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything.
`theme.meta` (default `false`) keeps the browser's bar in the page's colour, for an installed web app: the head script sets the `content` of every `<meta name="theme-color">` without a `media` attribute to the resolved theme's `surface` — of the profile in `<html data-scheme>` — before the first paint, adding one to `<head>` when there is none. It follows every later change of `data-theme` or `data-scheme` (`$store.theme.set()`/`toggle()`, an OS change while `system`, `previewScheme()`), and paints the next page's meta after `wire:navigate`. A theme-color meta the layout renders itself goes before `<x-theme-script />` (after it, the script has already added one, and the page ends up with two), or is left out. A `media="(prefers-color-scheme: …)"` pair follows the OS instead of the visitor's choice: drop it when turning this on.
## Safe areas
Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through `var(--material-safe-top|bottom|left|right, env(safe-area-inset-…))`. The layout needs `viewport-fit=cover` in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on `<html>` or any ancestor: a browser test fakes a notch with `document.documentElement.style.setProperty('--material-safe-top', '47px')`, and an app that draws its own status strip adds its height.
`--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `<x-app-shell>` (an offline banner): the shell adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at `bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`, below `sm` only.
## Toasts
@@ -186,7 +195,7 @@ One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, size
### `<x-theme-script>`
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`.
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`. With `theme.meta` on it also paints `<meta name="theme-color">` (see Theme); a layout's own theme-color meta goes before it.
### `<x-button>`
@@ -235,7 +244,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), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
`<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`. `<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`), `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`. 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.
### `<x-button-group>`
@@ -253,7 +262,7 @@ A choice between a few options as a connected button group of native radios (che
]" hint="Recipients lose access after that" />
```
Props: `label`, `hint`, `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
Props: `label`, `hint`, `hint-class` (classes for the hint, as on the fields; a colour there paints it), `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
### `<x-split-button>`
@@ -280,7 +289,7 @@ 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. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard as `<x-menu>`.
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as `<x-menu>`.
### `<x-loading>`
@@ -304,6 +313,16 @@ materialToast('Share deleted', { type: 'success', description: null, timeout: 40
`type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer.
- `action`: `label`, plus `handler` (a function) and/or `event` (a name). Pressing it closes the snackbar, calls `handler`, then dispatches `new CustomEvent(event)` on `window`; give both and both run. Use `event` where a function cannot travel, such as a toast built from JSON.
- `sticky: true` keeps a toast until it is dismissed or its action is pressed (any `timeout` is ignored), without holding the queue up: a toast dispatched meanwhile shows in its place, and the sticky one comes back once the queue is empty. One sticky toast is kept at a time; a newer one replaces it. Use it for a question that must be answered, not for news:
```js
window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: 'A new version is ready', sticky: true, action: { label: 'Reload', event: 'app:update' } } }))
window.addEventListener('app:update', () => location.reload())
```
- Hooks: `data-toast` on the snackbar on screen, `data-toast-action` on its action button (`[data-toast]` is absent while nothing shows). Target these in tests, not classes.
### `<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.
@@ -334,7 +353,10 @@ A value the server changes animates after a morph (the SVG is `wire:ignore`; onl
### `<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 value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
- `<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 edge. `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-variant 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" class="size-3" /> Pro</x-badge>`. `value` is escaped. A slot that holds only whitespace or comments is still a dot.
### `<x-alert>`
@@ -360,7 +382,7 @@ A few lines of context around a trigger, with an optional `title` and `actions`
</x-rich-tooltip>
```
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). `side`: `bottom` (default), `top`, `left`, `right`.
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`.
### `<x-stat>`
@@ -370,6 +392,15 @@ Shows on hover and keyboard focus; `persistent` opens it on press and keeps it u
"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.
```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: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`; slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). Do not pass `bg-*`; use `variant`.
@@ -405,6 +436,15 @@ A card or list item that opens something is a **row**: `data-list-row` on it and
A disclosure on native `<details>`: `<x-collapse title="Advanced" icon="tune" open variant="filled">…</x-collapse>` (`variant` `plain` or `filled`; `heading` slot for rich titles). Keeps its state through a morph.
Bind the open state to a boolean, both ways, with `wire:model` (any modifiers; `.live` sends each toggle at once) or `x-model`:
```blade
<x-collapse title="Fine-tuning" wire:model="fineTuning">…</x-collapse>
<div x-data="{ advanced: false }"><x-collapse title="Advanced" x-model="advanced">…</x-collapse></div>
```
Toggling writes the property; changing the property (in an action or in Alpine) opens or closes it. With `wire:model` the server renders it open or closed as the property is, so there is no flash, and `open` is ignored; with `x-model`, `open` is only the first paint until Alpine starts. The bound state is `collapseOpen` in the `<details>` scope. Without a binding there is no Alpine on it.
### `<x-modal>`
An M3 dialog on native `<dialog>`. Bind with `wire:model` to a flag or an id; closing (Escape, scrim, `close()`) writes back `false` or `null`. Without `wire:model` it uses `open` from the surrounding Alpine scope.
@@ -422,7 +462,7 @@ Props: `title`, `subtitle`, `icon` (centred hero icon), `separator`, `persistent
### `<x-drawer>`
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`.
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Escape leaves a pane open unless `pane-close-on-escape`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`.
### `<x-bottom-sheet>`
@@ -495,7 +535,7 @@ A one-column grid of fields with an `actions` slot at the foot (the slot takes i
### `<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 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 and sets `aria-invalid`. `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="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 and sets `aria-invalid`. `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), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
- `<x-password>`: a reveal button; `icon`, `size`.
@@ -552,6 +592,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
<x-datepicker label="Expires on" wire:model.live="expiresOn" :min="now()" :max="now()->addMonth()" />
<x-datepicker label="Birthday" mode="modal" wire:model="birthday" :max="now()" />
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" clearable />
<x-datepicker label="Race day" wire:model="raceDay" :week-start="$user->week_start" :format="$user->date_format" />
```
| Prop | Default | |
@@ -563,8 +604,10 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
| `value` | `null` | the initial value without `wire:model` |
| `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) |
| `clearable` | `false` | a button that empties the field (both ends of a range) once it holds a date |
| `week-start` | `null` | the first day of the week, `0` (Sunday) to `6` (Saturday), instead of the locale's: the calendar's columns, weekday header and Home/End follow it. Anything else is ignored |
| `format` | `null` | the typed and displayed format instead of the locale's: `dd`, `MM` and `yyyy`, each once, around one delimiter (`.`, `/`, `-`) — `dd.MM.yyyy`, `dd/MM/yyyy`, `MM/dd/yyyy`, `yyyy-MM-dd`. The field, the dialog's text fields, a range and the error message follow it; `wire:model` still stores `Y-m-d`. Anything else is ignored |
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()`. Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()` (the last two unless `week-start` and `format` say otherwise — for a per-person setting). Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
### `<x-timepicker>`
@@ -644,10 +687,10 @@ The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a co
</x-app-shell>
```
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the current one), `badge` (`true` for a dot, or a count), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the page's, also during a Livewire update request), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for the badge: "3 unread"), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` 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 `sm`).
- Below `sm` the shell sets `--material-bottom-bar`, so the snackbar and a `fab` button clear the bar; pad anything else you pin to the bottom with it.
- Below `sm` the shell sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, a `fab` button 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-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
@@ -733,7 +776,7 @@ M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skip
### `<x-section-nav>`
Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the request's. `label`, `no-wire-navigate`.
Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below, whose items mark the current section as the page (`current`) and carry each section's badge. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). `label`, `no-wire-navigate`.
### `<x-account-menu>`
+1 -1
View File
@@ -24,7 +24,7 @@
z-index: 20;
display: block;
min-height: var(--app-bar-height);
padding-top: env(safe-area-inset-top);
padding-top: var(--material-safe-top, env(safe-area-inset-top));
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
transition: background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
+17 -10
View File
@@ -15,18 +15,25 @@
*
* Split button (`<x-split-button>`): the same idea for two halves; the trailing half turns
* round and its chevron turns over while its menu is open (SplitButton*Tokens).
*
* "Full" here is half the size's height (`--group-full`), never `--md-sys-shape-corner-full`'s
* 9999px. One element mixes full outer corners with small inner ones, and when a box's radii
* add up to more than its side, CSS scales every radius by the same factor: a 9999px corner
* beside an 8px one shrank the 8px one to a hundredth of a pixel, so the inner corners drew
* square.
*/
[data-button-group] {
--group-inner: var(--md-sys-shape-corner-sm);
--group-inner-pressed: var(--md-sys-shape-corner-xs);
--group-full: 1.25rem;
}
[data-button-group][data-size='xs'] { --group-pad: 0.75rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; }
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; }
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; }
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); }
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); }
[data-button-group][data-size='xs'] { --group-pad: 0.75rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; --group-full: 1rem; }
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; --group-full: 1.25rem; }
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; --group-full: 1.75rem; }
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); --group-full: 3rem; }
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); --group-full: 4.25rem; }
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) {
padding-inline: calc(var(--group-pad) + var(--group-grow));
@@ -60,19 +67,19 @@
[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)),
[data-split='trailing'][aria-expanded='true'] {
--group-corner: var(--md-sys-shape-corner-full);
--group-corner: var(--group-full);
}
[data-button-group='connected'] > :first-child,
[data-split='leading'] {
border-start-start-radius: var(--md-sys-shape-corner-full);
border-end-start-radius: var(--md-sys-shape-corner-full);
border-start-start-radius: var(--group-full);
border-end-start-radius: var(--group-full);
}
[data-button-group='connected'] > :last-child,
[data-split='trailing'] {
border-start-end-radius: var(--md-sys-shape-corner-full);
border-end-end-radius: var(--md-sys-shape-corner-full);
border-start-end-radius: var(--group-full);
border-end-end-radius: var(--group-full);
}
[data-split='trailing'] svg {
+5 -5
View File
@@ -66,8 +66,8 @@
[data-navigation-bar] {
container-type: inline-size;
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
padding-inline: var(--material-safe-left, env(safe-area-inset-left)) var(--material-safe-right, env(safe-area-inset-right));
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant);
}
@@ -206,7 +206,7 @@
at once when the rail expands, while the width is still growing; the clip keeps it
from spilling over the page for those frames. */
overflow-x: clip;
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
transition:
@@ -307,12 +307,12 @@
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding-top: calc(env(safe-area-inset-top) + 2.75rem);
padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 2.75rem);
padding-bottom: 2rem;
}
[data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child {
padding-top: calc(env(safe-area-inset-top) + 2.75rem);
padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 2.75rem);
}
[data-navigation-rail-destinations] {
+3 -3
View File
@@ -165,8 +165,8 @@
[data-search][data-full-screen] [data-search-bar] {
position: fixed;
inset: 0 0 auto;
height: calc(4.5rem + env(safe-area-inset-top));
padding-top: env(safe-area-inset-top);
height: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top)));
padding-top: var(--material-safe-top, env(safe-area-inset-top));
padding-inline: 0.25rem;
border-radius: 0;
}
@@ -175,7 +175,7 @@
position: fixed;
inset: 0;
max-height: none;
padding-top: calc(4.5rem + env(safe-area-inset-top));
padding-top: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top)));
border-radius: 0;
box-shadow: none;
}
+4 -4
View File
@@ -19,11 +19,11 @@
[data-toolbar][data-variant="docked"] {
width: 100%;
min-height: calc(4rem + env(safe-area-inset-bottom));
min-height: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
justify-content: center;
column-gap: clamp(0.25rem, 4vw, 2rem);
padding-inline: 1rem;
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant);
}
@@ -74,7 +74,7 @@
/* Placed over the page: centred above the bottom edge, or centred against the end edge. */
[data-toolbar-place="bottom"] {
position: fixed;
bottom: calc(1rem + env(safe-area-inset-bottom));
bottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
left: 50%;
z-index: 30;
translate: -50% 0;
@@ -83,7 +83,7 @@
[data-toolbar-place="end"] {
position: fixed;
top: 50%;
inset-inline-end: calc(1rem + env(safe-area-inset-right));
inset-inline-end: calc(1rem + var(--material-safe-right, env(safe-area-inset-right)));
z-index: 30;
translate: 0 -50%;
}
+4
View File
@@ -11,6 +11,10 @@ document.addEventListener('alpine:init', () => {
collapsed: false,
height: null,
frame: null,
// Set in init(). Declared here, or Alpine writes them to the outermost x-data scope,
// where a second app bar in the same page scope would take the first one's observer.
schedule: null,
resizes: null,
init() {
this.measure = this.measure.bind(this)
+34 -10
View File
@@ -11,7 +11,9 @@
* Dates are ISO strings (`2026-09-13`) throughout, computed in UTC so no time zone or daylight
* saving change can move a day; only "today" is read in the browser's own zone. Month and weekday
* names, the week's first day and the typed format come from `Intl` for the locale the server
* passes (the application's).
* passes (the application's), unless the component names the first day (`weekStart`, 0 for Sunday
* to 6) or the format (`format`, such as `dd.MM.yyyy`); everything that reads `firstDay` and
* `format` below then follows those.
*
* The keyboard is WAI-ARIA's date picker dialog: arrows move a day or a week, Home and End go to
* the start and end of the week, PageUp and PageDown a month (with Shift a year), Space selects,
@@ -82,8 +84,12 @@ function localToday() {
return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
/** 0 for Sunday … 6 for Saturday. */
function firstDayOfWeek(locale) {
/** 0 for Sunday … 6 for Saturday: `weekStart` when it is one of those, otherwise the locale's. */
function firstDayOfWeek(locale, weekStart = null) {
if (Number.isInteger(weekStart) && weekStart >= 0 && weekStart <= 6) {
return weekStart
}
try {
const tag = new Intl.Locale(locale)
const info = typeof tag.getWeekInfo === 'function' ? tag.getWeekInfo() : tag.weekInfo
@@ -111,9 +117,16 @@ function firstDayOfWeek(locale) {
/**
* The typed format: the locale's short numeric date, reduced to `dd`, `MM` and `yyyy` and one
* delimiter — androidx's `datePatternAsInputFormat`, fed from `formatToParts` because `Intl` has
* no pattern to give. `de` gives `dd.MM.yyyy`, `en-US` `MM/dd/yyyy`, `ja` `yyyy/MM/dd`.
* no pattern to give. `de` gives `dd.MM.yyyy`, `en-US` `MM/dd/yyyy`, `ja` `yyyy/MM/dd`. A `chosen`
* pattern of the same shape (each unit once, one delimiter) replaces the locale's.
*/
function inputFormat(locale) {
function inputFormat(locale, chosen = null) {
const units = /^(dd|MM|yyyy)([/\-.])(dd|MM|yyyy)\2(dd|MM|yyyy)$/.exec(typeof chosen === 'string' ? chosen : '')
if (units && new Set([units[1], units[3], units[4]]).size === 3) {
return formatOf(chosen)
}
let pattern = ''
try {
@@ -131,10 +144,11 @@ function inputFormat(locale) {
pattern = ''
}
if (!/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern)) {
pattern = 'yyyy-MM-dd'
}
return formatOf(/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern) ? pattern : 'yyyy-MM-dd')
}
/** A pattern, the placeholder it shows (`DD.MM.YYYY`) and the order its units are typed in (`dMy`). */
function formatOf(pattern) {
return {
pattern,
placeholder: pattern.toUpperCase(),
@@ -164,13 +178,23 @@ document.addEventListener('alpine:init', () => {
today: localToday(),
compact: false,
refocus: true,
// Set in init() from the config. Declared here, or Alpine writes them to the outermost
// x-data scope, where every picker inside the same page scope would share the last one's.
firstDay: 0,
format: null,
numbers: null,
formats: {},
min: null,
max: null,
yearsFrom: null,
yearsTo: null,
init() {
const locale = config.locale || document.documentElement.lang || 'en'
const format = (options) => new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options })
this.firstDay = firstDayOfWeek(locale)
this.format = inputFormat(locale)
this.firstDay = firstDayOfWeek(locale, config.weekStart ?? null)
this.format = inputFormat(locale, config.format ?? null)
this.numbers = new Intl.NumberFormat(locale, { useGrouping: false })
this.formats = {
monthYear: format({ year: 'numeric', month: 'long' }),
+78 -7
View File
@@ -3,17 +3,28 @@
*
* The menu button is the trigger's first button or link. Its ARIA attributes are written by
* script, which a Livewire morph removes along with anything else the server did not render,
* so they are written again whenever the trigger is used.
* so they are written again whenever the trigger is used and after every morph — an open menu
* lives through one (the popover is keyed), and its button must still say so.
*
* The popover hangs on the menu button by CSS anchor positioning. The server can only name the
* wrapper around the trigger slot, and a trigger taken out of the flow — a `position: fixed` FAB
* in a corner of the window — leaves that wrapper behind as an empty box where the page put it,
* so the menu opened there. Script moves the name onto the menu button, beside any name the button
* carries itself (a button's tooltip anchors on it too), and moves it again after every morph,
* which puts the server's attributes, and a fresh name, back.
*/
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
// A popover="auto" closes on the press that lands on its trigger, and the click that follows
// would open it again. A close this recent is taken as that press.
// would open it again. A close this recent is taken as that press. It is timed from
// `beforetoggle`, which fires as the popover closes: `toggle` is queued, and arrives after that
// click.
const REOPEN_GUARD_MS = 250
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialMenu', () => ({
closedAt: -Infinity,
anchored: null,
returnFocus: true,
focusWasInside: false,
listeners: [],
@@ -22,6 +33,25 @@ document.addEventListener('alpine:init', () => {
const menu = this.$refs.menu
this.label()
this.anchor()
// A morph rewrites the wrapper's style with this render's name and the button's without
// it, takes the button's ARIA attributes away and gives the popover a new id; the
// observer runs before the next frame is drawn, so an open menu never moves and its
// button never shows it shut.
const observer = new MutationObserver(() => {
this.anchor()
this.label()
})
observer.observe(this.$refs.trigger, {
attributes: true,
attributeFilter: ['style', 'aria-haspopup', 'aria-controls', 'aria-expanded'],
childList: true,
subtree: true,
})
observer.observe(menu, { attributes: true, attributeFilter: ['id'] })
this.listeners.push(() => observer.disconnect())
// Only closes the browser starts — Escape, a press outside — arrive here alone; open()
// and close() have already done their part, synchronously, because this event is
@@ -32,6 +62,10 @@ document.addEventListener('alpine:init', () => {
// that was a focusable region around the trigger).
this.listen(menu, 'beforetoggle', (event) => {
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
if (event.newState === 'closed') {
this.closedAt = performance.now()
}
})
this.listen(menu, 'toggle', (event) => {
@@ -43,8 +77,6 @@ document.addEventListener('alpine:init', () => {
return
}
this.closedAt = performance.now()
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
this.control()?.focus()
}
@@ -64,6 +96,40 @@ document.addEventListener('alpine:init', () => {
return this.$refs.trigger.querySelector('button, a[href], [tabindex]')
},
/**
* Moves the anchor name the server gave the wrapper onto the menu button. The wrapper holds a
* name only as rendered — this render's, which the popover's `position-anchor` matches — so
* it is read there.
*/
anchor() {
const trigger = this.$refs.trigger
const control = this.control()
const rendered = trigger.style.getPropertyValue('anchor-name').trim()
const name = rendered.startsWith('--') ? rendered : this.anchored
// No menu button, or an engine without anchor positioning: the wrapper keeps the name.
if (!control || !name) {
return
}
const names = control.style
.getPropertyValue('anchor-name')
.split(',')
.map((each) => each.trim())
.filter((each) => each.startsWith('--'))
if (!names.includes(name)) {
control.style.setProperty('anchor-name', [...names.filter((each) => each !== this.anchored), name].join(', '))
}
this.anchored = name
if (rendered !== '') {
trigger.style.removeProperty('anchor-name')
}
},
/** Writes only what differs: the observer that calls this watches these same attributes. */
label() {
const control = this.control()
@@ -71,9 +137,13 @@ document.addEventListener('alpine:init', () => {
return
}
control.setAttribute('aria-haspopup', 'menu')
control.setAttribute('aria-controls', this.$refs.menu.id)
control.setAttribute('aria-expanded', String(this.isOpen()))
const attributes = { 'aria-haspopup': 'menu', 'aria-controls': this.$refs.menu.id, 'aria-expanded': String(this.isOpen()) }
for (const [name, value] of Object.entries(attributes)) {
if (control.getAttribute(name) !== value) {
control.setAttribute(name, value)
}
}
},
isOpen() {
@@ -82,6 +152,7 @@ document.addEventListener('alpine:init', () => {
open(focus = 'first') {
this.label()
this.anchor()
if (!this.isOpen()) {
this.$refs.menu.showPopover()
+43 -7
View File
@@ -4,6 +4,11 @@
* One snackbar at a time, as M3 shows them. Each waits its turn, stays for its timeout (paused
* while hovered or focused, so it is never pulled away from someone reading or reaching for its
* action) and is replaced by the next.
*
* A `sticky` toast ("A new version is ready" with a Reload action) stays until it is answered, but
* never holds the queue up: it is kept aside rather than queued, a toast that arrives while it shows
* takes its place, and it comes back once the queue is empty. Only one is kept — a newer sticky
* toast replaces it. Dismissing it, or pressing its action, lets it go.
*/
const DEFAULT_TIMEOUT_MS = 4000
@@ -25,6 +30,7 @@ document.addEventListener('alpine:init', () => {
window.Alpine.data('materialSnackbar', () => ({
queue: [],
current: null,
sticky: null,
timer: null,
remaining: 0,
startedAt: 0,
@@ -44,24 +50,43 @@ document.addEventListener('alpine:init', () => {
// Livewire dispatches named arguments as the detail object; a positional dispatch
// arrives as an array whose first entry is that object.
const toast = Array.isArray(detail) ? detail[0] : detail
const sticky = toast.sticky === true
this.queue.push({
const entry = {
id: ++sequence,
type: toast.type ?? null,
title: toast.title ?? '',
description: toast.description ?? null,
timeout: toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS),
timeout: sticky || toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS),
action: toast.action ?? null,
})
sticky,
}
if (!this.current) {
if (sticky) {
const showing = !this.current || this.current === this.sticky
this.sticky = entry
if (showing) {
this.next()
}
return
}
this.queue.push(entry)
// A sticky toast steps aside for it, and comes back from next() once the queue is empty.
if (!this.current || this.current === this.sticky) {
this.next()
}
},
next() {
// Cleared and forgotten here, so a toast dismissed early never leaves its timer running
// to cut the next one short.
clearTimeout(this.timer)
this.current = this.queue.shift() ?? null
this.timer = null
this.current = this.queue.shift() ?? this.sticky
if (this.current?.timeout) {
this.remaining = this.current.timeout
@@ -92,13 +117,24 @@ document.addEventListener('alpine:init', () => {
},
dismiss() {
this.timer = null
if (this.current && this.current === this.sticky) {
this.sticky = null
}
this.next()
},
// Closed before the handler and the event run, so a toast either of them shows is not the
// one dismissed.
act() {
this.current?.action?.handler?.()
const action = this.current?.action
this.dismiss()
action?.handler?.()
if (typeof action?.event === 'string' && action.event !== '') {
window.dispatchEvent(new CustomEvent(action.event))
}
},
icon(type) {
File diff suppressed because one or more lines are too long
+14 -8
View File
@@ -20,7 +20,9 @@
remembered and applied before the first paint (`$store.rail`, <x-theme-script>).
`destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally
`active` (by default: the URL is the current one), `badge` (`true` for a dot, or a count),
`active` (by default: the URL is the page's; during a Livewire update request, the page the
component was rendered on rather than the update endpoint), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for
the badge instead: "3 unread"),
`section` (a heading the destination is grouped under in the rail; only an expanded rail shows
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
`navigate` (`false` for a full page load instead of `wire:navigate`).
@@ -35,7 +37,10 @@
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`.
below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`:
the bar's 64px, the bottom safe area (`--material-safe-bottom`, else the device's inset) and
`--material-bottom-extra` (0px unless the application docks something, an offline banner, on
top of the bar).
`max-lg:overflow-x-clip` on the content region is the backstop under every page, and it stays
`clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll
@@ -53,7 +58,7 @@
@php
$label ??= __('Main');
$current = request()->url();
$current = \Livewire\Livewire::isLivewireRequest() ? \Livewire\Livewire::originalUrl() : request()->url();
$items = collect($destinations)
->filter(fn ($item): bool => is_array($item) && filled($item['title'] ?? null))
@@ -63,6 +68,7 @@
'url' => $item['url'] ?? null,
'active' => (bool) ($item['active'] ?? (filled($item['url'] ?? null) && rtrim(url($item['url']), '/') === rtrim($current, '/'))),
'badge' => $item['badge'] ?? null,
'badgeLabel' => filled($item['badgeLabel'] ?? null) ? (string) $item['badgeLabel'] : null,
'section' => filled($item['section'] ?? null) ? (string) $item['section'] : null,
'bar' => ($item['bar'] ?? true) !== false,
'navigate' => ($item['navigate'] ?? true) !== false,
@@ -79,13 +85,13 @@
data-app-shell
@class([
'min-h-dvh bg-surface text-on-surface sm:flex',
'max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]' => $barItems->isNotEmpty(),
'max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty(),
])
>
<a
href="#content"
data-skip-link
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(env(safe-area-inset-top)+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
>{{ __('Skip to content') }}</a>
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
@@ -101,12 +107,12 @@
@if ($group->first()['section'] !== null)
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
@foreach ($group as $item)
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
@endforeach
</x-livewire-material::navigation-rail-section>
@else
@foreach ($group as $item)
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
@endforeach
@endif
@endforeach
@@ -136,7 +142,7 @@
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
<x-livewire-material::navigation-bar :label="$label">
@foreach ($barItems as $item)
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
@endforeach
</x-livewire-material::navigation-bar>
</div>
+30 -9
View File
@@ -10,8 +10,18 @@
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
The status label is not an M3 badge but every app needs one: `tonal` draws the value in the
colour's container ("Expired" in error-container), `outline` in a neutral edge. `color` (alias
`tone`): `error` (the default), `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
colour's container ("Expired" in error-container), `solid` in the colour itself (a label that
has to stand out, "Built in" in primary), `outline` in a neutral edge. `color` (alias
`tone`): `error` (the default), `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`,
and two without a hue of their own:
- `neutral` neutral ink on every variant: on-surface-variant with surface text as a dot or
count (the ink an outline badge already writes in), surface-container-high with
on-surface-variant text when `tonal`, the outline-variant edge when `outline`.
- `plain` no background, text or border colour at all, only shape, size and type, so the
caller's classes paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`.
The value is `value`, or the slot, which renders as HTML an icon beside the word:
`<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. With neither it is a dot.
A count or dot says nothing to a screen reader on its own: give the icon's control a label
that includes it ("Notifications, 4 new"), or pass `label` here. --}}
@@ -22,29 +32,41 @@
'color' => null,
'tone' => null,
'tonal' => false,
'solid' => false,
'outline' => false,
'floating' => false,
'label' => null,
])
@php
$color = in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) ? ($color ?? $tone) : 'error';
$text = $value ?? ($slot->isNotEmpty() ? trim((string) $slot) : null);
$color = in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info', 'neutral', 'plain'], true) ? ($color ?? $tone) : 'error';
// hasActualContent(): a slot holding only a comment, or an empty @foreach, is still a dot.
$markup = $value === null && $slot->hasActualContent();
$text = $value ?? ($markup ? trim((string) $slot) : null);
$dot = blank($text);
$status = ($tonal || $outline) && ! $dot;
$status = ($tonal || $solid || $outline) && ! $dot;
if (! $dot && $max !== null && is_numeric($text) && (int) $text > (int) $max) {
$text = $max.'+';
$markup = false;
}
$filled = [
'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary',
'error' => 'bg-error text-on-error', 'success' => 'bg-success text-on-success', 'warning' => 'bg-warning text-on-warning', 'info' => 'bg-info text-on-info',
'neutral' => 'bg-on-surface-variant text-surface',
];
$container = [
'primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container',
'error' => 'bg-error-container text-on-error-container', 'success' => 'bg-success-container text-on-success-container', 'warning' => 'bg-warning-container text-on-warning-container', 'info' => 'bg-info-container text-on-info-container',
'neutral' => 'bg-surface-container-high text-on-surface-variant',
];
$paint = match (true) {
$color === 'plain' => '',
! $status, $solid => $filled[$color],
$tonal => $container[$color],
default => 'border-outline-variant text-on-surface-variant',
};
$attributes = $attributes
->class([
@@ -52,9 +74,8 @@
'size-1.5 rounded-corner-full' => $dot,
'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums' => ! $dot && ! $status,
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
$filled[$color] => ! $status,
$container[$color] => $tonal && ! $dot,
'border border-outline-variant text-on-surface-variant' => $outline && ! $tonal && ! $dot,
'border' => $outline && ! $tonal && ! $solid && ! $dot,
$paint => $paint !== '',
'absolute top-0.5 end-0.5' => $floating && $dot,
'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
])
@@ -64,4 +85,4 @@
]));
@endphp
<span {{ $attributes }}>@unless ($dot){{ $text }}@endunless</span>
<span {{ $attributes }}>@unless ($dot)@if ($markup){{ $slot }}@else{{ $text }}@endif@endunless</span>
@@ -52,7 +52,7 @@
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
style="--sheet-max-height: {{ $height }}"
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[env(safe-area-inset-bottom)] text-on-surface shadow-elevation-1',
'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[var(--material-safe-bottom,env(safe-area-inset-bottom))] text-on-surface shadow-elevation-1',
$attributes->get('class'),
]) }}
>
+5 -3
View File
@@ -13,8 +13,8 @@
With an `icon` and no label it is an icon button: `width` is `narrow`, `default` or `wide`,
`variant="text"` is M3's standard icon button, and the tooltip or label names it for screen
readers. `selected` makes it a toggle: `true` or `false` sets `aria-pressed` and M3's
selected colours, and a selected round button turns square (a selected square icon button
readers. `selected` makes it a toggle: `true` or `false` sets `aria-pressed` (not on a `link`,
which is no toggle give it `aria-current` instead) and M3's selected colours, and a selected round button turns square (a selected square icon button
turns round). Text buttons are not toggles in M3; a selected one takes the tonal container.
Values from androidx Compose Material 3's tokens (Button*Tokens, *IconButtonTokens,
@@ -191,7 +191,9 @@
'type' => $isLink ? null : $type,
'disabled' => ! $isLink && $disabled ? true : null,
'aria-label' => $iconOnly && ! $attributes->has('aria-label') ? ($label ?? $tip) : null,
'aria-pressed' => $selected === null ? null : ($selected ? 'true' : 'false'),
// A link is not a toggle: ARIA defines aria-pressed for buttons only. A selected link keeps
// the selected look; `aria-current` is the caller's to set (`:aria-current="'page'"`).
'aria-pressed' => $selected === null || $isLink ? null : ($selected ? 'true' : 'false'),
'data-icon-button' => $iconOnly ? true : null,
'wire:loading.attr' => $spinnerTarget ? 'disabled' : null,
'wire:target' => $spinnerTarget,
@@ -44,7 +44,9 @@
longer slides inside its mask. RTL mirrors the keylines, keys and buttons.
Re-measures itself when resized, when a Livewire morph resets its styles and when items
come and go. --}}
come and go. The row's id, which the buttons control, is new with every render; the row
carries a `wire:key` (see `<x-menu>`), so a morph patches it in place its scroll position
and listeners kept rather than swapping in a copy. --}}
@props([
'layout' => 'multi-browse',
@@ -116,6 +118,7 @@
<div
x-ref="scroller"
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-carousel']) }}
id="{{ $scrollerId }}"
role="region"
aria-roledescription="{{ __('carousel') }}"
+30 -3
View File
@@ -6,7 +6,18 @@
`heading` slot), an optional leading `icon`, a chevron that turns over on the spatial spring,
and where the browser supports animating `details` content (`interpolate-size`) a height
that eases open. `open` starts it open. `variant`: `plain` (the default, on the surface around
it) or `filled` (a surface-container tile with a large corner). --}}
it) or `filled` (a surface-container tile with a large corner).
Its open state can be bound, both ways:
- `wire:model` (any modifiers; `.live` tells the server at once) entangles it with a Livewire
boolean property. The server renders `open` from the property, so the first paint matches it
and `open` is ignored; toggling writes the property, and the property changing opens or
closes it.
- `x-model` binds an Alpine property through `x-modelable`; `open` is then only the first
paint, until Alpine starts and applies the property.
The state lives in `collapseOpen` on the `<details>`, a name kept clear of `open`, which a
dialog inside the slot may be reading from a scope around it. Without a binding there is no
Alpine on it at all. --}}
@props([
'title' => null,
@@ -15,10 +26,26 @@
'variant' => 'plain',
])
@php
$model = $attributes->wire('model')->value() ?: null;
$bound = $model !== null || count($attributes->whereStartsWith('x-model')->getAttributes()) > 0;
$expanded = (bool) $open;
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$expanded = (bool) data_get($component, $model);
}
@endphp
<details
wire:ignore.self
@if ($open) open @endif
{{ $attributes->class([
@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-on:toggle="collapseOpen = $el.open"
@endif
@if ($expanded) open @endif
{{ $attributes->whereDoesntStartWith('wire:model')->class([
'group/collapse [interpolate-size:allow-keywords]',
'rounded-corner-lg bg-surface-container' => $variant === 'filled',
]) }}
@@ -27,8 +27,14 @@
the `wire:model` name replace the hint, and so does a typed date that cannot be read.
Month and weekday names, the first day of the week and the typed format come from `Intl` for
`app()->getLocale()` (resources/js/datepicker.js). Replaces ReStride's flatpickr picker: its
`config` becomes `min`, `max`, `range` and `mode`.
`app()->getLocale()` (resources/js/datepicker.js). An application that lets each person choose
overrides the last two: `week-start` is the first day of the week, 0 (Sunday) to 6 (Saturday),
and `format` the typed and displayed format, `dd`, `MM` and `yyyy` in any order around one
delimiter (`.`, `/` or `-`: `dd.MM.yyyy`, `MM/dd/yyyy`, `yyyy-MM-dd`). The field, the typed-date
reader, the calendar's columns and weekday header, Home and End, and the dialog's text fields
all follow them; the names stay the locale's, and `wire:model` still stores `Y-m-d`. A value
that is neither (`week-start="7"`, `format="d.M.yy"`) is ignored, as `null` is. Replaces
ReStride's flatpickr picker: its `config` becomes `min`, `max`, `range` and `mode`.
M3's date pickers (DatePickerModalTokens and DateInputModalTokens from androidx Compose
Material 3, androidx commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, with the layout of
@@ -50,6 +56,8 @@
'max' => null,
'value' => null,
'clearable' => false,
'weekStart' => null,
'format' => null,
])
@php
@@ -63,6 +71,10 @@
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
: [];
$locale = str_replace('_', '-', app()->getLocale());
$weekStart = (is_int($weekStart) || is_string($weekStart)) && preg_match('/^[0-6]\z/', (string) $weekStart) === 1 ? (int) $weekStart : null;
$format = is_string($format) && preg_match('/^(dd|MM|yyyy)([.\/-])(dd|MM|yyyy)\2(dd|MM|yyyy)\z/', $format, $units) === 1 && count(array_unique([$units[1], $units[3], $units[4]])) === 3
? $format
: null;
$toIso = function (mixed $date): ?string {
if ($date instanceof \DateTimeInterface) {
@@ -85,9 +97,9 @@
? ['start' => $toIso(data_get($current, 'start')), 'end' => $toIso(data_get($current, 'end'))]
: $toIso($current);
// The typed format as resources/js/datepicker.js derives it, from ICU's short date when PHP has intl.
$pattern = 'yyyy-MM-dd';
if (class_exists(\IntlDateFormatter::class)) {
// The typed format: `format`, or as resources/js/datepicker.js derives it, from ICU's short date when PHP has intl.
$pattern = $format ?? 'yyyy-MM-dd';
if ($format === null && class_exists(\IntlDateFormatter::class)) {
$short = (string) (new \IntlDateFormatter(str_replace('-', '_', $locale), \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE))->getPattern();
$candidate = rtrim(str_replace('My', 'M/y', (string) preg_replace(['/[^dMy\/\-.]/', '/d{1,2}/', '/M{1,2}/', '/y{1,4}/'], ['', 'dd', 'MM', 'yyyy'], $short)), '.');
if (preg_match('/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[\/\-.][dMy]+[\/\-.][dMy]+$/', $candidate) === 1) {
@@ -110,6 +122,8 @@
'range' => (bool) $range,
'min' => $toIso($min),
'max' => $toIso($max),
'weekStart' => $weekStart,
'format' => $format,
'disabled' => (bool) $attributes->get('disabled'),
'readonly' => (bool) $attributes->get('readonly'),
'strings' => [
+8 -5
View File
@@ -15,7 +15,9 @@
As a pane (`pane`, from `xl`) nothing is covered: the page renders the drawer after its list in
an `xl:flex xl:items-start xl:gap-6` row, the drawer sticks under the top of the viewport, the
list stays usable and another row swaps what it shows no scrim, no trap, no inert page. While
closed it takes no room. `pane-width` sizes the pane (the sheet's width by default). The body is
closed it takes no room. `pane-width` sizes the pane (the sheet's width by default). Escape closes
the sheet but leaves a pane open, since the page beside it is still in use; `pane-close-on-escape`
closes the pane on Escape too, for a pane that is a transient detail. The body is
a size container, so its contents lay out by the room the sheet or pane actually has (`@md:`),
never by the viewport.
@@ -34,6 +36,7 @@
'width' => '25rem',
'pane' => false,
'paneWidth' => null,
'paneCloseOnEscape' => false,
])
@php
@@ -55,11 +58,11 @@
},
@endif
}"
@if ($closeOnEscape) x-on:keydown.window.escape="if (open && ! wide) close()" @endif
@if ($closeOnEscape) x-on:keydown.window.escape="{{ $paneCloseOnEscape ? 'if (open) close()' : 'if (open && ! wide) close()' }}" @endif
data-sheet="{{ $id }}"
@if ($pane)
x-bind:class="! open && 'xl:hidden'"
class="xl:sticky xl:top-[calc(env(safe-area-inset-top)+1.25rem)] xl:shrink-0 xl:self-start"
class="xl:sticky xl:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1.25rem)] xl:shrink-0 xl:self-start"
data-pane
@endif
>
@@ -90,11 +93,11 @@
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth ?? $width }}"
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
'fixed top-[env(safe-area-inset-top)] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1',
'fixed top-[var(--material-safe-top,env(safe-area-inset-top))] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1',
'end-0 sm:rounded-s-corner-lg' => ! $start,
'start-0 sm:rounded-e-corner-lg' => $start,
'sm:w-(--sheet-width) sm:max-w-[calc(100vw-4rem)]',
'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-env(safe-area-inset-top))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane,
'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-var(--material-safe-top,env(safe-area-inset-top)))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane,
$attributes->get('class'),
]) }}
>
@@ -8,7 +8,17 @@
Not an M3 component; built from M3 Expressive's parts `shape` (any `<x-shape>` name,
`cookie-9` by default) in secondary-container behind the `icon` in on-secondary-container,
a title-large `title`, body-medium `description` or slot. For "your filter matched nothing"
use a plain line of text instead: a picture there is consolation for a typo. --}}
use a plain line of text instead: a picture there is consolation for a typo.
An application with its own artwork puts it in the `illustration` slot, which is drawn in place
of the shape and icon (both props are then unused). The slot sizes itself; its attributes go on
the element around it, so `class` can set the colour an SVG's `currentColor` takes:
<x-empty-state title="No routes yet">
<x-slot:illustration class="text-primary"><svg class="size-32" aria-hidden="true"></svg></x-slot:illustration>
</x-empty-state>
A slot holding only whitespace or comments counts as empty, and the shape and icon stay. --}}
@props([
'icon' => 'inbox',
@@ -18,10 +28,14 @@
])
<div {{ $attributes->class('flex flex-col items-center gap-4 px-4 py-10 text-center') }}>
<div class="relative grid size-28 place-items-center">
<x-livewire-material::shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" />
<x-livewire-material::icon :name="$icon" class="relative size-12 text-on-secondary-container" />
</div>
@if (isset($illustration) && $illustration->hasActualContent())
<div {{ $illustration->attributes }}>{{ $illustration }}</div>
@else
<div class="relative grid size-28 place-items-center">
<x-livewire-material::shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" />
<x-livewire-material::icon :name="$icon" class="relative size-12 text-on-secondary-container" />
</div>
@endif
@if ($title)
<h3 class="type-title-lg">{{ $title }}</h3>
@@ -10,7 +10,8 @@
Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a
round close button in the colour itself while the list is open above it, end-aligned; the
list is a `popover="auto"` menu with the menu keyboard of `<x-menu>`. `label` names the FAB
for screen readers. Give the items the same `color`.
for screen readers. Give the items the same `color`. Like `<x-menu>`'s, the list is keyed for
Livewire, so it stays open through a render of the component around it.
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px
above the close button. --}}
@@ -56,6 +57,7 @@
<div
x-ref="menu"
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-fab-menu']) }}
id="material-fab-menu-{{ $key }}"
popover="auto"
role="menu"
+14 -4
View File
@@ -12,13 +12,19 @@
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
`inline`. An option with `'disabled' => true` greys its own segment.
ReStride's props, kept: `label`, `hint`, `name` (needed with `x-model`, which names no
property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`, `variant`,
`multiple`, `inline`. A validation message for the bound property replaces the hint. --}}
ReStride's props, kept: `label`, `hint`, `hint-class`, `name` (needed with `x-model`, which
names no property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`,
`variant`, `multiple`, `inline`. A validation message for the bound property replaces the hint.
`hint-class` adds classes to the hint, as on `<x-field>`: a colour there paints it
(`hint-class="text-warning"` for a hint that warns). The hint's own colour then carries no
specificity, as the field's does in the components layer, because which of two colour
utilities wins depends on the order Tailwind emits them. --}}
@props([
'label' => null,
'hint' => null,
'hintClass' => null,
'name' => null,
'options' => [],
'optionValue' => 'id',
@@ -46,6 +52,10 @@
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
][$size];
$hintClasses = filled($hintClass)
? \Illuminate\Support\Arr::toCssClasses(['mt-1 type-body-sm [:where(&)]:text-on-surface-variant', $hintClass])
: 'mt-1 type-body-sm text-on-surface-variant';
$iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
$colours = match ($variant) {
@@ -94,6 +104,6 @@
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
@endforeach
@elseif (filled($hint))
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
<p class="{{ $hintClasses }}">{{ $hint }}</p>
@endif
</fieldset>
+34 -7
View File
@@ -1,11 +1,21 @@
{{-- One item in an `<x-menu>`: an action, a link, or a choice.
`label`, a leading `icon`, an `icon-right`, a `description` under the label and a
`shortcut` at the end (M3's trailing supporting text: "⌘C"). `link` makes it an anchor, with
`wire:navigate` unless `external` or `no-wire-navigate`. `selected` (true or false) makes it a
`menuitemcheckbox` with `aria-checked`; a selected item takes Expressive's selected shape and
tertiary-container. `disabled` keeps it in the list, out of reach. `keep-open` leaves the menu
open when it is activated for a choice the person may want to change twice.
`label`, a leading `icon` (`icon-class` adds classes to it), an `icon-right`, a `description`
under the label and a `shortcut` at the end (M3's trailing supporting text: "⌘C"). `link`
makes it an anchor, with `wire:navigate` unless `external` or `no-wire-navigate`. `selected`
(true or false) makes it a `menuitemcheckbox` with `aria-checked`; a selected item takes
Expressive's selected shape and tertiary-container. `current` is for a menu of places rather
than choices a section picker and marks the page you are on: `aria-current="page"`, the
selected shape in secondary-container, the colour M3 gives the navigation indicator. `badge`
draws `<x-badge>` at the end of the row: `true` for a dot, or a count. `disabled` keeps it in the list, out of
reach. `keep-open` leaves the menu open when it is activated for a choice the person may
want to change twice.
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
sport's colour (`icon-class="text-sport-run"`). A colour there paints the icon, a selected
item's too: the icon's own colour then carries no specificity, because which of two colour
utilities wins depends on the order Tailwind emits them. A disabled item's icon stays
disabled.
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
12px at the ends of the list. --}}
@@ -13,6 +23,7 @@
@props([
'label' => null,
'icon' => null,
'iconClass' => null,
'iconRight' => null,
'description' => null,
'shortcut' => null,
@@ -20,6 +31,8 @@
'external' => false,
'noWireNavigate' => false,
'selected' => null,
'current' => false,
'badge' => null,
'disabled' => false,
'keepOpen' => false,
])
@@ -36,11 +49,13 @@
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary',
'py-2' => filled($description),
'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true,
'rounded-corner-md bg-secondary-container text-on-secondary-container' => $current && $selected !== true,
'pointer-events-none text-on-surface/38' => $disabled,
])
->merge(array_filter([
'role' => $selected === null ? 'menuitem' : 'menuitemcheckbox',
'aria-checked' => $selected === null ? null : ($selected ? 'true' : 'false'),
'aria-current' => $current ? 'page' : null,
'aria-disabled' => $disabled ? 'true' : null,
'tabindex' => '-1',
'type' => $isLink ? null : 'button',
@@ -54,13 +69,22 @@
$iconInk = match (true) {
$disabled => 'text-on-surface/38',
$selected === true => 'text-on-tertiary-container',
$current => 'text-on-secondary-container',
default => 'text-on-surface-variant',
};
$leadingIcon = match (true) {
blank($iconClass) => 'size-5 '.$iconInk,
$disabled => \Illuminate\Support\Arr::toCssClasses(['size-5', $iconClass, 'text-on-surface/38!']),
$selected === true => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-tertiary-container', $iconClass]),
$current => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-secondary-container', $iconClass]),
default => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-surface-variant', $iconClass]),
};
@endphp
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="'size-5 '.$iconInk" />
<x-livewire-material::icon :name="$icon" :filled="$selected === true || $current" :class="$leadingIcon" />
@endif
<span class="min-w-0 flex-1">
@@ -71,6 +95,9 @@
@endif
</span>
@if ($badge !== null && $badge !== false && $badge !== '')
<x-livewire-material::badge :value="$badge === true ? null : $badge" class="shrink-0" />
@endif
@if ($shortcut)
<span @class(['shrink-0 type-label-sm', $iconInk])>{{ $shortcut }}</span>
@endif
+20 -5
View File
@@ -13,10 +13,24 @@
The trigger's first button or link becomes the menu button (aria-haspopup, aria-expanded,
aria-controls). The list is a `popover="auto"` in the top layer, placed by CSS anchor
positioning at `position` (`bottom-start`, `bottom-end`, `top-start`, `top-end`) and flipping
when there is no room; a click outside or Escape closes it. The keyboard is WAI-ARIA's menu
button: Enter, Space or ArrowDown open on the first item, ArrowUp on the last; arrows, Home,
End and typing a letter move between items; Tab closes; activating an item closes the menu
unless the item says `keep-open`, and Escape returns focus to the trigger.
when there is no room to the other side, the other end, or both, so a menu on a FAB in a
corner of the window opens back across it; a click outside or Escape closes it. The keyboard
is WAI-ARIA's menu button: Enter, Space or ArrowDown open on the first item, ArrowUp on the
last; arrows, Home, End and typing a letter move between items; Tab closes; activating an item
closes the menu unless the item says `keep-open`, and Escape returns focus to the trigger.
The anchor name is rendered on the wrapper around the trigger slot, the only element the
server can name, and resources/js/menu.js moves it onto the menu button itself: a trigger
that is `position: fixed` (`<x-button fab>` on a phone) leaves the wrapper behind as an empty
box where the page put it, and the menu opened there.
The id and the anchor name are new with every render. The popover carries a `wire:key`, which
a Livewire morph matches it by before the id, so a render of the component around an open
menu patches it in place still open, focus and listeners kept instead of swapping in a
closed copy; menu.js then writes the menu button's ARIA attributes again. The key goes
through an attribute bag: Livewire compiles a `wire:key` written in a template into the key
of the loop iteration around it, which would give every child component after the menu the
same key.
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
2), or `vibrant` in tertiary-container StandardMenuTokens and VibrantMenuTokens from
@@ -43,6 +57,7 @@
<div
x-ref="menu"
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-menu']) }}
id="material-menu-{{ $key }}"
popover="auto"
role="menu"
@@ -53,7 +68,7 @@
x-on:click="activate($event)"
@class([
'm-0 min-w-28 max-w-70 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
'my-1 [position-try-fallbacks:flip-block,flip-inline]',
'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
'bg-surface-container-low text-on-surface' => ! $vibrant,
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
+1 -1
View File
@@ -64,7 +64,7 @@
>
<div @class([
'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3',
'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[env(safe-area-inset-top)]' => $fullscreen,
'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen,
$boxClass,
])>
@if ($fullscreen)
@@ -10,6 +10,11 @@
form M3 asks for when it has actions. The bubble is a popover in surface-container with a
medium corner and elevation 2, 312px at most, placed by anchor positioning on `side`.
The bubble's id and anchor name are new with every render; its `wire:key` (see `<x-menu>`)
lets a Livewire morph patch it in place, so an open bubble stays open through its own
action's `wire:click` too and resources/js/rich-tooltip.js keeps showing and hiding the
element on the page rather than one the morph took away.
RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
text in on-surface-variant, label-large actions in primary. --}}
@@ -35,6 +40,7 @@
<span
x-ref="bubble"
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-rich-tooltip']) }}
id="material-rich-tooltip-{{ $key }}"
popover="{{ $persistent ? 'auto' : 'manual' }}"
role="{{ $persistent ? 'dialog' : 'tooltip' }}"
@@ -2,11 +2,15 @@
single destination in the app's own navigation.
`items`, a list of `['title' => …, 'url' => …]` with an optional `icon`, `active` and `badge`
(an item is current when `active` is true, or when its `url` is the request's). From `sm` they
(an item is current when `active` is true, or when its `url` is the page's). From `sm` they
are M3's secondary tabs as links, the current one underlined; below `sm`, where a row of them
never fits, a button naming the current section opens a menu of all of them. The same list is
never fits, a button naming the current section opens a menu of all of them, the current one
marked `aria-current="page"` and each with its badge. The same list is
rendered for both, and CSS shows one.
The page's URL is `Livewire::originalUrl()`: while a Livewire component on the page updates, the
request is Livewire's update endpoint, and comparing with it left no section lit.
A row too long for its column wraps onto a grid rather than scrolling: below `xl` five or six
sections go 3 + 3 and seven or more go four to a row tabs that scroll hid the last sections on
a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless
@@ -20,7 +24,8 @@
@php
$label ??= __('Sections');
$isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && url()->current() === url($item['url']));
$page = \Livewire\Livewire::originalUrl();
$isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && $page === url($item['url']));
$current = collect($items)->first($isCurrent) ?? ($items[0] ?? null);
$layout = match (true) {
@@ -45,7 +50,7 @@
</x-slot:trigger>
@foreach ($items as $item)
<x-livewire-material::menu-item :label="$item['title']" :icon="$item['icon'] ?? null" :link="$item['url']" :selected="$isCurrent($item)" :no-wire-navigate="$noWireNavigate" />
<x-livewire-material::menu-item :label="$item['title']" :icon="$item['icon'] ?? null" :link="$item['url']" :current="$isCurrent($item)" :badge="$item['badge'] ?? null" :no-wire-navigate="$noWireNavigate" />
@endforeach
</x-livewire-material::menu>
</div>
@@ -21,10 +21,21 @@
rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
changes it.
With `theme.meta` on, the browser's own chrome follows too: the `content` of every
<meta name="theme-color"> without a `media` attribute one is added to <head> when there is
none is the resolved theme's `surface`, from the scheme file (`Scheme`), for the profile in
<html data-scheme> (else the active one). A MutationObserver on <html> keeps it in step with
whatever changes `data-theme` or `data-scheme` afterwards: `$store.theme.set()` and `toggle()`,
an OS change while `system`, a profile preview, the application's own script. A layout's own
theme-color meta belongs before this script: one written after it is only painted on
DOMContentLoaded, beside the one added here. Off by default, and then none of it is emitted.
wire:navigate swaps the body, merges the head without running this again, and gives <html>
the next page's attributes which the server rendered without any of these, so Livewire
removes them. They are put back as the new page is swapped in (`onSwap`, in the same task,
before anything paints), so this only has to run on a full load. --}}
before anything paints), so this only has to run on a full load. The head merge also puts the
next page's server-rendered theme-color meta in place of the painted one, so it is painted
again there, and once more on `livewire:navigated`. --}}
@php
$theme = config('livewire-material.theme');
@@ -40,6 +51,18 @@
'key' => $rail['storage_key'] ?? 'material-rail',
],
];
// Only the surfaces the meta can show: the active scheme's, and every profile's for a preview.
if ((bool) ($theme['meta'] ?? false)) {
$surfaces = fn (array $scheme): array => ['light' => $scheme['light']['surface'], 'dark' => $scheme['dark']['surface']];
$schemeProfiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles();
$settings['meta'] = [
// PHP 8.5 deprecates a null array offset: without profiles the scheme is null.
...$surfaces(($settings['scheme'] !== null ? ($schemeProfiles[$settings['scheme']] ?? null) : null) ?? \NoNameWeb\LivewireMaterial\Support\Scheme::load()),
'profiles' => (object) collect($schemeProfiles)->map($surfaces)->all(),
];
}
@endphp
<script>
@@ -94,6 +117,39 @@
apply();
media.addEventListener('change', apply);
@if (isset($settings['meta']))
var paintThemeColor = function () {
var theme = root.getAttribute('data-theme');
var scheme = root.getAttribute('data-scheme');
if ((theme !== 'light' && theme !== 'dark') || !document.head) {
return;
}
var colour = (Object.prototype.hasOwnProperty.call(settings.meta.profiles, scheme) ? settings.meta.profiles[scheme] : settings.meta)[theme];
var metas = document.head.querySelectorAll('meta[name="theme-color"]:not([media])');
if (metas.length === 0) {
var meta = document.createElement('meta');
meta.setAttribute('name', 'theme-color');
document.head.appendChild(meta);
metas = [meta];
}
Array.prototype.forEach.call(metas, function (meta) {
if (meta.getAttribute('content') !== colour) {
meta.setAttribute('content', colour);
}
});
};
paintThemeColor();
new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme'] });
document.addEventListener('DOMContentLoaded', paintThemeColor);
document.addEventListener('livewire:navigated', paintThemeColor);
@endif
document.addEventListener('livewire:navigating', function (event) {
var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) {
@@ -106,6 +162,10 @@
root.setAttribute(attribute[0], attribute[1]);
}
});
@if (isset($settings['meta']))
paintThemeColor();
@endif
});
});
})(@json($settings));
+14 -4
View File
@@ -5,9 +5,18 @@
It shows every `toast` browser event what `NoNameWeb\LivewireMaterial\Concerns\Toasts`
dispatches from a Livewire component and for `window.materialToast(title, options)` from
JavaScript (`{ type, description, timeout, action: { label, handler } }`). Toasts queue and
show in turn, each for its `timeout` (4s by default; M3 asks for 410s), paused while the
pointer or focus is on it. A toast with an action or no timeout gets a close button.
JavaScript (`{ type, description, timeout, sticky, action: { label, handler, event } }`).
Toasts queue and show in turn, each for its `timeout` (4s by default; M3 asks for 410s),
paused while the pointer or focus is on it. A toast with an action or no timeout gets a close
button. Pressing the action closes the snackbar, calls `handler` and dispatches `event` (a
name) on `window`; both may be given.
`sticky: true` keeps a toast until it is dismissed or its action pressed, without holding up
the queue: a toast that arrives meanwhile shows in its place, and the sticky one comes back
once the queue is empty. One is kept at a time; a newer sticky toast replaces it.
Hooks for tests and styling: `data-toast` on the snackbar on screen, `data-toast-action` on its
action button.
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
still on screen when the next page arrives.
@@ -32,6 +41,7 @@
<template x-if="current">
<div
x-bind:key="current.id"
data-toast
x-bind:role="current.type === 'error' || current.type === 'warning' ? 'alert' : 'status'"
aria-live="polite"
x-on:mouseenter="pause()"
@@ -60,7 +70,7 @@
</div>
<template x-if="current.action">
<button type="button" class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
<button type="button" data-toast-action class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
</template>
<template x-if="current.action || ! current.timeout">
@@ -84,7 +84,7 @@
['id' => 'system', 'name' => 'System', 'icon' => 'computer'],
]" />
<x-group label="Days" name="showcase-days" x-model="days" multiple variant="outlined" hint="Choose any" :options="[
<x-group label="Days" name="showcase-days" x-model="days" multiple variant="outlined" hint="Thursday is fully booked" hint-class="text-warning" :options="[
['id' => 'mon', 'name' => 'Mon'],
['id' => 'tue', 'name' => 'Tue'],
['id' => 'wed', 'name' => 'Wed'],
@@ -7,13 +7,22 @@
<x-badge value="Expired" tonal />
<x-badge value="Active" color="success" tonal />
<x-badge value="Password" color="info" tonal />
<x-badge value="Built in" color="primary" solid />
<x-badge value="Pro" outline />
<x-badge value="Draft" color="neutral" tonal />
<x-badge value="Archived" color="neutral" outline />
<span class="relative inline-flex"><x-icon name="chat" /><x-badge value="2" color="neutral" floating /></span>
<x-badge tonal color="tertiary"><x-icon name="bolt" filled class="size-3" /> Pro</x-badge>
<x-badge value="Beta" tonal color="plain" class="bg-primary-fixed text-on-primary-fixed" />
BLADE,
'Snackbars' => <<<'BLADE'
<x-button label="Saved" variant="tonal" x-on:click="materialToast('Settings saved', { type: 'success' })" />
<x-button label="With a description" variant="tonal" x-on:click="materialToast('Upload failed', { type: 'error', description: 'The file is larger than 4 GB.' })" />
<x-button label="With an action" variant="tonal" x-on:click="materialToast('Share deleted', { action: { label: 'Undo', handler: () => materialToast('Share restored', { type: 'info' }) } })" />
<x-button label="Until dismissed" variant="tonal" x-on:click="materialToast('Your storage is almost full', { type: 'warning', timeout: 0 })" />
<div x-data class="flex flex-wrap items-center gap-4">
<x-button label="Saved" variant="tonal" x-on:click="materialToast('Settings saved', { type: 'success' })" />
<x-button label="With a description" variant="tonal" x-on:click="materialToast('Upload failed', { type: 'error', description: 'The file is larger than 4 GB.' })" />
<x-button label="With an action" variant="tonal" x-on:click="materialToast('Share deleted', { action: { label: 'Undo', handler: () => materialToast('Share restored', { type: 'info' }) } })" />
<x-button label="Until dismissed" variant="tonal" x-on:click="materialToast('Your storage is almost full', { type: 'warning', timeout: 0 })" />
<x-button label="Sticky, with an event" variant="tonal" x-on:click="materialToast('A new version is ready', { type: 'info', sticky: true, action: { label: 'Reload', event: 'showcase:reload' } })" x-on:showcase:reload.window="materialToast('Reloading…')" />
</div>
BLADE,
'Plain tooltips' => <<<'BLADE'
<x-button icon="content_copy" tooltip="Copy link" />
@@ -58,6 +67,22 @@
</x-slot:actions>
</x-empty-state>
BLADE,
'Empty state with an illustration' => <<<'BLADE'
<x-empty-state title="No routes yet" description="Draw a route on the map, or import one from a GPX file." class="w-full">
<x-slot:illustration class="text-primary">
<svg class="size-32" viewBox="0 0 120 120" fill="none" aria-hidden="true">
<circle cx="60" cy="60" r="56" class="fill-primary-container" />
<path d="M28 86C40 62 56 92 68 64S86 42 90 46" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-dasharray="1 10" />
<circle cx="28" cy="86" r="7" fill="currentColor" />
<path d="M90 18a12 12 0 0 1 12 12c0 10-12 22-12 22S78 40 78 30a12 12 0 0 1 12-12Z" class="fill-tertiary" />
<circle cx="90" cy="30" r="4" class="fill-on-tertiary" />
</svg>
</x-slot:illustration>
<x-slot:actions>
<x-button label="Draw a route" icon="route" variant="filled" />
</x-slot:actions>
</x-empty-state>
BLADE,
];
@endphp
@@ -55,6 +55,18 @@
<div class="flex h-10 items-center gap-4"><span>Left</span><x-divider vertical /><span>Right</span></div>
</div>
BLADE,
'Collapse bound to a property' => <<<'BLADE'
<div x-data="{ advanced: false }" class="w-full space-y-4">
<div class="flex flex-wrap items-center gap-4">
<x-button label="Open" variant="tonal" x-on:click="advanced = true" />
<x-button label="Close" variant="tonal" x-on:click="advanced = false" />
<span class="type-body-md text-on-surface-variant">advanced: <span x-text="advanced"></span></span>
</div>
<x-collapse title="Advanced settings" icon="tune" variant="filled" x-model="advanced">
Bound with x-model here. In a Livewire view, wire:model="advanced" binds a boolean property the same way, and the page arrives with it open or closed as the property is.
</x-collapse>
</div>
BLADE,
'Dialogs' => <<<'BLADE'
<div x-data="{ open: false }">
<x-button label="Basic dialog" variant="tonal" x-on:click="open = true" />
@@ -36,6 +36,18 @@
<x-menu-item label="Upload a folder" icon="drive_folder_upload" />
</x-menu>
BLADE,
'Icons in their own colour' => <<<'BLADE'
<x-menu label="New plan">
<x-slot:trigger>
<x-button label="New plan" icon="add" variant="filled" />
</x-slot:trigger>
<x-menu-item label="Running" icon="directions_run" icon-class="text-tertiary" />
<x-menu-item label="Cycling" icon="directions_bike" icon-class="text-secondary" />
<x-menu-item label="Swimming" icon="pool" icon-class="text-info" />
<x-menu-item label="Rowing" icon="rowing" icon-class="text-tertiary" disabled />
</x-menu>
BLADE,
];
@endphp
@@ -39,6 +39,19 @@
</div>
</div>
BLADE,
'First day of the week and format' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ race: '{{ now()->addMonth()->format('Y-m-d') }}', block: { start: null, end: null } }">
<div class="grid content-start gap-4">
<x-datepicker label="Race day" week-start="0" format="yyyy-MM-dd" x-model="race" hint="Weeks start on Sunday; typed year first" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(race)"></code></p>
</div>
<div class="grid content-start gap-4">
<x-datepicker label="Training block" range mode="modal" week-start="1" format="dd.MM.yyyy" x-model="block" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(block)"></code></p>
</div>
</div>
BLADE,
'Limits, errors and states' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2">
<div class="grid content-start gap-4">
@@ -62,7 +75,7 @@
<h2 class="type-headline-md">Date pickers</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-datepicker&gt;</code> docked, modal and modal input; single dates and ranges. Month and weekday names and the first day of the week follow the application's locale.
<code>&lt;x-datepicker&gt;</code> docked, modal and modal input; single dates and ranges. Month and weekday names, the first day of the week and the typed format follow the application's locale; <code>week-start</code> and <code>format</code> set the last two for someone who chose their own.
</p>
@foreach ($examples as $title => $code)
+1 -1
View File
@@ -59,7 +59,7 @@
</x-slot:actions>
<x-slot:top>
<header class="sticky top-0 z-20 flex h-16 items-center gap-1 bg-surface px-1 pt-[env(safe-area-inset-top)] sm:px-4">
<header class="sticky top-0 z-20 flex h-16 items-center gap-1 bg-surface px-1 pt-[var(--material-safe-top,env(safe-area-inset-top))] sm:px-4">
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="shell-menu" /></span>
<h1 class="min-w-0 flex-1 truncate px-3 type-title-lg sm:px-0">{{ $current['title'] }}</h1>
<x-livewire-material::button icon="search" tooltip="Search" />
+56 -15
View File
@@ -18,6 +18,7 @@ class SchemeCommand extends Command
protected $signature = 'material:scheme
{seed? : The source colour, as #rrggbb; without it, every profile in livewire-material.profiles}
{--variant=tonal-spot : tonal-spot, vibrant, expressive, neutral, fidelity, content, monochrome, rainbow or fruit-salad}
{--spec=2025 : The colour spec: 2025 (M3 Expressive) or 2021 (M3 as it first shipped)}
{--contrast=0 : The contrast level, from -1 to 1}
{--success=#22a06b : The source of the success colour}
{--warning=#e2a400 : The source of the warning colour}
@@ -29,19 +30,44 @@ class SchemeCommand extends Command
*/
protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour, or every configured colour profile';
/**
* The colour specs Google's colour utilities know. 2025 is M3 Expressive's colour; the library
* falls back to 2021 by itself for the variants 2025 does not define.
*
* @var list<string>
*/
protected const SPECS = ['2021', '2025'];
public function handle(Filesystem $files): int
{
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
$data = preg_replace('/\.css$/', '', $stylesheet).'.json';
$spec = (string) $this->option('spec');
if (! in_array($spec, self::SPECS, true)) {
$this->components->error("Unknown spec \"{$spec}\". Use one of: ".implode(', ', self::SPECS).'.');
return self::FAILURE;
}
if (filled($this->argument('seed'))) {
$scheme = $this->generate((string) $this->argument('seed'), (string) $this->option('variant'), (float) $this->option('contrast'));
$input = [
'seed' => (string) $this->argument('seed'),
'variant' => (string) $this->option('variant'),
'spec' => $spec,
'contrast' => (float) $this->option('contrast'),
'success' => (string) $this->option('success'),
'warning' => (string) $this->option('warning'),
'info' => (string) $this->option('info'),
];
$scheme = $this->generate($input);
if ($scheme === null) {
return self::FAILURE;
}
return $this->write($files, $stylesheet, $data, $this->stylesheet($scheme), $scheme);
return $this->write($files, $stylesheet, $data, $this->stylesheet($scheme, $input), $scheme);
}
$profiles = config('livewire-material.profiles');
@@ -61,7 +87,16 @@ class SchemeCommand extends Command
return self::FAILURE;
}
$scheme = $this->generate((string) ($profile['seed'] ?? ''), (string) ($profile['variant'] ?? 'tonal-spot'), (float) ($profile['contrast'] ?? 0), "Profile \"{$name}\": ");
// A profile's own spec and state colours win; without them, the command's options apply.
$scheme = $this->generate([
'seed' => (string) ($profile['seed'] ?? ''),
'variant' => (string) ($profile['variant'] ?? 'tonal-spot'),
'spec' => (string) ($profile['spec'] ?? $spec),
'contrast' => (float) ($profile['contrast'] ?? 0),
'success' => (string) ($profile['success'] ?? $this->option('success')),
'warning' => (string) ($profile['warning'] ?? $this->option('warning')),
'info' => (string) ($profile['info'] ?? $this->option('info')),
], "Profile \"{$name}\": ");
if ($scheme === null) {
return self::FAILURE;
@@ -83,21 +118,15 @@ class SchemeCommand extends Command
/**
* One scheme from Google's colour utilities, or null once the reason has been shown.
*
* @param array{seed: string, variant: string, spec: string, contrast: float, success: string, warning: string, info: string} $input
* @return array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}|null
*/
protected function generate(string $seed, string $variant, float $contrast, string $context = ''): ?array
protected function generate(array $input, string $context = ''): ?array
{
$result = Process::run([
config('livewire-material.node', 'node'),
__DIR__.'/../../resources/node/scheme.mjs',
json_encode([
'seed' => $seed,
'variant' => $variant,
'contrast' => $contrast,
'success' => $this->option('success'),
'warning' => $this->option('warning'),
'info' => $this->option('info'),
]),
json_encode($input),
]);
if ($result->failed()) {
@@ -130,15 +159,26 @@ class SchemeCommand extends Command
}
/**
* The stylesheet, headed by the command that regenerates it: every option that differs from
* its default is written out.
*
* @param array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme
* @param array{seed: string, variant: string, spec: string, contrast: float, success: string, warning: string, info: string} $input
*/
protected function stylesheet(array $scheme): string
protected function stylesheet(array $scheme, array $input): string
{
$states = collect(['success', 'warning', 'info'])
->reject(fn (string $state): bool => strtolower($input[$state]) === strtolower((string) $this->getDefinition()->getOption($state)->getDefault()))
->map(fn (string $state): string => sprintf(' --%s="%s"', $state, strtolower($input[$state])))
->implode('');
$command = sprintf(
'php artisan material:scheme "%s" --variant=%s%s',
'php artisan material:scheme "%s" --variant=%s%s%s%s',
$scheme['seed'],
$scheme['variant'],
$input['spec'] !== '2025' ? ' --spec='.$input['spec'] : '',
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
$states,
);
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
@@ -171,10 +211,11 @@ class SchemeCommand extends Command
{
$list = collect($profiles)
->map(fn (array $profile, string $name): string => sprintf(
' * %-12s %s, %s%s',
' * %-12s %s, %s%s%s',
$name,
$profile['seed'],
$profile['variant'],
$profile['spec'] !== $profiles[$default]['spec'] ? ', spec '.$profile['spec'] : '',
$profile['contrast'] != 0 ? ', contrast '.$profile['contrast'] : '',
))
->implode("\n");
+4 -2
View File
@@ -175,12 +175,14 @@ class LivewireMaterialServiceProvider extends ServiceProvider
*/
protected function registerShowcase(): void
{
// Registered even with the showcase off: `php artisan view:cache` compiles every view in the
// package's namespace, the showcase's pages included, and <x-showcase::…> must resolve there.
Blade::anonymousComponentPath(__DIR__.'/../resources/views/showcase/components', 'showcase');
if (! config('livewire-material.showcase.enabled')) {
return;
}
Blade::anonymousComponentPath(__DIR__.'/../resources/views/showcase/components', 'showcase');
if ($this->app->routesAreCached()) {
return;
}
+342 -2
View File
@@ -1,7 +1,131 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
const MORE = '#menus [aria-label="More"]';
class MenuAnchorProbe extends Component
{
public int $renders = 0;
public function touch(): void
{
$this->renders++;
}
public function render(): string
{
return <<<'BLADE'
<div class="grid justify-items-start gap-6 p-4">
<p>renders: <span id="renders">{{ $renders }}</span></p>
<x-menu label="Share actions">
<x-slot:trigger>
<x-button icon="more_vert" tooltip="More" data-test="more" />
</x-slot:trigger>
<x-menu-item label="Copy link" icon="content_copy" />
<x-menu-item label="Download" icon="download" />
</x-menu>
<x-menu label="Create">
<x-slot:trigger>
<x-button fab icon="add" label="New plan" data-test="fab" />
</x-slot:trigger>
<x-menu-item label="Running plan" icon="directions_run" />
<x-menu-item label="Cycling plan" icon="directions_bike" />
</x-menu>
</div>
BLADE;
}
}
/**
* A Livewire component that renders again while its menus are open: `touch` from outside, a
* `keep-open` item's action, or a FAB menu item's action.
*/
class MenuMorphProbe extends Component
{
public int $renders = 0;
public string $sort = 'newest';
public string $created = '';
public function touch(): void
{
$this->renders++;
}
public function sortBy(string $sort): void
{
$this->sort = $sort;
}
public function create(string $kind): void
{
$this->created = $kind;
}
public function render(): string
{
return <<<'BLADE'
<div class="p-4">
<p id="outside">renders: <span id="renders">{{ $renders }}</span>, created: <span id="created">{{ $created }}</span></p>
<x-menu label="Sort">
<x-slot:trigger>
<x-button label="Sort" data-test="sort" />
</x-slot:trigger>
<x-menu-item label="Newest" :selected="$sort === 'newest'" wire:click="sortBy('newest')" keep-open />
<x-menu-item label="Largest" :selected="$sort === 'largest'" wire:click="sortBy('largest')" keep-open />
</x-menu>
<div style="position: fixed; right: 16px; bottom: 16px">
<x-fab-menu label="New">
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="create('files')" />
<x-fab-menu-item label="Paste text" icon="content_paste" wire:click="create('text')" />
</x-fab-menu>
</div>
</div>
BLADE;
}
}
const SORT_MENU = "document.querySelector('[role=\"menu\"][aria-label=\"Sort\"]')";
const FAB_MENU = "document.querySelector('[role=\"menu\"][aria-label=\"New\"]')";
const NEW_FAB = 'button[aria-label="New"]';
function menuMorphProbe()
{
Livewire::component('menu-morph-probe', MenuMorphProbe::class);
Route::middleware('web')->get('/menu-morph-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:menu-morph-probe />
@livewireScripts
</body>
</html>
BLADE));
return visit('/menu-morph-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
function showcase(string $section = 'buttons')
{
return visit("/material/{$section}")->waitForEvent('networkidle')
@@ -94,7 +218,7 @@ it('moves a connected group\'s choice with the arrow keys', function () {
$page->keys(':focus', 'ArrowLeft')
->assertScript($checked('dark'))
->assertScript("getComputedStyle(document.querySelector('#buttons input[value=\"dark\"]').parentElement).borderTopLeftRadius === '9999px'");
->assertScript("(el => getComputedStyle(el).borderTopLeftRadius === (el.offsetHeight / 2) + 'px')(document.querySelector('#buttons input[value=\"dark\"]').parentElement)");
});
it('rounds a split button\'s trailing half while its menu is open', function () {
@@ -103,7 +227,16 @@ it('rounds a split button\'s trailing half while its menu is open', function ()
showcase()
->click('[data-split="trailing"] >> nth=0')
->assertScript("{$trailing}.getAttribute('aria-expanded') === 'true'")
->assertScript("getComputedStyle({$trailing}).borderTopLeftRadius === '9999px'");
->assertScript("getComputedStyle({$trailing}).borderTopLeftRadius === ({$trailing}.offsetHeight / 2) + 'px'");
});
it('keeps a connected segment\'s small inner corners, which a 9999px outer corner would scale away', function () {
// When a box's radii add up to more than a side, CSS shrinks every radius by the same
// factor: a full corner written as 9999px drew the 8px inner corners square. Every radius
// in a connected group or a split button stays within half its height, so none is scaled.
showcase()
->assertScript("[...document.querySelectorAll('[data-button-group=\"connected\"] > *, [data-split]')].every((el) => { const cs = getComputedStyle(el); const half = el.offsetHeight / 2 + 0.5; return el.offsetHeight === 0 || ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius'].every((corner) => parseFloat(cs[corner]) <= half); })")
->assertScript("(el => getComputedStyle(el).borderTopRightRadius === '8px')(document.querySelector('#buttons input[name=\"showcase-theme\"][value=\"light\"]').parentElement)");
});
it('turns the FAB into a close button while its menu is open', function () {
@@ -124,3 +257,210 @@ it('animates the loading indicator in the browser', function () {
showcase()->assertScript("{$clock} > 0.1");
});
/** A script giving the rects of a menu button (`control`) and of the menu it opens (`menu`). */
function menuAgainst(string $test, string $label): string
{
return "(() => { const control = document.querySelector('[data-test=\"{$test}\"]').getBoundingClientRect(); const menu = document.querySelector('[role=\"menu\"][aria-label=\"{$label}\"]').getBoundingClientRect(); return { control, menu }; })()";
}
it('hangs a menu on its menu button, even when the button is fixed to a corner of the window', function () {
Livewire::component('menu-anchor-probe', MenuAnchorProbe::class);
Route::middleware('web')->get('/menu-anchor-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:menu-anchor-probe />
@livewireScripts
</body>
</html>
BLADE));
$placed = menuAgainst('fab', 'Create');
$above = "(({ control, menu }) => getComputedStyle(document.querySelector('[data-test=\"fab\"]')).position === 'fixed' && menu.bottom <= control.top && control.top - menu.bottom <= 16 && Math.abs(menu.right - control.right) <= 16 && menu.left >= 0 && menu.top >= 0)({$placed})";
$page = visit('/menu-anchor-probe')->resize(400, 800)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->click('@fab')
->assertAttribute('@fab', 'aria-expanded', 'true')
->assertScript($above);
$page->keys(':focus', 'Escape')->assertAttribute('@fab', 'aria-expanded', 'false');
// A Livewire render names the anchor afresh, on the wrapper again. Opened from the keyboard: a
// press this soon after the menu closed is taken for the light-dismiss press and ignored.
$page->script('window.eval("Livewire.first().touch()")');
$page->assertSeeIn('#renders', '1')
->script("document.querySelector('[data-test=\"fab\"]').focus()");
$page->keys(':focus', 'ArrowDown')
->assertAttribute('@fab', 'aria-expanded', 'true')
->assertScript($above);
// A button that also anchors its own tooltip keeps it, and the menu hangs under the button.
$page->resize(1024, 800)
->click('@more')
->assertAttribute('@more', 'aria-expanded', 'true')
->assertScript("(() => { const names = getComputedStyle(document.querySelector('[data-test=\"more\"]')).getPropertyValue('anchor-name'); return names.includes('--material-button-') && names.includes('--material-menu-'); })()")
->assertScript('(({ control, menu }) => menu.top >= control.bottom && menu.top - control.bottom <= 16 && Math.abs(menu.left - control.left) <= 16)('.menuAgainst('more', 'Share actions').')');
});
it('paints a group\'s hint and a menu item\'s icon in the colour their classes name', function () {
Route::middleware('web')->get('/colour-class-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
</head>
<body class="bg-surface">
<span id="error-ink" class="text-error">Reference</span>
<div id="terrain">
<x-group name="terrain" hint="No elevation data here" hint-class="text-error" :options="[['id' => 'flat', 'name' => 'Flat']]" />
</div>
<x-menu-item id="run" label="Running plan" icon="directions_run" icon-class="text-error" />
<x-menu-item id="chosen" label="Cycling plan" icon="directions_bike" icon-class="text-error" :selected="true" />
<x-menu-item id="off" label="Swimming plan" icon="pool" icon-class="text-error" disabled />
</body>
</html>
BLADE));
$ink = fn (string $element): string => "getComputedStyle({$element}).color";
$error = $ink("document.querySelector('#error-ink')");
visit('/colour-class-probe')->waitForEvent('networkidle')
->assertScript($ink("document.querySelector('#terrain p')")." === {$error}")
->assertScript($ink("document.querySelector('#run svg')")." === {$error}")
->assertScript($ink("document.querySelector('#chosen svg')")." === {$error}")
->assertScript($ink("document.querySelector('#off svg')")." !== {$error}");
});
it('keeps a menu open while the component around it renders, and closes it cleanly after', function () {
$page = menuMorphProbe()->assertNoJavaScriptErrors();
$page->click('@sort')
->assertAttribute('@sort', 'aria-expanded', 'true');
$page->script('window.eval("Livewire.first().touch()")');
$page->assertSeeIn('#renders', '1')
->assertScript(SORT_MENU.".matches(':popover-open')")
->assertAttribute('@sort', 'aria-expanded', 'true')
->assertScript("document.querySelector('[data-test=\"sort\"]').getAttribute('aria-controls') === ".SORT_MENU.'.id')
->assertScript(focused("textContent.trim().startsWith('Newest')"));
$page->keys(':focus', 'Escape')
->assertAttribute('@sort', 'aria-expanded', 'false')
->assertScript('! '.SORT_MENU.".matches(':popover-open')")
->assertScript(focused("dataset.test === 'sort'"));
// Opened from the keyboard: a press this soon after the menu closed is taken for the
// light-dismiss press and ignored.
$page->keys('@sort', 'ArrowDown')
->assertAttribute('@sort', 'aria-expanded', 'true');
$page->script('window.eval("Livewire.first().touch()")');
$page->assertSeeIn('#renders', '2')
->click('#outside')
->assertAttribute('@sort', 'aria-expanded', 'false')
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
});
it('closes a menu on a second press of its menu button, and after the component renders', function () {
$page = menuMorphProbe();
// The press closes the menu before its click reaches the button: the guard against that click
// opening it again once waited for the queued toggle event, which comes after the click.
$page->click('@sort')
->assertAttribute('@sort', 'aria-expanded', 'true')
->click('@sort')
->assertAttribute('@sort', 'aria-expanded', 'false')
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
$page->keys('@sort', 'ArrowDown')
->assertAttribute('@sort', 'aria-expanded', 'true');
$page->script('window.eval("Livewire.first().touch()")');
// Past the reopen guard of the close above, so the second press is on its own.
$page->assertSeeIn('#renders', '1')
->wait(0.3);
$page->click('@sort')
->assertAttribute('@sort', 'aria-expanded', 'false')
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
});
it('keeps a menu open while a keep-open item\'s action runs', function () {
$page = menuMorphProbe();
$page->click('@sort')
->click('[role="menuitemcheckbox"]:has-text("Largest")')
->assertAttribute('[role="menuitemcheckbox"]:has-text("Largest")', 'aria-checked', 'true')
->assertScript(SORT_MENU.".matches(':popover-open')")
->assertAttribute('@sort', 'aria-expanded', 'true');
$page->click('[role="menuitemcheckbox"]:has-text("Newest")')
->assertAttribute('[role="menuitemcheckbox"]:has-text("Newest")', 'aria-checked', 'true')
->assertScript(SORT_MENU.".matches(':popover-open')");
});
it('keeps a FAB menu open while the component around it renders, and closes it cleanly after', function () {
$page = menuMorphProbe();
$page->click(NEW_FAB)
->assertAttribute(NEW_FAB, 'aria-expanded', 'true');
$page->script('window.eval("Livewire.first().touch()")');
$page->assertSeeIn('#renders', '1')
->assertScript(FAB_MENU.".matches(':popover-open')")
->assertAttribute(NEW_FAB, 'aria-expanded', 'true')
->assertScript("document.querySelector('".NEW_FAB."').getAttribute('aria-controls') === ".FAB_MENU.'.id');
$page->keys(':focus', 'Escape')
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
->assertScript(focused("getAttribute('aria-label') === 'New'"));
$page->keys(NEW_FAB, 'ArrowDown')
->assertAttribute(NEW_FAB, 'aria-expanded', 'true');
$page->script('window.eval("Livewire.first().touch()")');
// Past the reopen guard of the Escape above, so the second press is on its own.
$page->assertSeeIn('#renders', '2')
->wait(0.3);
$page->click(NEW_FAB)
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
->assertScript('! '.FAB_MENU.".matches(':popover-open')");
});
it('closes a FAB menu when an item\'s action runs, and opens and closes it cleanly after', function () {
$page = menuMorphProbe();
$page->click(NEW_FAB)
->click('[role="menuitem"]:has-text("Upload files")')
->assertSeeIn('#created', 'files')
->assertAttribute(NEW_FAB, 'aria-expanded', 'false');
$page->script("document.querySelector('".NEW_FAB."').focus()");
$page->keys(NEW_FAB, 'ArrowDown')
->assertAttribute(NEW_FAB, 'aria-expanded', 'true')
->assertScript(focused("textContent.trim() === 'Upload files'"));
$page->keys(':focus', 'Escape')
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
->assertScript('! '.FAB_MENU.".matches(':popover-open')")
->assertScript(focused("getAttribute('aria-label') === 'New'"));
});
+3 -2
View File
@@ -141,6 +141,7 @@ it('turns section tabs into a picker on a phone', function () {
->assertScript("getComputedStyle(document.querySelector('[data-section-nav] nav')).display === 'none'")
->click('[data-section-picker] button')
->assertScript("document.querySelector('[data-section-picker] [popover]').matches(':popover-open')")
->assertAttribute('[data-section-picker] [role="menuitemcheckbox"][href="#profile"]', 'aria-checked', 'true')
->assertAttribute('[data-section-picker] [role="menuitemcheckbox"][href="#security"]', 'aria-checked', 'false');
// A menu of places: the current section is the page, not a checked choice.
->assertAttribute('[data-section-picker] [role="menuitem"][href="#profile"]', 'aria-current', 'page')
->assertScript("! document.querySelector('[data-section-picker] [role=\"menuitem\"][href=\"#security\"]').hasAttribute('aria-current')");
});
+14 -5
View File
@@ -10,7 +10,7 @@ use Livewire\Livewire;
*/
class CarouselMorphProbe extends Component
{
public int $count = 3;
public int $count = 6;
public function add(): void
{
@@ -219,10 +219,19 @@ it('measures itself again after a Livewire morph adds an item', function () {
$page = visit('/carousel-morph-probe')->waitForEvent('networkidle')
->assertNoJavaScriptErrors()
->assertScript($masked, 3)
->assertAttribute('[data-material-carousel-item] >> nth=2', 'aria-label', '3 of 3');
->assertScript($masked, 6)
->assertAttribute('[data-material-carousel-item] >> nth=5', 'aria-label', '6 of 6');
$page->click('Add')
->assertScript($masked, 4)
->assertAttribute('[data-material-carousel-item] >> nth=3', 'aria-label', '4 of 4');
->assertScript($masked, 7)
->assertAttribute('[data-material-carousel-item] >> nth=6', 'aria-label', '7 of 7');
// Scrolled once the morph has settled, so only the row's own scroll listener can re-mask the
// items: a row the morph swapped for a copy scrolls with its items' masks left as they were.
$page->script(onCarousel(0, "await pause(300); scroller.style.scrollSnapType = 'none'; scroller.scrollTo({ left: 2 * (size + gap), behavior: 'instant' })", 'body'));
$page->assertScript(onCarousel(0, <<<'JS'
await pause(50)
return inset(0) > 0.5 && inset(2) < 0.5
JS, 'body'));
});
+143
View File
@@ -1,7 +1,48 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
/**
* A Livewire component that renders again while a rich tooltip is open, from the tooltip's own
* action.
*/
class RichTooltipMorphProbe extends Component
{
public int $renders = 0;
public function touch(): void
{
$this->renders++;
}
public function render(): string
{
return <<<'BLADE'
<div class="p-4">
<p id="outside">renders: <span id="renders">{{ $renders }}</span></p>
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time." persistent>
<x-button label="Details" data-test="details" />
<x-slot:actions><x-button label="Refresh" wire:click="touch" data-test="refresh" /></x-slot:actions>
</x-rich-tooltip>
<div style="margin-top: 200px">
<x-rich-tooltip text="Shown on hover.">
<x-button label="Hint" data-test="hint" />
</x-rich-tooltip>
</div>
</div>
BLADE;
}
}
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
const TOAST = "document.querySelector('[data-toast]')";
it('shows a toast as a snackbar, then the next in turn', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
@@ -39,6 +80,65 @@ it('runs a toast\'s action and dismisses it', function () {
->assertScript(SNACKBAR.' === null');
});
it('does not let a toast dismissed early cut the next one short', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("window.eval(\"materialToast('Share deleted', { timeout: 800, action: { label: 'Undo' } }); materialToast('Link copied', { timeout: 2500 })\")");
// A click with no hover or focus before it, as a screen reader activates a button: nothing has
// paused the first toast's timer.
$page->script("document.querySelector('[data-toast] button[aria-label=\"Dismiss\"]').click()");
$page->assertScript(TOAST."?.textContent.includes('Link copied')")
->wait(1.2);
$page->assertScript(TOAST."?.textContent.includes('Link copied')");
});
it('keeps a sticky toast aside while a passing one shows, brings back the newest, and lets it go once dismissed', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
// As an application dispatches it; built in the page's realm (see above).
$page->script("window.eval(\"window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: 'A new version is ready', sticky: true, timeout: 300, action: { label: 'Reload', event: 'material:test-reload' } } }))\")");
$page->assertScript(TOAST."?.textContent.includes('A new version is ready')")
->wait(0.6);
$page->assertScript(TOAST."?.textContent.includes('A new version is ready')");
$page->script("window.eval(\"materialToast('Settings saved', { type: 'success', timeout: 900 }); materialToast('Version 2 is ready', { sticky: true })\")");
$page->assertScript(TOAST."?.textContent.includes('Settings saved')")
->wait(1.3);
$page->assertScript(TOAST."?.textContent.includes('Version 2 is ready')");
$page->click('[data-toast] button[aria-label="Dismiss"]')
->assertScript(TOAST.' === null');
// Dismissed rather than left to time out: the pointer still rests where the snackbar appears,
// and hovering pauses it.
$page->script("window.eval(\"materialToast('Link copied', { timeout: 0 })\")");
$page->assertScript(TOAST."?.textContent.includes('Link copied')")
->click('[data-toast] button[aria-label="Dismiss"]')
->assertScript(TOAST.' === null');
});
it('dispatches a toast action\'s window event alongside its handler, and closes', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("window.eval(\"window.reloads = 0; window.handled = 0; window.addEventListener('material:test-reload', () => window.reloads++); materialToast('A new version is ready', { sticky: true, action: { label: 'Reload', event: 'material:test-reload', handler: () => window.handled++ } })\")");
$page->click('[data-toast-action]')
->assertScript(TOAST.' === null')
->assertScript("window.eval('window.reloads') === 1")
->assertScript("window.eval('window.handled') === 1");
});
it('opens a persistent rich tooltip on press', function () {
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
@@ -47,3 +147,46 @@ it('opens a persistent rich tooltip on press', function () {
->click('#communication button:has-text("Press for details")')
->assertScript("{$bubble}.matches(':popover-open')");
});
it('keeps a rich tooltip open while its action renders the component, and opens it again after', function () {
Livewire::component('rich-tooltip-morph-probe', RichTooltipMorphProbe::class);
Route::middleware('web')->get('/rich-tooltip-morph-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:rich-tooltip-morph-probe />
@livewireScripts
</body>
</html>
BLADE));
$persistent = "document.querySelector('[role=\"dialog\"][popover]')";
$transient = "document.querySelector('[role=\"tooltip\"][popover]')";
$page = visit('/rich-tooltip-morph-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
->assertNoJavaScriptErrors();
$page->click('@details')
->assertScript("{$persistent}.matches(':popover-open')")
->click('@refresh')
->assertSeeIn('#renders', '1')
->assertScript("{$persistent}.matches(':popover-open')");
$page->click('#outside')
->assertScript("! {$persistent}.matches(':popover-open')");
$page->click('@details')
->assertScript("{$persistent}.matches(':popover-open')");
$page->click('#outside')
->hover('@hint')
->assertScript("{$transient}.matches(':popover-open')")
->assertNoJavaScriptErrors();
});
+104
View File
@@ -41,6 +41,73 @@ class OverlayProbe extends Component
}
}
class CollapseProbe extends Component
{
public bool $fineTuning = false;
public int $renders = 0;
public function touch(): void
{
$this->renders++;
}
public function expand(): void
{
$this->fineTuning = true;
}
public function collapse(): void
{
$this->fineTuning = false;
}
public function render(): string
{
return <<<'BLADE'
<div class="space-y-4 p-4">
<p>fine tuning: <span id="fine-tuning">{{ var_export($fineTuning, true) }}</span></p>
<p>renders: <span id="renders">{{ $renders }}</span></p>
<x-button label="Re-render" wire:click="touch" />
<x-button label="Open from the server" wire:click="expand" />
<x-button label="Close from the server" wire:click="collapse" />
<x-collapse id="fine-tuning-collapse" title="Fine-tuning" wire:model="fineTuning">Zones and paces.</x-collapse>
<div x-data="{ advanced: false }">
<p>advanced: <span id="advanced" x-text="advanced"></span></p>
<x-button label="Close from Alpine" x-on:click="advanced = false" />
<x-collapse id="advanced-collapse" title="Advanced" x-model="advanced">Everything else.</x-collapse>
</div>
</div>
BLADE;
}
}
function collapseProbe()
{
Livewire::component('collapse-probe', CollapseProbe::class);
Route::middleware('web')->get('/collapse-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:collapse-probe />
@livewireScripts
</body>
</html>
BLADE));
return visit('/collapse-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
function overlayProbe()
{
Livewire::component('overlay-probe', OverlayProbe::class);
@@ -155,3 +222,40 @@ it('opens a row\'s opener from a press anywhere on the row, but not from its own
$page->click('#containment [data-card][data-list-row] button:has-text("Copy link")')
->assertScript('window.__opened === 1');
});
it('binds a collapse to a Livewire property both ways', function () {
$collapse = "document.querySelector('#fine-tuning-collapse')";
$page = collapseProbe()
->assertScript("{$collapse}.open === false");
$page->click('#fine-tuning-collapse summary')
->assertScript("{$collapse}.open === true")
->click('button:has-text("Re-render")')
->assertSeeIn('#renders', '1')
->assertSeeIn('#fine-tuning', 'true')
->assertScript("{$collapse}.open === true");
$page->click('button:has-text("Close from the server")')
->assertSeeIn('#fine-tuning', 'false')
->assertScript("{$collapse}.open === false");
$page->click('button:has-text("Open from the server")')
->assertSeeIn('#fine-tuning', 'true')
->assertScript("{$collapse}.open === true");
});
it('binds a collapse to an Alpine property both ways', function () {
$collapse = "document.querySelector('#advanced-collapse')";
$page = collapseProbe()
->assertScript("{$collapse}.open === false");
$page->click('#advanced-collapse summary')
->assertScript("{$collapse}.open === true")
->assertSeeIn('#advanced', 'true');
$page->click('button:has-text("Close from Alpine")')
->assertSeeIn('#advanced', 'false')
->assertScript("{$collapse}.open === false");
});
+191
View File
@@ -69,6 +69,102 @@ function dateProbe(string $locale = 'en')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
class DateFormatProbe extends Component
{
public ?string $sunday = '2026-09-13';
public ?string $iso = '2026-09-13';
public ?string $dotted = '2026-09-13';
/** @var array{start: ?string, end: ?string} */
public array $span = ['start' => '2026-09-13', 'end' => '2026-09-15'];
public function render(): string
{
return <<<'BLADE'
<div class="grid max-w-md gap-6 p-4">
<p>sunday: <span id="sunday">{{ $sunday }}</span></p>
<p>iso: <span id="iso">{{ $iso }}</span></p>
<p>dotted: <span id="dotted">{{ $dotted }}</span></p>
<p>span: <span id="span">{{ json_encode($span) }}</span></p>
<x-datepicker id="sunday-field" label="Sunday first" wire:model.live="sunday" week-start="0" />
<x-datepicker id="iso-field" label="ISO" wire:model.live="iso" format="yyyy-MM-dd" />
<x-datepicker id="dotted-field" label="Dotted" mode="input" wire:model.live="dotted" format="dd.MM.yyyy" />
<x-datepicker id="span-field" label="Span" range mode="modal" wire:model.live="span" format="dd/MM/yyyy" week-start="6" />
</div>
BLADE;
}
}
function dateFormatProbe(string $locale = 'en')
{
Livewire::component('date-format-probe', DateFormatProbe::class);
Route::middleware('web')->get('/date-format-probe/{locale}', function (string $locale) {
app()->setLocale($locale);
return Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:date-format-probe />
@livewireScripts
</body>
</html>
BLADE);
});
return visit("/date-format-probe/{$locale}")->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
class ScopedDateProbe extends Component
{
public ?string $early = '2026-09-13';
public ?string $late = '2026-09-13';
public function render(): string
{
return <<<'BLADE'
<div class="grid max-w-md gap-6 p-4" x-data="{ step: 1 }">
<x-datepicker id="early-field" label="Early" wire:model.live="early" min="2026-09-10" week-start="0" />
<x-datepicker id="late-field" label="Late" wire:model.live="late" week-start="1" />
</div>
BLADE;
}
}
function scopedDateProbe()
{
Livewire::component('scoped-date-probe', ScopedDateProbe::class);
Route::middleware('web')->get('/scoped-date-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:scoped-date-probe />
@livewireScripts
</body>
</html>
BLADE));
return visit('/scoped-date-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
/** A picker's day cell, by its ISO date. */
function day(string $picker, string $date): string
{
@@ -311,3 +407,98 @@ it('empties a date, or both ends of a range, with its clear button', function ()
->assertSeeIn('#trip', '{"start":null,"end":null}')
->assertValue('#trip-field', '');
});
it('starts the week on the day week-start names, whatever the locale says', function () {
$page = dateFormatProbe('de')
->assertValue('#sunday-field', '13.09.2026')
->click('[aria-controls="sunday-field-picker"][data-datepicker-toggle]')
->assertScript("document.querySelector('#sunday-field-picker thead th').getAttribute('abbr') === 'Sonntag'")
->assertScript("document.querySelector('#sunday-field-picker tbody td').dataset.value === '2026-08-30'")
->assertScript(focusedDay('2026-09-13'));
$page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-19'));
$page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-13'));
$page->keys(':focus', 'Enter')
->assertSeeIn('#sunday', '2026-09-13')
->assertValue('#sunday-field', '13.09.2026');
});
it('shows and reads a year-first format and still binds Y-m-d', function () {
$page = dateFormatProbe()
->assertValue('#iso-field', '2026-09-13')
->assertAttribute('#iso-field', 'placeholder', 'YYYY-MM-DD')
->type('#iso-field', '2026-10-01')
->assertSeeIn('#iso', '2026-10-01');
$page->type('#iso-field', '10/02/2026')
->keys('#iso-field', 'Enter')
->assertSee('Date does not match expected pattern: YYYY-MM-DD')
->assertSeeIn('#iso', '2026-10-01');
$page->click('[aria-controls="iso-field-picker"][data-datepicker-toggle]')
->assertScript(focusedDay('2026-10-01'));
$page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-10-02'));
$page->keys(':focus', 'Enter')
->assertSeeIn('#iso', '2026-10-02')
->assertValue('#iso-field', '2026-10-02');
});
it('reads the dialog\'s text field in the given format, not the locale\'s', function () {
$dialog = "document.querySelector('#dotted-field-picker')";
$page = dateFormatProbe()
->assertValue('#dotted-field', '13.09.2026')
->click('#dotted-field')
->assertScript("{$dialog}.matches(':modal')")
->assertScript("document.activeElement.id === 'dotted-field-entry'")
->assertValue('#dotted-field-entry', '13.09.2026')
->assertAttribute('#dotted-field-entry', 'placeholder', 'DD.MM.YYYY');
$page->type('#dotted-field-entry', '2026-10-01')
->keys('#dotted-field-entry', 'Enter')
->assertSeeIn('#dotted-field-entry-support', 'Date does not match expected pattern: DD.MM.YYYY')
->assertScript("{$dialog}.open");
$page->type('#dotted-field-entry', '1.10.2026')
->assertSeeIn('#dotted-field-picker [data-datepicker-headline]', 'Oct 1, 2026')
->keys('#dotted-field-entry', 'Enter')
->assertSeeIn('#dotted', '2026-10-01')
->assertScript("! {$dialog}.open")
->assertValue('#dotted-field', '01.10.2026');
});
it('lays out and shows a range in the given first day and format', function () {
$dialog = "document.querySelector('#span-field-picker')";
$page = dateFormatProbe()->assertValue('#span-field', '13/09/2026 15/09/2026');
$page->script("document.querySelector('#span-field').focus()");
$page->keys('#span-field', 'Enter')
->assertScript("{$dialog}.matches(':modal')")
->assertScript("document.querySelector('#span-field-picker thead th').getAttribute('abbr') === 'Saturday'")
->assertScript(focusedDay('2026-09-13'));
$page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-12'));
$page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-18'));
$page->click(day('span-field', '2026-09-20'))
->click(day('span-field', '2026-09-24'))
->click('#span-field-picker [data-datepicker-confirm]')
->assertSeeIn('#span', '{"start":"2026-09-20","end":"2026-09-24"}')
->assertValue('#span-field', '20/09/2026 24/09/2026');
});
it('keeps each picker\'s own settings inside a page\'s outer x-data scope', function () {
// Settings assigned in init() without being declared would land on the outermost scope,
// where the last picker's null min and Monday start would overwrite the first's.
$page = scopedDateProbe()
->click('[aria-controls="early-field-picker"][data-datepicker-toggle]')
->assertScript(focusedDay('2026-09-13'));
$page->assertAttribute(day('early-field', '2026-09-09'), 'aria-disabled', 'true')
->assertScript('! (\'min\' in Alpine.$data(document.querySelector(\'[x-data="{ step: 1 }"]\')))');
});
+28
View File
@@ -176,3 +176,31 @@ it('skips to the content', function () {
->assertScript("location.hash === '#content'")
->assertScript("document.activeElement === document.getElementById('content')");
});
it('moves an app bar\'s content under a safe area an application sets', function () {
$row = "Math.round(document.querySelector('[data-app-bar] [data-app-bar-row]').getBoundingClientRect().top)";
$page = navigationReady(visit('/material'));
$top = (int) $page->script($row);
$page->script("document.documentElement.style.setProperty('--material-safe-top', '47px')");
$page->assertScript("{$row} === ".($top + 47))
->assertNoJavaScriptErrors();
});
it('lifts the snackbar and the page above something docked on the phone\'s bar', function () {
$snackbarBottom = "Math.round(parseFloat(getComputedStyle(document.querySelector('[x-data=\"materialSnackbar\"]')).bottom))";
$contentPadding = "Math.round(parseFloat(getComputedStyle(document.getElementById('content')).paddingBottom))";
$page = shellPage(400, 860);
$snackbar = (int) $page->script($snackbarBottom);
$content = (int) $page->script($contentPadding);
expect($content)->toBe(64)->and($snackbar)->toBe(80);
$page->script("document.documentElement.style.setProperty('--material-bottom-extra', '40px')");
$page->assertScript("{$snackbarBottom} === 120")
->assertScript("{$contentPadding} === 104");
});
+97
View File
@@ -1,5 +1,11 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use NoNameWeb\LivewireMaterial\Support\Scheme;
use function Orchestra\Testbench\workbench_path;
/**
* The theme <html> shows. A bare `html` selector would be read as text to search for.
*/
@@ -56,3 +62,94 @@ it('repaints a section that sets its own theme', function () {
->assertScript(theme('data-theme', 'light'))
->assertScript("{$swatch(0)} !== {$swatch(1)}");
});
/**
* The page has exactly one theme-color meta without a media query, and it shows the given colour.
*/
function themeColorIs(string $hex): string
{
return "(() => { const metas = document.head.querySelectorAll('meta[name=theme-color]:not([media])'); return metas.length === 1 && metas[0].getAttribute('content') === '{$hex}'; })()";
}
/**
* The surface colour role, as <html> resolves it.
*/
function pageSurfaceIs(string $hex): string
{
return "getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-surface').trim() === '{$hex}'";
}
function themeReady(mixed $page): mixed
{
return $page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
it('adds a theme-color meta in the painted surface, and follows the theme and the colour profile', function () {
config([
'livewire-material.theme.meta' => true,
'livewire-material.scheme' => workbench_path('resources/css/material-scheme.json'),
]);
$profiles = Scheme::profiles();
$page = themeReady(visit('/material/colour')->inLightMode())
->assertScript(theme('data-scheme', 'baseline'))
->assertScript(themeColorIs($profiles['baseline']['light']['surface']))
->assertScript(pageSurfaceIs($profiles['baseline']['light']['surface']));
$page->click('[data-theme-option="dark"]')
->assertScript(theme('data-theme', 'dark'))
->assertScript(themeColorIs($profiles['baseline']['dark']['surface']))
->assertScript(pageSurfaceIs($profiles['baseline']['dark']['surface']));
$page->click('#colour [data-scheme-option="rose"]')
->assertScript(theme('data-scheme', 'rose'))
->assertScript(themeColorIs($profiles['rose']['dark']['surface']))
->assertScript(pageSurfaceIs($profiles['rose']['dark']['surface']));
$page->click('[data-theme-option="light"]')
->assertScript(themeColorIs($profiles['rose']['light']['surface']))
->assertScript(pageSurfaceIs($profiles['rose']['light']['surface']))
->assertNoJavaScriptErrors();
});
it('repaints the page\'s own theme-color meta, and the next page\'s after wire:navigate', function () {
config(['livewire-material.theme.meta' => true]);
Route::middleware('web')->get('/theme-color-probe/{page}', fn (string $page) => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<meta name="theme-color" content="#000000" />
<meta name="theme-color" media="print" content="#ffffff" />
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<p id="page">This is page {{ $page }}.</p>
<a id="next" href="/theme-color-probe/two" wire:navigate>Next</a>
@livewireScripts
</body>
</html>
BLADE, ['page' => $page]));
$scheme = Scheme::load();
$page = themeReady(visit('/theme-color-probe/one')->inLightMode())
->assertScript(themeColorIs($scheme['light']['surface']))
->assertScript("document.head.querySelector('meta[media]').getAttribute('content') === '#ffffff'");
$page->script("window.eval(\"Alpine.store('theme').set('dark'); window.samePage = true\")");
$page->assertScript(themeColorIs($scheme['dark']['surface']));
$page->click('#next')
->assertSeeIn('#page', 'This is page two.')
->assertScript("window.eval('window.samePage') === true")
->assertScript(theme('data-theme', 'dark'))
->assertScript(themeColorIs($scheme['dark']['surface']))
->assertScript("document.head.querySelector('meta[media]').getAttribute('content') === '#ffffff'")
->assertNoJavaScriptErrors();
});
+45 -1
View File
@@ -2,6 +2,8 @@
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
function shellDestinations(): array
{
@@ -42,6 +44,16 @@ it('puts every destination in the rail and only those marked for the bar in the
->and(substr_count($html, 'aria-current="page"'))->toBe(2);
});
it('speaks a destination\'s badge in its own words when it has them', function () {
$html = (string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => [
['title' => 'Get started', 'icon' => 'rocket_launch', 'url' => '/start', 'badge' => '0/3', 'badgeLabel' => '0 of 3 done'],
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'badge' => 4],
]]);
expect($html)->toContain('0 of 3 done')
->and(substr_count($html, '0 of 3 done'))->toBeGreaterThanOrEqual(2);
});
it('marks the destination at the current URL when none says it is active', function () {
Route::get('/shell-probe/inbox', fn () => Blade::render('<x-app-shell :destinations="$destinations" />', ['destinations' => [
['title' => 'Inbox', 'icon' => 'inbox', 'url' => url('/shell-probe/inbox')],
@@ -54,14 +66,46 @@ it('marks the destination at the current URL when none says it is active', funct
->and(substr_count($html, 'aria-current="page"'))->toBe(2);
});
it('keeps the destination at the page\'s URL current while a Livewire component on it updates', function () {
Livewire::component('app-shell-probe', new class extends Component
{
public string $page = '';
public function mount(): void
{
$this->page = url()->current();
}
public function render(): string
{
return '<div><x-app-shell :destinations="[[\'title\' => \'Inbox\', \'icon\' => \'inbox\', \'url\' => $page], [\'title\' => \'Sent\', \'icon\' => \'send\', \'url\' => \'/sent\']]" /></div>';
}
});
$probe = Livewire::test('app-shell-probe');
expect(substr_count($probe->html(), 'aria-current="page"'))->toBe(2)
->and(substr_count($probe->call('$refresh')->html(), 'aria-current="page"'))->toBe(2)
->and($probe->html())->toMatch('/href="[^"]*\/livewire-unit-test-endpoint\/[^"]*"[^>]*aria-current="page"/');
});
it('lifts the snackbar above the bar only when there is a bar', function () {
expect((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
->toContain('max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]')
->toContain('max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]')
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]]))
->not->toContain('data-app-shell-bar')
->not->toContain('--material-bottom-bar:');
});
it('reads the safe area and anything docked on the bar through variables an application can set', function () {
$html = (string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]);
expect($html)
->toContain('+var(--material-bottom-extra,0px))]')
->toContain('focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)]')
->not->toMatch('/(?<!,)env\(safe-area-inset/');
});
it('places each slot once', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-app-shell :destinations="$destinations">
+60
View File
@@ -30,3 +30,63 @@ it('draws a status label in a container or an outline', function () {
->toContain('border border-outline-variant text-on-surface-variant')
->not->toContain('bg-error');
});
it('renders its slot as HTML, and is a dot while the slot holds nothing but comments', function () {
$html = (string) $this->blade('<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>');
expect($html)
->toContain('h-6 gap-1 rounded-corner-sm')
->toContain('<svg')
->toContain(' Pro</span>')
->not->toContain('&lt;svg')
->and((string) $this->blade('<x-badge value="<b>4</b>" />'))->toContain('&lt;b&gt;4&lt;/b&gt;')
->and((string) $this->blade("<x-badge tonal>\n <!-- nothing yet -->\n</x-badge>"))->toContain('size-1.5 rounded-corner-full')
->and((string) $this->blade('<x-badge max="99">120</x-badge>'))->toContain('>99+</span>');
});
it('draws neutral ink on every variant', function () {
expect((string) $this->blade('<x-badge color="neutral" />'))->toContain('size-1.5 rounded-corner-full bg-on-surface-variant text-surface')
->and((string) $this->blade('<x-badge value="7" color="neutral" />'))->toContain('tabular-nums bg-on-surface-variant text-surface')
->and((string) $this->blade('<x-badge value="Draft" tone="neutral" tonal />'))->toContain('type-label-md bg-surface-container-high text-on-surface-variant')
->and((string) $this->blade('<x-badge value="Draft" color="neutral" outline />'))->toContain('type-label-md border border-outline-variant text-on-surface-variant');
});
it('leaves a plain badge to the caller\'s colour classes', function (string $badge, string $shape) {
$html = (string) $this->blade($badge);
preg_match('/class="([^"]*)"/', $html, $class);
expect($class[1])
->toContain($shape)
->toEndWith('bg-tertiary-container text-on-tertiary-container')
->and(preg_replace('/bg-tertiary-container text-on-tertiary-container$/', '', $class[1]))->not->toMatch('/(^|\s)(bg|text|border)-/');
})->with([
'dot' => ['<x-badge color="plain" class="bg-tertiary-container text-on-tertiary-container" />', 'size-1.5 rounded-corner-full'],
'count' => ['<x-badge value="3" color="plain" class="bg-tertiary-container text-on-tertiary-container" />', 'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums'],
'tonal' => ['<x-badge value="Run" color="plain" tonal class="bg-tertiary-container text-on-tertiary-container" />', 'h-6 gap-1 rounded-corner-sm px-2 type-label-md'],
'outline' => ['<x-badge value="Run" color="plain" outline class="bg-tertiary-container text-on-tertiary-container" />', 'h-6 gap-1 rounded-corner-sm px-2 type-label-md border'],
]);
it('keeps its default colours, and falls back to error on a colour it does not know', function () {
expect((string) $this->blade('<x-badge value="4" />'))
->toContain('class="inline-flex shrink-0 items-center justify-center whitespace-nowrap h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums bg-error text-on-error"')
->and((string) $this->blade('<x-badge value="Active" tonal />'))
->toContain('class="inline-flex shrink-0 items-center justify-center whitespace-nowrap h-6 gap-1 rounded-corner-sm px-2 type-label-md bg-error-container text-on-error-container"')
->and((string) $this->blade('<x-badge value="Pro" outline color="success" />'))
->toContain('class="inline-flex shrink-0 items-center justify-center whitespace-nowrap h-6 gap-1 rounded-corner-sm px-2 type-label-md border border-outline-variant text-on-surface-variant"')
->and((string) $this->blade('<x-badge value="3" color="sport-run" />'))->toContain('bg-error text-on-error');
});
it('draws a solid status label in the colour itself, spoken like any label', function () {
$html = (string) $this->blade('<x-badge value="Built in" solid color="primary" />');
expect($html)
->toContain('bg-primary text-on-primary')
->toContain('h-6 gap-1 rounded-corner-sm px-2 type-label-md')
->not->toContain('aria-hidden')
->toContain('>Built in<')
->and((string) $this->blade('<x-badge value="Draft" solid color="neutral" />'))
->toContain('bg-on-surface-variant text-surface')
->and((string) $this->blade('<x-badge solid />'))
->toContain('size-1.5');
});
@@ -83,3 +83,17 @@ it('binds to a Livewire property and shows its validation message', function ()
->assertSee('Pick light.')
->assertDontSee('How it looks');
});
it('adds hint-class to the hint, which a validation message still replaces', function () {
$options = [['id' => 'flat', 'name' => 'Flat'], ['id' => 'hilly', 'name' => 'Hilly']];
expect((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options]))
->toContain('<p class="mt-1 type-body-sm [:where(&amp;)]:text-on-surface-variant text-warning">No elevation data here</p>')
->and((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" :$options />', ['options' => $options]))
->toContain('<p class="mt-1 type-body-sm text-on-surface-variant">No elevation data here</p>');
expect((string) $this->withViewErrors(['terrain' => 'Pick a terrain.'])->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options]))
->toContain('<p class="mt-1 type-body-sm text-error">Pick a terrain.</p>')
->not->toContain('No elevation data here')
->not->toContain('text-warning');
});
+7
View File
@@ -156,3 +156,10 @@ it('submits a form when asked', function () {
$this->blade('<x-button label="Save" type="submit" />')
->assertSee('type="submit"', false);
});
it('keeps aria-pressed off a selected link, which is not a toggle', function () {
expect((string) $this->blade('<x-button label="Plans" link="/plans" :selected="true" />'))
->not->toContain('aria-pressed')
->and((string) $this->blade('<x-button label="Bold" :selected="true" />'))
->toContain('aria-pressed="true"');
});
+50
View File
@@ -1,5 +1,8 @@
<?php
use Livewire\Component;
use Livewire\Livewire;
it('discloses on the native details element, kept open through a morph', function () {
$html = (string) $this->blade('<x-collapse title="How long do links last?" icon="schedule" open variant="filled">Until the expiry.</x-collapse>');
@@ -12,3 +15,50 @@ it('discloses on the native details element, kept open through a morph', functio
->toContain('Until the expiry.')
->toContain('group-open/collapse:rotate-180');
});
it('has no Alpine on it without a binding', function () {
$html = (string) $this->blade('<x-collapse title="Advanced" open>Body</x-collapse>');
expect($html)
->toMatch('/<details\s+wire:ignore.self\s+open\s+class="group\/collapse/')
->not->toContain('x-data')
->not->toContain('x-modelable')
->not->toContain('x-on:toggle')
->and((string) $this->blade('<x-collapse title="Advanced">Body</x-collapse>'))
->toMatch('/<details\s+wire:ignore.self\s+class="group\/collapse/');
});
it('binds its open state through x-model, drawing the open prop until Alpine starts', function () {
$html = (string) $this->blade('<x-collapse title="Advanced" x-model="advanced" open>Body</x-collapse>');
expect($html)
->toMatch('/x-data="{ collapseOpen:\s*true\s*}"/')
->toContain('x-modelable="collapseOpen"')
->toContain('x-model="advanced"')
->toContain('x-effect="$el.open = collapseOpen"')
->toContain('x-on:toggle="collapseOpen = $el.open"')
->toMatch('/\sopen\s/');
});
it('entangles its open state with a Livewire property and renders open as the property is', function (bool $fineTuning) {
Livewire::component('collapse-probe', new class extends Component
{
public bool $fineTuning = false;
public function render(): string
{
return '<div><x-collapse title="Fine-tuning" wire:model.live="fineTuning" :open="! $fineTuning">Body</x-collapse></div>';
}
});
$html = Livewire::test('collapse-probe', ['fineTuning' => $fineTuning])->html();
expect($html)
->toContain(".entangle('fineTuning').live")
->not->toContain('x-modelable')
->not->toContain('wire:model');
preg_match('/<details[^>]*>/', $html, $tag);
expect(preg_match('/\sopen\s/', $tag[0]) === 1)->toBe($fineTuning);
})->with(['open' => true, 'closed' => false]);
@@ -72,6 +72,27 @@ it('hands min, max and the application locale to the picker as Y-m-d', function
->toMatchArray(['min' => null, 'max' => null]);
});
it('hands a chosen first day of the week and format to the picker, and ignores values that are neither', function () {
app()->setLocale('de');
$html = (string) $this->blade('<x-datepicker label="Date" week-start="0" format="yyyy-MM-dd" value="2026-09-13" />');
expect(datepickerConfig($html))->toMatchArray(['weekStart' => 0, 'format' => 'yyyy-MM-dd', 'locale' => 'de'])
->and($html)->toContain('value="2026-09-13"')
->and(datepickerConfig((string) $this->blade('<x-datepicker label="Date" :week-start="6" format="MM/dd/yyyy" />')))
->toMatchArray(['weekStart' => 6, 'format' => 'MM/dd/yyyy'])
->and((string) $this->blade('<x-datepicker label="Trip" range format="dd/MM/yyyy" :value="[\'start\' => \'2026-09-13\', \'end\' => \'2026-09-20\']" />'))
->toContain('value="13/09/2026 20/09/2026"');
foreach (['week-start="7"', 'week-start="-1"', 'week-start="monday"', 'week-start', 'format="d.M.yy"', 'format="dd.MM/yyyy"', 'format="dd.dd.yyyy"', 'format="yyyy-mm-dd"'] as $attribute) {
expect(datepickerConfig((string) $this->blade("<x-datepicker label=\"Date\" {$attribute} />")))
->toMatchArray(['weekStart' => null, 'format' => null]);
}
expect(datepickerConfig((string) $this->blade('<x-datepicker label="Date" />')))->toMatchArray(['weekStart' => null, 'format' => null])
->and((string) $this->blade('<x-datepicker label="Date" format="dd/MM/y" value="2026-09-13" />'))->toContain('value="13.09.2026"');
});
it('shows the value in the locale\'s numeric format before Alpine starts', function () {
expect((string) $this->blade('<x-datepicker label="Date" value="2026-09-13" name="expires" />'))
->toContain('value="09/13/2026"')
@@ -15,3 +15,34 @@ it('sets an icon on an Expressive shape above its words and action', function ()
->toContain('Upload files')
->and(substr_count($html, '<svg'))->toBe(2);
});
it('draws an illustration in place of the shape and icon', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-empty-state icon="upload_file" title="No routes yet">
<x-slot:illustration class="text-primary"><svg class="size-32" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="4" /></svg></x-slot:illustration>
</x-empty-state>
BLADE);
expect($html)
->toContain('<div class="text-primary"><svg class="size-32" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="4" /></svg></div>')
->not->toContain('text-secondary-container')
->not->toContain('text-on-secondary-container')
->toContain('No routes yet')
->and(substr_count($html, '<svg'))->toBe(1);
});
it('keeps the shape and icon while the illustration slot holds nothing but comments', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-empty-state title="No shares yet">
<x-slot:illustration>
<!-- artwork to come -->
</x-slot:illustration>
</x-empty-state>
BLADE);
expect($html)
->toContain('text-secondary-container')
->toContain('text-on-secondary-container')
->not->toContain('artwork to come')
->and(substr_count($html, '<svg'))->toBe(2);
});
+29
View File
@@ -53,6 +53,20 @@ it('makes a selectable item a menuitemcheckbox', function () {
->toContain('aria-checked="false"');
});
it('marks the page an item leads to, and carries a badge', function () {
$html = (string) $this->blade('<x-menu-item label="Support" icon="support" link="/admin/support" current badge="3" />');
expect($html)
->toContain('role="menuitem"')
->toContain('aria-current="page"')
->not->toContain('aria-checked')
->toContain('bg-secondary-container text-on-secondary-container')
->toContain('>3<')
->and((string) $this->blade('<x-menu-item label="Users" link="/admin/users" />'))
->not->toContain('aria-current')
->not->toContain('secondary-container');
});
it('links an item, and keeps a disabled one out of reach', function () {
$this->blade('<x-menu-item label="Settings" link="/settings" />')
->assertSee('href="/settings"', false)
@@ -69,3 +83,18 @@ it('separates and labels groups', function () {
$this->blade('<x-menu-group label="Sort by"><x-menu-item label="Newest" /></x-menu-group>')
->assertSee('role="group" aria-label="Sort by"', false);
});
it('adds icon-class to the leading icon, over its own colour but not over disabled', function () {
$leading = fn (string $html): string => preg_match('/<svg[^>]*class="([^"]*)"/', $html, $icon) ? $icon[1] : '';
expect($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" icon-right="chevron_right" />')))
->toBe('shrink-0 size-5 [:where(&amp;)]:text-on-surface-variant text-sport-run')
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" :selected="true" />')))
->toBe('shrink-0 size-5 [:where(&amp;)]:text-on-tertiary-container text-sport-run')
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" disabled />')))
->toBe('shrink-0 size-5 text-sport-run text-on-surface/38!')
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" />')))
->toBe('shrink-0 size-5 text-on-surface-variant')
->and((string) $this->blade('<x-menu-item label="Next" icon-right="chevron_right" icon-class="text-sport-run" />'))
->not->toContain('text-sport-run');
});
@@ -0,0 +1,48 @@
<?php
use Livewire\Component;
use Livewire\Livewire;
/** The child component in each row of `KeyedRowsProbe`. */
class KeyedRowChildProbe extends Component
{
public function render(): string
{
return '<p data-row-child>Row</p>';
}
}
/**
* A list whose rows each hold `$markup`, then a child component. Livewire keys a child from the
* loop around it, and takes a `wire:key` written in any template rendered in the row for the row's
* key: a component that wrote its own that way would key every row's child alike.
*/
class KeyedRowsProbe extends Component
{
public static string $markup = '';
public function render(): string
{
return '<div>@foreach ([1, 2] as $row)<div wire:key="row-{{ $row }}">'.static::$markup.'<livewire:keyed-row-child-probe /></div>@endforeach</div>';
}
}
it('keys what a morph must patch in place, without keying the child components after it', function (string $markup, string $key) {
Livewire::component('keyed-row-child-probe', KeyedRowChildProbe::class);
Livewire::component('keyed-rows-probe', KeyedRowsProbe::class);
KeyedRowsProbe::$markup = $markup;
$html = Livewire::test('keyed-rows-probe')->html();
preg_match_all('/<p\b[^>]*\bwire:key="([^"]+)"[^>]*>Row<\/p>/', $html, $children);
expect(substr_count($html, "wire:key=\"{$key}\""))->toBe(2)
->and($children[1])->toHaveCount(2)
->and(array_unique($children[1]))->toHaveCount(2);
})->with([
'menu' => ['<x-menu label="Row actions"><x-slot:trigger><button>More</button></x-slot:trigger><x-menu-item label="Delete" /></x-menu>', 'material-menu'],
'FAB menu' => ['<x-fab-menu label="New"><x-fab-menu-item label="Upload" /></x-fab-menu>', 'material-fab-menu'],
'rich tooltip' => ['<x-rich-tooltip text="Details"><button>i</button></x-rich-tooltip>', 'material-rich-tooltip'],
'carousel' => ['<x-carousel label="Photos"><x-carousel-item><div class="size-full"></div></x-carousel-item></x-carousel>', 'material-carousel'],
]);
+9
View File
@@ -55,6 +55,15 @@ it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))->toContain('<p class="type-body-md text-on-surface-variant">Only a subtitle</p>');
});
it('leaves a pane open on Escape unless it is asked to close then too', function () {
expect((string) $this->blade('<x-drawer pane>Body</x-drawer>'))
->toContain('x-on:keydown.window.escape="if (open &amp;&amp; ! wide) close()"')
->and((string) $this->blade('<x-drawer pane pane-close-on-escape>Body</x-drawer>'))
->toContain('x-on:keydown.window.escape="if (open) close()"')
->and((string) $this->blade('<x-drawer pane pane-close-on-escape :close-on-escape="false">Body</x-drawer>'))
->not->toContain('keydown.window.escape');
});
it('slides a side sheet in from either edge, and is a pane from xl when asked', function () {
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
->toContain('x-trap.inert.noscroll="open && ! wide"')
+31 -1
View File
@@ -67,7 +67,11 @@ it('draws section navigation as secondary tabs and a picker', function () {
->toContain('sm:flex')
->toMatch('/href="\/settings\/security"\s+data-tab\s+aria-current="page"\s+wire:navigate/')
->not->toMatch('/href="\/settings\/profile"\s+data-tab\s+aria-current/')
->toContain('role="menuitemcheckbox"');
// The picker is a menu of places: the current one is the page, not a checked choice,
// and a section's badge shows there as well as on its tab.
->not->toContain('role="menuitemcheckbox"')
->toMatch('/role="menuitem"[^>]*aria-current="page"[^>]*href="\/settings\/security"/')
->toMatch('/data-section-picker.*Security.*>\s*1\s*<.*<nav/s');
});
it('marks the section whose url is the request\'s, and wraps many sections onto a grid', function () {
@@ -80,3 +84,29 @@ it('marks the section whose url is the request\'s, and wraps many sections onto
->toMatch('/data-tab\s+aria-current="page"\s*>\s*<span data-tab-content>\s*<span class="truncate">S3/')
->not->toContain('wire:navigate');
});
it('keeps the page\'s section current while a Livewire component on it updates', function () {
Livewire::component('section-nav-probe', new class extends Component
{
public string $page = '';
public function mount(): void
{
$this->page = url()->current();
}
public function render(): string
{
return '<div><x-section-nav :items="[[\'title\' => \'Here\', \'url\' => $page], [\'title\' => \'Elsewhere\', \'url\' => \'/elsewhere\']]" no-wire-navigate /></div>';
}
});
$current = '/href="[^"]*\/livewire-unit-test-endpoint\/[^"]*"\s+data-tab\s+aria-current="page"/';
$probe = Livewire::test('section-nav-probe');
expect($probe->html())->toMatch($current)
->and($probe->call('$refresh')->html())->toMatch($current)
// Once as the tab, once as the picker's item: one section, drawn for both widths.
->and(substr_count($probe->html(), 'aria-current="page"'))->toBe(2);
});
@@ -67,3 +67,63 @@ it('names the active colour profile for <html data-scheme>, and none for a singl
File::delete($path);
}
});
it('leaves the theme-color meta alone by default', function () {
$this->blade('<x-theme-script />')
->assertDontSee('theme-color', false)
->assertDontSee('"meta"', false)
->assertDontSee('MutationObserver', false);
});
it('paints the theme-color meta in the resolved theme\'s surface when asked', function () {
config(['livewire-material.theme.meta' => true]);
$scheme = Scheme::load();
$this->blade('<x-theme-script />')
->assertSee('"meta":{"light":"'.$scheme['light']['surface'].'","dark":"'.$scheme['dark']['surface'].'","profiles":{}}', false)
->assertSee('document.head.querySelectorAll(\'meta[name="theme-color"]:not([media])\')', false)
->assertSee("new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme'] });", false)
->assertSee("document.addEventListener('livewire:navigated', paintThemeColor);", false);
});
it('gives the theme-color meta every profile\'s surfaces, the active one\'s first', function () {
$path = sys_get_temp_dir().'/theme-script-meta-'.uniqid().'.json';
File::put($path, json_encode([
'default' => 'indigo',
'profiles' => [
'indigo' => ['label' => 'Indigo', 'light' => ['surface' => '#fbf8ff'], 'dark' => ['surface' => '#12131a']],
'teal' => ['label' => 'Teal', 'light' => ['surface' => '#f4fbf8'], 'dark' => ['surface' => '#0e1513']],
],
]));
config(['livewire-material.scheme' => $path, 'livewire-material.theme.meta' => true]);
Scheme::resolveProfileUsing(fn (): string => 'teal');
try {
$this->blade('<x-theme-script />')
->assertSee('"meta":{"light":"#f4fbf8","dark":"#0e1513","profiles":{"indigo":{"light":"#fbf8ff","dark":"#12131a"},"teal":{"light":"#f4fbf8","dark":"#0e1513"}}}', false);
} finally {
Scheme::resolveProfileUsing(null);
File::delete($path);
}
});
it('paints the meta without profiles and without a deprecation on PHP 8.5', function () {
config(['livewire-material.theme.meta' => true, 'livewire-material.profiles' => []]);
$deprecations = [];
set_error_handler(function (int $level, string $message) use (&$deprecations): bool {
$deprecations[] = $message;
return true;
}, E_DEPRECATED | E_USER_DEPRECATED);
try {
$html = (string) $this->blade('<x-theme-script />');
} finally {
restore_error_handler();
}
expect($html)->toContain('"meta":{')
->and($deprecations)->toBe([]);
});
+8
View File
@@ -13,3 +13,11 @@ it('hosts the snackbar queue, kept across wire:navigate', function () {
it('can sit at the start', function () {
expect((string) $this->blade('<x-toast position="bottom-start" />'))->toContain('justify-start');
});
it('marks the snackbar and its action for tests and styling', function () {
$html = (string) $this->blade('<x-toast />');
expect($html)
->toMatch('/<div\s[^>]*\bdata-toast\b[^>]*aria-live="polite"/')
->toMatch('/<button\s[^>]*\bdata-toast-action\b[^>]*x-on:click="act\(\)"/');
});
+74
View File
@@ -56,6 +56,48 @@ it('generates a different scheme per variant', function () {
->and($vibrant['light']['primary'])->not->toBe($tonalSpot['light']['primary']);
});
it('generates with the 2021 colour spec when asked, and records it', function () {
$this->artisan('material:scheme', [
'seed' => '#00bc7d',
'--variant' => 'vibrant',
'--success' => '#00d390',
'--warning' => '#fcb700',
'--info' => '#00bafe',
'--spec' => '2021',
'--output' => $this->stylesheet,
])->assertSuccessful();
$scheme = json_decode(File::get($this->data), true);
expect($scheme['spec'])->toBe('2021')
->and($scheme['dark'])->toMatchArray([
'surface' => '#0b1610',
'primary' => '#00e297',
'on-primary' => '#003822',
'success' => '#2ce19c',
'warning' => '#ffbb16',
'info' => '#80cfff',
])
->and($scheme['light'])->toMatchArray([
'surface' => '#f0fdf2',
'primary' => '#006c46',
'success' => '#006c48',
'warning' => '#7c5800',
'info' => '#00658c',
])
->and(File::get($this->stylesheet))
->toContain('(spec 2021)')
->toContain('php artisan material:scheme "#00bc7d" --variant=vibrant --spec=2021 --success="#00d390" --warning="#fcb700" --info="#00bafe"'."\n");
});
it('refuses an unknown colour spec', function () {
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--spec' => '2023', '--output' => $this->stylesheet])
->expectsOutputToContain('Unknown spec "2023". Use one of: 2021, 2025.')
->assertFailed();
expect(File::exists($this->stylesheet))->toBeFalse();
});
it('refuses a seed that is not a colour', function () {
$this->artisan('material:scheme', ['seed' => 'indigo', '--output' => $this->stylesheet])
->expectsOutputToContain('#rrggbb')
@@ -118,6 +160,38 @@ it('generates every configured profile into one stylesheet keyed by data-scheme'
}
});
it('takes a profile\'s own spec and state colours, and the command\'s for a profile without them', function () {
config([
'livewire-material.profiles' => [
'expressive' => ['seed' => '#00bc7d', 'variant' => 'vibrant'],
'classic' => ['seed' => '#00bc7d', 'variant' => 'vibrant', 'spec' => 2021, 'success' => '#00d390', 'warning' => '#fcb700', 'info' => '#00bafe'],
],
]);
$this->artisan('material:scheme', ['--info' => '#00bafe', '--output' => $this->stylesheet])->assertSuccessful();
$profiles = json_decode(File::get($this->data), true)['profiles'];
expect($profiles['classic'])->spec->toBe('2021')
->and($profiles['classic']['dark'])->toMatchArray(['primary' => '#00e297', 'success' => '#2ce19c', 'warning' => '#ffbb16', 'info' => '#80cfff'])
->and($profiles['expressive'])->spec->toBe('2025')
->and($profiles['expressive']['dark']['primary'])->not->toBe('#00e297')
->and($profiles['expressive']['dark']['success'])->not->toBe('#2ce19c')
->and($profiles['expressive']['dark']['info'])->toBe('#80cfff')
->and(File::get($this->stylesheet))
->toContain('(spec 2025)')
->toMatch('/ \* classic\s+#00bc7d, vibrant, spec 2021\n/')
->toMatch('/ \* expressive\s+#00bc7d, vibrant\n/');
});
it('names the profile whose spec is unknown', function () {
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5', 'spec' => '2019']]]);
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
->expectsOutputToContain('Profile "indigo": Unknown spec "2019"')
->assertFailed();
});
it('names the profile a generator error belongs to', function () {
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5'], 'broken' => ['seed' => 'teal']]]);
+14
View File
@@ -71,6 +71,20 @@ it('does not mount the showcase when disabled', function () {
$this->get('/material')->assertNotFound();
});
it('compiles every package view with the showcase off, as view:cache does in production', function () {
rebootWithShowcase(false);
$compiled = sys_get_temp_dir().'/livewire-material-view-cache-'.uniqid();
config(['view.compiled' => $compiled]);
File::ensureDirectoryExists($compiled);
try {
$this->artisan('view:cache')->assertSuccessful();
} finally {
File::deleteDirectory($compiled);
}
});
it('serves a symbol for the icon search', function () {
$this->get('/material/symbols/filled/favorite.svg')
->assertOk()
+21
View File
@@ -64,6 +64,27 @@ it('leaves the choice of theme to the head script, never to a media query', func
}
});
it('reads every safe-area inset through a variable that can replace it', function () {
$files = collect([packageCss(), __DIR__.'/../../resources/views', __DIR__.'/../../resources/js'])
->flatMap(fn (string $path): array => File::allFiles($path));
$insets = 0;
foreach ($files as $file) {
preg_match_all('/env\(safe-area-inset-(top|bottom|left|right)\)/', $file->getContents(), $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[1] as [$side, $offset]) {
$insets++;
expect(substr($file->getContents(), 0, $offset - strlen('env(safe-area-inset-')))
->toMatch("/var\\(--material-safe-{$side},\\s?$/", "{$file->getRelativePathname()} reads safe-area-inset-{$side} directly");
}
}
expect($insets)->toBeGreaterThan(10)
->and(File::get(packageCss('components/app-bar.css')))->toContain('padding-top: var(--material-safe-top, env(safe-area-inset-top));');
});
it('makes every motion token instant under reduced motion', function () {
$motion = File::get(packageCss('tokens/motion.css'));
$reduced = Str::of($motion)->after('prefers-reduced-motion: reduce')->toString();