Compare commits
@@ -7,3 +7,6 @@
|
|||||||
/workbench/public/hot
|
/workbench/public/hot
|
||||||
/tests/Browser/Screenshots
|
/tests/Browser/Screenshots
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Planning notes stay local
|
||||||
|
/docs/plans
|
||||||
|
|||||||
@@ -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
|
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
|
#### 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
|
### 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.
|
- `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.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).
|
- `profiles`, `profile` — colour profiles and the default one (see Colour profiles).
|
||||||
- `fields.variant` — text fields `outlined` (default) or `filled`.
|
- `fields.variant` — text fields `outlined` (default) or `filled`.
|
||||||
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
|
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
|
||||||
|
|||||||
+8
-3
@@ -6,7 +6,8 @@
|
|||||||
* nothing but `node`. (The published library imports without file extensions, which
|
* nothing but `node`. (The published library imports without file extensions, which
|
||||||
* plain Node refuses, so it cannot be run unbundled anyway.)
|
* 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}}.
|
* Output: JSON on stdout — {seed, variant, spec, contrast, light: {role: hex}, dark: {role: hex}}.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
@@ -56,9 +57,12 @@ try {
|
|||||||
|
|
||||||
const hex = /^#[0-9a-f]{6}$/i
|
const hex = /^#[0-9a-f]{6}$/i
|
||||||
const Scheme = VARIANTS[input.variant]
|
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 (!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 (!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']) {
|
for (const state of ['success', 'warning', 'info']) {
|
||||||
if (!hex.test(input[state] ?? '')) fail(`The ${state} colour must be a #rrggbb colour, "${input[state]}" given.`)
|
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) {
|
function roles(isDark) {
|
||||||
// The 2025 spec is M3 Expressive's colour; the library falls back to 2021 for the
|
// 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, …).
|
// variants the new spec does not define (fidelity, content, monochrome, …). 2021 is
|
||||||
const scheme = new Scheme(source, isDark, contrast, '2025')
|
// M3's original colour, for an application whose palette was generated with it.
|
||||||
|
const scheme = new Scheme(source, isDark, contrast, spec)
|
||||||
const out = {}
|
const out = {}
|
||||||
|
|
||||||
for (const color of colors.allColors) {
|
for (const color of colors.allColors) {
|
||||||
|
|||||||
@@ -28,12 +28,20 @@ return [
|
|||||||
| localStorage under 'storage_key'; values found under 'legacy_keys' (an
|
| localStorage under 'storage_key'; values found under 'legacy_keys' (an
|
||||||
| earlier theme toggle's key) are adopted once and then removed.
|
| 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' => [
|
'theme' => [
|
||||||
'default' => 'system',
|
'default' => 'system',
|
||||||
'storage_key' => 'material-theme',
|
'storage_key' => 'material-theme',
|
||||||
'legacy_keys' => [],
|
'legacy_keys' => [],
|
||||||
|
'meta' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -113,7 +121,9 @@ return [
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
|
||||||
| Named schemes an installation can switch between. Each one is a 'label',
|
| 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
|
| seed, `php artisan material:scheme` generates every profile into one
|
||||||
| stylesheet keyed by <html data-scheme>. 'profile' names the default one
|
| stylesheet keyed by <html data-scheme>. 'profile' names the default one
|
||||||
| (else the first); the application says which is active with
|
| (else the first); the application says which is active with
|
||||||
|
|||||||
@@ -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 400–700, ROND 0–100).
|
|
||||||
- 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 2–98 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 4–10 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.1–0.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 00–11 outside
|
|
||||||
and 12–23 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 30–32 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
|
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
|
### Colour profiles
|
||||||
|
|
||||||
@@ -62,6 +62,7 @@ An installation that switches between several schemes lists them in `config/live
|
|||||||
php artisan material:scheme
|
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.
|
- 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:
|
- 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
|
## 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
|
## Toasts
|
||||||
|
|
||||||
@@ -186,7 +195,7 @@ One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, size
|
|||||||
|
|
||||||
### `<x-theme-script>`
|
### `<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>`
|
### `<x-button>`
|
||||||
|
|
||||||
@@ -235,7 +244,7 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
|
|||||||
</x-menu>
|
</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>`
|
### `<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" />
|
]" 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>`
|
### `<x-split-button>`
|
||||||
|
|
||||||
@@ -280,7 +289,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
|
|||||||
</div>
|
</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>`
|
### `<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.
|
`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>`
|
### `<x-progress>`
|
||||||
|
|
||||||
M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless a `size-*` class is passed), flat or `wavy`, determinate with a `value` or indeterminate without one.
|
M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless 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>`
|
||||||
|
|
||||||
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
|
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `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>`
|
### `<x-alert>`
|
||||||
|
|
||||||
@@ -360,7 +382,7 @@ A few lines of context around a trigger, with an optional `title` and `actions`
|
|||||||
</x-rich-tooltip>
|
</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>`
|
### `<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.
|
"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>`
|
### `<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`.
|
`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.
|
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>`
|
### `<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.
|
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>`
|
### `<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>`
|
### `<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>`
|
### `<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-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`.
|
- `<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="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="Birthday" mode="modal" wire:model="birthday" :max="now()" />
|
||||||
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" clearable />
|
<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 | |
|
| 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` |
|
| `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) |
|
| `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 |
|
| `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>`
|
### `<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>
|
</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`).
|
- 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`).
|
- 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.
|
- 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>`
|
### `<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>`
|
### `<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>`
|
### `<x-account-menu>`
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
display: block;
|
display: block;
|
||||||
min-height: var(--app-bar-height);
|
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);
|
background-color: var(--md-sys-color-surface);
|
||||||
color: var(--md-sys-color-on-surface);
|
color: var(--md-sys-color-on-surface);
|
||||||
transition: background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
transition: background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
||||||
|
|||||||
@@ -15,18 +15,25 @@
|
|||||||
*
|
*
|
||||||
* Split button (`<x-split-button>`): the same idea for two halves; the trailing half turns
|
* 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).
|
* 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] {
|
[data-button-group] {
|
||||||
--group-inner: var(--md-sys-shape-corner-sm);
|
--group-inner: var(--md-sys-shape-corner-sm);
|
||||||
--group-inner-pressed: var(--md-sys-shape-corner-xs);
|
--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='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; }
|
[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; }
|
[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); }
|
[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); }
|
[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']) {
|
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) {
|
||||||
padding-inline: calc(var(--group-pad) + var(--group-grow));
|
padding-inline: calc(var(--group-pad) + var(--group-grow));
|
||||||
@@ -60,19 +67,19 @@
|
|||||||
|
|
||||||
[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)),
|
[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)),
|
||||||
[data-split='trailing'][aria-expanded='true'] {
|
[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-button-group='connected'] > :first-child,
|
||||||
[data-split='leading'] {
|
[data-split='leading'] {
|
||||||
border-start-start-radius: var(--md-sys-shape-corner-full);
|
border-start-start-radius: var(--group-full);
|
||||||
border-end-start-radius: var(--md-sys-shape-corner-full);
|
border-end-start-radius: var(--group-full);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-button-group='connected'] > :last-child,
|
[data-button-group='connected'] > :last-child,
|
||||||
[data-split='trailing'] {
|
[data-split='trailing'] {
|
||||||
border-start-end-radius: var(--md-sys-shape-corner-full);
|
border-start-end-radius: var(--group-full);
|
||||||
border-end-end-radius: var(--md-sys-shape-corner-full);
|
border-end-end-radius: var(--group-full);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-split='trailing'] svg {
|
[data-split='trailing'] svg {
|
||||||
|
|||||||
@@ -66,8 +66,8 @@
|
|||||||
|
|
||||||
[data-navigation-bar] {
|
[data-navigation-bar] {
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
|
padding-inline: var(--material-safe-left, env(safe-area-inset-left)) var(--material-safe-right, env(safe-area-inset-right));
|
||||||
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);
|
background-color: var(--md-sys-color-surface-container);
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
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
|
at once when the rail expands, while the width is still growing; the clip keeps it
|
||||||
from spilling over the page for those frames. */
|
from spilling over the page for those frames. */
|
||||||
overflow-x: clip;
|
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);
|
background-color: var(--md-sys-color-surface);
|
||||||
color: var(--md-sys-color-on-surface);
|
color: var(--md-sys-color-on-surface);
|
||||||
transition:
|
transition:
|
||||||
@@ -307,12 +307,12 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.5rem;
|
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;
|
padding-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child {
|
[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] {
|
[data-navigation-rail-destinations] {
|
||||||
|
|||||||
@@ -165,8 +165,8 @@
|
|||||||
[data-search][data-full-screen] [data-search-bar] {
|
[data-search][data-full-screen] [data-search-bar] {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 0 auto;
|
inset: 0 0 auto;
|
||||||
height: calc(4.5rem + env(safe-area-inset-top));
|
height: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top)));
|
||||||
padding-top: env(safe-area-inset-top);
|
padding-top: var(--material-safe-top, env(safe-area-inset-top));
|
||||||
padding-inline: 0.25rem;
|
padding-inline: 0.25rem;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
max-height: none;
|
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;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,11 @@
|
|||||||
|
|
||||||
[data-toolbar][data-variant="docked"] {
|
[data-toolbar][data-variant="docked"] {
|
||||||
width: 100%;
|
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;
|
justify-content: center;
|
||||||
column-gap: clamp(0.25rem, 4vw, 2rem);
|
column-gap: clamp(0.25rem, 4vw, 2rem);
|
||||||
padding-inline: 1rem;
|
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);
|
background-color: var(--md-sys-color-surface-container);
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
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. */
|
/* Placed over the page: centred above the bottom edge, or centred against the end edge. */
|
||||||
[data-toolbar-place="bottom"] {
|
[data-toolbar-place="bottom"] {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: calc(1rem + env(safe-area-inset-bottom));
|
bottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
|
||||||
left: 50%;
|
left: 50%;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
translate: -50% 0;
|
translate: -50% 0;
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
[data-toolbar-place="end"] {
|
[data-toolbar-place="end"] {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 50%;
|
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;
|
z-index: 30;
|
||||||
translate: 0 -50%;
|
translate: 0 -50%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
collapsed: false,
|
collapsed: false,
|
||||||
height: null,
|
height: null,
|
||||||
frame: 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() {
|
init() {
|
||||||
this.measure = this.measure.bind(this)
|
this.measure = this.measure.bind(this)
|
||||||
|
|||||||
+34
-10
@@ -11,7 +11,9 @@
|
|||||||
* Dates are ISO strings (`2026-09-13`) throughout, computed in UTC so no time zone or daylight
|
* 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
|
* 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
|
* 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 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,
|
* 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())}`
|
return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 0 for Sunday … 6 for Saturday. */
|
/** 0 for Sunday … 6 for Saturday: `weekStart` when it is one of those, otherwise the locale's. */
|
||||||
function firstDayOfWeek(locale) {
|
function firstDayOfWeek(locale, weekStart = null) {
|
||||||
|
if (Number.isInteger(weekStart) && weekStart >= 0 && weekStart <= 6) {
|
||||||
|
return weekStart
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tag = new Intl.Locale(locale)
|
const tag = new Intl.Locale(locale)
|
||||||
const info = typeof tag.getWeekInfo === 'function' ? tag.getWeekInfo() : tag.weekInfo
|
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
|
* 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
|
* 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 = ''
|
let pattern = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -131,10 +144,11 @@ function inputFormat(locale) {
|
|||||||
pattern = ''
|
pattern = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern)) {
|
return formatOf(/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern) ? pattern : 'yyyy-MM-dd')
|
||||||
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 {
|
return {
|
||||||
pattern,
|
pattern,
|
||||||
placeholder: pattern.toUpperCase(),
|
placeholder: pattern.toUpperCase(),
|
||||||
@@ -164,13 +178,23 @@ document.addEventListener('alpine:init', () => {
|
|||||||
today: localToday(),
|
today: localToday(),
|
||||||
compact: false,
|
compact: false,
|
||||||
refocus: true,
|
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() {
|
init() {
|
||||||
const locale = config.locale || document.documentElement.lang || 'en'
|
const locale = config.locale || document.documentElement.lang || 'en'
|
||||||
const format = (options) => new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options })
|
const format = (options) => new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options })
|
||||||
|
|
||||||
this.firstDay = firstDayOfWeek(locale)
|
this.firstDay = firstDayOfWeek(locale, config.weekStart ?? null)
|
||||||
this.format = inputFormat(locale)
|
this.format = inputFormat(locale, config.format ?? null)
|
||||||
this.numbers = new Intl.NumberFormat(locale, { useGrouping: false })
|
this.numbers = new Intl.NumberFormat(locale, { useGrouping: false })
|
||||||
this.formats = {
|
this.formats = {
|
||||||
monthYear: format({ year: 'numeric', month: 'long' }),
|
monthYear: format({ year: 'numeric', month: 'long' }),
|
||||||
|
|||||||
+78
-7
@@ -3,17 +3,28 @@
|
|||||||
*
|
*
|
||||||
* The menu button is the trigger's first button or link. Its ARIA attributes are written by
|
* 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,
|
* 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"]'
|
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
|
// 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
|
const REOPEN_GUARD_MS = 250
|
||||||
|
|
||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
window.Alpine.data('materialMenu', () => ({
|
window.Alpine.data('materialMenu', () => ({
|
||||||
closedAt: -Infinity,
|
closedAt: -Infinity,
|
||||||
|
anchored: null,
|
||||||
returnFocus: true,
|
returnFocus: true,
|
||||||
focusWasInside: false,
|
focusWasInside: false,
|
||||||
listeners: [],
|
listeners: [],
|
||||||
@@ -22,6 +33,25 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const menu = this.$refs.menu
|
const menu = this.$refs.menu
|
||||||
|
|
||||||
this.label()
|
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()
|
// 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
|
// 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).
|
// that was a focusable region around the trigger).
|
||||||
this.listen(menu, 'beforetoggle', (event) => {
|
this.listen(menu, 'beforetoggle', (event) => {
|
||||||
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
|
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
|
||||||
|
|
||||||
|
if (event.newState === 'closed') {
|
||||||
|
this.closedAt = performance.now()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.listen(menu, 'toggle', (event) => {
|
this.listen(menu, 'toggle', (event) => {
|
||||||
@@ -43,8 +77,6 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.closedAt = performance.now()
|
|
||||||
|
|
||||||
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
|
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
|
||||||
this.control()?.focus()
|
this.control()?.focus()
|
||||||
}
|
}
|
||||||
@@ -64,6 +96,40 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return this.$refs.trigger.querySelector('button, a[href], [tabindex]')
|
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() {
|
label() {
|
||||||
const control = this.control()
|
const control = this.control()
|
||||||
|
|
||||||
@@ -71,9 +137,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
control.setAttribute('aria-haspopup', 'menu')
|
const attributes = { 'aria-haspopup': 'menu', 'aria-controls': this.$refs.menu.id, 'aria-expanded': String(this.isOpen()) }
|
||||||
control.setAttribute('aria-controls', this.$refs.menu.id)
|
|
||||||
control.setAttribute('aria-expanded', String(this.isOpen()))
|
for (const [name, value] of Object.entries(attributes)) {
|
||||||
|
if (control.getAttribute(name) !== value) {
|
||||||
|
control.setAttribute(name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
isOpen() {
|
isOpen() {
|
||||||
@@ -82,6 +152,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
open(focus = 'first') {
|
open(focus = 'first') {
|
||||||
this.label()
|
this.label()
|
||||||
|
this.anchor()
|
||||||
|
|
||||||
if (!this.isOpen()) {
|
if (!this.isOpen()) {
|
||||||
this.$refs.menu.showPopover()
|
this.$refs.menu.showPopover()
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
* One snackbar at a time, as M3 shows them. Each waits its turn, stays for its timeout (paused
|
* 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
|
* while hovered or focused, so it is never pulled away from someone reading or reaching for its
|
||||||
* action) and is replaced by the next.
|
* 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
|
const DEFAULT_TIMEOUT_MS = 4000
|
||||||
|
|
||||||
@@ -25,6 +30,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
window.Alpine.data('materialSnackbar', () => ({
|
window.Alpine.data('materialSnackbar', () => ({
|
||||||
queue: [],
|
queue: [],
|
||||||
current: null,
|
current: null,
|
||||||
|
sticky: null,
|
||||||
timer: null,
|
timer: null,
|
||||||
remaining: 0,
|
remaining: 0,
|
||||||
startedAt: 0,
|
startedAt: 0,
|
||||||
@@ -44,24 +50,43 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// Livewire dispatches named arguments as the detail object; a positional dispatch
|
// Livewire dispatches named arguments as the detail object; a positional dispatch
|
||||||
// arrives as an array whose first entry is that object.
|
// arrives as an array whose first entry is that object.
|
||||||
const toast = Array.isArray(detail) ? detail[0] : detail
|
const toast = Array.isArray(detail) ? detail[0] : detail
|
||||||
|
const sticky = toast.sticky === true
|
||||||
|
|
||||||
this.queue.push({
|
const entry = {
|
||||||
id: ++sequence,
|
id: ++sequence,
|
||||||
type: toast.type ?? null,
|
type: toast.type ?? null,
|
||||||
title: toast.title ?? '',
|
title: toast.title ?? '',
|
||||||
description: toast.description ?? null,
|
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,
|
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()
|
this.next()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
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)
|
clearTimeout(this.timer)
|
||||||
this.current = this.queue.shift() ?? null
|
this.timer = null
|
||||||
|
this.current = this.queue.shift() ?? this.sticky
|
||||||
|
|
||||||
if (this.current?.timeout) {
|
if (this.current?.timeout) {
|
||||||
this.remaining = this.current.timeout
|
this.remaining = this.current.timeout
|
||||||
@@ -92,13 +117,24 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
dismiss() {
|
dismiss() {
|
||||||
this.timer = null
|
if (this.current && this.current === this.sticky) {
|
||||||
|
this.sticky = null
|
||||||
|
}
|
||||||
|
|
||||||
this.next()
|
this.next()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Closed before the handler and the event run, so a toast either of them shows is not the
|
||||||
|
// one dismissed.
|
||||||
act() {
|
act() {
|
||||||
this.current?.action?.handler?.()
|
const action = this.current?.action
|
||||||
|
|
||||||
this.dismiss()
|
this.dismiss()
|
||||||
|
action?.handler?.()
|
||||||
|
|
||||||
|
if (typeof action?.event === 'string' && action.event !== '') {
|
||||||
|
window.dispatchEvent(new CustomEvent(action.event))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
icon(type) {
|
icon(type) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -20,7 +20,9 @@
|
|||||||
remembered and applied before the first paint (`$store.rail`, <x-theme-script>).
|
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
|
`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
|
`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
|
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
|
||||||
`navigate` (`false` for a full page load instead of `wire:navigate`).
|
`navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||||
@@ -35,7 +37,10 @@
|
|||||||
|
|
||||||
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
|
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
|
||||||
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
|
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
|
||||||
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
|
`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
|
`clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll
|
||||||
@@ -53,7 +58,7 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$label ??= __('Main');
|
$label ??= __('Main');
|
||||||
$current = request()->url();
|
$current = \Livewire\Livewire::isLivewireRequest() ? \Livewire\Livewire::originalUrl() : request()->url();
|
||||||
|
|
||||||
$items = collect($destinations)
|
$items = collect($destinations)
|
||||||
->filter(fn ($item): bool => is_array($item) && filled($item['title'] ?? null))
|
->filter(fn ($item): bool => is_array($item) && filled($item['title'] ?? null))
|
||||||
@@ -63,6 +68,7 @@
|
|||||||
'url' => $item['url'] ?? null,
|
'url' => $item['url'] ?? null,
|
||||||
'active' => (bool) ($item['active'] ?? (filled($item['url'] ?? null) && rtrim(url($item['url']), '/') === rtrim($current, '/'))),
|
'active' => (bool) ($item['active'] ?? (filled($item['url'] ?? null) && rtrim(url($item['url']), '/') === rtrim($current, '/'))),
|
||||||
'badge' => $item['badge'] ?? null,
|
'badge' => $item['badge'] ?? null,
|
||||||
|
'badgeLabel' => filled($item['badgeLabel'] ?? null) ? (string) $item['badgeLabel'] : null,
|
||||||
'section' => filled($item['section'] ?? null) ? (string) $item['section'] : null,
|
'section' => filled($item['section'] ?? null) ? (string) $item['section'] : null,
|
||||||
'bar' => ($item['bar'] ?? true) !== false,
|
'bar' => ($item['bar'] ?? true) !== false,
|
||||||
'navigate' => ($item['navigate'] ?? true) !== false,
|
'navigate' => ($item['navigate'] ?? true) !== false,
|
||||||
@@ -79,13 +85,13 @@
|
|||||||
data-app-shell
|
data-app-shell
|
||||||
@class([
|
@class([
|
||||||
'min-h-dvh bg-surface text-on-surface sm:flex',
|
'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
|
<a
|
||||||
href="#content"
|
href="#content"
|
||||||
data-skip-link
|
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>
|
>{{ __('Skip to content') }}</a>
|
||||||
|
|
||||||
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
|
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
|
||||||
@@ -101,12 +107,12 @@
|
|||||||
@if ($group->first()['section'] !== null)
|
@if ($group->first()['section'] !== null)
|
||||||
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
|
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
|
||||||
@foreach ($group as $item)
|
@foreach ($group as $item)
|
||||||
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||||||
@endforeach
|
@endforeach
|
||||||
</x-livewire-material::navigation-rail-section>
|
</x-livewire-material::navigation-rail-section>
|
||||||
@else
|
@else
|
||||||
@foreach ($group as $item)
|
@foreach ($group as $item)
|
||||||
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||||||
@endforeach
|
@endforeach
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
@@ -136,7 +142,7 @@
|
|||||||
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
||||||
<x-livewire-material::navigation-bar :label="$label">
|
<x-livewire-material::navigation-bar :label="$label">
|
||||||
@foreach ($barItems as $item)
|
@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
|
@endforeach
|
||||||
</x-livewire-material::navigation-bar>
|
</x-livewire-material::navigation-bar>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,8 +10,18 @@
|
|||||||
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
|
<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
|
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
|
colour's container ("Expired" in error-container), `solid` in the colour itself (a label that
|
||||||
`tone`): `error` (the default), `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
|
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
|
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. --}}
|
that includes it ("Notifications, 4 new"), or pass `label` here. --}}
|
||||||
@@ -22,29 +32,41 @@
|
|||||||
'color' => null,
|
'color' => null,
|
||||||
'tone' => null,
|
'tone' => null,
|
||||||
'tonal' => false,
|
'tonal' => false,
|
||||||
|
'solid' => false,
|
||||||
'outline' => false,
|
'outline' => false,
|
||||||
'floating' => false,
|
'floating' => false,
|
||||||
'label' => null,
|
'label' => null,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$color = in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) ? ($color ?? $tone) : 'error';
|
$color = in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info', 'neutral', 'plain'], true) ? ($color ?? $tone) : 'error';
|
||||||
$text = $value ?? ($slot->isNotEmpty() ? trim((string) $slot) : null);
|
// 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);
|
$dot = blank($text);
|
||||||
$status = ($tonal || $outline) && ! $dot;
|
$status = ($tonal || $solid || $outline) && ! $dot;
|
||||||
|
|
||||||
if (! $dot && $max !== null && is_numeric($text) && (int) $text > (int) $max) {
|
if (! $dot && $max !== null && is_numeric($text) && (int) $text > (int) $max) {
|
||||||
$text = $max.'+';
|
$text = $max.'+';
|
||||||
|
$markup = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$filled = [
|
$filled = [
|
||||||
'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary',
|
'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',
|
'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 = [
|
$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',
|
'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',
|
'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
|
$attributes = $attributes
|
||||||
->class([
|
->class([
|
||||||
@@ -52,9 +74,8 @@
|
|||||||
'size-1.5 rounded-corner-full' => $dot,
|
'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-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,
|
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
|
||||||
$filled[$color] => ! $status,
|
'border' => $outline && ! $tonal && ! $solid && ! $dot,
|
||||||
$container[$color] => $tonal && ! $dot,
|
$paint => $paint !== '',
|
||||||
'border border-outline-variant text-on-surface-variant' => $outline && ! $tonal && ! $dot,
|
|
||||||
'absolute top-0.5 end-0.5' => $floating && $dot,
|
'absolute top-0.5 end-0.5' => $floating && $dot,
|
||||||
'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
|
'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
|
||||||
])
|
])
|
||||||
@@ -64,4 +85,4 @@
|
|||||||
]));
|
]));
|
||||||
@endphp
|
@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
|
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
||||||
style="--sheet-max-height: {{ $height }}"
|
style="--sheet-max-height: {{ $height }}"
|
||||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
|
{{ $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'),
|
$attributes->get('class'),
|
||||||
]) }}
|
]) }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
|
|
||||||
With an `icon` and no label it is an icon button: `width` is `narrow`, `default` or `wide`,
|
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
|
`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
|
readers. `selected` makes it a toggle: `true` or `false` sets `aria-pressed` (not on a `link`,
|
||||||
selected colours, and a selected round button turns square (a selected square icon button
|
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.
|
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,
|
Values from androidx Compose Material 3's tokens (Button*Tokens, *IconButtonTokens,
|
||||||
@@ -191,7 +191,9 @@
|
|||||||
'type' => $isLink ? null : $type,
|
'type' => $isLink ? null : $type,
|
||||||
'disabled' => ! $isLink && $disabled ? true : null,
|
'disabled' => ! $isLink && $disabled ? true : null,
|
||||||
'aria-label' => $iconOnly && ! $attributes->has('aria-label') ? ($label ?? $tip) : 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,
|
'data-icon-button' => $iconOnly ? true : null,
|
||||||
'wire:loading.attr' => $spinnerTarget ? 'disabled' : null,
|
'wire:loading.attr' => $spinnerTarget ? 'disabled' : null,
|
||||||
'wire:target' => $spinnerTarget,
|
'wire:target' => $spinnerTarget,
|
||||||
|
|||||||
@@ -44,7 +44,9 @@
|
|||||||
longer slides inside its mask. RTL mirrors the keylines, keys and buttons.
|
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
|
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([
|
@props([
|
||||||
'layout' => 'multi-browse',
|
'layout' => 'multi-browse',
|
||||||
@@ -116,6 +118,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
x-ref="scroller"
|
x-ref="scroller"
|
||||||
|
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-carousel']) }}
|
||||||
id="{{ $scrollerId }}"
|
id="{{ $scrollerId }}"
|
||||||
role="region"
|
role="region"
|
||||||
aria-roledescription="{{ __('carousel') }}"
|
aria-roledescription="{{ __('carousel') }}"
|
||||||
|
|||||||
@@ -6,7 +6,18 @@
|
|||||||
`heading` slot), an optional leading `icon`, a chevron that turns over on the spatial spring,
|
`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
|
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
|
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([
|
@props([
|
||||||
'title' => null,
|
'title' => null,
|
||||||
@@ -15,10 +26,26 @@
|
|||||||
'variant' => 'plain',
|
'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
|
<details
|
||||||
wire:ignore.self
|
wire:ignore.self
|
||||||
@if ($open) open @endif
|
@if ($bound)
|
||||||
{{ $attributes->class([
|
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]',
|
'group/collapse [interpolate-size:allow-keywords]',
|
||||||
'rounded-corner-lg bg-surface-container' => $variant === 'filled',
|
'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.
|
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
|
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
|
`app()->getLocale()` (resources/js/datepicker.js). An application that lets each person choose
|
||||||
`config` becomes `min`, `max`, `range` and `mode`.
|
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
|
M3's date pickers (DatePickerModalTokens and DateInputModalTokens from androidx Compose
|
||||||
Material 3, androidx commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, with the layout of
|
Material 3, androidx commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, with the layout of
|
||||||
@@ -50,6 +56,8 @@
|
|||||||
'max' => null,
|
'max' => null,
|
||||||
'value' => null,
|
'value' => null,
|
||||||
'clearable' => false,
|
'clearable' => false,
|
||||||
|
'weekStart' => null,
|
||||||
|
'format' => null,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
@@ -63,6 +71,10 @@
|
|||||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
||||||
: [];
|
: [];
|
||||||
$locale = str_replace('_', '-', app()->getLocale());
|
$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 {
|
$toIso = function (mixed $date): ?string {
|
||||||
if ($date instanceof \DateTimeInterface) {
|
if ($date instanceof \DateTimeInterface) {
|
||||||
@@ -85,9 +97,9 @@
|
|||||||
? ['start' => $toIso(data_get($current, 'start')), 'end' => $toIso(data_get($current, 'end'))]
|
? ['start' => $toIso(data_get($current, 'start')), 'end' => $toIso(data_get($current, 'end'))]
|
||||||
: $toIso($current);
|
: $toIso($current);
|
||||||
|
|
||||||
// The typed format as resources/js/datepicker.js derives it, from ICU's short date when PHP has intl.
|
// The typed format: `format`, or as resources/js/datepicker.js derives it, from ICU's short date when PHP has intl.
|
||||||
$pattern = 'yyyy-MM-dd';
|
$pattern = $format ?? 'yyyy-MM-dd';
|
||||||
if (class_exists(\IntlDateFormatter::class)) {
|
if ($format === null && class_exists(\IntlDateFormatter::class)) {
|
||||||
$short = (string) (new \IntlDateFormatter(str_replace('-', '_', $locale), \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE))->getPattern();
|
$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)), '.');
|
$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) {
|
if (preg_match('/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[\/\-.][dMy]+[\/\-.][dMy]+$/', $candidate) === 1) {
|
||||||
@@ -110,6 +122,8 @@
|
|||||||
'range' => (bool) $range,
|
'range' => (bool) $range,
|
||||||
'min' => $toIso($min),
|
'min' => $toIso($min),
|
||||||
'max' => $toIso($max),
|
'max' => $toIso($max),
|
||||||
|
'weekStart' => $weekStart,
|
||||||
|
'format' => $format,
|
||||||
'disabled' => (bool) $attributes->get('disabled'),
|
'disabled' => (bool) $attributes->get('disabled'),
|
||||||
'readonly' => (bool) $attributes->get('readonly'),
|
'readonly' => (bool) $attributes->get('readonly'),
|
||||||
'strings' => [
|
'strings' => [
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
As a pane (`pane`, from `xl`) nothing is covered: the page renders the drawer after its list in
|
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
|
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
|
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:`),
|
a size container, so its contents lay out by the room the sheet or pane actually has (`@md:`),
|
||||||
never by the viewport.
|
never by the viewport.
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
'width' => '25rem',
|
'width' => '25rem',
|
||||||
'pane' => false,
|
'pane' => false,
|
||||||
'paneWidth' => null,
|
'paneWidth' => null,
|
||||||
|
'paneCloseOnEscape' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
@@ -55,11 +58,11 @@
|
|||||||
},
|
},
|
||||||
@endif
|
@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 }}"
|
data-sheet="{{ $id }}"
|
||||||
@if ($pane)
|
@if ($pane)
|
||||||
x-bind:class="! open && 'xl:hidden'"
|
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
|
data-pane
|
||||||
@endif
|
@endif
|
||||||
>
|
>
|
||||||
@@ -90,11 +93,11 @@
|
|||||||
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
||||||
style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth ?? $width }}"
|
style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth ?? $width }}"
|
||||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
|
{{ $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,
|
'end-0 sm:rounded-s-corner-lg' => ! $start,
|
||||||
'start-0 sm:rounded-e-corner-lg' => $start,
|
'start-0 sm:rounded-e-corner-lg' => $start,
|
||||||
'sm:w-(--sheet-width) sm:max-w-[calc(100vw-4rem)]',
|
'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'),
|
$attributes->get('class'),
|
||||||
]) }}
|
]) }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,7 +8,17 @@
|
|||||||
Not an M3 component; built from M3 Expressive's parts — `shape` (any `<x-shape>` name,
|
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,
|
`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"
|
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([
|
@props([
|
||||||
'icon' => 'inbox',
|
'icon' => 'inbox',
|
||||||
@@ -18,10 +28,14 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
<div {{ $attributes->class('flex flex-col items-center gap-4 px-4 py-10 text-center') }}>
|
<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">
|
@if (isset($illustration) && $illustration->hasActualContent())
|
||||||
<x-livewire-material::shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" />
|
<div {{ $illustration->attributes }}>{{ $illustration }}</div>
|
||||||
<x-livewire-material::icon :name="$icon" class="relative size-12 text-on-secondary-container" />
|
@else
|
||||||
</div>
|
<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)
|
@if ($title)
|
||||||
<h3 class="type-title-lg">{{ $title }}</h3>
|
<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
|
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
|
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
|
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
|
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px
|
||||||
above the close button. --}}
|
above the close button. --}}
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
x-ref="menu"
|
x-ref="menu"
|
||||||
|
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-fab-menu']) }}
|
||||||
id="material-fab-menu-{{ $key }}"
|
id="material-fab-menu-{{ $key }}"
|
||||||
popover="auto"
|
popover="auto"
|
||||||
role="menu"
|
role="menu"
|
||||||
|
|||||||
@@ -12,13 +12,19 @@
|
|||||||
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
|
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
|
||||||
`inline`. An option with `'disabled' => true` greys its own segment.
|
`inline`. An option with `'disabled' => true` greys its own segment.
|
||||||
|
|
||||||
ReStride's props, kept: `label`, `hint`, `name` (needed with `x-model`, which names no
|
ReStride's props, kept: `label`, `hint`, `hint-class`, `name` (needed with `x-model`, which
|
||||||
property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`, `variant`,
|
names no property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`,
|
||||||
`multiple`, `inline`. A validation message for the bound property replaces the hint. --}}
|
`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([
|
@props([
|
||||||
'label' => null,
|
'label' => null,
|
||||||
'hint' => null,
|
'hint' => null,
|
||||||
|
'hintClass' => null,
|
||||||
'name' => null,
|
'name' => null,
|
||||||
'options' => [],
|
'options' => [],
|
||||||
'optionValue' => 'id',
|
'optionValue' => 'id',
|
||||||
@@ -46,6 +52,10 @@
|
|||||||
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
|
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
|
||||||
][$size];
|
][$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];
|
$iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
|
||||||
|
|
||||||
$colours = match ($variant) {
|
$colours = match ($variant) {
|
||||||
@@ -94,6 +104,6 @@
|
|||||||
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
||||||
@endforeach
|
@endforeach
|
||||||
@elseif (filled($hint))
|
@elseif (filled($hint))
|
||||||
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
|
<p class="{{ $hintClasses }}">{{ $hint }}</p>
|
||||||
@endif
|
@endif
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
{{-- One item in an `<x-menu>`: an action, a link, or a choice.
|
{{-- 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
|
`label`, a leading `icon` (`icon-class` adds classes to it), an `icon-right`, a `description`
|
||||||
`shortcut` at the end (M3's trailing supporting text: "⌘C"). `link` makes it an anchor, with
|
under the label and a `shortcut` at the end (M3's trailing supporting text: "⌘C"). `link`
|
||||||
`wire:navigate` unless `external` or `no-wire-navigate`. `selected` (true or false) makes it a
|
makes it an anchor, with `wire:navigate` unless `external` or `no-wire-navigate`. `selected`
|
||||||
`menuitemcheckbox` with `aria-checked`; a selected item takes Expressive's selected shape and
|
(true or false) makes it a `menuitemcheckbox` with `aria-checked`; a selected item takes
|
||||||
tertiary-container. `disabled` keeps it in the list, out of reach. `keep-open` leaves the menu
|
Expressive's selected shape and tertiary-container. `current` is for a menu of places rather
|
||||||
open when it is activated — for a choice the person may want to change twice.
|
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
|
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
|
||||||
12px at the ends of the list. --}}
|
12px at the ends of the list. --}}
|
||||||
@@ -13,6 +23,7 @@
|
|||||||
@props([
|
@props([
|
||||||
'label' => null,
|
'label' => null,
|
||||||
'icon' => null,
|
'icon' => null,
|
||||||
|
'iconClass' => null,
|
||||||
'iconRight' => null,
|
'iconRight' => null,
|
||||||
'description' => null,
|
'description' => null,
|
||||||
'shortcut' => null,
|
'shortcut' => null,
|
||||||
@@ -20,6 +31,8 @@
|
|||||||
'external' => false,
|
'external' => false,
|
||||||
'noWireNavigate' => false,
|
'noWireNavigate' => false,
|
||||||
'selected' => null,
|
'selected' => null,
|
||||||
|
'current' => false,
|
||||||
|
'badge' => null,
|
||||||
'disabled' => false,
|
'disabled' => false,
|
||||||
'keepOpen' => false,
|
'keepOpen' => false,
|
||||||
])
|
])
|
||||||
@@ -36,11 +49,13 @@
|
|||||||
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary',
|
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary',
|
||||||
'py-2' => filled($description),
|
'py-2' => filled($description),
|
||||||
'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true,
|
'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,
|
'pointer-events-none text-on-surface/38' => $disabled,
|
||||||
])
|
])
|
||||||
->merge(array_filter([
|
->merge(array_filter([
|
||||||
'role' => $selected === null ? 'menuitem' : 'menuitemcheckbox',
|
'role' => $selected === null ? 'menuitem' : 'menuitemcheckbox',
|
||||||
'aria-checked' => $selected === null ? null : ($selected ? 'true' : 'false'),
|
'aria-checked' => $selected === null ? null : ($selected ? 'true' : 'false'),
|
||||||
|
'aria-current' => $current ? 'page' : null,
|
||||||
'aria-disabled' => $disabled ? 'true' : null,
|
'aria-disabled' => $disabled ? 'true' : null,
|
||||||
'tabindex' => '-1',
|
'tabindex' => '-1',
|
||||||
'type' => $isLink ? null : 'button',
|
'type' => $isLink ? null : 'button',
|
||||||
@@ -54,13 +69,22 @@
|
|||||||
$iconInk = match (true) {
|
$iconInk = match (true) {
|
||||||
$disabled => 'text-on-surface/38',
|
$disabled => 'text-on-surface/38',
|
||||||
$selected === true => 'text-on-tertiary-container',
|
$selected === true => 'text-on-tertiary-container',
|
||||||
|
$current => 'text-on-secondary-container',
|
||||||
default => 'text-on-surface-variant',
|
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
|
@endphp
|
||||||
|
|
||||||
<{{ $tag }} {{ $attributes }}>
|
<{{ $tag }} {{ $attributes }}>
|
||||||
@if ($icon)
|
@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
|
@endif
|
||||||
|
|
||||||
<span class="min-w-0 flex-1">
|
<span class="min-w-0 flex-1">
|
||||||
@@ -71,6 +95,9 @@
|
|||||||
@endif
|
@endif
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
@if ($badge !== null && $badge !== false && $badge !== '')
|
||||||
|
<x-livewire-material::badge :value="$badge === true ? null : $badge" class="shrink-0" />
|
||||||
|
@endif
|
||||||
@if ($shortcut)
|
@if ($shortcut)
|
||||||
<span @class(['shrink-0 type-label-sm', $iconInk])>{{ $shortcut }}</span>
|
<span @class(['shrink-0 type-label-sm', $iconInk])>{{ $shortcut }}</span>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -13,10 +13,24 @@
|
|||||||
The trigger's first button or link becomes the menu button (aria-haspopup, aria-expanded,
|
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
|
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
|
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
|
when there is no room — to the other side, the other end, or both, so a menu on a FAB in a
|
||||||
button: Enter, Space or ArrowDown open on the first item, ArrowUp on the last; arrows, Home,
|
corner of the window opens back across it; a click outside or Escape closes it. The keyboard
|
||||||
End and typing a letter move between items; Tab closes; activating an item closes the menu
|
is WAI-ARIA's menu button: Enter, Space or ArrowDown open on the first item, ArrowUp on the
|
||||||
unless the item says `keep-open`, and Escape returns focus to the trigger.
|
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
|
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
|
||||||
2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from
|
2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from
|
||||||
@@ -43,6 +57,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
x-ref="menu"
|
x-ref="menu"
|
||||||
|
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-menu']) }}
|
||||||
id="material-menu-{{ $key }}"
|
id="material-menu-{{ $key }}"
|
||||||
popover="auto"
|
popover="auto"
|
||||||
role="menu"
|
role="menu"
|
||||||
@@ -53,7 +68,7 @@
|
|||||||
x-on:click="activate($event)"
|
x-on:click="activate($event)"
|
||||||
@class([
|
@class([
|
||||||
'm-0 min-w-28 max-w-70 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
|
'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',
|
'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-surface-container-low text-on-surface' => ! $vibrant,
|
||||||
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
|
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
>
|
>
|
||||||
<div @class([
|
<div @class([
|
||||||
'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3',
|
'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,
|
$boxClass,
|
||||||
])>
|
])>
|
||||||
@if ($fullscreen)
|
@if ($fullscreen)
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
form M3 asks for when it has actions. The bubble is a popover in surface-container with a
|
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`.
|
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
|
RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
|
||||||
text in on-surface-variant, label-large actions in primary. --}}
|
text in on-surface-variant, label-large actions in primary. --}}
|
||||||
|
|
||||||
@@ -35,6 +40,7 @@
|
|||||||
|
|
||||||
<span
|
<span
|
||||||
x-ref="bubble"
|
x-ref="bubble"
|
||||||
|
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-rich-tooltip']) }}
|
||||||
id="material-rich-tooltip-{{ $key }}"
|
id="material-rich-tooltip-{{ $key }}"
|
||||||
popover="{{ $persistent ? 'auto' : 'manual' }}"
|
popover="{{ $persistent ? 'auto' : 'manual' }}"
|
||||||
role="{{ $persistent ? 'dialog' : 'tooltip' }}"
|
role="{{ $persistent ? 'dialog' : 'tooltip' }}"
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
single destination in the app's own navigation.
|
single destination in the app's own navigation.
|
||||||
|
|
||||||
`items`, a list of `['title' => …, 'url' => …]` with an optional `icon`, `active` and `badge`
|
`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
|
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.
|
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
|
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
|
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
|
a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless
|
||||||
@@ -20,7 +24,8 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$label ??= __('Sections');
|
$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);
|
$current = collect($items)->first($isCurrent) ?? ($items[0] ?? null);
|
||||||
|
|
||||||
$layout = match (true) {
|
$layout = match (true) {
|
||||||
@@ -45,7 +50,7 @@
|
|||||||
</x-slot:trigger>
|
</x-slot:trigger>
|
||||||
|
|
||||||
@foreach ($items as $item)
|
@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
|
@endforeach
|
||||||
</x-livewire-material::menu>
|
</x-livewire-material::menu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,10 +21,21 @@
|
|||||||
rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
|
rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
|
||||||
changes it.
|
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>
|
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
|
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,
|
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
|
@php
|
||||||
$theme = config('livewire-material.theme');
|
$theme = config('livewire-material.theme');
|
||||||
@@ -40,6 +51,18 @@
|
|||||||
'key' => $rail['storage_key'] ?? 'material-rail',
|
'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
|
@endphp
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -94,6 +117,39 @@
|
|||||||
apply();
|
apply();
|
||||||
|
|
||||||
media.addEventListener('change', 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) {
|
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) {
|
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]);
|
root.setAttribute(attribute[0], attribute[1]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@if (isset($settings['meta']))
|
||||||
|
|
||||||
|
paintThemeColor();
|
||||||
|
@endif
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})(@json($settings));
|
})(@json($settings));
|
||||||
|
|||||||
@@ -5,9 +5,18 @@
|
|||||||
|
|
||||||
It shows every `toast` browser event — what `NoNameWeb\LivewireMaterial\Concerns\Toasts`
|
It shows every `toast` browser event — what `NoNameWeb\LivewireMaterial\Concerns\Toasts`
|
||||||
dispatches from a Livewire component — and for `window.materialToast(title, options)` from
|
dispatches from a Livewire component — and for `window.materialToast(title, options)` from
|
||||||
JavaScript (`{ type, description, timeout, action: { label, handler } }`). Toasts queue and
|
JavaScript (`{ type, description, timeout, sticky, action: { label, handler, event } }`).
|
||||||
show in turn, each for its `timeout` (4s by default; M3 asks for 4–10s), paused while the
|
Toasts queue and show in turn, each for its `timeout` (4s by default; M3 asks for 4–10s),
|
||||||
pointer or focus is on it. A toast with an action or no timeout gets a close button.
|
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
|
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
|
||||||
still on screen when the next page arrives.
|
still on screen when the next page arrives.
|
||||||
@@ -32,6 +41,7 @@
|
|||||||
<template x-if="current">
|
<template x-if="current">
|
||||||
<div
|
<div
|
||||||
x-bind:key="current.id"
|
x-bind:key="current.id"
|
||||||
|
data-toast
|
||||||
x-bind:role="current.type === 'error' || current.type === 'warning' ? 'alert' : 'status'"
|
x-bind:role="current.type === 'error' || current.type === 'warning' ? 'alert' : 'status'"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
x-on:mouseenter="pause()"
|
x-on:mouseenter="pause()"
|
||||||
@@ -60,7 +70,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-if="current.action">
|
<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>
|
||||||
|
|
||||||
<template x-if="current.action || ! current.timeout">
|
<template x-if="current.action || ! current.timeout">
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
['id' => 'system', 'name' => 'System', 'icon' => 'computer'],
|
['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' => 'mon', 'name' => 'Mon'],
|
||||||
['id' => 'tue', 'name' => 'Tue'],
|
['id' => 'tue', 'name' => 'Tue'],
|
||||||
['id' => 'wed', 'name' => 'Wed'],
|
['id' => 'wed', 'name' => 'Wed'],
|
||||||
|
|||||||
@@ -7,13 +7,22 @@
|
|||||||
<x-badge value="Expired" tonal />
|
<x-badge value="Expired" tonal />
|
||||||
<x-badge value="Active" color="success" tonal />
|
<x-badge value="Active" color="success" tonal />
|
||||||
<x-badge value="Password" color="info" tonal />
|
<x-badge value="Password" color="info" tonal />
|
||||||
|
<x-badge value="Built in" color="primary" solid />
|
||||||
<x-badge value="Pro" outline />
|
<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,
|
BLADE,
|
||||||
'Snackbars' => <<<'BLADE'
|
'Snackbars' => <<<'BLADE'
|
||||||
<x-button label="Saved" variant="tonal" x-on:click="materialToast('Settings saved', { type: 'success' })" />
|
<div x-data class="flex flex-wrap items-center gap-4">
|
||||||
<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="Saved" variant="tonal" x-on:click="materialToast('Settings saved', { type: 'success' })" />
|
||||||
<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="With a description" variant="tonal" x-on:click="materialToast('Upload failed', { type: 'error', description: 'The file is larger than 4 GB.' })" />
|
||||||
<x-button label="Until dismissed" variant="tonal" x-on:click="materialToast('Your storage is almost full', { type: 'warning', timeout: 0 })" />
|
<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,
|
BLADE,
|
||||||
'Plain tooltips' => <<<'BLADE'
|
'Plain tooltips' => <<<'BLADE'
|
||||||
<x-button icon="content_copy" tooltip="Copy link" />
|
<x-button icon="content_copy" tooltip="Copy link" />
|
||||||
@@ -58,6 +67,22 @@
|
|||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-empty-state>
|
</x-empty-state>
|
||||||
BLADE,
|
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
|
@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 class="flex h-10 items-center gap-4"><span>Left</span><x-divider vertical /><span>Right</span></div>
|
||||||
</div>
|
</div>
|
||||||
BLADE,
|
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'
|
'Dialogs' => <<<'BLADE'
|
||||||
<div x-data="{ open: false }">
|
<div x-data="{ open: false }">
|
||||||
<x-button label="Basic dialog" variant="tonal" x-on:click="open = true" />
|
<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-item label="Upload a folder" icon="drive_folder_upload" />
|
||||||
</x-menu>
|
</x-menu>
|
||||||
BLADE,
|
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
|
@endphp
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
BLADE,
|
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'
|
'Limits, errors and states' => <<<'BLADE'
|
||||||
<div class="grid w-full gap-6 md:grid-cols-2">
|
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||||
<div class="grid content-start gap-4">
|
<div class="grid content-start gap-4">
|
||||||
@@ -62,7 +75,7 @@
|
|||||||
<h2 class="type-headline-md">Date pickers</h2>
|
<h2 class="type-headline-md">Date pickers</h2>
|
||||||
|
|
||||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||||
<code><x-datepicker></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><x-datepicker></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>
|
</p>
|
||||||
|
|
||||||
@foreach ($examples as $title => $code)
|
@foreach ($examples as $title => $code)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
|
|
||||||
<x-slot:top>
|
<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>
|
<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>
|
<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" />
|
<x-livewire-material::button icon="search" tooltip="Search" />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class SchemeCommand extends Command
|
|||||||
protected $signature = 'material:scheme
|
protected $signature = 'material:scheme
|
||||||
{seed? : The source colour, as #rrggbb; without it, every profile in livewire-material.profiles}
|
{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}
|
{--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}
|
{--contrast=0 : The contrast level, from -1 to 1}
|
||||||
{--success=#22a06b : The source of the success colour}
|
{--success=#22a06b : The source of the success colour}
|
||||||
{--warning=#e2a400 : The source of the warning 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';
|
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
|
public function handle(Filesystem $files): int
|
||||||
{
|
{
|
||||||
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
|
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
|
||||||
$data = preg_replace('/\.css$/', '', $stylesheet).'.json';
|
$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'))) {
|
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) {
|
if ($scheme === null) {
|
||||||
return self::FAILURE;
|
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');
|
$profiles = config('livewire-material.profiles');
|
||||||
@@ -61,7 +87,16 @@ class SchemeCommand extends Command
|
|||||||
return self::FAILURE;
|
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) {
|
if ($scheme === null) {
|
||||||
return self::FAILURE;
|
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.
|
* 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
|
* @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([
|
$result = Process::run([
|
||||||
config('livewire-material.node', 'node'),
|
config('livewire-material.node', 'node'),
|
||||||
__DIR__.'/../../resources/node/scheme.mjs',
|
__DIR__.'/../../resources/node/scheme.mjs',
|
||||||
json_encode([
|
json_encode($input),
|
||||||
'seed' => $seed,
|
|
||||||
'variant' => $variant,
|
|
||||||
'contrast' => $contrast,
|
|
||||||
'success' => $this->option('success'),
|
|
||||||
'warning' => $this->option('warning'),
|
|
||||||
'info' => $this->option('info'),
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($result->failed()) {
|
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, 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(
|
$command = sprintf(
|
||||||
'php artisan material:scheme "%s" --variant=%s%s',
|
'php artisan material:scheme "%s" --variant=%s%s%s%s',
|
||||||
$scheme['seed'],
|
$scheme['seed'],
|
||||||
$scheme['variant'],
|
$scheme['variant'],
|
||||||
|
$input['spec'] !== '2025' ? ' --spec='.$input['spec'] : '',
|
||||||
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
|
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
|
||||||
|
$states,
|
||||||
);
|
);
|
||||||
|
|
||||||
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
|
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
|
||||||
@@ -171,10 +211,11 @@ class SchemeCommand extends Command
|
|||||||
{
|
{
|
||||||
$list = collect($profiles)
|
$list = collect($profiles)
|
||||||
->map(fn (array $profile, string $name): string => sprintf(
|
->map(fn (array $profile, string $name): string => sprintf(
|
||||||
' * %-12s %s, %s%s',
|
' * %-12s %s, %s%s%s',
|
||||||
$name,
|
$name,
|
||||||
$profile['seed'],
|
$profile['seed'],
|
||||||
$profile['variant'],
|
$profile['variant'],
|
||||||
|
$profile['spec'] !== $profiles[$default]['spec'] ? ', spec '.$profile['spec'] : '',
|
||||||
$profile['contrast'] != 0 ? ', contrast '.$profile['contrast'] : '',
|
$profile['contrast'] != 0 ? ', contrast '.$profile['contrast'] : '',
|
||||||
))
|
))
|
||||||
->implode("\n");
|
->implode("\n");
|
||||||
|
|||||||
@@ -175,12 +175,14 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
protected function registerShowcase(): void
|
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')) {
|
if (! config('livewire-material.showcase.enabled')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Blade::anonymousComponentPath(__DIR__.'/../resources/views/showcase/components', 'showcase');
|
|
||||||
|
|
||||||
if ($this->app->routesAreCached()) {
|
if ($this->app->routesAreCached()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,131 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
const MORE = '#menus [aria-label="More"]';
|
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')
|
function showcase(string $section = 'buttons')
|
||||||
{
|
{
|
||||||
return visit("/material/{$section}")->waitForEvent('networkidle')
|
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')
|
$page->keys(':focus', 'ArrowLeft')
|
||||||
->assertScript($checked('dark'))
|
->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 () {
|
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()
|
showcase()
|
||||||
->click('[data-split="trailing"] >> nth=0')
|
->click('[data-split="trailing"] >> nth=0')
|
||||||
->assertScript("{$trailing}.getAttribute('aria-expanded') === 'true'")
|
->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 () {
|
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");
|
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'"));
|
||||||
|
});
|
||||||
|
|||||||
@@ -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'")
|
->assertScript("getComputedStyle(document.querySelector('[data-section-nav] nav')).display === 'none'")
|
||||||
->click('[data-section-picker] button')
|
->click('[data-section-picker] button')
|
||||||
->assertScript("document.querySelector('[data-section-picker] [popover]').matches(':popover-open')")
|
->assertScript("document.querySelector('[data-section-picker] [popover]').matches(':popover-open')")
|
||||||
->assertAttribute('[data-section-picker] [role="menuitemcheckbox"][href="#profile"]', 'aria-checked', 'true')
|
// A menu of places: the current section is the page, not a checked choice.
|
||||||
->assertAttribute('[data-section-picker] [role="menuitemcheckbox"][href="#security"]', 'aria-checked', 'false');
|
->assertAttribute('[data-section-picker] [role="menuitem"][href="#profile"]', 'aria-current', 'page')
|
||||||
|
->assertScript("! document.querySelector('[data-section-picker] [role=\"menuitem\"][href=\"#security\"]').hasAttribute('aria-current')");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use Livewire\Livewire;
|
|||||||
*/
|
*/
|
||||||
class CarouselMorphProbe extends Component
|
class CarouselMorphProbe extends Component
|
||||||
{
|
{
|
||||||
public int $count = 3;
|
public int $count = 6;
|
||||||
|
|
||||||
public function add(): void
|
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')
|
$page = visit('/carousel-morph-probe')->waitForEvent('networkidle')
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertScript($masked, 3)
|
->assertScript($masked, 6)
|
||||||
->assertAttribute('[data-material-carousel-item] >> nth=2', 'aria-label', '3 of 3');
|
->assertAttribute('[data-material-carousel-item] >> nth=5', 'aria-label', '6 of 6');
|
||||||
|
|
||||||
$page->click('Add')
|
$page->click('Add')
|
||||||
->assertScript($masked, 4)
|
->assertScript($masked, 7)
|
||||||
->assertAttribute('[data-material-carousel-item] >> nth=3', 'aria-label', '4 of 4');
|
->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'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,48 @@
|
|||||||
<?php
|
<?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 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 () {
|
it('shows a toast as a snackbar, then the next in turn', function () {
|
||||||
$page = visit('/material/communication')->waitForEvent('networkidle')
|
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->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');
|
->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 () {
|
it('opens a persistent rich tooltip on press', function () {
|
||||||
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
|
$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")')
|
->click('#communication button:has-text("Press for details")')
|
||||||
->assertScript("{$bubble}.matches(':popover-open')");
|
->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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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()
|
function overlayProbe()
|
||||||
{
|
{
|
||||||
Livewire::component('overlay-probe', OverlayProbe::class);
|
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")')
|
$page->click('#containment [data-card][data-list-row] button:has-text("Copy link")')
|
||||||
->assertScript('window.__opened === 1');
|
->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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -69,6 +69,102 @@ function dateProbe(string $locale = 'en')
|
|||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->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. */
|
/** A picker's day cell, by its ISO date. */
|
||||||
function day(string $picker, string $date): string
|
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}')
|
->assertSeeIn('#trip', '{"start":null,"end":null}')
|
||||||
->assertValue('#trip-field', '');
|
->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 }"]\')))');
|
||||||
|
});
|
||||||
|
|||||||
@@ -176,3 +176,31 @@ it('skips to the content', function () {
|
|||||||
->assertScript("location.hash === '#content'")
|
->assertScript("location.hash === '#content'")
|
||||||
->assertScript("document.activeElement === document.getElementById('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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<?php
|
<?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.
|
* 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(theme('data-theme', 'light'))
|
||||||
->assertScript("{$swatch(0)} !== {$swatch(1)}");
|
->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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
function shellDestinations(): array
|
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);
|
->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 () {
|
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' => [
|
Route::get('/shell-probe/inbox', fn () => Blade::render('<x-app-shell :destinations="$destinations" />', ['destinations' => [
|
||||||
['title' => 'Inbox', 'icon' => 'inbox', 'url' => url('/shell-probe/inbox')],
|
['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);
|
->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 () {
|
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()]))
|
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]]]))
|
->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('data-app-shell-bar')
|
||||||
->not->toContain('--material-bottom-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 () {
|
it('places each slot once', function () {
|
||||||
$html = (string) $this->blade(<<<'BLADE'
|
$html = (string) $this->blade(<<<'BLADE'
|
||||||
<x-app-shell :destinations="$destinations">
|
<x-app-shell :destinations="$destinations">
|
||||||
|
|||||||
@@ -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')
|
->toContain('border border-outline-variant text-on-surface-variant')
|
||||||
->not->toContain('bg-error');
|
->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('<svg')
|
||||||
|
->and((string) $this->blade('<x-badge value="<b>4</b>" />'))->toContain('<b>4</b>')
|
||||||
|
->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.')
|
->assertSee('Pick light.')
|
||||||
->assertDontSee('How it looks');
|
->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(&)]: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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -156,3 +156,10 @@ it('submits a form when asked', function () {
|
|||||||
$this->blade('<x-button label="Save" type="submit" />')
|
$this->blade('<x-button label="Save" type="submit" />')
|
||||||
->assertSee('type="submit"', false);
|
->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"');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
it('discloses on the native details element, kept open through a morph', function () {
|
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>');
|
$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('Until the expiry.')
|
||||||
->toContain('group-open/collapse:rotate-180');
|
->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]);
|
->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 () {
|
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" />'))
|
expect((string) $this->blade('<x-datepicker label="Date" value="2026-09-13" name="expires" />'))
|
||||||
->toContain('value="09/13/2026"')
|
->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')
|
->toContain('Upload files')
|
||||||
->and(substr_count($html, '<svg'))->toBe(2);
|
->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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -53,6 +53,20 @@ it('makes a selectable item a menuitemcheckbox', function () {
|
|||||||
->toContain('aria-checked="false"');
|
->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 () {
|
it('links an item, and keeps a disabled one out of reach', function () {
|
||||||
$this->blade('<x-menu-item label="Settings" link="/settings" />')
|
$this->blade('<x-menu-item label="Settings" link="/settings" />')
|
||||||
->assertSee('href="/settings"', false)
|
->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>')
|
$this->blade('<x-menu-group label="Sort by"><x-menu-item label="Newest" /></x-menu-group>')
|
||||||
->assertSee('role="group" aria-label="Sort by"', false);
|
->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(&)]: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(&)]: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'],
|
||||||
|
]);
|
||||||
@@ -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>');
|
->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 && ! 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 () {
|
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>'))
|
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
|
||||||
->toContain('x-trap.inert.noscroll="open && ! wide"')
|
->toContain('x-trap.inert.noscroll="open && ! wide"')
|
||||||
|
|||||||
@@ -67,7 +67,11 @@ it('draws section navigation as secondary tabs and a picker', function () {
|
|||||||
->toContain('sm:flex')
|
->toContain('sm:flex')
|
||||||
->toMatch('/href="\/settings\/security"\s+data-tab\s+aria-current="page"\s+wire:navigate/')
|
->toMatch('/href="\/settings\/security"\s+data-tab\s+aria-current="page"\s+wire:navigate/')
|
||||||
->not->toMatch('/href="\/settings\/profile"\s+data-tab\s+aria-current/')
|
->not->toMatch('/href="\/settings\/profile"\s+data-tab\s+aria-current/')
|
||||||
->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 () {
|
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/')
|
->toMatch('/data-tab\s+aria-current="page"\s*>\s*<span data-tab-content>\s*<span class="truncate">S3/')
|
||||||
->not->toContain('wire:navigate');
|
->not->toContain('wire:navigate');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
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([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,3 +13,11 @@ it('hosts the snackbar queue, kept across wire:navigate', function () {
|
|||||||
it('can sit at the start', function () {
|
it('can sit at the start', function () {
|
||||||
expect((string) $this->blade('<x-toast position="bottom-start" />'))->toContain('justify-start');
|
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\(\)"/');
|
||||||
|
});
|
||||||
|
|||||||
@@ -56,6 +56,48 @@ it('generates a different scheme per variant', function () {
|
|||||||
->and($vibrant['light']['primary'])->not->toBe($tonalSpot['light']['primary']);
|
->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 () {
|
it('refuses a seed that is not a colour', function () {
|
||||||
$this->artisan('material:scheme', ['seed' => 'indigo', '--output' => $this->stylesheet])
|
$this->artisan('material:scheme', ['seed' => 'indigo', '--output' => $this->stylesheet])
|
||||||
->expectsOutputToContain('#rrggbb')
|
->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 () {
|
it('names the profile a generator error belongs to', function () {
|
||||||
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5'], 'broken' => ['seed' => 'teal']]]);
|
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5'], 'broken' => ['seed' => 'teal']]]);
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,20 @@ it('does not mount the showcase when disabled', function () {
|
|||||||
$this->get('/material')->assertNotFound();
|
$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 () {
|
it('serves a symbol for the icon search', function () {
|
||||||
$this->get('/material/symbols/filled/favorite.svg')
|
$this->get('/material/symbols/filled/favorite.svg')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
|
|||||||
@@ -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 () {
|
it('makes every motion token instant under reduced motion', function () {
|
||||||
$motion = File::get(packageCss('tokens/motion.css'));
|
$motion = File::get(packageCss('tokens/motion.css'));
|
||||||
$reduced = Str::of($motion)->after('prefers-reduced-motion: reduce')->toString();
|
$reduced = Str::of($motion)->after('prefers-reduced-motion: reduce')->toString();
|
||||||
|
|||||||
Reference in New Issue
Block a user