Compare commits
@@ -7,3 +7,6 @@
|
||||
/workbench/public/hot
|
||||
/tests/Browser/Screenshots
|
||||
.DS_Store
|
||||
|
||||
# Planning notes stay local
|
||||
/docs/plans
|
||||
|
||||
@@ -72,7 +72,11 @@ Generate the scheme from a seed colour. It writes `resources/css/material-scheme
|
||||
php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
||||
```
|
||||
|
||||
Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--contrast` runs from -1 to 1; `--success`, `--warning` and `--info` seed the state colours. Regenerate instead of editing the file.
|
||||
Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (M3 Expressive, the default) or `2021` (M3 as it first shipped, for a palette generated before Expressive). `--contrast` runs from -1 to 1; `--success`, `--warning` and `--info` seed the state colours. The stylesheet's header records the command that regenerates it; regenerate instead of editing the file.
|
||||
|
||||
#### Colour profiles
|
||||
|
||||
To let an installation switch between several schemes, list them as `profiles` in the config (name ⇒ `label`, `seed`, `variant`, and 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
|
||||
|
||||
@@ -82,6 +86,8 @@ php artisan vendor:publish --tag=livewire-material-config
|
||||
|
||||
- `prefix` — components are `<x-button>`, `<x-card>`… Set `'m'` when a name clashes with the application's own components, and they become `<x-m::button>`. `<x-livewire-material::button>` always works.
|
||||
- `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys` (an earlier toggle's localStorage keys, adopted once).
|
||||
- `theme.meta` — keep `<meta name="theme-color">` (an installed web app's or a mobile browser's bar) on the resolved theme's `surface` and the active colour profile, before the first paint and after every change, `wire:navigate` included; one is added when the page has none (default `false`).
|
||||
- `profiles`, `profile` — colour profiles and the default one (see Colour profiles).
|
||||
- `fields.variant` — text fields `outlined` (default) or `filled`.
|
||||
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
|
||||
- `showcase.enabled`, `showcase.path`, `showcase.middleware`, `showcase.vite`.
|
||||
|
||||
+8
-3
@@ -6,7 +6,8 @@
|
||||
* nothing but `node`. (The published library imports without file extensions, which
|
||||
* plain Node refuses, so it cannot be run unbundled anyway.)
|
||||
*
|
||||
* Input: one JSON argument — {seed, variant, contrast, success, warning, info}.
|
||||
* Input: one JSON argument — {seed, variant, spec, contrast, success, warning, info}. `spec` is
|
||||
* the colour spec, '2025' (M3 Expressive, the default) or '2021' (M3 as it first shipped).
|
||||
* Output: JSON on stdout — {seed, variant, spec, contrast, light: {role: hex}, dark: {role: hex}}.
|
||||
*/
|
||||
import {
|
||||
@@ -56,9 +57,12 @@ try {
|
||||
|
||||
const hex = /^#[0-9a-f]{6}$/i
|
||||
const Scheme = VARIANTS[input.variant]
|
||||
const SPECS = ['2021', '2025']
|
||||
const spec = input.spec ?? '2025'
|
||||
|
||||
if (!hex.test(input.seed ?? '')) fail(`The seed must be a #rrggbb colour, "${input.seed}" given.`)
|
||||
if (!Scheme) fail(`Unknown variant "${input.variant}". Use one of: ${Object.keys(VARIANTS).join(', ')}.`)
|
||||
if (!SPECS.includes(spec)) fail(`Unknown spec "${spec}". Use one of: ${SPECS.join(', ')}.`)
|
||||
|
||||
for (const state of ['success', 'warning', 'info']) {
|
||||
if (!hex.test(input[state] ?? '')) fail(`The ${state} colour must be a #rrggbb colour, "${input[state]}" given.`)
|
||||
@@ -73,8 +77,9 @@ const colors = new MaterialDynamicColors()
|
||||
|
||||
function roles(isDark) {
|
||||
// The 2025 spec is M3 Expressive's colour; the library falls back to 2021 for the
|
||||
// variants the new spec does not define (fidelity, content, monochrome, …).
|
||||
const scheme = new Scheme(source, isDark, contrast, '2025')
|
||||
// variants the new spec does not define (fidelity, content, monochrome, …). 2021 is
|
||||
// M3's original colour, for an application whose palette was generated with it.
|
||||
const scheme = new Scheme(source, isDark, contrast, spec)
|
||||
const out = {}
|
||||
|
||||
for (const color of colors.allColors) {
|
||||
|
||||
@@ -28,12 +28,20 @@ return [
|
||||
| localStorage under 'storage_key'; values found under 'legacy_keys' (an
|
||||
| earlier theme toggle's key) are adopted once and then removed.
|
||||
|
|
||||
| 'meta' keeps <meta name="theme-color"> (the colour an installed web app
|
||||
| or a mobile browser gives its bar) on the resolved theme's surface, and
|
||||
| the active colour profile's: the head script sets it before the first
|
||||
| paint, adds one when the page has none, and follows every later change,
|
||||
| wire:navigate included. A theme-color meta with a `media` attribute is
|
||||
| left alone.
|
||||
|
|
||||
*/
|
||||
|
||||
'theme' => [
|
||||
'default' => 'system',
|
||||
'storage_key' => 'material-theme',
|
||||
'legacy_keys' => [],
|
||||
'meta' => false,
|
||||
],
|
||||
|
||||
/*
|
||||
@@ -107,6 +115,26 @@ return [
|
||||
|
||||
'scheme' => resource_path('css/material-scheme.json'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Colour profiles
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Named schemes an installation can switch between. Each one is a 'label',
|
||||
| a 'seed' (#rrggbb), a 'variant' and an optional 'contrast'; 'spec'
|
||||
| ('2025' or '2021') and the 'success', 'warning' and 'info' sources are
|
||||
| optional too, taken from the command's options when left out. Without a
|
||||
| seed, `php artisan material:scheme` generates every profile into one
|
||||
| stylesheet keyed by <html data-scheme>. 'profile' names the default one
|
||||
| (else the first); the application says which is active with
|
||||
| Scheme::resolveProfileUsing(). Regenerate after changing either.
|
||||
|
|
||||
*/
|
||||
|
||||
'profiles' => [],
|
||||
|
||||
'profile' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mail
|
||||
|
||||
@@ -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.
|
||||
@@ -5,7 +5,7 @@ This application uses `nonameweb/livewire-material`: Material 3 Expressive compo
|
||||
|
||||
- Components are anonymous Blade components, unprefixed unless `config/livewire-material.php` sets a `prefix`. Before writing or changing a view that uses them, activate the `livewire-material-development` skill for the props, slots and traps of each component.
|
||||
- Never write maryUI tags (`<x-mary-*>`) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`…). They compile to nothing and fail silently.
|
||||
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand.
|
||||
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand. With colour profiles (`livewire-material.profiles`), run it without a seed after changing them; the active profile comes from `Scheme::resolveProfileUsing()`.
|
||||
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
|
||||
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
|
||||
@endverbatim
|
||||
|
||||
@@ -44,7 +44,36 @@ The scheme is generated, never hand-edited. Regenerate it with the seed and vari
|
||||
php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
||||
```
|
||||
|
||||
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The command also writes `material-scheme.json` beside the stylesheet.
|
||||
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (default, M3 Expressive) or `2021` (M3's original colour — keep it for a palette generated before Expressive; the library itself uses 2021 for variants 2025 does not define, and the header records the spec actually used). `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The header of the stylesheet records the whole command, every option that differs from its default included. The command also writes `material-scheme.json` beside the stylesheet.
|
||||
|
||||
### Colour profiles
|
||||
|
||||
An installation that switches between several schemes lists them in `config/livewire-material.php` and runs the command without a seed, which generates every profile into the same stylesheet, keyed by `<html data-scheme>`:
|
||||
|
||||
```php
|
||||
'profiles' => [
|
||||
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
|
||||
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
|
||||
],
|
||||
'profile' => 'indigo', // the default; else the first
|
||||
```
|
||||
|
||||
```bash
|
||||
php artisan material:scheme
|
||||
```
|
||||
|
||||
- Each profile: `seed`, and optionally `label` (default: the name as a headline), `variant` (default `tonal-spot`), `contrast` (default 0), `spec`, `success`, `warning`, `info` (for these four, without the key the command's `--spec`, `--success`, `--warning`, `--info` or their defaults apply).
|
||||
- Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet.
|
||||
- The application says which profile is active, once, in a service provider. The closure runs every time a colour is drawn (head script, mail, error page), so it may read the database; a name that is not a generated profile, or a closure that throws, falls back to the default:
|
||||
|
||||
```php
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
|
||||
```
|
||||
|
||||
- `<x-theme-script>` writes the active profile to `<html data-scheme>` before the first paint; mails and error pages draw it too. `Scheme::profiles()` lists the generated profiles (name ⇒ label, light and dark roles) and `Scheme::profile()` names the active one — validate a stored choice with `Rule::in(array_keys(Scheme::profiles()))`.
|
||||
- Choose with `<x-scheme-picker wire:model="colorProfile" />` (see Components). Never set `data-scheme` on an element inside the page expecting a different profile there: profiles key on `<html>`.
|
||||
|
||||
## Tokens
|
||||
|
||||
@@ -62,7 +91,15 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
|
||||
|
||||
## Theme
|
||||
|
||||
`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control.
|
||||
`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
|
||||
|
||||
@@ -158,7 +195,7 @@ One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, size
|
||||
|
||||
### `<x-theme-script>`
|
||||
|
||||
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`.
|
||||
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`. With `theme.meta` on it also paints `<meta name="theme-color">` (see Theme); a layout's own theme-color meta goes before it.
|
||||
|
||||
### `<x-button>`
|
||||
|
||||
@@ -207,7 +244,7 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
|
||||
</x-menu>
|
||||
```
|
||||
|
||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
|
||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
|
||||
|
||||
### `<x-button-group>`
|
||||
|
||||
@@ -225,7 +262,7 @@ A choice between a few options as a connected button group of native radios (che
|
||||
]" hint="Recipients lose access after that" />
|
||||
```
|
||||
|
||||
Props: `label`, `hint`, `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
|
||||
Props: `label`, `hint`, `hint-class` (classes for the hint, as on the fields; a colour there paints it), `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
|
||||
|
||||
### `<x-split-button>`
|
||||
|
||||
@@ -252,7 +289,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
|
||||
</div>
|
||||
```
|
||||
|
||||
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard as `<x-menu>`.
|
||||
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as `<x-menu>`.
|
||||
|
||||
### `<x-loading>`
|
||||
|
||||
@@ -276,6 +313,16 @@ materialToast('Share deleted', { type: 'success', description: null, timeout: 40
|
||||
|
||||
`type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer.
|
||||
|
||||
- `action`: `label`, plus `handler` (a function) and/or `event` (a name). Pressing it closes the snackbar, calls `handler`, then dispatches `new CustomEvent(event)` on `window`; give both and both run. Use `event` where a function cannot travel, such as a toast built from JSON.
|
||||
- `sticky: true` keeps a toast until it is dismissed or its action is pressed (any `timeout` is ignored), without holding the queue up: a toast dispatched meanwhile shows in its place, and the sticky one comes back once the queue is empty. One sticky toast is kept at a time; a newer one replaces it. Use it for a question that must be answered, not for news:
|
||||
|
||||
```js
|
||||
window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: 'A new version is ready', sticky: true, action: { label: 'Reload', event: 'app:update' } } }))
|
||||
window.addEventListener('app:update', () => location.reload())
|
||||
```
|
||||
|
||||
- Hooks: `data-toast` on the snackbar on screen, `data-toast-action` on its action button (`[data-toast]` is absent while nothing shows). Target these in tests, not classes.
|
||||
|
||||
### `<x-progress>`
|
||||
|
||||
M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless a `size-*` class is passed), flat or `wavy`, determinate with a `value` or indeterminate without one.
|
||||
@@ -306,7 +353,10 @@ A value the server changes animates after a morph (the SVG is `wire:ignore`; onl
|
||||
### `<x-badge>`
|
||||
|
||||
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
|
||||
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
|
||||
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Built in" color="primary" solid />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container, in the colour itself (`solid`, for a label that has to stand out), or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`, `neutral`, `plain`; an unknown colour is `error`.
|
||||
- `color="neutral"` — neutral ink on every variant: a dot or count in on-surface-variant with surface text, `tonal` in surface-container-high with on-surface-variant text, `outline` in the outline-variant edge with on-surface-variant text.
|
||||
- `color="plain"` — no background, text or border colour in any variant (shape, size and type stay), so the classes you pass paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`. Pass both a background and a text class; an `outline` badge's edge takes the text colour unless you pass a `border-*` colour.
|
||||
- The value is `value` or the slot; the slot renders as HTML: `<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. `value` is escaped. A slot that holds only whitespace or comments is still a dot.
|
||||
|
||||
### `<x-alert>`
|
||||
|
||||
@@ -332,7 +382,7 @@ A few lines of context around a trigger, with an optional `title` and `actions`
|
||||
</x-rich-tooltip>
|
||||
```
|
||||
|
||||
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). `side`: `bottom` (default), `top`, `left`, `right`.
|
||||
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`.
|
||||
|
||||
### `<x-stat>`
|
||||
|
||||
@@ -342,6 +392,15 @@ Shows on hover and keyboard focus; `persistent` opens it on press and keeps it u
|
||||
|
||||
"Nothing here yet": `icon` on an Expressive `shape` (`cookie-9` by default), `title`, `description` or slot, and an `actions` slot. Use it for an empty collection, not for a filter that matched nothing.
|
||||
|
||||
The `illustration` slot draws the application's own artwork in place of the shape and icon (`icon` and `shape` are then unused). Size the artwork yourself; the slot's attributes go on the element around it, so its `class` sets the colour `currentColor` takes. Mark decorative SVG `aria-hidden="true"`. A slot holding only whitespace or comments leaves the shape and icon.
|
||||
|
||||
```blade
|
||||
<x-empty-state title="No routes yet" description="Draw one on the map.">
|
||||
<x-slot:illustration class="text-primary"><svg class="size-32" viewBox="0 0 120 120" aria-hidden="true">…</svg></x-slot:illustration>
|
||||
<x-slot:actions><x-button label="Draw a route" variant="filled" /></x-slot:actions>
|
||||
</x-empty-state>
|
||||
```
|
||||
|
||||
### `<x-card>`
|
||||
|
||||
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`; slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). Do not pass `bg-*`; use `variant`.
|
||||
@@ -377,6 +436,15 @@ A card or list item that opens something is a **row**: `data-list-row` on it and
|
||||
|
||||
A disclosure on native `<details>`: `<x-collapse title="Advanced" icon="tune" open variant="filled">…</x-collapse>` (`variant` `plain` or `filled`; `heading` slot for rich titles). Keeps its state through a morph.
|
||||
|
||||
Bind the open state to a boolean, both ways, with `wire:model` (any modifiers; `.live` sends each toggle at once) or `x-model`:
|
||||
|
||||
```blade
|
||||
<x-collapse title="Fine-tuning" wire:model="fineTuning">…</x-collapse>
|
||||
<div x-data="{ advanced: false }"><x-collapse title="Advanced" x-model="advanced">…</x-collapse></div>
|
||||
```
|
||||
|
||||
Toggling writes the property; changing the property (in an action or in Alpine) opens or closes it. With `wire:model` the server renders it open or closed as the property is, so there is no flash, and `open` is ignored; with `x-model`, `open` is only the first paint until Alpine starts. The bound state is `collapseOpen` in the `<details>` scope. Without a binding there is no Alpine on it.
|
||||
|
||||
### `<x-modal>`
|
||||
|
||||
An M3 dialog on native `<dialog>`. Bind with `wire:model` to a flag or an id; closing (Escape, scrim, `close()`) writes back `false` or `null`. Without `wire:model` it uses `open` from the surrounding Alpine scope.
|
||||
@@ -394,7 +462,7 @@ Props: `title`, `subtitle`, `icon` (centred hero icon), `separator`, `persistent
|
||||
|
||||
### `<x-drawer>`
|
||||
|
||||
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`.
|
||||
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Escape leaves a pane open unless `pane-close-on-escape`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`.
|
||||
|
||||
### `<x-bottom-sheet>`
|
||||
|
||||
@@ -467,7 +535,7 @@ A one-column grid of fields with an `actions` slot at the foot (the slot takes i
|
||||
|
||||
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
|
||||
|
||||
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint and sets `aria-invalid`. `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant` (and all but `<x-file>` a `hint-class`, classes added to the hint: `hint-class="text-warning"`), and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint and sets `aria-invalid`. `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||
|
||||
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
|
||||
- `<x-password>`: a reveal button; `icon`, `size`.
|
||||
@@ -524,6 +592,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
|
||||
<x-datepicker label="Expires on" wire:model.live="expiresOn" :min="now()" :max="now()->addMonth()" />
|
||||
<x-datepicker label="Birthday" mode="modal" wire:model="birthday" :max="now()" />
|
||||
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" clearable />
|
||||
<x-datepicker label="Race day" wire:model="raceDay" :week-start="$user->week_start" :format="$user->date_format" />
|
||||
```
|
||||
|
||||
| Prop | Default | |
|
||||
@@ -535,8 +604,10 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
|
||||
| `value` | `null` | the initial value without `wire:model` |
|
||||
| `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) |
|
||||
| `clearable` | `false` | a button that empties the field (both ends of a range) once it holds a date |
|
||||
| `week-start` | `null` | the first day of the week, `0` (Sunday) to `6` (Saturday), instead of the locale's: the calendar's columns, weekday header and Home/End follow it. Anything else is ignored |
|
||||
| `format` | `null` | the typed and displayed format instead of the locale's: `dd`, `MM` and `yyyy`, each once, around one delimiter (`.`, `/`, `-`) — `dd.MM.yyyy`, `dd/MM/yyyy`, `MM/dd/yyyy`, `yyyy-MM-dd`. The field, the dialog's text fields, a range and the error message follow it; `wire:model` still stores `Y-m-d`. Anything else is ignored |
|
||||
|
||||
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()`. Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
|
||||
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()` (the last two unless `week-start` and `format` say otherwise — for a per-person setting). Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
|
||||
|
||||
### `<x-timepicker>`
|
||||
|
||||
@@ -616,10 +687,10 @@ The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a co
|
||||
</x-app-shell>
|
||||
```
|
||||
|
||||
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the current one), `badge` (`true` for a dot, or a count), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the page's, also during a Livewire update request), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for the badge: "3 unread"), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||
- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
|
||||
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`).
|
||||
- Below `sm` the shell sets `--material-bottom-bar`, so the snackbar and a `fab` button clear the bar; pad anything else you pin to the bottom with it.
|
||||
- Below `sm` the shell sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, a `fab` button and the page's bottom padding clear the bar; pad anything else you pin to the bottom with it. See Safe areas.
|
||||
- The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
|
||||
|
||||
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
|
||||
@@ -705,7 +776,7 @@ M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skip
|
||||
|
||||
### `<x-section-nav>`
|
||||
|
||||
Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the request's. `label`, `no-wire-navigate`.
|
||||
Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below, whose items mark the current section as the page (`current`) and carry each section's badge. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). `label`, `no-wire-navigate`.
|
||||
|
||||
### `<x-account-menu>`
|
||||
|
||||
@@ -724,6 +795,14 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
|
||||
|
||||
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (segmented buttons for settings pages). Every toggle on a page shares the store.
|
||||
|
||||
### `<x-scheme-picker>`
|
||||
|
||||
A choice of colour profile (see Colour profiles): a swatch per generated profile — its name and its primary, secondary and tertiary colour — over native radios. `wire:model` or `x-model` (with `name`) binds the chosen name; choosing previews it on the page at once; storing it is the application's. `label`, `hint`, `name`, `profiles` (default `Scheme::profiles()`). A validation error for the bound property replaces the hint. Without profiles it renders nothing.
|
||||
|
||||
```blade
|
||||
<x-scheme-picker :label="__('Colour profile')" wire:model="colorProfile" :hint="__('Applies to every page after saving')" />
|
||||
```
|
||||
|
||||
### `<x-table>`, `<x-sort-header>`
|
||||
|
||||
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>` (`size="xs"` for a dense one); cell utilities (`text-end`, `whitespace-nowrap`) always win. Scrolling is yours: wrap it in `<div class="overflow-x-auto">`. A row that opens something is `data-list-row` with one `data-list-open` control; a selected row is `aria-selected="true"`.
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
z-index: 20;
|
||||
display: block;
|
||||
min-height: var(--app-bar-height);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-top: var(--material-safe-top, env(safe-area-inset-top));
|
||||
background-color: var(--md-sys-color-surface);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
transition: background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
||||
|
||||
@@ -15,18 +15,25 @@
|
||||
*
|
||||
* Split button (`<x-split-button>`): the same idea for two halves; the trailing half turns
|
||||
* round and its chevron turns over while its menu is open (SplitButton*Tokens).
|
||||
*
|
||||
* "Full" here is half the size's height (`--group-full`), never `--md-sys-shape-corner-full`'s
|
||||
* 9999px. One element mixes full outer corners with small inner ones, and when a box's radii
|
||||
* add up to more than its side, CSS scales every radius by the same factor: a 9999px corner
|
||||
* beside an 8px one shrank the 8px one to a hundredth of a pixel, so the inner corners drew
|
||||
* square.
|
||||
*/
|
||||
|
||||
[data-button-group] {
|
||||
--group-inner: var(--md-sys-shape-corner-sm);
|
||||
--group-inner-pressed: var(--md-sys-shape-corner-xs);
|
||||
--group-full: 1.25rem;
|
||||
}
|
||||
|
||||
[data-button-group][data-size='xs'] { --group-pad: 0.75rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; }
|
||||
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; }
|
||||
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; }
|
||||
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); }
|
||||
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); }
|
||||
[data-button-group][data-size='xs'] { --group-pad: 0.75rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; --group-full: 1rem; }
|
||||
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; --group-full: 1.25rem; }
|
||||
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; --group-full: 1.75rem; }
|
||||
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); --group-full: 3rem; }
|
||||
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); --group-full: 4.25rem; }
|
||||
|
||||
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) {
|
||||
padding-inline: calc(var(--group-pad) + var(--group-grow));
|
||||
@@ -60,19 +67,19 @@
|
||||
|
||||
[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)),
|
||||
[data-split='trailing'][aria-expanded='true'] {
|
||||
--group-corner: var(--md-sys-shape-corner-full);
|
||||
--group-corner: var(--group-full);
|
||||
}
|
||||
|
||||
[data-button-group='connected'] > :first-child,
|
||||
[data-split='leading'] {
|
||||
border-start-start-radius: var(--md-sys-shape-corner-full);
|
||||
border-end-start-radius: var(--md-sys-shape-corner-full);
|
||||
border-start-start-radius: var(--group-full);
|
||||
border-end-start-radius: var(--group-full);
|
||||
}
|
||||
|
||||
[data-button-group='connected'] > :last-child,
|
||||
[data-split='trailing'] {
|
||||
border-start-end-radius: var(--md-sys-shape-corner-full);
|
||||
border-end-end-radius: var(--md-sys-shape-corner-full);
|
||||
border-start-end-radius: var(--group-full);
|
||||
border-end-end-radius: var(--group-full);
|
||||
}
|
||||
|
||||
[data-split='trailing'] svg {
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
|
||||
[data-navigation-bar] {
|
||||
container-type: inline-size;
|
||||
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-inline: var(--material-safe-left, env(safe-area-inset-left)) var(--material-safe-right, env(safe-area-inset-right));
|
||||
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
@@ -206,7 +206,7 @@
|
||||
at once when the rail expands, while the width is still growing; the clip keeps it
|
||||
from spilling over the page for those frames. */
|
||||
overflow-x: clip;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
|
||||
background-color: var(--md-sys-color-surface);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
transition:
|
||||
@@ -307,12 +307,12 @@
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding-top: calc(env(safe-area-inset-top) + 2.75rem);
|
||||
padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 2.75rem);
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
[data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child {
|
||||
padding-top: calc(env(safe-area-inset-top) + 2.75rem);
|
||||
padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 2.75rem);
|
||||
}
|
||||
|
||||
[data-navigation-rail-destinations] {
|
||||
|
||||
@@ -165,8 +165,8 @@
|
||||
[data-search][data-full-screen] [data-search-bar] {
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
height: calc(4.5rem + env(safe-area-inset-top));
|
||||
padding-top: env(safe-area-inset-top);
|
||||
height: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top)));
|
||||
padding-top: var(--material-safe-top, env(safe-area-inset-top));
|
||||
padding-inline: 0.25rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
@@ -175,7 +175,7 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
max-height: none;
|
||||
padding-top: calc(4.5rem + env(safe-area-inset-top));
|
||||
padding-top: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top)));
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
|
||||
[data-toolbar][data-variant="docked"] {
|
||||
width: 100%;
|
||||
min-height: calc(4rem + env(safe-area-inset-bottom));
|
||||
min-height: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
|
||||
justify-content: center;
|
||||
column-gap: clamp(0.25rem, 4vw, 2rem);
|
||||
padding-inline: 1rem;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
@@ -74,7 +74,7 @@
|
||||
/* Placed over the page: centred above the bottom edge, or centred against the end edge. */
|
||||
[data-toolbar-place="bottom"] {
|
||||
position: fixed;
|
||||
bottom: calc(1rem + env(safe-area-inset-bottom));
|
||||
bottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
|
||||
left: 50%;
|
||||
z-index: 30;
|
||||
translate: -50% 0;
|
||||
@@ -83,7 +83,7 @@
|
||||
[data-toolbar-place="end"] {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
inset-inline-end: calc(1rem + env(safe-area-inset-right));
|
||||
inset-inline-end: calc(1rem + var(--material-safe-right, env(safe-area-inset-right)));
|
||||
z-index: 30;
|
||||
translate: 0 -50%;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ document.addEventListener('alpine:init', () => {
|
||||
collapsed: false,
|
||||
height: null,
|
||||
frame: null,
|
||||
// Set in init(). Declared here, or Alpine writes them to the outermost x-data scope,
|
||||
// where a second app bar in the same page scope would take the first one's observer.
|
||||
schedule: null,
|
||||
resizes: null,
|
||||
|
||||
init() {
|
||||
this.measure = this.measure.bind(this)
|
||||
|
||||
+34
-10
@@ -11,7 +11,9 @@
|
||||
* Dates are ISO strings (`2026-09-13`) throughout, computed in UTC so no time zone or daylight
|
||||
* saving change can move a day; only "today" is read in the browser's own zone. Month and weekday
|
||||
* names, the week's first day and the typed format come from `Intl` for the locale the server
|
||||
* passes (the application's).
|
||||
* passes (the application's), unless the component names the first day (`weekStart`, 0 for Sunday
|
||||
* to 6) or the format (`format`, such as `dd.MM.yyyy`); everything that reads `firstDay` and
|
||||
* `format` below then follows those.
|
||||
*
|
||||
* The keyboard is WAI-ARIA's date picker dialog: arrows move a day or a week, Home and End go to
|
||||
* the start and end of the week, PageUp and PageDown a month (with Shift a year), Space selects,
|
||||
@@ -82,8 +84,12 @@ function localToday() {
|
||||
return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
}
|
||||
|
||||
/** 0 for Sunday … 6 for Saturday. */
|
||||
function firstDayOfWeek(locale) {
|
||||
/** 0 for Sunday … 6 for Saturday: `weekStart` when it is one of those, otherwise the locale's. */
|
||||
function firstDayOfWeek(locale, weekStart = null) {
|
||||
if (Number.isInteger(weekStart) && weekStart >= 0 && weekStart <= 6) {
|
||||
return weekStart
|
||||
}
|
||||
|
||||
try {
|
||||
const tag = new Intl.Locale(locale)
|
||||
const info = typeof tag.getWeekInfo === 'function' ? tag.getWeekInfo() : tag.weekInfo
|
||||
@@ -111,9 +117,16 @@ function firstDayOfWeek(locale) {
|
||||
/**
|
||||
* The typed format: the locale's short numeric date, reduced to `dd`, `MM` and `yyyy` and one
|
||||
* delimiter — androidx's `datePatternAsInputFormat`, fed from `formatToParts` because `Intl` has
|
||||
* no pattern to give. `de` gives `dd.MM.yyyy`, `en-US` `MM/dd/yyyy`, `ja` `yyyy/MM/dd`.
|
||||
* no pattern to give. `de` gives `dd.MM.yyyy`, `en-US` `MM/dd/yyyy`, `ja` `yyyy/MM/dd`. A `chosen`
|
||||
* pattern of the same shape (each unit once, one delimiter) replaces the locale's.
|
||||
*/
|
||||
function inputFormat(locale) {
|
||||
function inputFormat(locale, chosen = null) {
|
||||
const units = /^(dd|MM|yyyy)([/\-.])(dd|MM|yyyy)\2(dd|MM|yyyy)$/.exec(typeof chosen === 'string' ? chosen : '')
|
||||
|
||||
if (units && new Set([units[1], units[3], units[4]]).size === 3) {
|
||||
return formatOf(chosen)
|
||||
}
|
||||
|
||||
let pattern = ''
|
||||
|
||||
try {
|
||||
@@ -131,10 +144,11 @@ function inputFormat(locale) {
|
||||
pattern = ''
|
||||
}
|
||||
|
||||
if (!/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern)) {
|
||||
pattern = 'yyyy-MM-dd'
|
||||
}
|
||||
return formatOf(/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern) ? pattern : 'yyyy-MM-dd')
|
||||
}
|
||||
|
||||
/** A pattern, the placeholder it shows (`DD.MM.YYYY`) and the order its units are typed in (`dMy`). */
|
||||
function formatOf(pattern) {
|
||||
return {
|
||||
pattern,
|
||||
placeholder: pattern.toUpperCase(),
|
||||
@@ -164,13 +178,23 @@ document.addEventListener('alpine:init', () => {
|
||||
today: localToday(),
|
||||
compact: false,
|
||||
refocus: true,
|
||||
// Set in init() from the config. Declared here, or Alpine writes them to the outermost
|
||||
// x-data scope, where every picker inside the same page scope would share the last one's.
|
||||
firstDay: 0,
|
||||
format: null,
|
||||
numbers: null,
|
||||
formats: {},
|
||||
min: null,
|
||||
max: null,
|
||||
yearsFrom: null,
|
||||
yearsTo: null,
|
||||
|
||||
init() {
|
||||
const locale = config.locale || document.documentElement.lang || 'en'
|
||||
const format = (options) => new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options })
|
||||
|
||||
this.firstDay = firstDayOfWeek(locale)
|
||||
this.format = inputFormat(locale)
|
||||
this.firstDay = firstDayOfWeek(locale, config.weekStart ?? null)
|
||||
this.format = inputFormat(locale, config.format ?? null)
|
||||
this.numbers = new Intl.NumberFormat(locale, { useGrouping: false })
|
||||
this.formats = {
|
||||
monthYear: format({ year: 'numeric', month: 'long' }),
|
||||
|
||||
+78
-7
@@ -3,17 +3,28 @@
|
||||
*
|
||||
* The menu button is the trigger's first button or link. Its ARIA attributes are written by
|
||||
* script, which a Livewire morph removes along with anything else the server did not render,
|
||||
* so they are written again whenever the trigger is used.
|
||||
* so they are written again whenever the trigger is used and after every morph — an open menu
|
||||
* lives through one (the popover is keyed), and its button must still say so.
|
||||
*
|
||||
* The popover hangs on the menu button by CSS anchor positioning. The server can only name the
|
||||
* wrapper around the trigger slot, and a trigger taken out of the flow — a `position: fixed` FAB
|
||||
* in a corner of the window — leaves that wrapper behind as an empty box where the page put it,
|
||||
* so the menu opened there. Script moves the name onto the menu button, beside any name the button
|
||||
* carries itself (a button's tooltip anchors on it too), and moves it again after every morph,
|
||||
* which puts the server's attributes, and a fresh name, back.
|
||||
*/
|
||||
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
|
||||
|
||||
// A popover="auto" closes on the press that lands on its trigger, and the click that follows
|
||||
// would open it again. A close this recent is taken as that press.
|
||||
// would open it again. A close this recent is taken as that press. It is timed from
|
||||
// `beforetoggle`, which fires as the popover closes: `toggle` is queued, and arrives after that
|
||||
// click.
|
||||
const REOPEN_GUARD_MS = 250
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialMenu', () => ({
|
||||
closedAt: -Infinity,
|
||||
anchored: null,
|
||||
returnFocus: true,
|
||||
focusWasInside: false,
|
||||
listeners: [],
|
||||
@@ -22,6 +33,25 @@ document.addEventListener('alpine:init', () => {
|
||||
const menu = this.$refs.menu
|
||||
|
||||
this.label()
|
||||
this.anchor()
|
||||
|
||||
// A morph rewrites the wrapper's style with this render's name and the button's without
|
||||
// it, takes the button's ARIA attributes away and gives the popover a new id; the
|
||||
// observer runs before the next frame is drawn, so an open menu never moves and its
|
||||
// button never shows it shut.
|
||||
const observer = new MutationObserver(() => {
|
||||
this.anchor()
|
||||
this.label()
|
||||
})
|
||||
|
||||
observer.observe(this.$refs.trigger, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'aria-haspopup', 'aria-controls', 'aria-expanded'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
observer.observe(menu, { attributes: true, attributeFilter: ['id'] })
|
||||
this.listeners.push(() => observer.disconnect())
|
||||
|
||||
// Only closes the browser starts — Escape, a press outside — arrive here alone; open()
|
||||
// and close() have already done their part, synchronously, because this event is
|
||||
@@ -32,6 +62,10 @@ document.addEventListener('alpine:init', () => {
|
||||
// that was a focusable region around the trigger).
|
||||
this.listen(menu, 'beforetoggle', (event) => {
|
||||
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
|
||||
|
||||
if (event.newState === 'closed') {
|
||||
this.closedAt = performance.now()
|
||||
}
|
||||
})
|
||||
|
||||
this.listen(menu, 'toggle', (event) => {
|
||||
@@ -43,8 +77,6 @@ document.addEventListener('alpine:init', () => {
|
||||
return
|
||||
}
|
||||
|
||||
this.closedAt = performance.now()
|
||||
|
||||
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
|
||||
this.control()?.focus()
|
||||
}
|
||||
@@ -64,6 +96,40 @@ document.addEventListener('alpine:init', () => {
|
||||
return this.$refs.trigger.querySelector('button, a[href], [tabindex]')
|
||||
},
|
||||
|
||||
/**
|
||||
* Moves the anchor name the server gave the wrapper onto the menu button. The wrapper holds a
|
||||
* name only as rendered — this render's, which the popover's `position-anchor` matches — so
|
||||
* it is read there.
|
||||
*/
|
||||
anchor() {
|
||||
const trigger = this.$refs.trigger
|
||||
const control = this.control()
|
||||
const rendered = trigger.style.getPropertyValue('anchor-name').trim()
|
||||
const name = rendered.startsWith('--') ? rendered : this.anchored
|
||||
|
||||
// No menu button, or an engine without anchor positioning: the wrapper keeps the name.
|
||||
if (!control || !name) {
|
||||
return
|
||||
}
|
||||
|
||||
const names = control.style
|
||||
.getPropertyValue('anchor-name')
|
||||
.split(',')
|
||||
.map((each) => each.trim())
|
||||
.filter((each) => each.startsWith('--'))
|
||||
|
||||
if (!names.includes(name)) {
|
||||
control.style.setProperty('anchor-name', [...names.filter((each) => each !== this.anchored), name].join(', '))
|
||||
}
|
||||
|
||||
this.anchored = name
|
||||
|
||||
if (rendered !== '') {
|
||||
trigger.style.removeProperty('anchor-name')
|
||||
}
|
||||
},
|
||||
|
||||
/** Writes only what differs: the observer that calls this watches these same attributes. */
|
||||
label() {
|
||||
const control = this.control()
|
||||
|
||||
@@ -71,9 +137,13 @@ document.addEventListener('alpine:init', () => {
|
||||
return
|
||||
}
|
||||
|
||||
control.setAttribute('aria-haspopup', 'menu')
|
||||
control.setAttribute('aria-controls', this.$refs.menu.id)
|
||||
control.setAttribute('aria-expanded', String(this.isOpen()))
|
||||
const attributes = { 'aria-haspopup': 'menu', 'aria-controls': this.$refs.menu.id, 'aria-expanded': String(this.isOpen()) }
|
||||
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
if (control.getAttribute(name) !== value) {
|
||||
control.setAttribute(name, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
isOpen() {
|
||||
@@ -82,6 +152,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
open(focus = 'first') {
|
||||
this.label()
|
||||
this.anchor()
|
||||
|
||||
if (!this.isOpen()) {
|
||||
this.$refs.menu.showPopover()
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
* One snackbar at a time, as M3 shows them. Each waits its turn, stays for its timeout (paused
|
||||
* while hovered or focused, so it is never pulled away from someone reading or reaching for its
|
||||
* action) and is replaced by the next.
|
||||
*
|
||||
* A `sticky` toast ("A new version is ready" with a Reload action) stays until it is answered, but
|
||||
* never holds the queue up: it is kept aside rather than queued, a toast that arrives while it shows
|
||||
* takes its place, and it comes back once the queue is empty. Only one is kept — a newer sticky
|
||||
* toast replaces it. Dismissing it, or pressing its action, lets it go.
|
||||
*/
|
||||
const DEFAULT_TIMEOUT_MS = 4000
|
||||
|
||||
@@ -25,6 +30,7 @@ document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialSnackbar', () => ({
|
||||
queue: [],
|
||||
current: null,
|
||||
sticky: null,
|
||||
timer: null,
|
||||
remaining: 0,
|
||||
startedAt: 0,
|
||||
@@ -44,24 +50,43 @@ document.addEventListener('alpine:init', () => {
|
||||
// Livewire dispatches named arguments as the detail object; a positional dispatch
|
||||
// arrives as an array whose first entry is that object.
|
||||
const toast = Array.isArray(detail) ? detail[0] : detail
|
||||
const sticky = toast.sticky === true
|
||||
|
||||
this.queue.push({
|
||||
const entry = {
|
||||
id: ++sequence,
|
||||
type: toast.type ?? null,
|
||||
title: toast.title ?? '',
|
||||
description: toast.description ?? null,
|
||||
timeout: toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS),
|
||||
timeout: sticky || toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS),
|
||||
action: toast.action ?? null,
|
||||
})
|
||||
sticky,
|
||||
}
|
||||
|
||||
if (!this.current) {
|
||||
if (sticky) {
|
||||
const showing = !this.current || this.current === this.sticky
|
||||
this.sticky = entry
|
||||
|
||||
if (showing) {
|
||||
this.next()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.queue.push(entry)
|
||||
|
||||
// A sticky toast steps aside for it, and comes back from next() once the queue is empty.
|
||||
if (!this.current || this.current === this.sticky) {
|
||||
this.next()
|
||||
}
|
||||
},
|
||||
|
||||
next() {
|
||||
// Cleared and forgotten here, so a toast dismissed early never leaves its timer running
|
||||
// to cut the next one short.
|
||||
clearTimeout(this.timer)
|
||||
this.current = this.queue.shift() ?? null
|
||||
this.timer = null
|
||||
this.current = this.queue.shift() ?? this.sticky
|
||||
|
||||
if (this.current?.timeout) {
|
||||
this.remaining = this.current.timeout
|
||||
@@ -92,13 +117,24 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
dismiss() {
|
||||
this.timer = null
|
||||
if (this.current && this.current === this.sticky) {
|
||||
this.sticky = null
|
||||
}
|
||||
|
||||
this.next()
|
||||
},
|
||||
|
||||
// Closed before the handler and the event run, so a toast either of them shows is not the
|
||||
// one dismissed.
|
||||
act() {
|
||||
this.current?.action?.handler?.()
|
||||
const action = this.current?.action
|
||||
|
||||
this.dismiss()
|
||||
action?.handler?.()
|
||||
|
||||
if (typeof action?.event === 'string' && action.event !== '') {
|
||||
window.dispatchEvent(new CustomEvent(action.event))
|
||||
}
|
||||
},
|
||||
|
||||
icon(type) {
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
*
|
||||
* `value` is an accessor, so binding a control with `x-model="$store.theme.value"` goes
|
||||
* through the same write as `set()` and `toggle()`: the attributes, then localStorage.
|
||||
*
|
||||
* `scheme` is the colour profile on screen (<html data-scheme>, which the server chose), and
|
||||
* `previewScheme(name)` shows another one on this page without storing anything — the
|
||||
* application saves a choice itself, and the next full load draws what it saved.
|
||||
*/
|
||||
document.addEventListener('alpine:init', () => {
|
||||
const root = document.documentElement
|
||||
@@ -18,6 +22,7 @@ document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.store('theme', {
|
||||
choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system',
|
||||
resolved: root.dataset.theme === 'dark' ? 'dark' : 'light',
|
||||
scheme: root.dataset.scheme || null,
|
||||
|
||||
get value() {
|
||||
return this.choice
|
||||
@@ -48,6 +53,15 @@ document.addEventListener('alpine:init', () => {
|
||||
toggle() {
|
||||
this.set(this.resolved === 'dark' ? 'light' : 'dark')
|
||||
},
|
||||
|
||||
previewScheme(name) {
|
||||
if (typeof name !== 'string' || !/^[a-z0-9-]+$/.test(name)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.scheme = name
|
||||
root.dataset.scheme = name
|
||||
},
|
||||
})
|
||||
|
||||
// The head script repaints on an OS change while the choice is `system`; this keeps the
|
||||
|
||||
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>).
|
||||
|
||||
`destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally
|
||||
`active` (by default: the URL is the current one), `badge` (`true` for a dot, or a count),
|
||||
`active` (by default: the URL is the page's; during a Livewire update request, the page the
|
||||
component was rendered on rather than the update endpoint), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for
|
||||
the badge instead: "3 unread"),
|
||||
`section` (a heading the destination is grouped under in the rail; only an expanded rail shows
|
||||
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
|
||||
`navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||
@@ -35,7 +37,10 @@
|
||||
|
||||
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
|
||||
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
|
||||
below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`.
|
||||
below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`:
|
||||
the bar's 64px, the bottom safe area (`--material-safe-bottom`, else the device's inset) and
|
||||
`--material-bottom-extra` (0px unless the application docks something, an offline banner, on
|
||||
top of the bar).
|
||||
|
||||
`max-lg:overflow-x-clip` on the content region is the backstop under every page, and it stays
|
||||
`clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll
|
||||
@@ -53,7 +58,7 @@
|
||||
|
||||
@php
|
||||
$label ??= __('Main');
|
||||
$current = request()->url();
|
||||
$current = \Livewire\Livewire::isLivewireRequest() ? \Livewire\Livewire::originalUrl() : request()->url();
|
||||
|
||||
$items = collect($destinations)
|
||||
->filter(fn ($item): bool => is_array($item) && filled($item['title'] ?? null))
|
||||
@@ -63,6 +68,7 @@
|
||||
'url' => $item['url'] ?? null,
|
||||
'active' => (bool) ($item['active'] ?? (filled($item['url'] ?? null) && rtrim(url($item['url']), '/') === rtrim($current, '/'))),
|
||||
'badge' => $item['badge'] ?? null,
|
||||
'badgeLabel' => filled($item['badgeLabel'] ?? null) ? (string) $item['badgeLabel'] : null,
|
||||
'section' => filled($item['section'] ?? null) ? (string) $item['section'] : null,
|
||||
'bar' => ($item['bar'] ?? true) !== false,
|
||||
'navigate' => ($item['navigate'] ?? true) !== false,
|
||||
@@ -79,13 +85,13 @@
|
||||
data-app-shell
|
||||
@class([
|
||||
'min-h-dvh bg-surface text-on-surface sm:flex',
|
||||
'max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]' => $barItems->isNotEmpty(),
|
||||
'max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty(),
|
||||
])
|
||||
>
|
||||
<a
|
||||
href="#content"
|
||||
data-skip-link
|
||||
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(env(safe-area-inset-top)+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
|
||||
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
|
||||
>{{ __('Skip to content') }}</a>
|
||||
|
||||
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
|
||||
@@ -101,12 +107,12 @@
|
||||
@if ($group->first()['section'] !== null)
|
||||
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
|
||||
@foreach ($group as $item)
|
||||
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
||||
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||||
@endforeach
|
||||
</x-livewire-material::navigation-rail-section>
|
||||
@else
|
||||
@foreach ($group as $item)
|
||||
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
||||
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
@@ -136,7 +142,7 @@
|
||||
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
||||
<x-livewire-material::navigation-bar :label="$label">
|
||||
@foreach ($barItems as $item)
|
||||
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
||||
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||||
@endforeach
|
||||
</x-livewire-material::navigation-bar>
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,18 @@
|
||||
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
|
||||
|
||||
The status label is not an M3 badge but every app needs one: `tonal` draws the value in the
|
||||
colour's container ("Expired" in error-container), `outline` in a neutral edge. `color` (alias
|
||||
`tone`): `error` (the default), `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
|
||||
colour's container ("Expired" in error-container), `solid` in the colour itself (a label that
|
||||
has to stand out, "Built in" in primary), `outline` in a neutral edge. `color` (alias
|
||||
`tone`): `error` (the default), `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`,
|
||||
and two without a hue of their own:
|
||||
- `neutral` — neutral ink on every variant: on-surface-variant with surface text as a dot or
|
||||
count (the ink an outline badge already writes in), surface-container-high with
|
||||
on-surface-variant text when `tonal`, the outline-variant edge when `outline`.
|
||||
- `plain` — no background, text or border colour at all, only shape, size and type, so the
|
||||
caller's classes paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`.
|
||||
|
||||
The value is `value`, or the slot, which renders as HTML — an icon beside the word:
|
||||
`<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. With neither it is a dot.
|
||||
|
||||
A count or dot says nothing to a screen reader on its own: give the icon's control a label
|
||||
that includes it ("Notifications, 4 new"), or pass `label` here. --}}
|
||||
@@ -22,29 +32,41 @@
|
||||
'color' => null,
|
||||
'tone' => null,
|
||||
'tonal' => false,
|
||||
'solid' => false,
|
||||
'outline' => false,
|
||||
'floating' => false,
|
||||
'label' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$color = in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) ? ($color ?? $tone) : 'error';
|
||||
$text = $value ?? ($slot->isNotEmpty() ? trim((string) $slot) : null);
|
||||
$color = in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info', 'neutral', 'plain'], true) ? ($color ?? $tone) : 'error';
|
||||
// hasActualContent(): a slot holding only a comment, or an empty @foreach, is still a dot.
|
||||
$markup = $value === null && $slot->hasActualContent();
|
||||
$text = $value ?? ($markup ? trim((string) $slot) : null);
|
||||
$dot = blank($text);
|
||||
$status = ($tonal || $outline) && ! $dot;
|
||||
$status = ($tonal || $solid || $outline) && ! $dot;
|
||||
|
||||
if (! $dot && $max !== null && is_numeric($text) && (int) $text > (int) $max) {
|
||||
$text = $max.'+';
|
||||
$markup = false;
|
||||
}
|
||||
|
||||
$filled = [
|
||||
'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary',
|
||||
'error' => 'bg-error text-on-error', 'success' => 'bg-success text-on-success', 'warning' => 'bg-warning text-on-warning', 'info' => 'bg-info text-on-info',
|
||||
'neutral' => 'bg-on-surface-variant text-surface',
|
||||
];
|
||||
$container = [
|
||||
'primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container',
|
||||
'error' => 'bg-error-container text-on-error-container', 'success' => 'bg-success-container text-on-success-container', 'warning' => 'bg-warning-container text-on-warning-container', 'info' => 'bg-info-container text-on-info-container',
|
||||
'neutral' => 'bg-surface-container-high text-on-surface-variant',
|
||||
];
|
||||
$paint = match (true) {
|
||||
$color === 'plain' => '',
|
||||
! $status, $solid => $filled[$color],
|
||||
$tonal => $container[$color],
|
||||
default => 'border-outline-variant text-on-surface-variant',
|
||||
};
|
||||
|
||||
$attributes = $attributes
|
||||
->class([
|
||||
@@ -52,9 +74,8 @@
|
||||
'size-1.5 rounded-corner-full' => $dot,
|
||||
'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums' => ! $dot && ! $status,
|
||||
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
|
||||
$filled[$color] => ! $status,
|
||||
$container[$color] => $tonal && ! $dot,
|
||||
'border border-outline-variant text-on-surface-variant' => $outline && ! $tonal && ! $dot,
|
||||
'border' => $outline && ! $tonal && ! $solid && ! $dot,
|
||||
$paint => $paint !== '',
|
||||
'absolute top-0.5 end-0.5' => $floating && $dot,
|
||||
'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
|
||||
])
|
||||
@@ -64,4 +85,4 @@
|
||||
]));
|
||||
@endphp
|
||||
|
||||
<span {{ $attributes }}>@unless ($dot){{ $text }}@endunless</span>
|
||||
<span {{ $attributes }}>@unless ($dot)@if ($markup){{ $slot }}@else{{ $text }}@endif@endunless</span>
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
||||
style="--sheet-max-height: {{ $height }}"
|
||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
|
||||
'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[env(safe-area-inset-bottom)] text-on-surface shadow-elevation-1',
|
||||
'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[var(--material-safe-bottom,env(safe-area-inset-bottom))] text-on-surface shadow-elevation-1',
|
||||
$attributes->get('class'),
|
||||
]) }}
|
||||
>
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
|
||||
With an `icon` and no label it is an icon button: `width` is `narrow`, `default` or `wide`,
|
||||
`variant="text"` is M3's standard icon button, and the tooltip or label names it for screen
|
||||
readers. `selected` makes it a toggle: `true` or `false` sets `aria-pressed` and M3's
|
||||
selected colours, and a selected round button turns square (a selected square icon button
|
||||
readers. `selected` makes it a toggle: `true` or `false` sets `aria-pressed` (not on a `link`,
|
||||
which is no toggle — give it `aria-current` instead) and M3's selected colours, and a selected round button turns square (a selected square icon button
|
||||
turns round). Text buttons are not toggles in M3; a selected one takes the tonal container.
|
||||
|
||||
Values from androidx Compose Material 3's tokens (Button*Tokens, *IconButtonTokens,
|
||||
@@ -191,7 +191,9 @@
|
||||
'type' => $isLink ? null : $type,
|
||||
'disabled' => ! $isLink && $disabled ? true : null,
|
||||
'aria-label' => $iconOnly && ! $attributes->has('aria-label') ? ($label ?? $tip) : null,
|
||||
'aria-pressed' => $selected === null ? null : ($selected ? 'true' : 'false'),
|
||||
// A link is not a toggle: ARIA defines aria-pressed for buttons only. A selected link keeps
|
||||
// the selected look; `aria-current` is the caller's to set (`:aria-current="'page'"`).
|
||||
'aria-pressed' => $selected === null || $isLink ? null : ($selected ? 'true' : 'false'),
|
||||
'data-icon-button' => $iconOnly ? true : null,
|
||||
'wire:loading.attr' => $spinnerTarget ? 'disabled' : null,
|
||||
'wire:target' => $spinnerTarget,
|
||||
|
||||
@@ -44,7 +44,9 @@
|
||||
longer slides inside its mask. RTL mirrors the keylines, keys and buttons.
|
||||
|
||||
Re-measures itself when resized, when a Livewire morph resets its styles and when items
|
||||
come and go. --}}
|
||||
come and go. The row's id, which the buttons control, is new with every render; the row
|
||||
carries a `wire:key` (see `<x-menu>`), so a morph patches it in place — its scroll position
|
||||
and listeners kept — rather than swapping in a copy. --}}
|
||||
|
||||
@props([
|
||||
'layout' => 'multi-browse',
|
||||
@@ -116,6 +118,7 @@
|
||||
|
||||
<div
|
||||
x-ref="scroller"
|
||||
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-carousel']) }}
|
||||
id="{{ $scrollerId }}"
|
||||
role="region"
|
||||
aria-roledescription="{{ __('carousel') }}"
|
||||
|
||||
@@ -6,7 +6,18 @@
|
||||
`heading` slot), an optional leading `icon`, a chevron that turns over on the spatial spring,
|
||||
and — where the browser supports animating `details` content (`interpolate-size`) — a height
|
||||
that eases open. `open` starts it open. `variant`: `plain` (the default, on the surface around
|
||||
it) or `filled` (a surface-container tile with a large corner). --}}
|
||||
it) or `filled` (a surface-container tile with a large corner).
|
||||
|
||||
Its open state can be bound, both ways:
|
||||
- `wire:model` (any modifiers; `.live` tells the server at once) entangles it with a Livewire
|
||||
boolean property. The server renders `open` from the property, so the first paint matches it
|
||||
and `open` is ignored; toggling writes the property, and the property changing opens or
|
||||
closes it.
|
||||
- `x-model` binds an Alpine property through `x-modelable`; `open` is then only the first
|
||||
paint, until Alpine starts and applies the property.
|
||||
The state lives in `collapseOpen` on the `<details>`, a name kept clear of `open`, which a
|
||||
dialog inside the slot may be reading from a scope around it. Without a binding there is no
|
||||
Alpine on it at all. --}}
|
||||
|
||||
@props([
|
||||
'title' => null,
|
||||
@@ -15,10 +26,26 @@
|
||||
'variant' => 'plain',
|
||||
])
|
||||
|
||||
@php
|
||||
$model = $attributes->wire('model')->value() ?: null;
|
||||
$bound = $model !== null || count($attributes->whereStartsWith('x-model')->getAttributes()) > 0;
|
||||
$expanded = (bool) $open;
|
||||
|
||||
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
|
||||
$expanded = (bool) data_get($component, $model);
|
||||
}
|
||||
@endphp
|
||||
|
||||
<details
|
||||
wire:ignore.self
|
||||
@if ($open) open @endif
|
||||
{{ $attributes->class([
|
||||
@if ($bound)
|
||||
x-data="{ collapseOpen: @if ($model !== null) @entangle($attributes->wire('model')) @else @js($expanded) @endif }"
|
||||
@if ($model === null) x-modelable="collapseOpen" @endif
|
||||
x-effect="$el.open = collapseOpen"
|
||||
x-on:toggle="collapseOpen = $el.open"
|
||||
@endif
|
||||
@if ($expanded) open @endif
|
||||
{{ $attributes->whereDoesntStartWith('wire:model')->class([
|
||||
'group/collapse [interpolate-size:allow-keywords]',
|
||||
'rounded-corner-lg bg-surface-container' => $variant === 'filled',
|
||||
]) }}
|
||||
|
||||
@@ -27,8 +27,14 @@
|
||||
the `wire:model` name replace the hint, and so does a typed date that cannot be read.
|
||||
|
||||
Month and weekday names, the first day of the week and the typed format come from `Intl` for
|
||||
`app()->getLocale()` (resources/js/datepicker.js). Replaces ReStride's flatpickr picker: its
|
||||
`config` becomes `min`, `max`, `range` and `mode`.
|
||||
`app()->getLocale()` (resources/js/datepicker.js). An application that lets each person choose
|
||||
overrides the last two: `week-start` is the first day of the week, 0 (Sunday) to 6 (Saturday),
|
||||
and `format` the typed and displayed format, `dd`, `MM` and `yyyy` in any order around one
|
||||
delimiter (`.`, `/` or `-`: `dd.MM.yyyy`, `MM/dd/yyyy`, `yyyy-MM-dd`). The field, the typed-date
|
||||
reader, the calendar's columns and weekday header, Home and End, and the dialog's text fields
|
||||
all follow them; the names stay the locale's, and `wire:model` still stores `Y-m-d`. A value
|
||||
that is neither (`week-start="7"`, `format="d.M.yy"`) is ignored, as `null` is. Replaces
|
||||
ReStride's flatpickr picker: its `config` becomes `min`, `max`, `range` and `mode`.
|
||||
|
||||
M3's date pickers (DatePickerModalTokens and DateInputModalTokens from androidx Compose
|
||||
Material 3, androidx commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, with the layout of
|
||||
@@ -50,6 +56,8 @@
|
||||
'max' => null,
|
||||
'value' => null,
|
||||
'clearable' => false,
|
||||
'weekStart' => null,
|
||||
'format' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -63,6 +71,10 @@
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
||||
: [];
|
||||
$locale = str_replace('_', '-', app()->getLocale());
|
||||
$weekStart = (is_int($weekStart) || is_string($weekStart)) && preg_match('/^[0-6]\z/', (string) $weekStart) === 1 ? (int) $weekStart : null;
|
||||
$format = is_string($format) && preg_match('/^(dd|MM|yyyy)([.\/-])(dd|MM|yyyy)\2(dd|MM|yyyy)\z/', $format, $units) === 1 && count(array_unique([$units[1], $units[3], $units[4]])) === 3
|
||||
? $format
|
||||
: null;
|
||||
|
||||
$toIso = function (mixed $date): ?string {
|
||||
if ($date instanceof \DateTimeInterface) {
|
||||
@@ -85,9 +97,9 @@
|
||||
? ['start' => $toIso(data_get($current, 'start')), 'end' => $toIso(data_get($current, 'end'))]
|
||||
: $toIso($current);
|
||||
|
||||
// The typed format as resources/js/datepicker.js derives it, from ICU's short date when PHP has intl.
|
||||
$pattern = 'yyyy-MM-dd';
|
||||
if (class_exists(\IntlDateFormatter::class)) {
|
||||
// The typed format: `format`, or as resources/js/datepicker.js derives it, from ICU's short date when PHP has intl.
|
||||
$pattern = $format ?? 'yyyy-MM-dd';
|
||||
if ($format === null && class_exists(\IntlDateFormatter::class)) {
|
||||
$short = (string) (new \IntlDateFormatter(str_replace('-', '_', $locale), \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE))->getPattern();
|
||||
$candidate = rtrim(str_replace('My', 'M/y', (string) preg_replace(['/[^dMy\/\-.]/', '/d{1,2}/', '/M{1,2}/', '/y{1,4}/'], ['', 'dd', 'MM', 'yyyy'], $short)), '.');
|
||||
if (preg_match('/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[\/\-.][dMy]+[\/\-.][dMy]+$/', $candidate) === 1) {
|
||||
@@ -110,6 +122,8 @@
|
||||
'range' => (bool) $range,
|
||||
'min' => $toIso($min),
|
||||
'max' => $toIso($max),
|
||||
'weekStart' => $weekStart,
|
||||
'format' => $format,
|
||||
'disabled' => (bool) $attributes->get('disabled'),
|
||||
'readonly' => (bool) $attributes->get('readonly'),
|
||||
'strings' => [
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
As a pane (`pane`, from `xl`) nothing is covered: the page renders the drawer after its list in
|
||||
an `xl:flex xl:items-start xl:gap-6` row, the drawer sticks under the top of the viewport, the
|
||||
list stays usable and another row swaps what it shows — no scrim, no trap, no inert page. While
|
||||
closed it takes no room. `pane-width` sizes the pane (the sheet's width by default). The body is
|
||||
closed it takes no room. `pane-width` sizes the pane (the sheet's width by default). Escape closes
|
||||
the sheet but leaves a pane open, since the page beside it is still in use; `pane-close-on-escape`
|
||||
closes the pane on Escape too, for a pane that is a transient detail. The body is
|
||||
a size container, so its contents lay out by the room the sheet or pane actually has (`@md:`),
|
||||
never by the viewport.
|
||||
|
||||
@@ -34,6 +36,7 @@
|
||||
'width' => '25rem',
|
||||
'pane' => false,
|
||||
'paneWidth' => null,
|
||||
'paneCloseOnEscape' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -55,11 +58,11 @@
|
||||
},
|
||||
@endif
|
||||
}"
|
||||
@if ($closeOnEscape) x-on:keydown.window.escape="if (open && ! wide) close()" @endif
|
||||
@if ($closeOnEscape) x-on:keydown.window.escape="{{ $paneCloseOnEscape ? 'if (open) close()' : 'if (open && ! wide) close()' }}" @endif
|
||||
data-sheet="{{ $id }}"
|
||||
@if ($pane)
|
||||
x-bind:class="! open && 'xl:hidden'"
|
||||
class="xl:sticky xl:top-[calc(env(safe-area-inset-top)+1.25rem)] xl:shrink-0 xl:self-start"
|
||||
class="xl:sticky xl:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1.25rem)] xl:shrink-0 xl:self-start"
|
||||
data-pane
|
||||
@endif
|
||||
>
|
||||
@@ -90,11 +93,11 @@
|
||||
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
||||
style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth ?? $width }}"
|
||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
|
||||
'fixed top-[env(safe-area-inset-top)] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1',
|
||||
'fixed top-[var(--material-safe-top,env(safe-area-inset-top))] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1',
|
||||
'end-0 sm:rounded-s-corner-lg' => ! $start,
|
||||
'start-0 sm:rounded-e-corner-lg' => $start,
|
||||
'sm:w-(--sheet-width) sm:max-w-[calc(100vw-4rem)]',
|
||||
'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-env(safe-area-inset-top))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane,
|
||||
'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-var(--material-safe-top,env(safe-area-inset-top)))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane,
|
||||
$attributes->get('class'),
|
||||
]) }}
|
||||
>
|
||||
|
||||
@@ -8,7 +8,17 @@
|
||||
Not an M3 component; built from M3 Expressive's parts — `shape` (any `<x-shape>` name,
|
||||
`cookie-9` by default) in secondary-container behind the `icon` in on-secondary-container,
|
||||
a title-large `title`, body-medium `description` or slot. For "your filter matched nothing"
|
||||
use a plain line of text instead: a picture there is consolation for a typo. --}}
|
||||
use a plain line of text instead: a picture there is consolation for a typo.
|
||||
|
||||
An application with its own artwork puts it in the `illustration` slot, which is drawn in place
|
||||
of the shape and icon (both props are then unused). The slot sizes itself; its attributes go on
|
||||
the element around it, so `class` can set the colour an SVG's `currentColor` takes:
|
||||
|
||||
<x-empty-state title="No routes yet">
|
||||
<x-slot:illustration class="text-primary"><svg class="size-32" aria-hidden="true">…</svg></x-slot:illustration>
|
||||
</x-empty-state>
|
||||
|
||||
A slot holding only whitespace or comments counts as empty, and the shape and icon stay. --}}
|
||||
|
||||
@props([
|
||||
'icon' => 'inbox',
|
||||
@@ -18,10 +28,14 @@
|
||||
])
|
||||
|
||||
<div {{ $attributes->class('flex flex-col items-center gap-4 px-4 py-10 text-center') }}>
|
||||
<div class="relative grid size-28 place-items-center">
|
||||
<x-livewire-material::shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" />
|
||||
<x-livewire-material::icon :name="$icon" class="relative size-12 text-on-secondary-container" />
|
||||
</div>
|
||||
@if (isset($illustration) && $illustration->hasActualContent())
|
||||
<div {{ $illustration->attributes }}>{{ $illustration }}</div>
|
||||
@else
|
||||
<div class="relative grid size-28 place-items-center">
|
||||
<x-livewire-material::shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" />
|
||||
<x-livewire-material::icon :name="$icon" class="relative size-12 text-on-secondary-container" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($title)
|
||||
<h3 class="type-title-lg">{{ $title }}</h3>
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a
|
||||
round close button in the colour itself while the list is open above it, end-aligned; the
|
||||
list is a `popover="auto"` menu with the menu keyboard of `<x-menu>`. `label` names the FAB
|
||||
for screen readers. Give the items the same `color`.
|
||||
for screen readers. Give the items the same `color`. Like `<x-menu>`'s, the list is keyed for
|
||||
Livewire, so it stays open through a render of the component around it.
|
||||
|
||||
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px
|
||||
above the close button. --}}
|
||||
@@ -56,6 +57,7 @@
|
||||
|
||||
<div
|
||||
x-ref="menu"
|
||||
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-fab-menu']) }}
|
||||
id="material-fab-menu-{{ $key }}"
|
||||
popover="auto"
|
||||
role="menu"
|
||||
|
||||
@@ -12,13 +12,19 @@
|
||||
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
|
||||
`inline`. An option with `'disabled' => true` greys its own segment.
|
||||
|
||||
ReStride's props, kept: `label`, `hint`, `name` (needed with `x-model`, which names no
|
||||
property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`, `variant`,
|
||||
`multiple`, `inline`. A validation message for the bound property replaces the hint. --}}
|
||||
ReStride's props, kept: `label`, `hint`, `hint-class`, `name` (needed with `x-model`, which
|
||||
names no property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`,
|
||||
`variant`, `multiple`, `inline`. A validation message for the bound property replaces the hint.
|
||||
|
||||
`hint-class` adds classes to the hint, as on `<x-field>`: a colour there paints it
|
||||
(`hint-class="text-warning"` for a hint that warns). The hint's own colour then carries no
|
||||
specificity, as the field's does in the components layer, because which of two colour
|
||||
utilities wins depends on the order Tailwind emits them. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'hint' => null,
|
||||
'hintClass' => null,
|
||||
'name' => null,
|
||||
'options' => [],
|
||||
'optionValue' => 'id',
|
||||
@@ -46,6 +52,10 @@
|
||||
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
|
||||
][$size];
|
||||
|
||||
$hintClasses = filled($hintClass)
|
||||
? \Illuminate\Support\Arr::toCssClasses(['mt-1 type-body-sm [:where(&)]:text-on-surface-variant', $hintClass])
|
||||
: 'mt-1 type-body-sm text-on-surface-variant';
|
||||
|
||||
$iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
|
||||
|
||||
$colours = match ($variant) {
|
||||
@@ -94,6 +104,6 @@
|
||||
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
||||
@endforeach
|
||||
@elseif (filled($hint))
|
||||
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
|
||||
<p class="{{ $hintClasses }}">{{ $hint }}</p>
|
||||
@endif
|
||||
</fieldset>
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
{{-- One item in an `<x-menu>`: an action, a link, or a choice.
|
||||
|
||||
`label`, a leading `icon`, an `icon-right`, a `description` under the label and a
|
||||
`shortcut` at the end (M3's trailing supporting text: "⌘C"). `link` makes it an anchor, with
|
||||
`wire:navigate` unless `external` or `no-wire-navigate`. `selected` (true or false) makes it a
|
||||
`menuitemcheckbox` with `aria-checked`; a selected item takes Expressive's selected shape and
|
||||
tertiary-container. `disabled` keeps it in the list, out of reach. `keep-open` leaves the menu
|
||||
open when it is activated — for a choice the person may want to change twice.
|
||||
`label`, a leading `icon` (`icon-class` adds classes to it), an `icon-right`, a `description`
|
||||
under the label and a `shortcut` at the end (M3's trailing supporting text: "⌘C"). `link`
|
||||
makes it an anchor, with `wire:navigate` unless `external` or `no-wire-navigate`. `selected`
|
||||
(true or false) makes it a `menuitemcheckbox` with `aria-checked`; a selected item takes
|
||||
Expressive's selected shape and tertiary-container. `current` is for a menu of places rather
|
||||
than choices — a section picker — and marks the page you are on: `aria-current="page"`, the
|
||||
selected shape in secondary-container, the colour M3 gives the navigation indicator. `badge`
|
||||
draws `<x-badge>` at the end of the row: `true` for a dot, or a count. `disabled` keeps it in the list, out of
|
||||
reach. `keep-open` leaves the menu open when it is activated — for a choice the person may
|
||||
want to change twice.
|
||||
|
||||
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
|
||||
sport's colour (`icon-class="text-sport-run"`). A colour there paints the icon, a selected
|
||||
item's too: the icon's own colour then carries no specificity, because which of two colour
|
||||
utilities wins depends on the order Tailwind emits them. A disabled item's icon stays
|
||||
disabled.
|
||||
|
||||
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
|
||||
12px at the ends of the list. --}}
|
||||
@@ -13,6 +23,7 @@
|
||||
@props([
|
||||
'label' => null,
|
||||
'icon' => null,
|
||||
'iconClass' => null,
|
||||
'iconRight' => null,
|
||||
'description' => null,
|
||||
'shortcut' => null,
|
||||
@@ -20,6 +31,8 @@
|
||||
'external' => false,
|
||||
'noWireNavigate' => false,
|
||||
'selected' => null,
|
||||
'current' => false,
|
||||
'badge' => null,
|
||||
'disabled' => false,
|
||||
'keepOpen' => false,
|
||||
])
|
||||
@@ -36,11 +49,13 @@
|
||||
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary',
|
||||
'py-2' => filled($description),
|
||||
'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true,
|
||||
'rounded-corner-md bg-secondary-container text-on-secondary-container' => $current && $selected !== true,
|
||||
'pointer-events-none text-on-surface/38' => $disabled,
|
||||
])
|
||||
->merge(array_filter([
|
||||
'role' => $selected === null ? 'menuitem' : 'menuitemcheckbox',
|
||||
'aria-checked' => $selected === null ? null : ($selected ? 'true' : 'false'),
|
||||
'aria-current' => $current ? 'page' : null,
|
||||
'aria-disabled' => $disabled ? 'true' : null,
|
||||
'tabindex' => '-1',
|
||||
'type' => $isLink ? null : 'button',
|
||||
@@ -54,13 +69,22 @@
|
||||
$iconInk = match (true) {
|
||||
$disabled => 'text-on-surface/38',
|
||||
$selected === true => 'text-on-tertiary-container',
|
||||
$current => 'text-on-secondary-container',
|
||||
default => 'text-on-surface-variant',
|
||||
};
|
||||
|
||||
$leadingIcon = match (true) {
|
||||
blank($iconClass) => 'size-5 '.$iconInk,
|
||||
$disabled => \Illuminate\Support\Arr::toCssClasses(['size-5', $iconClass, 'text-on-surface/38!']),
|
||||
$selected === true => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-tertiary-container', $iconClass]),
|
||||
$current => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-secondary-container', $iconClass]),
|
||||
default => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-surface-variant', $iconClass]),
|
||||
};
|
||||
@endphp
|
||||
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($icon)
|
||||
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="'size-5 '.$iconInk" />
|
||||
<x-livewire-material::icon :name="$icon" :filled="$selected === true || $current" :class="$leadingIcon" />
|
||||
@endif
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
@@ -71,6 +95,9 @@
|
||||
@endif
|
||||
</span>
|
||||
|
||||
@if ($badge !== null && $badge !== false && $badge !== '')
|
||||
<x-livewire-material::badge :value="$badge === true ? null : $badge" class="shrink-0" />
|
||||
@endif
|
||||
@if ($shortcut)
|
||||
<span @class(['shrink-0 type-label-sm', $iconInk])>{{ $shortcut }}</span>
|
||||
@endif
|
||||
|
||||
@@ -13,10 +13,24 @@
|
||||
The trigger's first button or link becomes the menu button (aria-haspopup, aria-expanded,
|
||||
aria-controls). The list is a `popover="auto"` in the top layer, placed by CSS anchor
|
||||
positioning at `position` (`bottom-start`, `bottom-end`, `top-start`, `top-end`) and flipping
|
||||
when there is no room; a click outside or Escape closes it. The keyboard is WAI-ARIA's menu
|
||||
button: Enter, Space or ArrowDown open on the first item, ArrowUp on the last; arrows, Home,
|
||||
End and typing a letter move between items; Tab closes; activating an item closes the menu
|
||||
unless the item says `keep-open`, and Escape returns focus to the trigger.
|
||||
when there is no room — to the other side, the other end, or both, so a menu on a FAB in a
|
||||
corner of the window opens back across it; a click outside or Escape closes it. The keyboard
|
||||
is WAI-ARIA's menu button: Enter, Space or ArrowDown open on the first item, ArrowUp on the
|
||||
last; arrows, Home, End and typing a letter move between items; Tab closes; activating an item
|
||||
closes the menu unless the item says `keep-open`, and Escape returns focus to the trigger.
|
||||
|
||||
The anchor name is rendered on the wrapper around the trigger slot, the only element the
|
||||
server can name, and resources/js/menu.js moves it onto the menu button itself: a trigger
|
||||
that is `position: fixed` (`<x-button fab>` on a phone) leaves the wrapper behind as an empty
|
||||
box where the page put it, and the menu opened there.
|
||||
|
||||
The id and the anchor name are new with every render. The popover carries a `wire:key`, which
|
||||
a Livewire morph matches it by before the id, so a render of the component around an open
|
||||
menu patches it in place — still open, focus and listeners kept — instead of swapping in a
|
||||
closed copy; menu.js then writes the menu button's ARIA attributes again. The key goes
|
||||
through an attribute bag: Livewire compiles a `wire:key` written in a template into the key
|
||||
of the loop iteration around it, which would give every child component after the menu the
|
||||
same key.
|
||||
|
||||
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
|
||||
2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from
|
||||
@@ -43,6 +57,7 @@
|
||||
|
||||
<div
|
||||
x-ref="menu"
|
||||
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-menu']) }}
|
||||
id="material-menu-{{ $key }}"
|
||||
popover="auto"
|
||||
role="menu"
|
||||
@@ -53,7 +68,7 @@
|
||||
x-on:click="activate($event)"
|
||||
@class([
|
||||
'm-0 min-w-28 max-w-70 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
|
||||
'my-1 [position-try-fallbacks:flip-block,flip-inline]',
|
||||
'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
|
||||
'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
|
||||
'bg-surface-container-low text-on-surface' => ! $vibrant,
|
||||
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
>
|
||||
<div @class([
|
||||
'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3',
|
||||
'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[env(safe-area-inset-top)]' => $fullscreen,
|
||||
'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen,
|
||||
$boxClass,
|
||||
])>
|
||||
@if ($fullscreen)
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
form M3 asks for when it has actions. The bubble is a popover in surface-container with a
|
||||
medium corner and elevation 2, 312px at most, placed by anchor positioning on `side`.
|
||||
|
||||
The bubble's id and anchor name are new with every render; its `wire:key` (see `<x-menu>`)
|
||||
lets a Livewire morph patch it in place, so an open bubble stays open — through its own
|
||||
action's `wire:click` too — and resources/js/rich-tooltip.js keeps showing and hiding the
|
||||
element on the page rather than one the morph took away.
|
||||
|
||||
RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
|
||||
text in on-surface-variant, label-large actions in primary. --}}
|
||||
|
||||
@@ -35,6 +40,7 @@
|
||||
|
||||
<span
|
||||
x-ref="bubble"
|
||||
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-rich-tooltip']) }}
|
||||
id="material-rich-tooltip-{{ $key }}"
|
||||
popover="{{ $persistent ? 'auto' : 'manual' }}"
|
||||
role="{{ $persistent ? 'dialog' : 'tooltip' }}"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
{{-- A choice of colour profile: one swatch per profile `material:scheme` generated from
|
||||
`livewire-material.profiles`, each its name and its primary, secondary and tertiary colour.
|
||||
|
||||
<x-scheme-picker label="Colour profile" wire:model="colorProfile" hint="Applies to every page after saving" />
|
||||
|
||||
Native radios under the swatches, so `wire:model` and `x-model` bind as on any input and the
|
||||
arrow keys move the choice. Choosing one shows it on the page at once
|
||||
(`$store.theme.previewScheme`); storing it — and telling `Scheme::resolveProfileUsing()` — is
|
||||
the application's. The dots are the only colours not drawn from tokens: they show other
|
||||
profiles than the page's, so they are custom properties set inline from the scheme file's
|
||||
checked hexes, light or dark with the page. Without profiles it renders nothing.
|
||||
|
||||
Props: `label`, `hint`, `name` (needed with `x-model`), `profiles` (default: every generated
|
||||
profile). A validation message for the bound property replaces the hint. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'hint' => null,
|
||||
'name' => null,
|
||||
'profiles' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$profiles ??= \NoNameWeb\LivewireMaterial\Support\Scheme::profiles();
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$name ??= $model ?: 'scheme';
|
||||
$errorKey = $model ?: (filled($attributes->get('name')) ? (string) $attributes->get('name') : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
@if ($profiles !== [])
|
||||
<fieldset x-data data-scheme-picker {{ $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except('name')->class('min-w-0') }}>
|
||||
@if (filled($label))
|
||||
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
@foreach ($profiles as $profile => $scheme)
|
||||
<label
|
||||
data-scheme-option="{{ $profile }}"
|
||||
class="state-layer flex min-w-0 cursor-pointer select-none flex-col items-start gap-2 rounded-corner-lg border border-outline-variant bg-surface-container-low p-3 text-on-surface transition-[background-color,border-color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary"
|
||||
>
|
||||
<input
|
||||
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
|
||||
type="radio"
|
||||
name="{{ $name }}"
|
||||
value="{{ $profile }}"
|
||||
x-on:change="$store.theme.previewScheme($event.target.value)"
|
||||
class="peer sr-only"
|
||||
/>
|
||||
|
||||
<span class="flex shrink-0 -space-x-1.5" aria-hidden="true">
|
||||
@foreach (['primary', 'secondary', 'tertiary'] as $role)
|
||||
<span
|
||||
style="--swatch-light: {{ $scheme['light'][$role] }}; --swatch-dark: {{ $scheme['dark'][$role] }}"
|
||||
class="size-5 rounded-full bg-(--swatch-light) ring-2 ring-surface-container-low in-has-checked:ring-secondary-container dark:bg-(--swatch-dark)"
|
||||
></span>
|
||||
@endforeach
|
||||
</span>
|
||||
|
||||
<span class="w-full truncate pe-6 type-label-lg">{{ __($scheme['label']) }}</span>
|
||||
|
||||
{{-- In the corner, so a long name keeps its room. The wrapper carries the position. --}}
|
||||
<span class="pointer-events-none absolute end-2 top-2 hidden peer-checked:block">
|
||||
<x-livewire-material::icon name="check" class="size-5" />
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($messages !== [])
|
||||
@foreach ($messages as $message)
|
||||
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
||||
@endforeach
|
||||
@elseif (filled($hint))
|
||||
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
|
||||
@endif
|
||||
</fieldset>
|
||||
@endif
|
||||
@@ -2,11 +2,15 @@
|
||||
single destination in the app's own navigation.
|
||||
|
||||
`items`, a list of `['title' => …, 'url' => …]` with an optional `icon`, `active` and `badge`
|
||||
(an item is current when `active` is true, or when its `url` is the request's). From `sm` they
|
||||
(an item is current when `active` is true, or when its `url` is the page's). From `sm` they
|
||||
are M3's secondary tabs as links, the current one underlined; below `sm`, where a row of them
|
||||
never fits, a button naming the current section opens a menu of all of them. The same list is
|
||||
never fits, a button naming the current section opens a menu of all of them, the current one
|
||||
marked `aria-current="page"` and each with its badge. The same list is
|
||||
rendered for both, and CSS shows one.
|
||||
|
||||
The page's URL is `Livewire::originalUrl()`: while a Livewire component on the page updates, the
|
||||
request is Livewire's update endpoint, and comparing with it left no section lit.
|
||||
|
||||
A row too long for its column wraps onto a grid rather than scrolling: below `xl` five or six
|
||||
sections go 3 + 3 and seven or more go four to a row — tabs that scroll hid the last sections on
|
||||
a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless
|
||||
@@ -20,7 +24,8 @@
|
||||
|
||||
@php
|
||||
$label ??= __('Sections');
|
||||
$isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && url()->current() === url($item['url']));
|
||||
$page = \Livewire\Livewire::originalUrl();
|
||||
$isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && $page === url($item['url']));
|
||||
$current = collect($items)->first($isCurrent) ?? ($items[0] ?? null);
|
||||
|
||||
$layout = match (true) {
|
||||
@@ -45,7 +50,7 @@
|
||||
</x-slot:trigger>
|
||||
|
||||
@foreach ($items as $item)
|
||||
<x-livewire-material::menu-item :label="$item['title']" :icon="$item['icon'] ?? null" :link="$item['url']" :selected="$isCurrent($item)" :no-wire-navigate="$noWireNavigate" />
|
||||
<x-livewire-material::menu-item :label="$item['title']" :icon="$item['icon'] ?? null" :link="$item['url']" :current="$isCurrent($item)" :badge="$item['badge'] ?? null" :no-wire-navigate="$noWireNavigate" />
|
||||
@endforeach
|
||||
</x-livewire-material::menu>
|
||||
</div>
|
||||
|
||||
@@ -11,22 +11,38 @@
|
||||
JSON-encoded (maryUI's `"dark"`) — is adopted once and removed. Nothing is written for a
|
||||
visitor who never chose, so changing `theme.default` later reaches them too.
|
||||
|
||||
With colour profiles (`livewire-material.profiles`, generated by `material:scheme`), the
|
||||
active one — `Scheme::profile()`, which asks the application's resolver — is written to
|
||||
<html data-scheme>, which the generated stylesheet keys each profile on.
|
||||
|
||||
The rail rides along for the theme's reason: <html data-rail> is `expanded` or `collapsed`
|
||||
(`livewire-material.rail.storage_key`, falling back to `rail.default`), and a collapsible
|
||||
rail's width is CSS keyed on it (the `rail-collapsed:` variant). Set any later, a collapsed
|
||||
rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
|
||||
changes it.
|
||||
|
||||
With `theme.meta` on, the browser's own chrome follows too: the `content` of every
|
||||
<meta name="theme-color"> without a `media` attribute — one is added to <head> when there is
|
||||
none — is the resolved theme's `surface`, from the scheme file (`Scheme`), for the profile in
|
||||
<html data-scheme> (else the active one). A MutationObserver on <html> keeps it in step with
|
||||
whatever changes `data-theme` or `data-scheme` afterwards: `$store.theme.set()` and `toggle()`,
|
||||
an OS change while `system`, a profile preview, the application's own script. A layout's own
|
||||
theme-color meta belongs before this script: one written after it is only painted on
|
||||
DOMContentLoaded, beside the one added here. Off by default, and then none of it is emitted.
|
||||
|
||||
wire:navigate swaps the body, merges the head without running this again, and gives <html>
|
||||
the next page's attributes — which the server rendered without any of these, so Livewire
|
||||
removes them. They are put back as the new page is swapped in (`onSwap`, in the same task,
|
||||
before anything paints), so this only has to run on a full load. --}}
|
||||
before anything paints), so this only has to run on a full load. The head merge also puts the
|
||||
next page's server-rendered theme-color meta in place of the painted one, so it is painted
|
||||
again there, and once more on `livewire:navigated`. --}}
|
||||
|
||||
@php
|
||||
$theme = config('livewire-material.theme');
|
||||
$rail = config('livewire-material.rail');
|
||||
|
||||
$settings = [
|
||||
'scheme' => \NoNameWeb\LivewireMaterial\Support\Scheme::profile(),
|
||||
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
|
||||
'key' => $theme['storage_key'] ?? 'material-theme',
|
||||
'legacy' => array_values($theme['legacy_keys'] ?? []),
|
||||
@@ -35,6 +51,18 @@
|
||||
'key' => $rail['storage_key'] ?? 'material-rail',
|
||||
],
|
||||
];
|
||||
|
||||
// Only the surfaces the meta can show: the active scheme's, and every profile's for a preview.
|
||||
if ((bool) ($theme['meta'] ?? false)) {
|
||||
$surfaces = fn (array $scheme): array => ['light' => $scheme['light']['surface'], 'dark' => $scheme['dark']['surface']];
|
||||
$schemeProfiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles();
|
||||
|
||||
$settings['meta'] = [
|
||||
// PHP 8.5 deprecates a null array offset: without profiles the scheme is null.
|
||||
...$surfaces(($settings['scheme'] !== null ? ($schemeProfiles[$settings['scheme']] ?? null) : null) ?? \NoNameWeb\LivewireMaterial\Support\Scheme::load()),
|
||||
'profiles' => (object) collect($schemeProfiles)->map($surfaces)->all(),
|
||||
];
|
||||
}
|
||||
@endphp
|
||||
|
||||
<script>
|
||||
@@ -78,6 +106,10 @@
|
||||
root.setAttribute('data-theme', current === 'system' ? (media.matches ? 'dark' : 'light') : current);
|
||||
};
|
||||
|
||||
if (settings.scheme) {
|
||||
root.setAttribute('data-scheme', settings.scheme);
|
||||
}
|
||||
|
||||
root.setAttribute('data-theme-key', settings.key);
|
||||
root.setAttribute('data-theme-choice', choice);
|
||||
root.setAttribute('data-rail-key', settings.rail.key);
|
||||
@@ -85,9 +117,42 @@
|
||||
apply();
|
||||
|
||||
media.addEventListener('change', apply);
|
||||
@if (isset($settings['meta']))
|
||||
|
||||
var paintThemeColor = function () {
|
||||
var theme = root.getAttribute('data-theme');
|
||||
var scheme = root.getAttribute('data-scheme');
|
||||
|
||||
if ((theme !== 'light' && theme !== 'dark') || !document.head) {
|
||||
return;
|
||||
}
|
||||
|
||||
var colour = (Object.prototype.hasOwnProperty.call(settings.meta.profiles, scheme) ? settings.meta.profiles[scheme] : settings.meta)[theme];
|
||||
var metas = document.head.querySelectorAll('meta[name="theme-color"]:not([media])');
|
||||
|
||||
if (metas.length === 0) {
|
||||
var meta = document.createElement('meta');
|
||||
|
||||
meta.setAttribute('name', 'theme-color');
|
||||
document.head.appendChild(meta);
|
||||
metas = [meta];
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(metas, function (meta) {
|
||||
if (meta.getAttribute('content') !== colour) {
|
||||
meta.setAttribute('content', colour);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
paintThemeColor();
|
||||
new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme'] });
|
||||
document.addEventListener('DOMContentLoaded', paintThemeColor);
|
||||
document.addEventListener('livewire:navigated', paintThemeColor);
|
||||
@endif
|
||||
|
||||
document.addEventListener('livewire:navigating', function (event) {
|
||||
var kept = ['data-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) {
|
||||
return [name, root.getAttribute(name)];
|
||||
});
|
||||
|
||||
@@ -97,6 +162,10 @@
|
||||
root.setAttribute(attribute[0], attribute[1]);
|
||||
}
|
||||
});
|
||||
@if (isset($settings['meta']))
|
||||
|
||||
paintThemeColor();
|
||||
@endif
|
||||
});
|
||||
});
|
||||
})(@json($settings));
|
||||
|
||||
@@ -5,9 +5,18 @@
|
||||
|
||||
It shows every `toast` browser event — what `NoNameWeb\LivewireMaterial\Concerns\Toasts`
|
||||
dispatches from a Livewire component — and for `window.materialToast(title, options)` from
|
||||
JavaScript (`{ type, description, timeout, action: { label, handler } }`). Toasts queue and
|
||||
show in turn, each for its `timeout` (4s by default; M3 asks for 4–10s), paused while the
|
||||
pointer or focus is on it. A toast with an action or no timeout gets a close button.
|
||||
JavaScript (`{ type, description, timeout, sticky, action: { label, handler, event } }`).
|
||||
Toasts queue and show in turn, each for its `timeout` (4s by default; M3 asks for 4–10s),
|
||||
paused while the pointer or focus is on it. A toast with an action or no timeout gets a close
|
||||
button. Pressing the action closes the snackbar, calls `handler` and dispatches `event` (a
|
||||
name) on `window`; both may be given.
|
||||
|
||||
`sticky: true` keeps a toast until it is dismissed or its action pressed, without holding up
|
||||
the queue: a toast that arrives meanwhile shows in its place, and the sticky one comes back
|
||||
once the queue is empty. One is kept at a time; a newer sticky toast replaces it.
|
||||
|
||||
Hooks for tests and styling: `data-toast` on the snackbar on screen, `data-toast-action` on its
|
||||
action button.
|
||||
|
||||
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
|
||||
still on screen when the next page arrives.
|
||||
@@ -32,6 +41,7 @@
|
||||
<template x-if="current">
|
||||
<div
|
||||
x-bind:key="current.id"
|
||||
data-toast
|
||||
x-bind:role="current.type === 'error' || current.type === 'warning' ? 'alert' : 'status'"
|
||||
aria-live="polite"
|
||||
x-on:mouseenter="pause()"
|
||||
@@ -60,7 +70,7 @@
|
||||
</div>
|
||||
|
||||
<template x-if="current.action">
|
||||
<button type="button" class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
|
||||
<button type="button" data-toast-action class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
|
||||
</template>
|
||||
|
||||
<template x-if="current.action || ! current.timeout">
|
||||
|
||||
@@ -132,6 +132,19 @@
|
||||
</div>
|
||||
|
||||
<x-slot:actions>
|
||||
{{-- With colour profiles, a preview of each on this page; nothing is stored. --}}
|
||||
@if (($profiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles()) !== [])
|
||||
<x-livewire-material::menu label="Colour profile" position="bottom-end" data-test="showcase-profiles">
|
||||
<x-slot:trigger>
|
||||
<x-livewire-material::button icon="palette" tooltip="Colour profile" />
|
||||
</x-slot:trigger>
|
||||
|
||||
@foreach ($profiles as $name => $profile)
|
||||
<x-livewire-material::menu-item :label="$profile['label']" x-on:click="$store.theme.previewScheme('{{ $name }}')" data-scheme-preview="{{ $name }}" />
|
||||
@endforeach
|
||||
</x-livewire-material::menu>
|
||||
@endif
|
||||
|
||||
<span class="ms-2 me-3 max-md:hidden"><x-livewire-material::theme-toggle mode="picker" /></span>
|
||||
<span class="md:hidden"><x-livewire-material::theme-toggle mode="cycle" /></span>
|
||||
</x-slot:actions>
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
['id' => 'system', 'name' => 'System', 'icon' => 'computer'],
|
||||
]" />
|
||||
|
||||
<x-group label="Days" name="showcase-days" x-model="days" multiple variant="outlined" hint="Choose any" :options="[
|
||||
<x-group label="Days" name="showcase-days" x-model="days" multiple variant="outlined" hint="Thursday is fully booked" hint-class="text-warning" :options="[
|
||||
['id' => 'mon', 'name' => 'Mon'],
|
||||
['id' => 'tue', 'name' => 'Tue'],
|
||||
['id' => 'wed', 'name' => 'Wed'],
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
'Surface' => ['bg-surface', 'bg-surface-dim', 'bg-surface-bright', 'bg-surface-container-lowest', 'bg-surface-container-low', 'bg-surface-container', 'bg-surface-container-high', 'bg-surface-container-highest', 'bg-on-surface', 'bg-on-surface-variant', 'bg-inverse-surface', 'bg-inverse-on-surface', 'bg-outline', 'bg-outline-variant'],
|
||||
'Ink and lines' => ['bg-body', 'bg-meta', 'bg-quiet', 'bg-structure', 'bg-chrome', 'bg-divider'],
|
||||
];
|
||||
|
||||
$examples = [
|
||||
'Colour profiles' => <<<'BLADE'
|
||||
<div x-data="{ profile: $store.theme.scheme }" class="w-full max-w-3xl">
|
||||
<x-scheme-picker label="Colour profile" name="profile" x-model="profile" hint="Previews on this page; an application stores the choice and names it with Scheme::resolveProfileUsing()." />
|
||||
</div>
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section id="colour" class="scroll-mt-24 space-y-6">
|
||||
@@ -20,6 +28,16 @@
|
||||
<code>php artisan material:scheme</code>. Both themes side by side, whatever the page is showing.
|
||||
</p>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
With colour profiles in <code>livewire-material.profiles</code>, the command generates each one under
|
||||
<code><html data-scheme></code>, and <code><x-scheme-picker></code> chooses between them. Without
|
||||
profiles the picker draws nothing.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
<x-showcase::example :$title :$code />
|
||||
@endforeach
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
@foreach (['light', 'dark'] as $theme)
|
||||
<div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface">
|
||||
|
||||
@@ -7,13 +7,22 @@
|
||||
<x-badge value="Expired" tonal />
|
||||
<x-badge value="Active" color="success" tonal />
|
||||
<x-badge value="Password" color="info" tonal />
|
||||
<x-badge value="Built in" color="primary" solid />
|
||||
<x-badge value="Pro" outline />
|
||||
<x-badge value="Draft" color="neutral" tonal />
|
||||
<x-badge value="Archived" color="neutral" outline />
|
||||
<span class="relative inline-flex"><x-icon name="chat" /><x-badge value="2" color="neutral" floating /></span>
|
||||
<x-badge tonal color="tertiary"><x-icon name="bolt" filled class="size-3" /> Pro</x-badge>
|
||||
<x-badge value="Beta" tonal color="plain" class="bg-primary-fixed text-on-primary-fixed" />
|
||||
BLADE,
|
||||
'Snackbars' => <<<'BLADE'
|
||||
<x-button label="Saved" variant="tonal" x-on:click="materialToast('Settings saved', { type: 'success' })" />
|
||||
<x-button label="With a description" variant="tonal" x-on:click="materialToast('Upload failed', { type: 'error', description: 'The file is larger than 4 GB.' })" />
|
||||
<x-button label="With an action" variant="tonal" x-on:click="materialToast('Share deleted', { action: { label: 'Undo', handler: () => materialToast('Share restored', { type: 'info' }) } })" />
|
||||
<x-button label="Until dismissed" variant="tonal" x-on:click="materialToast('Your storage is almost full', { type: 'warning', timeout: 0 })" />
|
||||
<div x-data class="flex flex-wrap items-center gap-4">
|
||||
<x-button label="Saved" variant="tonal" x-on:click="materialToast('Settings saved', { type: 'success' })" />
|
||||
<x-button label="With a description" variant="tonal" x-on:click="materialToast('Upload failed', { type: 'error', description: 'The file is larger than 4 GB.' })" />
|
||||
<x-button label="With an action" variant="tonal" x-on:click="materialToast('Share deleted', { action: { label: 'Undo', handler: () => materialToast('Share restored', { type: 'info' }) } })" />
|
||||
<x-button label="Until dismissed" variant="tonal" x-on:click="materialToast('Your storage is almost full', { type: 'warning', timeout: 0 })" />
|
||||
<x-button label="Sticky, with an event" variant="tonal" x-on:click="materialToast('A new version is ready', { type: 'info', sticky: true, action: { label: 'Reload', event: 'showcase:reload' } })" x-on:showcase:reload.window="materialToast('Reloading…')" />
|
||||
</div>
|
||||
BLADE,
|
||||
'Plain tooltips' => <<<'BLADE'
|
||||
<x-button icon="content_copy" tooltip="Copy link" />
|
||||
@@ -58,6 +67,22 @@
|
||||
</x-slot:actions>
|
||||
</x-empty-state>
|
||||
BLADE,
|
||||
'Empty state with an illustration' => <<<'BLADE'
|
||||
<x-empty-state title="No routes yet" description="Draw a route on the map, or import one from a GPX file." class="w-full">
|
||||
<x-slot:illustration class="text-primary">
|
||||
<svg class="size-32" viewBox="0 0 120 120" fill="none" aria-hidden="true">
|
||||
<circle cx="60" cy="60" r="56" class="fill-primary-container" />
|
||||
<path d="M28 86C40 62 56 92 68 64S86 42 90 46" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-dasharray="1 10" />
|
||||
<circle cx="28" cy="86" r="7" fill="currentColor" />
|
||||
<path d="M90 18a12 12 0 0 1 12 12c0 10-12 22-12 22S78 40 78 30a12 12 0 0 1 12-12Z" class="fill-tertiary" />
|
||||
<circle cx="90" cy="30" r="4" class="fill-on-tertiary" />
|
||||
</svg>
|
||||
</x-slot:illustration>
|
||||
<x-slot:actions>
|
||||
<x-button label="Draw a route" icon="route" variant="filled" />
|
||||
</x-slot:actions>
|
||||
</x-empty-state>
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
|
||||
@@ -55,6 +55,18 @@
|
||||
<div class="flex h-10 items-center gap-4"><span>Left</span><x-divider vertical /><span>Right</span></div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Collapse bound to a property' => <<<'BLADE'
|
||||
<div x-data="{ advanced: false }" class="w-full space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<x-button label="Open" variant="tonal" x-on:click="advanced = true" />
|
||||
<x-button label="Close" variant="tonal" x-on:click="advanced = false" />
|
||||
<span class="type-body-md text-on-surface-variant">advanced: <span x-text="advanced"></span></span>
|
||||
</div>
|
||||
<x-collapse title="Advanced settings" icon="tune" variant="filled" x-model="advanced">
|
||||
Bound with x-model here. In a Livewire view, wire:model="advanced" binds a boolean property the same way, and the page arrives with it open or closed as the property is.
|
||||
</x-collapse>
|
||||
</div>
|
||||
BLADE,
|
||||
'Dialogs' => <<<'BLADE'
|
||||
<div x-data="{ open: false }">
|
||||
<x-button label="Basic dialog" variant="tonal" x-on:click="open = true" />
|
||||
|
||||
@@ -36,6 +36,18 @@
|
||||
<x-menu-item label="Upload a folder" icon="drive_folder_upload" />
|
||||
</x-menu>
|
||||
BLADE,
|
||||
'Icons in their own colour' => <<<'BLADE'
|
||||
<x-menu label="New plan">
|
||||
<x-slot:trigger>
|
||||
<x-button label="New plan" icon="add" variant="filled" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-item label="Running" icon="directions_run" icon-class="text-tertiary" />
|
||||
<x-menu-item label="Cycling" icon="directions_bike" icon-class="text-secondary" />
|
||||
<x-menu-item label="Swimming" icon="pool" icon-class="text-info" />
|
||||
<x-menu-item label="Rowing" icon="rowing" icon-class="text-tertiary" disabled />
|
||||
</x-menu>
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
|
||||
@@ -39,6 +39,19 @@
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'First day of the week and format' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ race: '{{ now()->addMonth()->format('Y-m-d') }}', block: { start: null, end: null } }">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Race day" week-start="0" format="yyyy-MM-dd" x-model="race" hint="Weeks start on Sunday; typed year first" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(race)"></code></p>
|
||||
</div>
|
||||
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Training block" range mode="modal" week-start="1" format="dd.MM.yyyy" x-model="block" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(block)"></code></p>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Limits, errors and states' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||
<div class="grid content-start gap-4">
|
||||
@@ -62,7 +75,7 @@
|
||||
<h2 class="type-headline-md">Date pickers</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><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>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
</x-slot:actions>
|
||||
|
||||
<x-slot:top>
|
||||
<header class="sticky top-0 z-20 flex h-16 items-center gap-1 bg-surface px-1 pt-[env(safe-area-inset-top)] sm:px-4">
|
||||
<header class="sticky top-0 z-20 flex h-16 items-center gap-1 bg-surface px-1 pt-[var(--material-safe-top,env(safe-area-inset-top))] sm:px-4">
|
||||
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="shell-menu" /></span>
|
||||
<h1 class="min-w-0 flex-1 truncate px-3 type-title-lg sm:px-0">{{ $current['title'] }}</h1>
|
||||
<x-livewire-material::button icon="search" tooltip="Search" />
|
||||
|
||||
+190
-35
@@ -5,6 +5,7 @@ namespace NoNameWeb\LivewireMaterial\Console;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
use JsonException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
|
||||
@@ -15,8 +16,9 @@ class SchemeCommand extends Command
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'material:scheme
|
||||
{seed : The source colour, as #rrggbb}
|
||||
{seed? : The source colour, as #rrggbb; without it, every profile in livewire-material.profiles}
|
||||
{--variant=tonal-spot : tonal-spot, vibrant, expressive, neutral, fidelity, content, monochrome, rainbow or fruit-salad}
|
||||
{--spec=2025 : The colour spec: 2025 (M3 Expressive) or 2021 (M3 as it first shipped)}
|
||||
{--contrast=0 : The contrast level, from -1 to 1}
|
||||
{--success=#22a06b : The source of the success colour}
|
||||
{--warning=#e2a400 : The source of the warning colour}
|
||||
@@ -26,43 +28,129 @@ class SchemeCommand extends Command
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour';
|
||||
protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour, or every configured colour profile';
|
||||
|
||||
/**
|
||||
* The colour specs Google's colour utilities know. 2025 is M3 Expressive's colour; the library
|
||||
* falls back to 2021 by itself for the variants 2025 does not define.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected const SPECS = ['2021', '2025'];
|
||||
|
||||
public function handle(Filesystem $files): int
|
||||
{
|
||||
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
|
||||
$data = preg_replace('/\.css$/', '', $stylesheet).'.json';
|
||||
$spec = (string) $this->option('spec');
|
||||
|
||||
if (! in_array($spec, self::SPECS, true)) {
|
||||
$this->components->error("Unknown spec \"{$spec}\". Use one of: ".implode(', ', self::SPECS).'.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (filled($this->argument('seed'))) {
|
||||
$input = [
|
||||
'seed' => (string) $this->argument('seed'),
|
||||
'variant' => (string) $this->option('variant'),
|
||||
'spec' => $spec,
|
||||
'contrast' => (float) $this->option('contrast'),
|
||||
'success' => (string) $this->option('success'),
|
||||
'warning' => (string) $this->option('warning'),
|
||||
'info' => (string) $this->option('info'),
|
||||
];
|
||||
|
||||
$scheme = $this->generate($input);
|
||||
|
||||
if ($scheme === null) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return $this->write($files, $stylesheet, $data, $this->stylesheet($scheme, $input), $scheme);
|
||||
}
|
||||
|
||||
$profiles = config('livewire-material.profiles');
|
||||
|
||||
if (! is_array($profiles) || $profiles === []) {
|
||||
$this->components->error('Give a seed colour (php artisan material:scheme "#4f46e5"), or list colour profiles in livewire-material.profiles.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$generated = [];
|
||||
|
||||
foreach ($profiles as $name => $profile) {
|
||||
if (! is_string($name) || preg_match('/^[a-z0-9-]+$/', $name) !== 1) {
|
||||
$this->components->error("A profile's name is lowercase letters, digits and dashes; \"{$name}\" is not.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// A profile's own spec and state colours win; without them, the command's options apply.
|
||||
$scheme = $this->generate([
|
||||
'seed' => (string) ($profile['seed'] ?? ''),
|
||||
'variant' => (string) ($profile['variant'] ?? 'tonal-spot'),
|
||||
'spec' => (string) ($profile['spec'] ?? $spec),
|
||||
'contrast' => (float) ($profile['contrast'] ?? 0),
|
||||
'success' => (string) ($profile['success'] ?? $this->option('success')),
|
||||
'warning' => (string) ($profile['warning'] ?? $this->option('warning')),
|
||||
'info' => (string) ($profile['info'] ?? $this->option('info')),
|
||||
], "Profile \"{$name}\": ");
|
||||
|
||||
if ($scheme === null) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$generated[$name] = ['label' => (string) ($profile['label'] ?? Str::headline($name)), ...$scheme];
|
||||
}
|
||||
|
||||
$configured = config('livewire-material.profile');
|
||||
$default = is_string($configured) && isset($generated[$configured]) ? $configured : array_key_first($generated);
|
||||
|
||||
return $this->write($files, $stylesheet, $data, $this->profilesStylesheet($generated, $default), [
|
||||
...collect($generated[$default])->except('label')->all(),
|
||||
'default' => $default,
|
||||
'profiles' => $generated,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One scheme from Google's colour utilities, or null once the reason has been shown.
|
||||
*
|
||||
* @param array{seed: string, variant: string, spec: string, contrast: float, success: string, warning: string, info: string} $input
|
||||
* @return array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}|null
|
||||
*/
|
||||
protected function generate(array $input, string $context = ''): ?array
|
||||
{
|
||||
$result = Process::run([
|
||||
config('livewire-material.node', 'node'),
|
||||
__DIR__.'/../../resources/node/scheme.mjs',
|
||||
json_encode([
|
||||
'seed' => $this->argument('seed'),
|
||||
'variant' => $this->option('variant'),
|
||||
'contrast' => (float) $this->option('contrast'),
|
||||
'success' => $this->option('success'),
|
||||
'warning' => $this->option('warning'),
|
||||
'info' => $this->option('info'),
|
||||
]),
|
||||
json_encode($input),
|
||||
]);
|
||||
|
||||
if ($result->failed()) {
|
||||
$this->components->error(trim($result->errorOutput()) ?: 'Node could not run the scheme generator. Is `node` installed and on the PATH?');
|
||||
$this->components->error($context.(trim($result->errorOutput()) ?: 'Node could not run the scheme generator. Is `node` installed and on the PATH?'));
|
||||
|
||||
return self::FAILURE;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme */
|
||||
$scheme = json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
|
||||
return json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
$this->components->error('The scheme generator answered with something other than JSON.');
|
||||
$this->components->error($context.'The scheme generator answered with something other than JSON.');
|
||||
|
||||
return self::FAILURE;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $scheme
|
||||
*/
|
||||
protected function write(Filesystem $files, string $stylesheet, string $data, string $css, array $scheme): int
|
||||
{
|
||||
$files->ensureDirectoryExists(dirname($stylesheet));
|
||||
$files->put($stylesheet, $this->stylesheet($scheme));
|
||||
$files->put($stylesheet, $css);
|
||||
$files->put($data, json_encode($scheme, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
|
||||
|
||||
$this->components->info("Wrote {$stylesheet} and {$data}.");
|
||||
@@ -71,20 +159,29 @@ class SchemeCommand extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* The stylesheet, headed by the command that regenerates it: every option that differs from
|
||||
* its default is written out.
|
||||
*
|
||||
* @param array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme
|
||||
* @param array{seed: string, variant: string, spec: string, contrast: float, success: string, warning: string, info: string} $input
|
||||
*/
|
||||
protected function stylesheet(array $scheme): string
|
||||
protected function stylesheet(array $scheme, array $input): string
|
||||
{
|
||||
$states = collect(['success', 'warning', 'info'])
|
||||
->reject(fn (string $state): bool => strtolower($input[$state]) === strtolower((string) $this->getDefinition()->getOption($state)->getDefault()))
|
||||
->map(fn (string $state): string => sprintf(' --%s="%s"', $state, strtolower($input[$state])))
|
||||
->implode('');
|
||||
|
||||
$command = sprintf(
|
||||
'php artisan material:scheme "%s" --variant=%s%s',
|
||||
'php artisan material:scheme "%s" --variant=%s%s%s%s',
|
||||
$scheme['seed'],
|
||||
$scheme['variant'],
|
||||
$input['spec'] !== '2025' ? ' --spec='.$input['spec'] : '',
|
||||
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
|
||||
$states,
|
||||
);
|
||||
|
||||
$block = fn (array $roles): string => collect($roles)
|
||||
->map(fn (string $hex, string $role): string => " --md-sys-color-{$role}: {$hex};")
|
||||
->implode("\n");
|
||||
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
|
||||
|
||||
return <<<CSS
|
||||
/*
|
||||
@@ -97,19 +194,77 @@ class SchemeCommand extends Command
|
||||
* first paint; the light block also stands without it.
|
||||
*/
|
||||
|
||||
:root,
|
||||
[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
{$block($scheme['light'])}
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
{$block($scheme['dark'])}
|
||||
}
|
||||
|
||||
{$blocks}
|
||||
CSS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default profile as the plain blocks, then every profile under its own data-scheme. A
|
||||
* profile's two-attribute selectors outrank the plain ones, and its one-attribute selector
|
||||
* comes after `:root`, which it only ties with: the order is part of the format. The
|
||||
* descendant selectors keep a nested `data-theme` panel (a light card on a dark page) in the
|
||||
* page's profile.
|
||||
*
|
||||
* @param array<string, array{label: string, seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}> $profiles
|
||||
*/
|
||||
protected function profilesStylesheet(array $profiles, string $default): string
|
||||
{
|
||||
$list = collect($profiles)
|
||||
->map(fn (array $profile, string $name): string => sprintf(
|
||||
' * %-12s %s, %s%s%s',
|
||||
$name,
|
||||
$profile['seed'],
|
||||
$profile['variant'],
|
||||
$profile['spec'] !== $profiles[$default]['spec'] ? ', spec '.$profile['spec'] : '',
|
||||
$profile['contrast'] != 0 ? ', contrast '.$profile['contrast'] : '',
|
||||
))
|
||||
->implode("\n");
|
||||
|
||||
$css = <<<CSS
|
||||
/*
|
||||
* Material 3 colour profiles, generated by Google's material-color-utilities (spec {$profiles[$default]['spec']}).
|
||||
*
|
||||
* php artisan material:scheme
|
||||
*
|
||||
* From livewire-material.profiles. "{$default}" is the default, and also stands without a
|
||||
* data-scheme attribute:
|
||||
*
|
||||
{$list}
|
||||
*
|
||||
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
|
||||
* M3's contrast guarantee only as generated. The head script sets data-theme and data-scheme
|
||||
* before the first paint.
|
||||
*/
|
||||
|
||||
CSS;
|
||||
|
||||
$css .= "\n".$this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $profiles[$default]);
|
||||
|
||||
foreach ($profiles as $name => $profile) {
|
||||
$css .= "\n".$this->blocks(
|
||||
["[data-scheme='{$name}']", "[data-scheme='{$name}'][data-theme='light']", "[data-scheme='{$name}'] [data-theme='light']"],
|
||||
["[data-scheme='{$name}'][data-theme='dark']", "[data-scheme='{$name}'] [data-theme='dark']"],
|
||||
$profile,
|
||||
);
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
/**
|
||||
* A light and a dark block of roles under the given selectors.
|
||||
*
|
||||
* @param list<string> $light
|
||||
* @param list<string> $dark
|
||||
* @param array{light: array<string, string>, dark: array<string, string>} $scheme
|
||||
*/
|
||||
protected function blocks(array $light, array $dark, array $scheme): string
|
||||
{
|
||||
$roles = fn (array $roles): string => collect($roles)
|
||||
->map(fn (string $hex, string $role): string => " --md-sys-color-{$role}: {$hex};")
|
||||
->implode("\n");
|
||||
|
||||
return implode(",\n", $light)." {\n color-scheme: light;\n\n".$roles($scheme['light'])."\n}\n\n"
|
||||
.implode(",\n", $dark)." {\n color-scheme: dark;\n\n".$roles($scheme['dark'])."\n}\n";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,12 +175,14 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
*/
|
||||
protected function registerShowcase(): void
|
||||
{
|
||||
// Registered even with the showcase off: `php artisan view:cache` compiles every view in the
|
||||
// package's namespace, the showcase's pages included, and <x-showcase::…> must resolve there.
|
||||
Blade::anonymousComponentPath(__DIR__.'/../resources/views/showcase/components', 'showcase');
|
||||
|
||||
if (! config('livewire-material.showcase.enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Blade::anonymousComponentPath(__DIR__.'/../resources/views/showcase/components', 'showcase');
|
||||
|
||||
if ($this->app->routesAreCached()) {
|
||||
return;
|
||||
}
|
||||
|
||||
+132
-14
@@ -2,9 +2,14 @@
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Support;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The colour scheme as data, for the places CSS custom properties cannot reach: a mail client
|
||||
* resolves none, and an error page whose build is missing has no stylesheet to declare them.
|
||||
* resolves none, an error page whose build is missing has no stylesheet to declare them, and the
|
||||
* head script has to name the active colour profile before the stylesheet applies.
|
||||
*
|
||||
* The data is the JSON `php artisan material:scheme` writes beside the stylesheet
|
||||
* (`livewire-material.scheme`, resources/css/material-scheme.json by default). Without that
|
||||
@@ -13,18 +18,51 @@ namespace NoNameWeb\LivewireMaterial\Support;
|
||||
* the role existed) is taken from the default, and a value that is not a #rrggbb hex is
|
||||
* refused, so nothing but a colour ever reaches a stylesheet.
|
||||
*
|
||||
* Read on every call and never kept: a regenerated scheme applies at once, and a long-running
|
||||
* worker holds nothing.
|
||||
* A file generated from `livewire-material.profiles` holds every profile under `profiles`, its
|
||||
* default under `default`, and the default's roles at the top level as a single scheme does. Which
|
||||
* profile is active is the application's to say, through resolveProfileUsing(); the resolver is
|
||||
* asked on every call, and a name it gives that is not in the file — or a resolver that throws, as
|
||||
* one reading a database may while an error page for that very database renders — falls back to
|
||||
* the file's default.
|
||||
*
|
||||
* Read on every call and never kept: a regenerated scheme or a newly chosen profile applies at
|
||||
* once, and a long-running worker holds nothing but the resolver itself.
|
||||
*/
|
||||
class Scheme
|
||||
{
|
||||
/**
|
||||
* @var (Closure(): ?string)|null
|
||||
*/
|
||||
protected static ?Closure $profileResolver = null;
|
||||
|
||||
/**
|
||||
* Name the active colour profile with the given closure, asked each time a colour is drawn.
|
||||
*
|
||||
* @param (Closure(): ?string)|null $resolver
|
||||
*/
|
||||
public static function resolveProfileUsing(?Closure $resolver): void
|
||||
{
|
||||
static::$profileResolver = $resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* The roles to draw: the given profile's, the active profile's, or the single scheme's.
|
||||
*
|
||||
* @return array{light: array<string, string>, dark: array<string, string>}
|
||||
*/
|
||||
public static function load(?string $path = null): array
|
||||
public static function load(?string $path = null, ?string $profile = null): array
|
||||
{
|
||||
$default = static::read(dirname(__DIR__, 2).'/resources/css/tokens/scheme.json');
|
||||
$scheme = static::read($path ?? (string) config('livewire-material.scheme'));
|
||||
$data = static::data($path);
|
||||
$profiles = static::profilesFrom($data);
|
||||
|
||||
if ($profiles !== []) {
|
||||
$name = $profile !== null && isset($profiles[$profile]) ? $profile : static::activeFrom($data, $profiles);
|
||||
|
||||
return ['light' => $profiles[$name]['light'], 'dark' => $profiles[$name]['dark']];
|
||||
}
|
||||
|
||||
$default = static::defaultScheme();
|
||||
$scheme = ['light' => static::roles($data['light'] ?? null), 'dark' => static::roles($data['dark'] ?? null)];
|
||||
|
||||
return [
|
||||
'light' => [...$default['light'], ...$scheme['light']],
|
||||
@@ -37,22 +75,102 @@ class Scheme
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function light(?string $path = null): array
|
||||
public static function light(?string $path = null, ?string $profile = null): array
|
||||
{
|
||||
return static::load($path)['light'];
|
||||
return static::load($path, $profile)['light'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{light: array<string, string>, dark: array<string, string>}
|
||||
* The generated colour profiles, in the order they were configured; empty for a single scheme.
|
||||
*
|
||||
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
|
||||
*/
|
||||
protected static function read(string $path): array
|
||||
public static function profiles(?string $path = null): array
|
||||
{
|
||||
return static::profilesFrom(static::data($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* The active profile's name, or null for a single scheme.
|
||||
*/
|
||||
public static function profile(?string $path = null): ?string
|
||||
{
|
||||
$data = static::data($path);
|
||||
$profiles = static::profilesFrom($data);
|
||||
|
||||
return $profiles === [] ? null : static::activeFrom($data, $profiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<mixed>
|
||||
*/
|
||||
protected static function data(?string $path): array
|
||||
{
|
||||
$path ??= (string) config('livewire-material.scheme');
|
||||
$data = is_file($path) ? json_decode((string) file_get_contents($path), true) : null;
|
||||
|
||||
return [
|
||||
'light' => static::roles($data['light'] ?? null),
|
||||
'dark' => static::roles($data['dark'] ?? null),
|
||||
];
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $data
|
||||
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
|
||||
*/
|
||||
protected static function profilesFrom(array $data): array
|
||||
{
|
||||
if (! is_array($data['profiles'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$default = static::defaultScheme();
|
||||
$profiles = [];
|
||||
|
||||
foreach ($data['profiles'] as $name => $profile) {
|
||||
if (! is_string($name) || preg_match('/^[a-z0-9-]+$/', $name) !== 1 || ! is_array($profile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$profiles[$name] = [
|
||||
'label' => is_string($profile['label'] ?? null) ? $profile['label'] : Str::headline($name),
|
||||
'light' => [...$default['light'], ...static::roles($profile['light'] ?? null)],
|
||||
'dark' => [...$default['dark'], ...static::roles($profile['dark'] ?? null)],
|
||||
];
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $data
|
||||
* @param non-empty-array<string, mixed> $profiles
|
||||
*/
|
||||
protected static function activeFrom(array $data, array $profiles): string
|
||||
{
|
||||
try {
|
||||
$resolved = static::$profileResolver ? (static::$profileResolver)() : null;
|
||||
} catch (Throwable) {
|
||||
$resolved = null;
|
||||
}
|
||||
|
||||
foreach ([$resolved, $data['default'] ?? null] as $candidate) {
|
||||
if (is_string($candidate) && isset($profiles[$candidate])) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return (string) array_key_first($profiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* The package's own scheme, which fills any role a file lacks.
|
||||
*
|
||||
* @return array{light: array<string, string>, dark: array<string, string>}
|
||||
*/
|
||||
protected static function defaultScheme(): array
|
||||
{
|
||||
$data = json_decode((string) file_get_contents(dirname(__DIR__, 2).'/resources/css/tokens/scheme.json'), true);
|
||||
|
||||
return ['light' => static::roles($data['light'] ?? null), 'dark' => static::roles($data['dark'] ?? null)];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,131 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
const MORE = '#menus [aria-label="More"]';
|
||||
|
||||
class MenuAnchorProbe extends Component
|
||||
{
|
||||
public int $renders = 0;
|
||||
|
||||
public function touch(): void
|
||||
{
|
||||
$this->renders++;
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div class="grid justify-items-start gap-6 p-4">
|
||||
<p>renders: <span id="renders">{{ $renders }}</span></p>
|
||||
|
||||
<x-menu label="Share actions">
|
||||
<x-slot:trigger>
|
||||
<x-button icon="more_vert" tooltip="More" data-test="more" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-item label="Copy link" icon="content_copy" />
|
||||
<x-menu-item label="Download" icon="download" />
|
||||
</x-menu>
|
||||
|
||||
<x-menu label="Create">
|
||||
<x-slot:trigger>
|
||||
<x-button fab icon="add" label="New plan" data-test="fab" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-item label="Running plan" icon="directions_run" />
|
||||
<x-menu-item label="Cycling plan" icon="directions_bike" />
|
||||
</x-menu>
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Livewire component that renders again while its menus are open: `touch` from outside, a
|
||||
* `keep-open` item's action, or a FAB menu item's action.
|
||||
*/
|
||||
class MenuMorphProbe extends Component
|
||||
{
|
||||
public int $renders = 0;
|
||||
|
||||
public string $sort = 'newest';
|
||||
|
||||
public string $created = '';
|
||||
|
||||
public function touch(): void
|
||||
{
|
||||
$this->renders++;
|
||||
}
|
||||
|
||||
public function sortBy(string $sort): void
|
||||
{
|
||||
$this->sort = $sort;
|
||||
}
|
||||
|
||||
public function create(string $kind): void
|
||||
{
|
||||
$this->created = $kind;
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div class="p-4">
|
||||
<p id="outside">renders: <span id="renders">{{ $renders }}</span>, created: <span id="created">{{ $created }}</span></p>
|
||||
|
||||
<x-menu label="Sort">
|
||||
<x-slot:trigger>
|
||||
<x-button label="Sort" data-test="sort" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-item label="Newest" :selected="$sort === 'newest'" wire:click="sortBy('newest')" keep-open />
|
||||
<x-menu-item label="Largest" :selected="$sort === 'largest'" wire:click="sortBy('largest')" keep-open />
|
||||
</x-menu>
|
||||
|
||||
<div style="position: fixed; right: 16px; bottom: 16px">
|
||||
<x-fab-menu label="New">
|
||||
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="create('files')" />
|
||||
<x-fab-menu-item label="Paste text" icon="content_paste" wire:click="create('text')" />
|
||||
</x-fab-menu>
|
||||
</div>
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
}
|
||||
|
||||
const SORT_MENU = "document.querySelector('[role=\"menu\"][aria-label=\"Sort\"]')";
|
||||
|
||||
const FAB_MENU = "document.querySelector('[role=\"menu\"][aria-label=\"New\"]')";
|
||||
|
||||
const NEW_FAB = 'button[aria-label="New"]';
|
||||
|
||||
function menuMorphProbe()
|
||||
{
|
||||
Livewire::component('menu-morph-probe', MenuMorphProbe::class);
|
||||
|
||||
Route::middleware('web')->get('/menu-morph-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<livewire:menu-morph-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
return visit('/menu-morph-probe')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
function showcase(string $section = 'buttons')
|
||||
{
|
||||
return visit("/material/{$section}")->waitForEvent('networkidle')
|
||||
@@ -94,7 +218,7 @@ it('moves a connected group\'s choice with the arrow keys', function () {
|
||||
|
||||
$page->keys(':focus', 'ArrowLeft')
|
||||
->assertScript($checked('dark'))
|
||||
->assertScript("getComputedStyle(document.querySelector('#buttons input[value=\"dark\"]').parentElement).borderTopLeftRadius === '9999px'");
|
||||
->assertScript("(el => getComputedStyle(el).borderTopLeftRadius === (el.offsetHeight / 2) + 'px')(document.querySelector('#buttons input[value=\"dark\"]').parentElement)");
|
||||
});
|
||||
|
||||
it('rounds a split button\'s trailing half while its menu is open', function () {
|
||||
@@ -103,7 +227,16 @@ it('rounds a split button\'s trailing half while its menu is open', function ()
|
||||
showcase()
|
||||
->click('[data-split="trailing"] >> nth=0')
|
||||
->assertScript("{$trailing}.getAttribute('aria-expanded') === 'true'")
|
||||
->assertScript("getComputedStyle({$trailing}).borderTopLeftRadius === '9999px'");
|
||||
->assertScript("getComputedStyle({$trailing}).borderTopLeftRadius === ({$trailing}.offsetHeight / 2) + 'px'");
|
||||
});
|
||||
|
||||
it('keeps a connected segment\'s small inner corners, which a 9999px outer corner would scale away', function () {
|
||||
// When a box's radii add up to more than a side, CSS shrinks every radius by the same
|
||||
// factor: a full corner written as 9999px drew the 8px inner corners square. Every radius
|
||||
// in a connected group or a split button stays within half its height, so none is scaled.
|
||||
showcase()
|
||||
->assertScript("[...document.querySelectorAll('[data-button-group=\"connected\"] > *, [data-split]')].every((el) => { const cs = getComputedStyle(el); const half = el.offsetHeight / 2 + 0.5; return el.offsetHeight === 0 || ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius'].every((corner) => parseFloat(cs[corner]) <= half); })")
|
||||
->assertScript("(el => getComputedStyle(el).borderTopRightRadius === '8px')(document.querySelector('#buttons input[name=\"showcase-theme\"][value=\"light\"]').parentElement)");
|
||||
});
|
||||
|
||||
it('turns the FAB into a close button while its menu is open', function () {
|
||||
@@ -124,3 +257,210 @@ it('animates the loading indicator in the browser', function () {
|
||||
|
||||
showcase()->assertScript("{$clock} > 0.1");
|
||||
});
|
||||
|
||||
/** A script giving the rects of a menu button (`control`) and of the menu it opens (`menu`). */
|
||||
function menuAgainst(string $test, string $label): string
|
||||
{
|
||||
return "(() => { const control = document.querySelector('[data-test=\"{$test}\"]').getBoundingClientRect(); const menu = document.querySelector('[role=\"menu\"][aria-label=\"{$label}\"]').getBoundingClientRect(); return { control, menu }; })()";
|
||||
}
|
||||
|
||||
it('hangs a menu on its menu button, even when the button is fixed to a corner of the window', function () {
|
||||
Livewire::component('menu-anchor-probe', MenuAnchorProbe::class);
|
||||
|
||||
Route::middleware('web')->get('/menu-anchor-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<livewire:menu-anchor-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
$placed = menuAgainst('fab', 'Create');
|
||||
$above = "(({ control, menu }) => getComputedStyle(document.querySelector('[data-test=\"fab\"]')).position === 'fixed' && menu.bottom <= control.top && control.top - menu.bottom <= 16 && Math.abs(menu.right - control.right) <= 16 && menu.left >= 0 && menu.top >= 0)({$placed})";
|
||||
|
||||
$page = visit('/menu-anchor-probe')->resize(400, 800)->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
|
||||
$page->click('@fab')
|
||||
->assertAttribute('@fab', 'aria-expanded', 'true')
|
||||
->assertScript($above);
|
||||
|
||||
$page->keys(':focus', 'Escape')->assertAttribute('@fab', 'aria-expanded', 'false');
|
||||
|
||||
// A Livewire render names the anchor afresh, on the wrapper again. Opened from the keyboard: a
|
||||
// press this soon after the menu closed is taken for the light-dismiss press and ignored.
|
||||
$page->script('window.eval("Livewire.first().touch()")');
|
||||
|
||||
$page->assertSeeIn('#renders', '1')
|
||||
->script("document.querySelector('[data-test=\"fab\"]').focus()");
|
||||
|
||||
$page->keys(':focus', 'ArrowDown')
|
||||
->assertAttribute('@fab', 'aria-expanded', 'true')
|
||||
->assertScript($above);
|
||||
|
||||
// A button that also anchors its own tooltip keeps it, and the menu hangs under the button.
|
||||
$page->resize(1024, 800)
|
||||
->click('@more')
|
||||
->assertAttribute('@more', 'aria-expanded', 'true')
|
||||
->assertScript("(() => { const names = getComputedStyle(document.querySelector('[data-test=\"more\"]')).getPropertyValue('anchor-name'); return names.includes('--material-button-') && names.includes('--material-menu-'); })()")
|
||||
->assertScript('(({ control, menu }) => menu.top >= control.bottom && menu.top - control.bottom <= 16 && Math.abs(menu.left - control.left) <= 16)('.menuAgainst('more', 'Share actions').')');
|
||||
});
|
||||
|
||||
it('paints a group\'s hint and a menu item\'s icon in the colour their classes name', function () {
|
||||
Route::middleware('web')->get('/colour-class-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<span id="error-ink" class="text-error">Reference</span>
|
||||
<div id="terrain">
|
||||
<x-group name="terrain" hint="No elevation data here" hint-class="text-error" :options="[['id' => 'flat', 'name' => 'Flat']]" />
|
||||
</div>
|
||||
<x-menu-item id="run" label="Running plan" icon="directions_run" icon-class="text-error" />
|
||||
<x-menu-item id="chosen" label="Cycling plan" icon="directions_bike" icon-class="text-error" :selected="true" />
|
||||
<x-menu-item id="off" label="Swimming plan" icon="pool" icon-class="text-error" disabled />
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
$ink = fn (string $element): string => "getComputedStyle({$element}).color";
|
||||
$error = $ink("document.querySelector('#error-ink')");
|
||||
|
||||
visit('/colour-class-probe')->waitForEvent('networkidle')
|
||||
->assertScript($ink("document.querySelector('#terrain p')")." === {$error}")
|
||||
->assertScript($ink("document.querySelector('#run svg')")." === {$error}")
|
||||
->assertScript($ink("document.querySelector('#chosen svg')")." === {$error}")
|
||||
->assertScript($ink("document.querySelector('#off svg')")." !== {$error}");
|
||||
});
|
||||
|
||||
it('keeps a menu open while the component around it renders, and closes it cleanly after', function () {
|
||||
$page = menuMorphProbe()->assertNoJavaScriptErrors();
|
||||
|
||||
$page->click('@sort')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||||
|
||||
$page->script('window.eval("Livewire.first().touch()")');
|
||||
|
||||
$page->assertSeeIn('#renders', '1')
|
||||
->assertScript(SORT_MENU.".matches(':popover-open')")
|
||||
->assertAttribute('@sort', 'aria-expanded', 'true')
|
||||
->assertScript("document.querySelector('[data-test=\"sort\"]').getAttribute('aria-controls') === ".SORT_MENU.'.id')
|
||||
->assertScript(focused("textContent.trim().startsWith('Newest')"));
|
||||
|
||||
$page->keys(':focus', 'Escape')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||||
->assertScript('! '.SORT_MENU.".matches(':popover-open')")
|
||||
->assertScript(focused("dataset.test === 'sort'"));
|
||||
|
||||
// Opened from the keyboard: a press this soon after the menu closed is taken for the
|
||||
// light-dismiss press and ignored.
|
||||
$page->keys('@sort', 'ArrowDown')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||||
|
||||
$page->script('window.eval("Livewire.first().touch()")');
|
||||
|
||||
$page->assertSeeIn('#renders', '2')
|
||||
->click('#outside')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||||
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
|
||||
});
|
||||
|
||||
it('closes a menu on a second press of its menu button, and after the component renders', function () {
|
||||
$page = menuMorphProbe();
|
||||
|
||||
// The press closes the menu before its click reaches the button: the guard against that click
|
||||
// opening it again once waited for the queued toggle event, which comes after the click.
|
||||
$page->click('@sort')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'true')
|
||||
->click('@sort')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||||
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
|
||||
|
||||
$page->keys('@sort', 'ArrowDown')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||||
|
||||
$page->script('window.eval("Livewire.first().touch()")');
|
||||
|
||||
// Past the reopen guard of the close above, so the second press is on its own.
|
||||
$page->assertSeeIn('#renders', '1')
|
||||
->wait(0.3);
|
||||
|
||||
$page->click('@sort')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||||
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
|
||||
});
|
||||
|
||||
it('keeps a menu open while a keep-open item\'s action runs', function () {
|
||||
$page = menuMorphProbe();
|
||||
|
||||
$page->click('@sort')
|
||||
->click('[role="menuitemcheckbox"]:has-text("Largest")')
|
||||
->assertAttribute('[role="menuitemcheckbox"]:has-text("Largest")', 'aria-checked', 'true')
|
||||
->assertScript(SORT_MENU.".matches(':popover-open')")
|
||||
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||||
|
||||
$page->click('[role="menuitemcheckbox"]:has-text("Newest")')
|
||||
->assertAttribute('[role="menuitemcheckbox"]:has-text("Newest")', 'aria-checked', 'true')
|
||||
->assertScript(SORT_MENU.".matches(':popover-open')");
|
||||
});
|
||||
|
||||
it('keeps a FAB menu open while the component around it renders, and closes it cleanly after', function () {
|
||||
$page = menuMorphProbe();
|
||||
|
||||
$page->click(NEW_FAB)
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'true');
|
||||
|
||||
$page->script('window.eval("Livewire.first().touch()")');
|
||||
|
||||
$page->assertSeeIn('#renders', '1')
|
||||
->assertScript(FAB_MENU.".matches(':popover-open')")
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'true')
|
||||
->assertScript("document.querySelector('".NEW_FAB."').getAttribute('aria-controls') === ".FAB_MENU.'.id');
|
||||
|
||||
$page->keys(':focus', 'Escape')
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
|
||||
->assertScript(focused("getAttribute('aria-label') === 'New'"));
|
||||
|
||||
$page->keys(NEW_FAB, 'ArrowDown')
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'true');
|
||||
|
||||
$page->script('window.eval("Livewire.first().touch()")');
|
||||
|
||||
// Past the reopen guard of the Escape above, so the second press is on its own.
|
||||
$page->assertSeeIn('#renders', '2')
|
||||
->wait(0.3);
|
||||
|
||||
$page->click(NEW_FAB)
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
|
||||
->assertScript('! '.FAB_MENU.".matches(':popover-open')");
|
||||
});
|
||||
|
||||
it('closes a FAB menu when an item\'s action runs, and opens and closes it cleanly after', function () {
|
||||
$page = menuMorphProbe();
|
||||
|
||||
$page->click(NEW_FAB)
|
||||
->click('[role="menuitem"]:has-text("Upload files")')
|
||||
->assertSeeIn('#created', 'files')
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'false');
|
||||
|
||||
$page->script("document.querySelector('".NEW_FAB."').focus()");
|
||||
|
||||
$page->keys(NEW_FAB, 'ArrowDown')
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'true')
|
||||
->assertScript(focused("textContent.trim() === 'Upload files'"));
|
||||
|
||||
$page->keys(':focus', 'Escape')
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
|
||||
->assertScript('! '.FAB_MENU.".matches(':popover-open')")
|
||||
->assertScript(focused("getAttribute('aria-label') === 'New'"));
|
||||
});
|
||||
|
||||
@@ -141,6 +141,7 @@ it('turns section tabs into a picker on a phone', function () {
|
||||
->assertScript("getComputedStyle(document.querySelector('[data-section-nav] nav')).display === 'none'")
|
||||
->click('[data-section-picker] button')
|
||||
->assertScript("document.querySelector('[data-section-picker] [popover]').matches(':popover-open')")
|
||||
->assertAttribute('[data-section-picker] [role="menuitemcheckbox"][href="#profile"]', 'aria-checked', 'true')
|
||||
->assertAttribute('[data-section-picker] [role="menuitemcheckbox"][href="#security"]', 'aria-checked', 'false');
|
||||
// A menu of places: the current section is the page, not a checked choice.
|
||||
->assertAttribute('[data-section-picker] [role="menuitem"][href="#profile"]', 'aria-current', 'page')
|
||||
->assertScript("! document.querySelector('[data-section-picker] [role=\"menuitem\"][href=\"#security\"]').hasAttribute('aria-current')");
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ use Livewire\Livewire;
|
||||
*/
|
||||
class CarouselMorphProbe extends Component
|
||||
{
|
||||
public int $count = 3;
|
||||
public int $count = 6;
|
||||
|
||||
public function add(): void
|
||||
{
|
||||
@@ -219,10 +219,19 @@ it('measures itself again after a Livewire morph adds an item', function () {
|
||||
|
||||
$page = visit('/carousel-morph-probe')->waitForEvent('networkidle')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertScript($masked, 3)
|
||||
->assertAttribute('[data-material-carousel-item] >> nth=2', 'aria-label', '3 of 3');
|
||||
->assertScript($masked, 6)
|
||||
->assertAttribute('[data-material-carousel-item] >> nth=5', 'aria-label', '6 of 6');
|
||||
|
||||
$page->click('Add')
|
||||
->assertScript($masked, 4)
|
||||
->assertAttribute('[data-material-carousel-item] >> nth=3', 'aria-label', '4 of 4');
|
||||
->assertScript($masked, 7)
|
||||
->assertAttribute('[data-material-carousel-item] >> nth=6', 'aria-label', '7 of 7');
|
||||
|
||||
// Scrolled once the morph has settled, so only the row's own scroll listener can re-mask the
|
||||
// items: a row the morph swapped for a copy scrolls with its items' masks left as they were.
|
||||
$page->script(onCarousel(0, "await pause(300); scroller.style.scrollSnapType = 'none'; scroller.scrollTo({ left: 2 * (size + gap), behavior: 'instant' })", 'body'));
|
||||
|
||||
$page->assertScript(onCarousel(0, <<<'JS'
|
||||
await pause(50)
|
||||
return inset(0) > 0.5 && inset(2) < 0.5
|
||||
JS, 'body'));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
use function Orchestra\Testbench\workbench_path;
|
||||
|
||||
/*
|
||||
* The Workbench's three profiles (baseline, teal, rose), generated into its stylesheet. Tests other
|
||||
* than these run without profiles, so each one here points the package at the scheme file.
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
config(['livewire-material.scheme' => workbench_path('resources/css/material-scheme.json')]);
|
||||
|
||||
$this->profiles = json_decode(file_get_contents(workbench_path('resources/css/material-scheme.json')), true)['profiles'];
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Scheme::resolveProfileUsing(null);
|
||||
});
|
||||
|
||||
/**
|
||||
* A #rrggbb hex as the rgb() a computed style reports.
|
||||
*/
|
||||
function rgb(string $hex): string
|
||||
{
|
||||
[$red, $green, $blue] = sscanf($hex, '#%02x%02x%02x');
|
||||
|
||||
return "rgb({$red}, {$green}, {$blue})";
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's primary colour role, as <html> resolves it.
|
||||
*/
|
||||
function pagePrimary(): string
|
||||
{
|
||||
return "getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-primary').trim()";
|
||||
}
|
||||
|
||||
function profilesReady(mixed $page): mixed
|
||||
{
|
||||
return $page->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
it('draws each profile in light and dark, and the default without the attribute', function () {
|
||||
$page = profilesReady(visit('/material/colour')->inLightMode())
|
||||
->assertScript("document.documentElement.getAttribute('data-scheme') === 'baseline'")
|
||||
->assertScript(pagePrimary()." === '{$this->profiles['baseline']['light']['primary']}'");
|
||||
|
||||
$page->script("document.documentElement.setAttribute('data-scheme', 'teal')");
|
||||
$page->assertScript(pagePrimary()." === '{$this->profiles['teal']['light']['primary']}'");
|
||||
|
||||
$page->script("document.documentElement.setAttribute('data-theme', 'dark')");
|
||||
$page->assertScript(pagePrimary()." === '{$this->profiles['teal']['dark']['primary']}'")
|
||||
// A panel that sets its own theme keeps the page's profile.
|
||||
->assertScript("getComputedStyle(document.querySelector('#colour [data-theme=\"light\"] .bg-primary')).backgroundColor === '".rgb($this->profiles['teal']['light']['primary'])."'");
|
||||
|
||||
$page->script("document.documentElement.removeAttribute('data-scheme')");
|
||||
$page->assertScript(pagePrimary()." === '{$this->profiles['baseline']['dark']['primary']}'")
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
it('previews a profile from the picker and the showcase menu, and keeps it through wire:navigate', function () {
|
||||
$page = profilesReady(visit('/material/colour')->inLightMode());
|
||||
|
||||
$page->click('#colour [data-scheme-option="rose"]')
|
||||
->assertScript("document.documentElement.getAttribute('data-scheme') === 'rose'")
|
||||
->assertScript(pagePrimary()." === '{$this->profiles['rose']['light']['primary']}'")
|
||||
->assertScript("window.eval(\"Alpine.store('theme').scheme\") === 'rose'");
|
||||
|
||||
$page->click('[data-test="showcase-profiles"] [aria-haspopup="menu"]')
|
||||
->click('[data-scheme-preview="teal"]')
|
||||
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'");
|
||||
|
||||
$page->click('[data-navigation-rail-panel] a[href$="/material/buttons"]')
|
||||
->assertScript("location.pathname.endsWith('/material/buttons')")
|
||||
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'")
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
it('paints the profile the application resolves from the first frame', function () {
|
||||
Scheme::resolveProfileUsing(fn (): string => 'rose');
|
||||
|
||||
profilesReady(visit('/material')->inDarkMode())
|
||||
->assertScript("document.documentElement.getAttribute('data-scheme') === 'rose'")
|
||||
->assertScript(pagePrimary()." === '{$this->profiles['rose']['dark']['primary']}'");
|
||||
});
|
||||
@@ -1,7 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* A Livewire component that renders again while a rich tooltip is open, from the tooltip's own
|
||||
* action.
|
||||
*/
|
||||
class RichTooltipMorphProbe extends Component
|
||||
{
|
||||
public int $renders = 0;
|
||||
|
||||
public function touch(): void
|
||||
{
|
||||
$this->renders++;
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div class="p-4">
|
||||
<p id="outside">renders: <span id="renders">{{ $renders }}</span></p>
|
||||
|
||||
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time." persistent>
|
||||
<x-button label="Details" data-test="details" />
|
||||
<x-slot:actions><x-button label="Refresh" wire:click="touch" data-test="refresh" /></x-slot:actions>
|
||||
</x-rich-tooltip>
|
||||
|
||||
<div style="margin-top: 200px">
|
||||
<x-rich-tooltip text="Shown on hover.">
|
||||
<x-button label="Hint" data-test="hint" />
|
||||
</x-rich-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
}
|
||||
|
||||
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
|
||||
|
||||
const TOAST = "document.querySelector('[data-toast]')";
|
||||
|
||||
it('shows a toast as a snackbar, then the next in turn', function () {
|
||||
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
@@ -39,6 +80,65 @@ it('runs a toast\'s action and dismisses it', function () {
|
||||
->assertScript(SNACKBAR.' === null');
|
||||
});
|
||||
|
||||
it('does not let a toast dismissed early cut the next one short', function () {
|
||||
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
|
||||
$page->script("window.eval(\"materialToast('Share deleted', { timeout: 800, action: { label: 'Undo' } }); materialToast('Link copied', { timeout: 2500 })\")");
|
||||
|
||||
// A click with no hover or focus before it, as a screen reader activates a button: nothing has
|
||||
// paused the first toast's timer.
|
||||
$page->script("document.querySelector('[data-toast] button[aria-label=\"Dismiss\"]').click()");
|
||||
|
||||
$page->assertScript(TOAST."?.textContent.includes('Link copied')")
|
||||
->wait(1.2);
|
||||
|
||||
$page->assertScript(TOAST."?.textContent.includes('Link copied')");
|
||||
});
|
||||
|
||||
it('keeps a sticky toast aside while a passing one shows, brings back the newest, and lets it go once dismissed', function () {
|
||||
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
|
||||
// As an application dispatches it; built in the page's realm (see above).
|
||||
$page->script("window.eval(\"window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: 'A new version is ready', sticky: true, timeout: 300, action: { label: 'Reload', event: 'material:test-reload' } } }))\")");
|
||||
|
||||
$page->assertScript(TOAST."?.textContent.includes('A new version is ready')")
|
||||
->wait(0.6);
|
||||
|
||||
$page->assertScript(TOAST."?.textContent.includes('A new version is ready')");
|
||||
|
||||
$page->script("window.eval(\"materialToast('Settings saved', { type: 'success', timeout: 900 }); materialToast('Version 2 is ready', { sticky: true })\")");
|
||||
|
||||
$page->assertScript(TOAST."?.textContent.includes('Settings saved')")
|
||||
->wait(1.3);
|
||||
|
||||
$page->assertScript(TOAST."?.textContent.includes('Version 2 is ready')");
|
||||
|
||||
$page->click('[data-toast] button[aria-label="Dismiss"]')
|
||||
->assertScript(TOAST.' === null');
|
||||
|
||||
// Dismissed rather than left to time out: the pointer still rests where the snackbar appears,
|
||||
// and hovering pauses it.
|
||||
$page->script("window.eval(\"materialToast('Link copied', { timeout: 0 })\")");
|
||||
|
||||
$page->assertScript(TOAST."?.textContent.includes('Link copied')")
|
||||
->click('[data-toast] button[aria-label="Dismiss"]')
|
||||
->assertScript(TOAST.' === null');
|
||||
});
|
||||
|
||||
it('dispatches a toast action\'s window event alongside its handler, and closes', function () {
|
||||
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
|
||||
$page->script("window.eval(\"window.reloads = 0; window.handled = 0; window.addEventListener('material:test-reload', () => window.reloads++); materialToast('A new version is ready', { sticky: true, action: { label: 'Reload', event: 'material:test-reload', handler: () => window.handled++ } })\")");
|
||||
|
||||
$page->click('[data-toast-action]')
|
||||
->assertScript(TOAST.' === null')
|
||||
->assertScript("window.eval('window.reloads') === 1")
|
||||
->assertScript("window.eval('window.handled') === 1");
|
||||
});
|
||||
|
||||
it('opens a persistent rich tooltip on press', function () {
|
||||
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
|
||||
|
||||
@@ -47,3 +147,46 @@ it('opens a persistent rich tooltip on press', function () {
|
||||
->click('#communication button:has-text("Press for details")')
|
||||
->assertScript("{$bubble}.matches(':popover-open')");
|
||||
});
|
||||
|
||||
it('keeps a rich tooltip open while its action renders the component, and opens it again after', function () {
|
||||
Livewire::component('rich-tooltip-morph-probe', RichTooltipMorphProbe::class);
|
||||
|
||||
Route::middleware('web')->get('/rich-tooltip-morph-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<livewire:rich-tooltip-morph-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
$persistent = "document.querySelector('[role=\"dialog\"][popover]')";
|
||||
$transient = "document.querySelector('[role=\"tooltip\"][popover]')";
|
||||
|
||||
$page = visit('/rich-tooltip-morph-probe')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
$page->click('@details')
|
||||
->assertScript("{$persistent}.matches(':popover-open')")
|
||||
->click('@refresh')
|
||||
->assertSeeIn('#renders', '1')
|
||||
->assertScript("{$persistent}.matches(':popover-open')");
|
||||
|
||||
$page->click('#outside')
|
||||
->assertScript("! {$persistent}.matches(':popover-open')");
|
||||
|
||||
$page->click('@details')
|
||||
->assertScript("{$persistent}.matches(':popover-open')");
|
||||
|
||||
$page->click('#outside')
|
||||
->hover('@hint')
|
||||
->assertScript("{$transient}.matches(':popover-open')")
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
@@ -41,6 +41,73 @@ class OverlayProbe extends Component
|
||||
}
|
||||
}
|
||||
|
||||
class CollapseProbe extends Component
|
||||
{
|
||||
public bool $fineTuning = false;
|
||||
|
||||
public int $renders = 0;
|
||||
|
||||
public function touch(): void
|
||||
{
|
||||
$this->renders++;
|
||||
}
|
||||
|
||||
public function expand(): void
|
||||
{
|
||||
$this->fineTuning = true;
|
||||
}
|
||||
|
||||
public function collapse(): void
|
||||
{
|
||||
$this->fineTuning = false;
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div class="space-y-4 p-4">
|
||||
<p>fine tuning: <span id="fine-tuning">{{ var_export($fineTuning, true) }}</span></p>
|
||||
<p>renders: <span id="renders">{{ $renders }}</span></p>
|
||||
|
||||
<x-button label="Re-render" wire:click="touch" />
|
||||
<x-button label="Open from the server" wire:click="expand" />
|
||||
<x-button label="Close from the server" wire:click="collapse" />
|
||||
|
||||
<x-collapse id="fine-tuning-collapse" title="Fine-tuning" wire:model="fineTuning">Zones and paces.</x-collapse>
|
||||
|
||||
<div x-data="{ advanced: false }">
|
||||
<p>advanced: <span id="advanced" x-text="advanced"></span></p>
|
||||
<x-button label="Close from Alpine" x-on:click="advanced = false" />
|
||||
<x-collapse id="advanced-collapse" title="Advanced" x-model="advanced">Everything else.</x-collapse>
|
||||
</div>
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
}
|
||||
|
||||
function collapseProbe()
|
||||
{
|
||||
Livewire::component('collapse-probe', CollapseProbe::class);
|
||||
|
||||
Route::middleware('web')->get('/collapse-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<livewire:collapse-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
return visit('/collapse-probe')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
function overlayProbe()
|
||||
{
|
||||
Livewire::component('overlay-probe', OverlayProbe::class);
|
||||
@@ -155,3 +222,40 @@ it('opens a row\'s opener from a press anywhere on the row, but not from its own
|
||||
$page->click('#containment [data-card][data-list-row] button:has-text("Copy link")')
|
||||
->assertScript('window.__opened === 1');
|
||||
});
|
||||
|
||||
it('binds a collapse to a Livewire property both ways', function () {
|
||||
$collapse = "document.querySelector('#fine-tuning-collapse')";
|
||||
|
||||
$page = collapseProbe()
|
||||
->assertScript("{$collapse}.open === false");
|
||||
|
||||
$page->click('#fine-tuning-collapse summary')
|
||||
->assertScript("{$collapse}.open === true")
|
||||
->click('button:has-text("Re-render")')
|
||||
->assertSeeIn('#renders', '1')
|
||||
->assertSeeIn('#fine-tuning', 'true')
|
||||
->assertScript("{$collapse}.open === true");
|
||||
|
||||
$page->click('button:has-text("Close from the server")')
|
||||
->assertSeeIn('#fine-tuning', 'false')
|
||||
->assertScript("{$collapse}.open === false");
|
||||
|
||||
$page->click('button:has-text("Open from the server")')
|
||||
->assertSeeIn('#fine-tuning', 'true')
|
||||
->assertScript("{$collapse}.open === true");
|
||||
});
|
||||
|
||||
it('binds a collapse to an Alpine property both ways', function () {
|
||||
$collapse = "document.querySelector('#advanced-collapse')";
|
||||
|
||||
$page = collapseProbe()
|
||||
->assertScript("{$collapse}.open === false");
|
||||
|
||||
$page->click('#advanced-collapse summary')
|
||||
->assertScript("{$collapse}.open === true")
|
||||
->assertSeeIn('#advanced', 'true');
|
||||
|
||||
$page->click('button:has-text("Close from Alpine")')
|
||||
->assertSeeIn('#advanced', 'false')
|
||||
->assertScript("{$collapse}.open === false");
|
||||
});
|
||||
|
||||
@@ -69,6 +69,102 @@ function dateProbe(string $locale = 'en')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
class DateFormatProbe extends Component
|
||||
{
|
||||
public ?string $sunday = '2026-09-13';
|
||||
|
||||
public ?string $iso = '2026-09-13';
|
||||
|
||||
public ?string $dotted = '2026-09-13';
|
||||
|
||||
/** @var array{start: ?string, end: ?string} */
|
||||
public array $span = ['start' => '2026-09-13', 'end' => '2026-09-15'];
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div class="grid max-w-md gap-6 p-4">
|
||||
<p>sunday: <span id="sunday">{{ $sunday }}</span></p>
|
||||
<p>iso: <span id="iso">{{ $iso }}</span></p>
|
||||
<p>dotted: <span id="dotted">{{ $dotted }}</span></p>
|
||||
<p>span: <span id="span">{{ json_encode($span) }}</span></p>
|
||||
|
||||
<x-datepicker id="sunday-field" label="Sunday first" wire:model.live="sunday" week-start="0" />
|
||||
<x-datepicker id="iso-field" label="ISO" wire:model.live="iso" format="yyyy-MM-dd" />
|
||||
<x-datepicker id="dotted-field" label="Dotted" mode="input" wire:model.live="dotted" format="dd.MM.yyyy" />
|
||||
<x-datepicker id="span-field" label="Span" range mode="modal" wire:model.live="span" format="dd/MM/yyyy" week-start="6" />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
}
|
||||
|
||||
function dateFormatProbe(string $locale = 'en')
|
||||
{
|
||||
Livewire::component('date-format-probe', DateFormatProbe::class);
|
||||
|
||||
Route::middleware('web')->get('/date-format-probe/{locale}', function (string $locale) {
|
||||
app()->setLocale($locale);
|
||||
|
||||
return Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<livewire:date-format-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE);
|
||||
});
|
||||
|
||||
return visit("/date-format-probe/{$locale}")->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
class ScopedDateProbe extends Component
|
||||
{
|
||||
public ?string $early = '2026-09-13';
|
||||
|
||||
public ?string $late = '2026-09-13';
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div class="grid max-w-md gap-6 p-4" x-data="{ step: 1 }">
|
||||
<x-datepicker id="early-field" label="Early" wire:model.live="early" min="2026-09-10" week-start="0" />
|
||||
<x-datepicker id="late-field" label="Late" wire:model.live="late" week-start="1" />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
}
|
||||
|
||||
function scopedDateProbe()
|
||||
{
|
||||
Livewire::component('scoped-date-probe', ScopedDateProbe::class);
|
||||
|
||||
Route::middleware('web')->get('/scoped-date-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<livewire:scoped-date-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
return visit('/scoped-date-probe')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
/** A picker's day cell, by its ISO date. */
|
||||
function day(string $picker, string $date): string
|
||||
{
|
||||
@@ -311,3 +407,98 @@ it('empties a date, or both ends of a range, with its clear button', function ()
|
||||
->assertSeeIn('#trip', '{"start":null,"end":null}')
|
||||
->assertValue('#trip-field', '');
|
||||
});
|
||||
|
||||
it('starts the week on the day week-start names, whatever the locale says', function () {
|
||||
$page = dateFormatProbe('de')
|
||||
->assertValue('#sunday-field', '13.09.2026')
|
||||
->click('[aria-controls="sunday-field-picker"][data-datepicker-toggle]')
|
||||
->assertScript("document.querySelector('#sunday-field-picker thead th').getAttribute('abbr') === 'Sonntag'")
|
||||
->assertScript("document.querySelector('#sunday-field-picker tbody td').dataset.value === '2026-08-30'")
|
||||
->assertScript(focusedDay('2026-09-13'));
|
||||
|
||||
$page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-19'));
|
||||
$page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-13'));
|
||||
|
||||
$page->keys(':focus', 'Enter')
|
||||
->assertSeeIn('#sunday', '2026-09-13')
|
||||
->assertValue('#sunday-field', '13.09.2026');
|
||||
});
|
||||
|
||||
it('shows and reads a year-first format and still binds Y-m-d', function () {
|
||||
$page = dateFormatProbe()
|
||||
->assertValue('#iso-field', '2026-09-13')
|
||||
->assertAttribute('#iso-field', 'placeholder', 'YYYY-MM-DD')
|
||||
->type('#iso-field', '2026-10-01')
|
||||
->assertSeeIn('#iso', '2026-10-01');
|
||||
|
||||
$page->type('#iso-field', '10/02/2026')
|
||||
->keys('#iso-field', 'Enter')
|
||||
->assertSee('Date does not match expected pattern: YYYY-MM-DD')
|
||||
->assertSeeIn('#iso', '2026-10-01');
|
||||
|
||||
$page->click('[aria-controls="iso-field-picker"][data-datepicker-toggle]')
|
||||
->assertScript(focusedDay('2026-10-01'));
|
||||
|
||||
$page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-10-02'));
|
||||
|
||||
$page->keys(':focus', 'Enter')
|
||||
->assertSeeIn('#iso', '2026-10-02')
|
||||
->assertValue('#iso-field', '2026-10-02');
|
||||
});
|
||||
|
||||
it('reads the dialog\'s text field in the given format, not the locale\'s', function () {
|
||||
$dialog = "document.querySelector('#dotted-field-picker')";
|
||||
|
||||
$page = dateFormatProbe()
|
||||
->assertValue('#dotted-field', '13.09.2026')
|
||||
->click('#dotted-field')
|
||||
->assertScript("{$dialog}.matches(':modal')")
|
||||
->assertScript("document.activeElement.id === 'dotted-field-entry'")
|
||||
->assertValue('#dotted-field-entry', '13.09.2026')
|
||||
->assertAttribute('#dotted-field-entry', 'placeholder', 'DD.MM.YYYY');
|
||||
|
||||
$page->type('#dotted-field-entry', '2026-10-01')
|
||||
->keys('#dotted-field-entry', 'Enter')
|
||||
->assertSeeIn('#dotted-field-entry-support', 'Date does not match expected pattern: DD.MM.YYYY')
|
||||
->assertScript("{$dialog}.open");
|
||||
|
||||
$page->type('#dotted-field-entry', '1.10.2026')
|
||||
->assertSeeIn('#dotted-field-picker [data-datepicker-headline]', 'Oct 1, 2026')
|
||||
->keys('#dotted-field-entry', 'Enter')
|
||||
->assertSeeIn('#dotted', '2026-10-01')
|
||||
->assertScript("! {$dialog}.open")
|
||||
->assertValue('#dotted-field', '01.10.2026');
|
||||
});
|
||||
|
||||
it('lays out and shows a range in the given first day and format', function () {
|
||||
$dialog = "document.querySelector('#span-field-picker')";
|
||||
|
||||
$page = dateFormatProbe()->assertValue('#span-field', '13/09/2026 – 15/09/2026');
|
||||
|
||||
$page->script("document.querySelector('#span-field').focus()");
|
||||
|
||||
$page->keys('#span-field', 'Enter')
|
||||
->assertScript("{$dialog}.matches(':modal')")
|
||||
->assertScript("document.querySelector('#span-field-picker thead th').getAttribute('abbr') === 'Saturday'")
|
||||
->assertScript(focusedDay('2026-09-13'));
|
||||
|
||||
$page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-12'));
|
||||
$page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-18'));
|
||||
|
||||
$page->click(day('span-field', '2026-09-20'))
|
||||
->click(day('span-field', '2026-09-24'))
|
||||
->click('#span-field-picker [data-datepicker-confirm]')
|
||||
->assertSeeIn('#span', '{"start":"2026-09-20","end":"2026-09-24"}')
|
||||
->assertValue('#span-field', '20/09/2026 – 24/09/2026');
|
||||
});
|
||||
|
||||
it('keeps each picker\'s own settings inside a page\'s outer x-data scope', function () {
|
||||
// Settings assigned in init() without being declared would land on the outermost scope,
|
||||
// where the last picker's null min and Monday start would overwrite the first's.
|
||||
$page = scopedDateProbe()
|
||||
->click('[aria-controls="early-field-picker"][data-datepicker-toggle]')
|
||||
->assertScript(focusedDay('2026-09-13'));
|
||||
|
||||
$page->assertAttribute(day('early-field', '2026-09-09'), 'aria-disabled', 'true')
|
||||
->assertScript('! (\'min\' in Alpine.$data(document.querySelector(\'[x-data="{ step: 1 }"]\')))');
|
||||
});
|
||||
|
||||
@@ -176,3 +176,31 @@ it('skips to the content', function () {
|
||||
->assertScript("location.hash === '#content'")
|
||||
->assertScript("document.activeElement === document.getElementById('content')");
|
||||
});
|
||||
|
||||
it('moves an app bar\'s content under a safe area an application sets', function () {
|
||||
$row = "Math.round(document.querySelector('[data-app-bar] [data-app-bar-row]').getBoundingClientRect().top)";
|
||||
|
||||
$page = navigationReady(visit('/material'));
|
||||
$top = (int) $page->script($row);
|
||||
|
||||
$page->script("document.documentElement.style.setProperty('--material-safe-top', '47px')");
|
||||
|
||||
$page->assertScript("{$row} === ".($top + 47))
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
it('lifts the snackbar and the page above something docked on the phone\'s bar', function () {
|
||||
$snackbarBottom = "Math.round(parseFloat(getComputedStyle(document.querySelector('[x-data=\"materialSnackbar\"]')).bottom))";
|
||||
$contentPadding = "Math.round(parseFloat(getComputedStyle(document.getElementById('content')).paddingBottom))";
|
||||
|
||||
$page = shellPage(400, 860);
|
||||
$snackbar = (int) $page->script($snackbarBottom);
|
||||
$content = (int) $page->script($contentPadding);
|
||||
|
||||
expect($content)->toBe(64)->and($snackbar)->toBe(80);
|
||||
|
||||
$page->script("document.documentElement.style.setProperty('--material-bottom-extra', '40px')");
|
||||
|
||||
$page->assertScript("{$snackbarBottom} === 120")
|
||||
->assertScript("{$contentPadding} === 104");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
use function Orchestra\Testbench\workbench_path;
|
||||
|
||||
/**
|
||||
* The theme <html> shows. A bare `html` selector would be read as text to search for.
|
||||
*/
|
||||
@@ -56,3 +62,94 @@ it('repaints a section that sets its own theme', function () {
|
||||
->assertScript(theme('data-theme', 'light'))
|
||||
->assertScript("{$swatch(0)} !== {$swatch(1)}");
|
||||
});
|
||||
|
||||
/**
|
||||
* The page has exactly one theme-color meta without a media query, and it shows the given colour.
|
||||
*/
|
||||
function themeColorIs(string $hex): string
|
||||
{
|
||||
return "(() => { const metas = document.head.querySelectorAll('meta[name=theme-color]:not([media])'); return metas.length === 1 && metas[0].getAttribute('content') === '{$hex}'; })()";
|
||||
}
|
||||
|
||||
/**
|
||||
* The surface colour role, as <html> resolves it.
|
||||
*/
|
||||
function pageSurfaceIs(string $hex): string
|
||||
{
|
||||
return "getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-surface').trim() === '{$hex}'";
|
||||
}
|
||||
|
||||
function themeReady(mixed $page): mixed
|
||||
{
|
||||
return $page->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
it('adds a theme-color meta in the painted surface, and follows the theme and the colour profile', function () {
|
||||
config([
|
||||
'livewire-material.theme.meta' => true,
|
||||
'livewire-material.scheme' => workbench_path('resources/css/material-scheme.json'),
|
||||
]);
|
||||
|
||||
$profiles = Scheme::profiles();
|
||||
|
||||
$page = themeReady(visit('/material/colour')->inLightMode())
|
||||
->assertScript(theme('data-scheme', 'baseline'))
|
||||
->assertScript(themeColorIs($profiles['baseline']['light']['surface']))
|
||||
->assertScript(pageSurfaceIs($profiles['baseline']['light']['surface']));
|
||||
|
||||
$page->click('[data-theme-option="dark"]')
|
||||
->assertScript(theme('data-theme', 'dark'))
|
||||
->assertScript(themeColorIs($profiles['baseline']['dark']['surface']))
|
||||
->assertScript(pageSurfaceIs($profiles['baseline']['dark']['surface']));
|
||||
|
||||
$page->click('#colour [data-scheme-option="rose"]')
|
||||
->assertScript(theme('data-scheme', 'rose'))
|
||||
->assertScript(themeColorIs($profiles['rose']['dark']['surface']))
|
||||
->assertScript(pageSurfaceIs($profiles['rose']['dark']['surface']));
|
||||
|
||||
$page->click('[data-theme-option="light"]')
|
||||
->assertScript(themeColorIs($profiles['rose']['light']['surface']))
|
||||
->assertScript(pageSurfaceIs($profiles['rose']['light']['surface']))
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
it('repaints the page\'s own theme-color meta, and the next page\'s after wire:navigate', function () {
|
||||
config(['livewire-material.theme.meta' => true]);
|
||||
|
||||
Route::middleware('web')->get('/theme-color-probe/{page}', fn (string $page) => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="theme-color" media="print" content="#ffffff" />
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<p id="page">This is page {{ $page }}.</p>
|
||||
<a id="next" href="/theme-color-probe/two" wire:navigate>Next</a>
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE, ['page' => $page]));
|
||||
|
||||
$scheme = Scheme::load();
|
||||
|
||||
$page = themeReady(visit('/theme-color-probe/one')->inLightMode())
|
||||
->assertScript(themeColorIs($scheme['light']['surface']))
|
||||
->assertScript("document.head.querySelector('meta[media]').getAttribute('content') === '#ffffff'");
|
||||
|
||||
$page->script("window.eval(\"Alpine.store('theme').set('dark'); window.samePage = true\")");
|
||||
|
||||
$page->assertScript(themeColorIs($scheme['dark']['surface']));
|
||||
|
||||
$page->click('#next')
|
||||
->assertSeeIn('#page', 'This is page two.')
|
||||
->assertScript("window.eval('window.samePage') === true")
|
||||
->assertScript(theme('data-theme', 'dark'))
|
||||
->assertScript(themeColorIs($scheme['dark']['surface']))
|
||||
->assertScript("document.head.querySelector('meta[media]').getAttribute('content') === '#ffffff'")
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function shellDestinations(): array
|
||||
{
|
||||
@@ -42,6 +44,16 @@ it('puts every destination in the rail and only those marked for the bar in the
|
||||
->and(substr_count($html, 'aria-current="page"'))->toBe(2);
|
||||
});
|
||||
|
||||
it('speaks a destination\'s badge in its own words when it has them', function () {
|
||||
$html = (string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => [
|
||||
['title' => 'Get started', 'icon' => 'rocket_launch', 'url' => '/start', 'badge' => '0/3', 'badgeLabel' => '0 of 3 done'],
|
||||
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'badge' => 4],
|
||||
]]);
|
||||
|
||||
expect($html)->toContain('0 of 3 done')
|
||||
->and(substr_count($html, '0 of 3 done'))->toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('marks the destination at the current URL when none says it is active', function () {
|
||||
Route::get('/shell-probe/inbox', fn () => Blade::render('<x-app-shell :destinations="$destinations" />', ['destinations' => [
|
||||
['title' => 'Inbox', 'icon' => 'inbox', 'url' => url('/shell-probe/inbox')],
|
||||
@@ -54,14 +66,46 @@ it('marks the destination at the current URL when none says it is active', funct
|
||||
->and(substr_count($html, 'aria-current="page"'))->toBe(2);
|
||||
});
|
||||
|
||||
it('keeps the destination at the page\'s URL current while a Livewire component on it updates', function () {
|
||||
Livewire::component('app-shell-probe', new class extends Component
|
||||
{
|
||||
public string $page = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->page = url()->current();
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return '<div><x-app-shell :destinations="[[\'title\' => \'Inbox\', \'icon\' => \'inbox\', \'url\' => $page], [\'title\' => \'Sent\', \'icon\' => \'send\', \'url\' => \'/sent\']]" /></div>';
|
||||
}
|
||||
});
|
||||
|
||||
$probe = Livewire::test('app-shell-probe');
|
||||
|
||||
expect(substr_count($probe->html(), 'aria-current="page"'))->toBe(2)
|
||||
->and(substr_count($probe->call('$refresh')->html(), 'aria-current="page"'))->toBe(2)
|
||||
->and($probe->html())->toMatch('/href="[^"]*\/livewire-unit-test-endpoint\/[^"]*"[^>]*aria-current="page"/');
|
||||
});
|
||||
|
||||
it('lifts the snackbar above the bar only when there is a bar', function () {
|
||||
expect((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
|
||||
->toContain('max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]')
|
||||
->toContain('max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]')
|
||||
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]]))
|
||||
->not->toContain('data-app-shell-bar')
|
||||
->not->toContain('--material-bottom-bar:');
|
||||
});
|
||||
|
||||
it('reads the safe area and anything docked on the bar through variables an application can set', function () {
|
||||
$html = (string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]);
|
||||
|
||||
expect($html)
|
||||
->toContain('+var(--material-bottom-extra,0px))]')
|
||||
->toContain('focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)]')
|
||||
->not->toMatch('/(?<!,)env\(safe-area-inset/');
|
||||
});
|
||||
|
||||
it('places each slot once', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-app-shell :destinations="$destinations">
|
||||
|
||||
@@ -30,3 +30,63 @@ it('draws a status label in a container or an outline', function () {
|
||||
->toContain('border border-outline-variant text-on-surface-variant')
|
||||
->not->toContain('bg-error');
|
||||
});
|
||||
|
||||
it('renders its slot as HTML, and is a dot while the slot holds nothing but comments', function () {
|
||||
$html = (string) $this->blade('<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>');
|
||||
|
||||
expect($html)
|
||||
->toContain('h-6 gap-1 rounded-corner-sm')
|
||||
->toContain('<svg')
|
||||
->toContain(' Pro</span>')
|
||||
->not->toContain('<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.')
|
||||
->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" />')
|
||||
->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
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('discloses on the native details element, kept open through a morph', function () {
|
||||
$html = (string) $this->blade('<x-collapse title="How long do links last?" icon="schedule" open variant="filled">Until the expiry.</x-collapse>');
|
||||
|
||||
@@ -12,3 +15,50 @@ it('discloses on the native details element, kept open through a morph', functio
|
||||
->toContain('Until the expiry.')
|
||||
->toContain('group-open/collapse:rotate-180');
|
||||
});
|
||||
|
||||
it('has no Alpine on it without a binding', function () {
|
||||
$html = (string) $this->blade('<x-collapse title="Advanced" open>Body</x-collapse>');
|
||||
|
||||
expect($html)
|
||||
->toMatch('/<details\s+wire:ignore.self\s+open\s+class="group\/collapse/')
|
||||
->not->toContain('x-data')
|
||||
->not->toContain('x-modelable')
|
||||
->not->toContain('x-on:toggle')
|
||||
->and((string) $this->blade('<x-collapse title="Advanced">Body</x-collapse>'))
|
||||
->toMatch('/<details\s+wire:ignore.self\s+class="group\/collapse/');
|
||||
});
|
||||
|
||||
it('binds its open state through x-model, drawing the open prop until Alpine starts', function () {
|
||||
$html = (string) $this->blade('<x-collapse title="Advanced" x-model="advanced" open>Body</x-collapse>');
|
||||
|
||||
expect($html)
|
||||
->toMatch('/x-data="{ collapseOpen:\s*true\s*}"/')
|
||||
->toContain('x-modelable="collapseOpen"')
|
||||
->toContain('x-model="advanced"')
|
||||
->toContain('x-effect="$el.open = collapseOpen"')
|
||||
->toContain('x-on:toggle="collapseOpen = $el.open"')
|
||||
->toMatch('/\sopen\s/');
|
||||
});
|
||||
|
||||
it('entangles its open state with a Livewire property and renders open as the property is', function (bool $fineTuning) {
|
||||
Livewire::component('collapse-probe', new class extends Component
|
||||
{
|
||||
public bool $fineTuning = false;
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return '<div><x-collapse title="Fine-tuning" wire:model.live="fineTuning" :open="! $fineTuning">Body</x-collapse></div>';
|
||||
}
|
||||
});
|
||||
|
||||
$html = Livewire::test('collapse-probe', ['fineTuning' => $fineTuning])->html();
|
||||
|
||||
expect($html)
|
||||
->toContain(".entangle('fineTuning').live")
|
||||
->not->toContain('x-modelable')
|
||||
->not->toContain('wire:model');
|
||||
|
||||
preg_match('/<details[^>]*>/', $html, $tag);
|
||||
|
||||
expect(preg_match('/\sopen\s/', $tag[0]) === 1)->toBe($fineTuning);
|
||||
})->with(['open' => true, 'closed' => false]);
|
||||
|
||||
@@ -72,6 +72,27 @@ it('hands min, max and the application locale to the picker as Y-m-d', function
|
||||
->toMatchArray(['min' => null, 'max' => null]);
|
||||
});
|
||||
|
||||
it('hands a chosen first day of the week and format to the picker, and ignores values that are neither', function () {
|
||||
app()->setLocale('de');
|
||||
|
||||
$html = (string) $this->blade('<x-datepicker label="Date" week-start="0" format="yyyy-MM-dd" value="2026-09-13" />');
|
||||
|
||||
expect(datepickerConfig($html))->toMatchArray(['weekStart' => 0, 'format' => 'yyyy-MM-dd', 'locale' => 'de'])
|
||||
->and($html)->toContain('value="2026-09-13"')
|
||||
->and(datepickerConfig((string) $this->blade('<x-datepicker label="Date" :week-start="6" format="MM/dd/yyyy" />')))
|
||||
->toMatchArray(['weekStart' => 6, 'format' => 'MM/dd/yyyy'])
|
||||
->and((string) $this->blade('<x-datepicker label="Trip" range format="dd/MM/yyyy" :value="[\'start\' => \'2026-09-13\', \'end\' => \'2026-09-20\']" />'))
|
||||
->toContain('value="13/09/2026 – 20/09/2026"');
|
||||
|
||||
foreach (['week-start="7"', 'week-start="-1"', 'week-start="monday"', 'week-start', 'format="d.M.yy"', 'format="dd.MM/yyyy"', 'format="dd.dd.yyyy"', 'format="yyyy-mm-dd"'] as $attribute) {
|
||||
expect(datepickerConfig((string) $this->blade("<x-datepicker label=\"Date\" {$attribute} />")))
|
||||
->toMatchArray(['weekStart' => null, 'format' => null]);
|
||||
}
|
||||
|
||||
expect(datepickerConfig((string) $this->blade('<x-datepicker label="Date" />')))->toMatchArray(['weekStart' => null, 'format' => null])
|
||||
->and((string) $this->blade('<x-datepicker label="Date" format="dd/MM/y" value="2026-09-13" />'))->toContain('value="13.09.2026"');
|
||||
});
|
||||
|
||||
it('shows the value in the locale\'s numeric format before Alpine starts', function () {
|
||||
expect((string) $this->blade('<x-datepicker label="Date" value="2026-09-13" name="expires" />'))
|
||||
->toContain('value="09/13/2026"')
|
||||
|
||||
@@ -15,3 +15,34 @@ it('sets an icon on an Expressive shape above its words and action', function ()
|
||||
->toContain('Upload files')
|
||||
->and(substr_count($html, '<svg'))->toBe(2);
|
||||
});
|
||||
|
||||
it('draws an illustration in place of the shape and icon', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-empty-state icon="upload_file" title="No routes yet">
|
||||
<x-slot:illustration class="text-primary"><svg class="size-32" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="4" /></svg></x-slot:illustration>
|
||||
</x-empty-state>
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('<div class="text-primary"><svg class="size-32" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="4" /></svg></div>')
|
||||
->not->toContain('text-secondary-container')
|
||||
->not->toContain('text-on-secondary-container')
|
||||
->toContain('No routes yet')
|
||||
->and(substr_count($html, '<svg'))->toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the shape and icon while the illustration slot holds nothing but comments', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-empty-state title="No shares yet">
|
||||
<x-slot:illustration>
|
||||
<!-- artwork to come -->
|
||||
</x-slot:illustration>
|
||||
</x-empty-state>
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('text-secondary-container')
|
||||
->toContain('text-on-secondary-container')
|
||||
->not->toContain('artwork to come')
|
||||
->and(substr_count($html, '<svg'))->toBe(2);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,20 @@ it('makes a selectable item a menuitemcheckbox', function () {
|
||||
->toContain('aria-checked="false"');
|
||||
});
|
||||
|
||||
it('marks the page an item leads to, and carries a badge', function () {
|
||||
$html = (string) $this->blade('<x-menu-item label="Support" icon="support" link="/admin/support" current badge="3" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('role="menuitem"')
|
||||
->toContain('aria-current="page"')
|
||||
->not->toContain('aria-checked')
|
||||
->toContain('bg-secondary-container text-on-secondary-container')
|
||||
->toContain('>3<')
|
||||
->and((string) $this->blade('<x-menu-item label="Users" link="/admin/users" />'))
|
||||
->not->toContain('aria-current')
|
||||
->not->toContain('secondary-container');
|
||||
});
|
||||
|
||||
it('links an item, and keeps a disabled one out of reach', function () {
|
||||
$this->blade('<x-menu-item label="Settings" link="/settings" />')
|
||||
->assertSee('href="/settings"', false)
|
||||
@@ -69,3 +83,18 @@ it('separates and labels groups', function () {
|
||||
$this->blade('<x-menu-group label="Sort by"><x-menu-item label="Newest" /></x-menu-group>')
|
||||
->assertSee('role="group" aria-label="Sort by"', false);
|
||||
});
|
||||
|
||||
it('adds icon-class to the leading icon, over its own colour but not over disabled', function () {
|
||||
$leading = fn (string $html): string => preg_match('/<svg[^>]*class="([^"]*)"/', $html, $icon) ? $icon[1] : '';
|
||||
|
||||
expect($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" icon-right="chevron_right" />')))
|
||||
->toBe('shrink-0 size-5 [:where(&)]: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>');
|
||||
});
|
||||
|
||||
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 () {
|
||||
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
|
||||
->toContain('x-trap.inert.noscroll="open && ! wide"')
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->path = sys_get_temp_dir().'/scheme-picker-'.uniqid().'.json';
|
||||
File::put($this->path, json_encode([
|
||||
'default' => 'indigo',
|
||||
'profiles' => [
|
||||
'indigo' => ['label' => 'Indigo', 'light' => ['primary' => '#4f46e5', 'secondary' => '#5b5d72', 'tertiary' => '#77536d'], 'dark' => ['primary' => '#c0c1ff', 'secondary' => '#c4c5dd', 'tertiary' => '#e6bad7']],
|
||||
'teal' => ['label' => 'Teal', 'light' => ['primary' => '#00897b', 'secondary' => '#4a635f', 'tertiary' => '#456179'], 'dark' => ['primary' => '#80cbc4', 'secondary' => '#b1ccc6', 'tertiary' => '#adcae5']],
|
||||
],
|
||||
]));
|
||||
config(['livewire-material.scheme' => $this->path]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
File::delete($this->path);
|
||||
});
|
||||
|
||||
it('draws a radio per generated profile, bound, named and previewing on change', function () {
|
||||
$html = (string) $this->blade('<x-scheme-picker label="Colour profile" hint="Applies after saving" wire:model="colorProfile" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Colour profile</legend>')
|
||||
->toContain('Applies after saving')
|
||||
->toContain('data-scheme-option="indigo"')
|
||||
->toContain('data-scheme-option="teal"')
|
||||
->toMatch('/<input\s+wire:model="colorProfile"\s+type="radio"\s+name="colorProfile"\s+value="teal"/')
|
||||
->toContain('x-on:change="$store.theme.previewScheme($event.target.value)"')
|
||||
->toContain('>Teal</span>')
|
||||
->toContain('style="--swatch-light: #00897b; --swatch-dark: #80cbc4"');
|
||||
});
|
||||
|
||||
it('puts its other attributes on the group and the binding on the radios', function () {
|
||||
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" data-test="color-profile" class="mt-4" />');
|
||||
|
||||
expect($html)->toMatch('/<fieldset x-data data-scheme-picker class="min-w-0 mt-4" data-test="color-profile">/')
|
||||
->and(substr_count($html, 'wire:model="colorProfile"'))->toBe(2)
|
||||
->and(substr_count($html, 'data-test="color-profile"'))->toBe(1);
|
||||
});
|
||||
|
||||
it('keeps its inline styles to the scheme file\'s checked colours', function () {
|
||||
File::put($this->path, json_encode([
|
||||
'default' => 'indigo',
|
||||
'profiles' => ['indigo' => ['label' => 'Indigo', 'light' => ['primary' => 'red; background: url(x)'], 'dark' => []]],
|
||||
]));
|
||||
|
||||
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" />');
|
||||
|
||||
preg_match_all('/style="([^"]*)"/', $html, $styles);
|
||||
|
||||
expect($styles[1])->not->toBeEmpty()
|
||||
->each->toMatch('/^--swatch-light: #[0-9a-fA-F]{6}; --swatch-dark: #[0-9a-fA-F]{6}$/');
|
||||
});
|
||||
|
||||
it('shows a validation message for the bound property instead of the hint', function () {
|
||||
$this->withViewErrors(['colorProfile' => ['Choose one of the colour profiles.']])
|
||||
->blade('<x-scheme-picker wire:model="colorProfile" hint="Applies after saving" />')
|
||||
->assertSee('Choose one of the colour profiles.')
|
||||
->assertDontSee('Applies after saving');
|
||||
});
|
||||
|
||||
it('renders nothing for a single scheme', function () {
|
||||
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => []]));
|
||||
|
||||
expect(trim((string) $this->blade('<x-scheme-picker wire:model="colorProfile" />')))->toBe('');
|
||||
});
|
||||
@@ -67,7 +67,11 @@ it('draws section navigation as secondary tabs and a picker', function () {
|
||||
->toContain('sm:flex')
|
||||
->toMatch('/href="\/settings\/security"\s+data-tab\s+aria-current="page"\s+wire:navigate/')
|
||||
->not->toMatch('/href="\/settings\/profile"\s+data-tab\s+aria-current/')
|
||||
->toContain('role="menuitemcheckbox"');
|
||||
// The picker is a menu of places: the current one is the page, not a checked choice,
|
||||
// and a section's badge shows there as well as on its tab.
|
||||
->not->toContain('role="menuitemcheckbox"')
|
||||
->toMatch('/role="menuitem"[^>]*aria-current="page"[^>]*href="\/settings\/security"/')
|
||||
->toMatch('/data-section-picker.*Security.*>\s*1\s*<.*<nav/s');
|
||||
});
|
||||
|
||||
it('marks the section whose url is the request\'s, and wraps many sections onto a grid', function () {
|
||||
@@ -80,3 +84,29 @@ it('marks the section whose url is the request\'s, and wraps many sections onto
|
||||
->toMatch('/data-tab\s+aria-current="page"\s*>\s*<span data-tab-content>\s*<span class="truncate">S3/')
|
||||
->not->toContain('wire:navigate');
|
||||
});
|
||||
|
||||
it('keeps the page\'s section current while a Livewire component on it updates', function () {
|
||||
Livewire::component('section-nav-probe', new class extends Component
|
||||
{
|
||||
public string $page = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->page = url()->current();
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return '<div><x-section-nav :items="[[\'title\' => \'Here\', \'url\' => $page], [\'title\' => \'Elsewhere\', \'url\' => \'/elsewhere\']]" no-wire-navigate /></div>';
|
||||
}
|
||||
});
|
||||
|
||||
$current = '/href="[^"]*\/livewire-unit-test-endpoint\/[^"]*"\s+data-tab\s+aria-current="page"/';
|
||||
|
||||
$probe = Livewire::test('section-nav-probe');
|
||||
|
||||
expect($probe->html())->toMatch($current)
|
||||
->and($probe->call('$refresh')->html())->toMatch($current)
|
||||
// Once as the tab, once as the picker's item: one section, drawn for both widths.
|
||||
->and(substr_count($probe->html(), 'aria-current="page"'))->toBe(2);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
it('follows the operating system until the visitor chooses, by default', function () {
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee('({"default":"system","key":"material-theme","legacy":[],"rail":{"default":"expanded","key":"material-rail"}})', false);
|
||||
->assertSee('({"scheme":null,"default":"system","key":"material-theme","legacy":[],"rail":{"default":"expanded","key":"material-rail"}})', false);
|
||||
});
|
||||
|
||||
it('takes the application\'s default, key and legacy keys', function () {
|
||||
@@ -39,6 +42,88 @@ it('starts a collapsible rail as the application says, expanded otherwise', func
|
||||
it('puts its attributes back on <html> when wire:navigate swaps the page', function () {
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee("document.addEventListener('livewire:navigating'", false)
|
||||
->assertSee("['data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key']", false)
|
||||
->assertSee("['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key']", false)
|
||||
->assertSee('event.detail.onSwap(', false);
|
||||
});
|
||||
|
||||
it('names the active colour profile for <html data-scheme>, and none for a single scheme', function () {
|
||||
$path = sys_get_temp_dir().'/theme-script-profiles-'.uniqid().'.json';
|
||||
File::put($path, json_encode([
|
||||
'default' => 'indigo',
|
||||
'profiles' => ['indigo' => ['label' => 'Indigo', 'light' => [], 'dark' => []], 'teal' => ['label' => 'Teal', 'light' => [], 'dark' => []]],
|
||||
]));
|
||||
config(['livewire-material.scheme' => $path]);
|
||||
|
||||
try {
|
||||
$this->blade('<x-theme-script />')
|
||||
->assertSee('({"scheme":"indigo",', false)
|
||||
->assertSee("root.setAttribute('data-scheme', settings.scheme);", false);
|
||||
|
||||
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||
|
||||
$this->blade('<x-theme-script />')->assertSee('({"scheme":"teal",', false);
|
||||
} finally {
|
||||
Scheme::resolveProfileUsing(null);
|
||||
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 () {
|
||||
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\(\)"/');
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->temporary = sys_get_temp_dir().'/livewire-material-errors-'.Str::random(8);
|
||||
@@ -128,6 +129,33 @@ it('still renders, in the application\'s scheme, when the Vite manifest is missi
|
||||
->assertDontSee('/build/', false);
|
||||
});
|
||||
|
||||
it('draws the active colour profile when the Vite manifest is missing', function () {
|
||||
File::put($this->temporary.'/material-scheme.json', json_encode([
|
||||
'light' => ['primary' => '#4f46e5'],
|
||||
'dark' => ['primary' => '#aaaaff'],
|
||||
'default' => 'indigo',
|
||||
'profiles' => [
|
||||
'indigo' => ['label' => 'Indigo', 'light' => ['primary' => '#4f46e5'], 'dark' => ['primary' => '#aaaaff']],
|
||||
'teal' => ['label' => 'Teal', 'light' => ['primary' => '#00897b'], 'dark' => ['primary' => '#80cbc4']],
|
||||
],
|
||||
]));
|
||||
|
||||
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
|
||||
app()->usePublicPath($this->temporary);
|
||||
Vite::useHotFile($this->temporary.'/hot');
|
||||
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||
|
||||
try {
|
||||
$this->get('/abort/500')
|
||||
->assertStatus(500)
|
||||
->assertSee('--md-sys-color-primary: #00897b;', false)
|
||||
->assertSee('--md-sys-color-primary: #80cbc4;', false)
|
||||
->assertDontSee('#4f46e5', false);
|
||||
} finally {
|
||||
Scheme::resolveProfileUsing(null);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the message an application passed for 403 and 503', function (int $code, string $message) {
|
||||
withoutSkeletonErrorViews();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
class ThemedProbeMail extends Mailable
|
||||
{
|
||||
@@ -113,6 +114,27 @@ it('inlines the application\'s scheme onto the mail', function () {
|
||||
->not->toContain('color-mix');
|
||||
});
|
||||
|
||||
it('inlines the active colour profile onto the mail', function () {
|
||||
File::put($this->temporary.'/material-scheme.json', json_encode([
|
||||
'light' => ['primary' => '#4f46e5'],
|
||||
'dark' => ['primary' => '#aaaaff'],
|
||||
'default' => 'indigo',
|
||||
'profiles' => [
|
||||
'indigo' => ['label' => 'Indigo', 'light' => ['primary' => '#4f46e5'], 'dark' => ['primary' => '#aaaaff']],
|
||||
'rose' => ['label' => 'Rose', 'light' => ['primary' => '#c2185b'], 'dark' => ['primary' => '#ffb0c8']],
|
||||
],
|
||||
]));
|
||||
|
||||
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
|
||||
Scheme::resolveProfileUsing(fn (): string => 'rose');
|
||||
|
||||
try {
|
||||
expect(inlineStyle((new ThemedProbeMail)->render(), 'button-primary'))->toContain('background-color: #c2185b');
|
||||
} finally {
|
||||
Scheme::resolveProfileUsing(null);
|
||||
}
|
||||
});
|
||||
|
||||
it('takes the theme from a mailable too', function () {
|
||||
config(['mail.markdown.theme' => 'default']);
|
||||
|
||||
|
||||
@@ -56,6 +56,48 @@ it('generates a different scheme per variant', function () {
|
||||
->and($vibrant['light']['primary'])->not->toBe($tonalSpot['light']['primary']);
|
||||
});
|
||||
|
||||
it('generates with the 2021 colour spec when asked, and records it', function () {
|
||||
$this->artisan('material:scheme', [
|
||||
'seed' => '#00bc7d',
|
||||
'--variant' => 'vibrant',
|
||||
'--success' => '#00d390',
|
||||
'--warning' => '#fcb700',
|
||||
'--info' => '#00bafe',
|
||||
'--spec' => '2021',
|
||||
'--output' => $this->stylesheet,
|
||||
])->assertSuccessful();
|
||||
|
||||
$scheme = json_decode(File::get($this->data), true);
|
||||
|
||||
expect($scheme['spec'])->toBe('2021')
|
||||
->and($scheme['dark'])->toMatchArray([
|
||||
'surface' => '#0b1610',
|
||||
'primary' => '#00e297',
|
||||
'on-primary' => '#003822',
|
||||
'success' => '#2ce19c',
|
||||
'warning' => '#ffbb16',
|
||||
'info' => '#80cfff',
|
||||
])
|
||||
->and($scheme['light'])->toMatchArray([
|
||||
'surface' => '#f0fdf2',
|
||||
'primary' => '#006c46',
|
||||
'success' => '#006c48',
|
||||
'warning' => '#7c5800',
|
||||
'info' => '#00658c',
|
||||
])
|
||||
->and(File::get($this->stylesheet))
|
||||
->toContain('(spec 2021)')
|
||||
->toContain('php artisan material:scheme "#00bc7d" --variant=vibrant --spec=2021 --success="#00d390" --warning="#fcb700" --info="#00bafe"'."\n");
|
||||
});
|
||||
|
||||
it('refuses an unknown colour spec', function () {
|
||||
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--spec' => '2023', '--output' => $this->stylesheet])
|
||||
->expectsOutputToContain('Unknown spec "2023". Use one of: 2021, 2025.')
|
||||
->assertFailed();
|
||||
|
||||
expect(File::exists($this->stylesheet))->toBeFalse();
|
||||
});
|
||||
|
||||
it('refuses a seed that is not a colour', function () {
|
||||
$this->artisan('material:scheme', ['seed' => 'indigo', '--output' => $this->stylesheet])
|
||||
->expectsOutputToContain('#rrggbb')
|
||||
@@ -78,3 +120,100 @@ it('fails without writing anything when node cannot run', function () {
|
||||
|
||||
expect(File::exists($this->stylesheet))->toBeFalse();
|
||||
});
|
||||
|
||||
it('generates every configured profile into one stylesheet keyed by data-scheme', function () {
|
||||
config([
|
||||
'livewire-material.profiles' => [
|
||||
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
|
||||
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
|
||||
'graphite' => ['seed' => '#5f6368', 'variant' => 'neutral'],
|
||||
],
|
||||
'livewire-material.profile' => 'teal',
|
||||
]);
|
||||
|
||||
$this->artisan('material:scheme', ['--output' => $this->stylesheet])->assertSuccessful();
|
||||
|
||||
$scheme = json_decode(File::get($this->data), true);
|
||||
$stylesheet = File::get($this->stylesheet);
|
||||
|
||||
expect($scheme['default'])->toBe('teal')
|
||||
->and(array_keys($scheme['profiles']))->toBe(['indigo', 'teal', 'graphite'])
|
||||
->and($scheme['profiles']['graphite'])->label->toBe('Graphite')->variant->toBe('neutral')
|
||||
->and($scheme['profiles']['indigo']['light']['primary'])->not->toBe($scheme['profiles']['teal']['light']['primary'])
|
||||
// The top level is the default profile, as a 1.0 reader expects.
|
||||
->and($scheme['light'])->toBe($scheme['profiles']['teal']['light'])
|
||||
->and($scheme['dark'])->toBe($scheme['profiles']['teal']['dark'])
|
||||
->and($scheme['seed'])->toBe('#00897b')
|
||||
->and($stylesheet)->toContain('php artisan material:scheme'."\n");
|
||||
|
||||
// The default's plain blocks come first, so a profile's single-attribute selector follows :root.
|
||||
expect(strpos($stylesheet, ":root,\n[data-theme='light'] {"))->toBeLessThan(strpos($stylesheet, "[data-scheme='indigo'],"));
|
||||
|
||||
foreach ($scheme['profiles'] as $name => $profile) {
|
||||
foreach (['light' => "[data-scheme='{$name}'] [data-theme='light'] {", 'dark' => "[data-scheme='{$name}'] [data-theme='dark'] {"] as $theme => $selector) {
|
||||
$block = Str::of($stylesheet)->after($selector)->before('}')->toString();
|
||||
|
||||
expect($block)->toContain("color-scheme: {$theme};")
|
||||
->toContain("--md-sys-color-primary: {$profile[$theme]['primary']};")
|
||||
->toContain("--md-sys-color-surface: {$profile[$theme]['surface']};");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('takes a profile\'s own spec and state colours, and the command\'s for a profile without them', function () {
|
||||
config([
|
||||
'livewire-material.profiles' => [
|
||||
'expressive' => ['seed' => '#00bc7d', 'variant' => 'vibrant'],
|
||||
'classic' => ['seed' => '#00bc7d', 'variant' => 'vibrant', 'spec' => 2021, 'success' => '#00d390', 'warning' => '#fcb700', 'info' => '#00bafe'],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->artisan('material:scheme', ['--info' => '#00bafe', '--output' => $this->stylesheet])->assertSuccessful();
|
||||
|
||||
$profiles = json_decode(File::get($this->data), true)['profiles'];
|
||||
|
||||
expect($profiles['classic'])->spec->toBe('2021')
|
||||
->and($profiles['classic']['dark'])->toMatchArray(['primary' => '#00e297', 'success' => '#2ce19c', 'warning' => '#ffbb16', 'info' => '#80cfff'])
|
||||
->and($profiles['expressive'])->spec->toBe('2025')
|
||||
->and($profiles['expressive']['dark']['primary'])->not->toBe('#00e297')
|
||||
->and($profiles['expressive']['dark']['success'])->not->toBe('#2ce19c')
|
||||
->and($profiles['expressive']['dark']['info'])->toBe('#80cfff')
|
||||
->and(File::get($this->stylesheet))
|
||||
->toContain('(spec 2025)')
|
||||
->toMatch('/ \* classic\s+#00bc7d, vibrant, spec 2021\n/')
|
||||
->toMatch('/ \* expressive\s+#00bc7d, vibrant\n/');
|
||||
});
|
||||
|
||||
it('names the profile whose spec is unknown', function () {
|
||||
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5', 'spec' => '2019']]]);
|
||||
|
||||
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
|
||||
->expectsOutputToContain('Profile "indigo": Unknown spec "2019"')
|
||||
->assertFailed();
|
||||
});
|
||||
|
||||
it('names the profile a generator error belongs to', function () {
|
||||
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5'], 'broken' => ['seed' => 'teal']]]);
|
||||
|
||||
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
|
||||
->expectsOutputToContain('Profile "broken"')
|
||||
->assertFailed();
|
||||
|
||||
expect(File::exists($this->stylesheet))->toBeFalse();
|
||||
});
|
||||
|
||||
it('asks for a seed or profiles when it has neither', function () {
|
||||
config(['livewire-material.profiles' => []]);
|
||||
|
||||
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
|
||||
->expectsOutputToContain('livewire-material.profiles')
|
||||
->assertFailed();
|
||||
});
|
||||
|
||||
it('refuses a profile name that cannot be an attribute value', function () {
|
||||
config(['livewire-material.profiles' => ['Ocean Blue' => ['seed' => '#0b57d0']]]);
|
||||
|
||||
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
|
||||
->expectsOutputToContain('"Ocean Blue" is not')
|
||||
->assertFailed();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
/**
|
||||
* A scheme file with colour profiles, as `material:scheme` writes one from config.
|
||||
*
|
||||
* @param array<string, string> $primaries profile => light primary
|
||||
*/
|
||||
function writeProfiles(string $path, array $primaries, string $default): void
|
||||
{
|
||||
$profiles = collect($primaries)->map(fn (string $primary, string $name): array => [
|
||||
'label' => Str::headline($name),
|
||||
'seed' => $primary,
|
||||
'variant' => 'vibrant',
|
||||
'spec' => '2025',
|
||||
'contrast' => 0,
|
||||
'light' => ['primary' => $primary, 'surface' => '#fafafa'],
|
||||
'dark' => ['primary' => '#eeeeee', 'surface' => '#111111'],
|
||||
])->all();
|
||||
|
||||
File::put($path, json_encode([...collect($profiles[$default])->except('label')->all(), 'default' => $default, 'profiles' => $profiles]));
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->path = sys_get_temp_dir().'/material-profiles-'.Str::random(8).'.json';
|
||||
writeProfiles($this->path, ['indigo' => '#4f46e5', 'teal' => '#00897b', 'rose' => '#c2185b'], 'indigo');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Scheme::resolveProfileUsing(null);
|
||||
File::delete($this->path);
|
||||
});
|
||||
|
||||
it('lists the generated profiles with their labels and roles, filling missing roles from the default', function () {
|
||||
$profiles = Scheme::profiles($this->path);
|
||||
|
||||
expect(array_keys($profiles))->toBe(['indigo', 'teal', 'rose'])
|
||||
->and($profiles['teal']['label'])->toBe('Teal')
|
||||
->and($profiles['teal']['light']['primary'])->toBe('#00897b')
|
||||
->and($profiles['teal']['light'])->toHaveKey('on-surface-variant')
|
||||
->and(Scheme::profiles(sys_get_temp_dir().'/missing-scheme.json'))->toBe([]);
|
||||
});
|
||||
|
||||
it('follows the resolver to the active profile, and falls back to the default', function () {
|
||||
expect(Scheme::profile($this->path))->toBe('indigo');
|
||||
|
||||
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||
expect(Scheme::profile($this->path))->toBe('teal')
|
||||
->and(Scheme::light($this->path)['primary'])->toBe('#00897b')
|
||||
->and(Scheme::load($this->path)['dark']['surface'])->toBe('#111111');
|
||||
|
||||
Scheme::resolveProfileUsing(fn (): string => 'ocean');
|
||||
expect(Scheme::profile($this->path))->toBe('indigo');
|
||||
|
||||
Scheme::resolveProfileUsing(fn (): ?string => null);
|
||||
expect(Scheme::profile($this->path))->toBe('indigo');
|
||||
|
||||
Scheme::resolveProfileUsing(fn () => throw new RuntimeException('The database is down.'));
|
||||
expect(Scheme::profile($this->path))->toBe('indigo');
|
||||
});
|
||||
|
||||
it('loads a profile by name, whatever is active', function () {
|
||||
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||
|
||||
expect(Scheme::light($this->path, 'rose')['primary'])->toBe('#c2185b')
|
||||
->and(Scheme::light($this->path, 'ocean')['primary'])->toBe('#00897b');
|
||||
});
|
||||
|
||||
it('has no profile for a single scheme, and loads it as before', function () {
|
||||
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => ['primary' => '#abcdef']]));
|
||||
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||
|
||||
expect(Scheme::profile($this->path))->toBeNull()
|
||||
->and(Scheme::profiles($this->path))->toBe([])
|
||||
->and(Scheme::light($this->path)['primary'])->toBe('#123456')
|
||||
->and(Scheme::light($this->path))->toHaveKey('on-surface');
|
||||
});
|
||||
@@ -71,6 +71,20 @@ it('does not mount the showcase when disabled', function () {
|
||||
$this->get('/material')->assertNotFound();
|
||||
});
|
||||
|
||||
it('compiles every package view with the showcase off, as view:cache does in production', function () {
|
||||
rebootWithShowcase(false);
|
||||
|
||||
$compiled = sys_get_temp_dir().'/livewire-material-view-cache-'.uniqid();
|
||||
config(['view.compiled' => $compiled]);
|
||||
File::ensureDirectoryExists($compiled);
|
||||
|
||||
try {
|
||||
$this->artisan('view:cache')->assertSuccessful();
|
||||
} finally {
|
||||
File::deleteDirectory($compiled);
|
||||
}
|
||||
});
|
||||
|
||||
it('serves a symbol for the icon search', function () {
|
||||
$this->get('/material/symbols/filled/favorite.svg')
|
||||
->assertOk()
|
||||
|
||||
@@ -64,6 +64,27 @@ it('leaves the choice of theme to the head script, never to a media query', func
|
||||
}
|
||||
});
|
||||
|
||||
it('reads every safe-area inset through a variable that can replace it', function () {
|
||||
$files = collect([packageCss(), __DIR__.'/../../resources/views', __DIR__.'/../../resources/js'])
|
||||
->flatMap(fn (string $path): array => File::allFiles($path));
|
||||
|
||||
$insets = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
preg_match_all('/env\(safe-area-inset-(top|bottom|left|right)\)/', $file->getContents(), $matches, PREG_OFFSET_CAPTURE);
|
||||
|
||||
foreach ($matches[1] as [$side, $offset]) {
|
||||
$insets++;
|
||||
|
||||
expect(substr($file->getContents(), 0, $offset - strlen('env(safe-area-inset-')))
|
||||
->toMatch("/var\\(--material-safe-{$side},\\s?$/", "{$file->getRelativePathname()} reads safe-area-inset-{$side} directly");
|
||||
}
|
||||
}
|
||||
|
||||
expect($insets)->toBeGreaterThan(10)
|
||||
->and(File::get(packageCss('components/app-bar.css')))->toContain('padding-top: var(--material-safe-top, env(safe-area-inset-top));');
|
||||
});
|
||||
|
||||
it('makes every motion token instant under reduced motion', function () {
|
||||
$motion = File::get(packageCss('tokens/motion.css'));
|
||||
$reduced = Str::of($motion)->after('prefers-reduced-motion: reduce')->toString();
|
||||
|
||||
@@ -23,6 +23,23 @@ class WorkbenchServiceProvider extends ServiceProvider
|
||||
'workbench/resources/js/app.js',
|
||||
]]);
|
||||
|
||||
// Three colour profiles, generated into workbench/resources/css/material-scheme.css with
|
||||
// `vendor/bin/testbench material:scheme --output=workbench/resources/css/material-scheme.css`.
|
||||
// Baseline is the package's own default, so the stylesheet draws it as before; the showcase
|
||||
// offers the others. Tests start without profiles and point at the file when they need it.
|
||||
config([
|
||||
'livewire-material.profiles' => [
|
||||
'baseline' => ['label' => 'Baseline', 'seed' => '#6750a4', 'variant' => 'tonal-spot'],
|
||||
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
|
||||
'rose' => ['label' => 'Rose', 'seed' => '#c2185b', 'variant' => 'vibrant'],
|
||||
],
|
||||
'livewire-material.profile' => 'baseline',
|
||||
]);
|
||||
|
||||
if (! $this->app->runningUnitTests()) {
|
||||
config(['livewire-material.scheme' => workbench_path('resources/css/material-scheme.json')]);
|
||||
}
|
||||
|
||||
Vite::useHotFile(workbench_path('public/hot'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
sources so the plans in docs/ are not scanned for class names. */
|
||||
@import 'tailwindcss' source(none);
|
||||
@import '../../../resources/css/material.css';
|
||||
@import './material-scheme.css';
|
||||
|
||||
@source '../../../resources/views';
|
||||
@source '../../../src';
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
/*
|
||||
* Material 3 colour profiles, generated by Google's material-color-utilities (spec 2025).
|
||||
*
|
||||
* php artisan material:scheme
|
||||
*
|
||||
* From livewire-material.profiles. "baseline" is the default, and also stands without a
|
||||
* data-scheme attribute:
|
||||
*
|
||||
* baseline #6750a4, tonal-spot
|
||||
* teal #00897b, vibrant
|
||||
* rose #c2185b, vibrant
|
||||
*
|
||||
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
|
||||
* M3's contrast guarantee only as generated. The head script sets data-theme and data-scheme
|
||||
* before the first paint.
|
||||
*/
|
||||
|
||||
:root,
|
||||
[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
--md-sys-color-background: #fdf7fe;
|
||||
--md-sys-color-on-background: #34313a;
|
||||
--md-sys-color-surface: #fdf7fe;
|
||||
--md-sys-color-surface-dim: #ded8e4;
|
||||
--md-sys-color-surface-bright: #fdf7fe;
|
||||
--md-sys-color-surface-container-lowest: #ffffff;
|
||||
--md-sys-color-surface-container-low: #f8f1fa;
|
||||
--md-sys-color-surface-container: #f2ecf5;
|
||||
--md-sys-color-surface-container-high: #ece6f0;
|
||||
--md-sys-color-surface-container-highest: #e7e0ec;
|
||||
--md-sys-color-on-surface: #34313a;
|
||||
--md-sys-color-on-surface-variant: #615d68;
|
||||
--md-sys-color-outline: #7d7983;
|
||||
--md-sys-color-outline-variant: #b5b0bb;
|
||||
--md-sys-color-inverse-surface: #0f0d12;
|
||||
--md-sys-color-inverse-on-surface: #a09ba1;
|
||||
--md-sys-color-primary: #655789;
|
||||
--md-sys-color-primary-dim: #594b7c;
|
||||
--md-sys-color-on-primary: #fdf7ff;
|
||||
--md-sys-color-primary-container: #d4c3fd;
|
||||
--md-sys-color-on-primary-container: #493c6c;
|
||||
--md-sys-color-primary-fixed: #d4c3fd;
|
||||
--md-sys-color-primary-fixed-dim: #c6b6ee;
|
||||
--md-sys-color-on-primary-fixed: #352857;
|
||||
--md-sys-color-on-primary-fixed-variant: #524576;
|
||||
--md-sys-color-inverse-primary: #d4c3fd;
|
||||
--md-sys-color-secondary: #625c71;
|
||||
--md-sys-color-secondary-dim: #565065;
|
||||
--md-sys-color-on-secondary: #fdf7ff;
|
||||
--md-sys-color-secondary-container: #e8def8;
|
||||
--md-sys-color-on-secondary-container: #554f63;
|
||||
--md-sys-color-secondary-fixed: #e8def8;
|
||||
--md-sys-color-secondary-fixed-dim: #dad0ea;
|
||||
--md-sys-color-on-secondary-fixed: #423c50;
|
||||
--md-sys-color-on-secondary-fixed-variant: #5f586e;
|
||||
--md-sys-color-tertiary: #7b5270;
|
||||
--md-sys-color-tertiary-dim: #6e4664;
|
||||
--md-sys-color-on-tertiary: #fff7f9;
|
||||
--md-sys-color-tertiary-container: #f4bfe3;
|
||||
--md-sys-color-on-tertiary-container: #5f3956;
|
||||
--md-sys-color-tertiary-fixed: #f4bfe3;
|
||||
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
|
||||
--md-sys-color-on-tertiary-fixed: #4a2642;
|
||||
--md-sys-color-on-tertiary-fixed-variant: #694260;
|
||||
--md-sys-color-error: #a8364b;
|
||||
--md-sys-color-error-dim: #6b0221;
|
||||
--md-sys-color-on-error: #fff7f7;
|
||||
--md-sys-color-error-container: #f97386;
|
||||
--md-sys-color-on-error-container: #6e0523;
|
||||
--md-sys-color-success: #006c45;
|
||||
--md-sys-color-on-success: #ffffff;
|
||||
--md-sys-color-success-container: #86f9bc;
|
||||
--md-sys-color-on-success-container: #002112;
|
||||
--md-sys-color-warning: #7c5800;
|
||||
--md-sys-color-on-warning: #ffffff;
|
||||
--md-sys-color-warning-container: #ffdea6;
|
||||
--md-sys-color-on-warning-container: #271900;
|
||||
--md-sys-color-info: #005ac4;
|
||||
--md-sys-color-on-info: #ffffff;
|
||||
--md-sys-color-info-container: #d8e2ff;
|
||||
--md-sys-color-on-info-container: #001a42;
|
||||
--md-sys-color-inverse-error: #f97386;
|
||||
--md-sys-color-inverse-success: #69dca1;
|
||||
--md-sys-color-inverse-warning: #fdbb28;
|
||||
--md-sys-color-inverse-info: #aec6ff;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--md-sys-color-background: #0f0d12;
|
||||
--md-sys-color-on-background: #eae3ef;
|
||||
--md-sys-color-surface: #0f0d12;
|
||||
--md-sys-color-surface-dim: #0f0d12;
|
||||
--md-sys-color-surface-bright: #2e2b34;
|
||||
--md-sys-color-surface-container-lowest: #000000;
|
||||
--md-sys-color-surface-container-low: #141218;
|
||||
--md-sys-color-surface-container: #1b181f;
|
||||
--md-sys-color-surface-container-high: #211e26;
|
||||
--md-sys-color-surface-container-highest: #27242d;
|
||||
--md-sys-color-on-surface: #eae3ef;
|
||||
--md-sys-color-on-surface-variant: #aea9b4;
|
||||
--md-sys-color-outline: #78737e;
|
||||
--md-sys-color-outline-variant: #4a4650;
|
||||
--md-sys-color-inverse-surface: #fdf7fe;
|
||||
--md-sys-color-inverse-on-surface: #575459;
|
||||
--md-sys-color-primary: #cdc0ec;
|
||||
--md-sys-color-primary-dim: #bfb2de;
|
||||
--md-sys-color-on-primary: #443a5f;
|
||||
--md-sys-color-primary-container: #574d72;
|
||||
--md-sys-color-on-primary-container: #e9deff;
|
||||
--md-sys-color-primary-fixed: #ded0fe;
|
||||
--md-sys-color-primary-fixed-dim: #d0c3ef;
|
||||
--md-sys-color-on-primary-fixed: #3c3256;
|
||||
--md-sys-color-on-primary-fixed-variant: #594e74;
|
||||
--md-sys-color-inverse-primary: #645980;
|
||||
--md-sys-color-secondary: #cbc2db;
|
||||
--md-sys-color-secondary-dim: #beb5cd;
|
||||
--md-sys-color-on-secondary: #433d51;
|
||||
--md-sys-color-secondary-container: #3e384c;
|
||||
--md-sys-color-on-secondary-container: #c4bbd4;
|
||||
--md-sys-color-secondary-fixed: #e8def8;
|
||||
--md-sys-color-secondary-fixed-dim: #dad0ea;
|
||||
--md-sys-color-on-secondary-fixed: #423c50;
|
||||
--md-sys-color-on-secondary-fixed-variant: #5f586e;
|
||||
--md-sys-color-tertiary: #ffcfef;
|
||||
--md-sys-color-tertiary-dim: #f4bfe3;
|
||||
--md-sys-color-on-tertiary: #69415f;
|
||||
--md-sys-color-tertiary-container: #f4bfe3;
|
||||
--md-sys-color-on-tertiary-container: #5f3956;
|
||||
--md-sys-color-tertiary-fixed: #f4bfe3;
|
||||
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
|
||||
--md-sys-color-on-tertiary-fixed: #4a2642;
|
||||
--md-sys-color-on-tertiary-fixed-variant: #694260;
|
||||
--md-sys-color-error: #f97386;
|
||||
--md-sys-color-error-dim: #c44b5f;
|
||||
--md-sys-color-on-error: #490013;
|
||||
--md-sys-color-error-container: #871c34;
|
||||
--md-sys-color-on-error-container: #ff97a3;
|
||||
--md-sys-color-success: #69dca1;
|
||||
--md-sys-color-on-success: #003822;
|
||||
--md-sys-color-success-container: #005233;
|
||||
--md-sys-color-on-success-container: #86f9bc;
|
||||
--md-sys-color-warning: #fdbb28;
|
||||
--md-sys-color-on-warning: #412d00;
|
||||
--md-sys-color-warning-container: #5e4200;
|
||||
--md-sys-color-on-warning-container: #ffdea6;
|
||||
--md-sys-color-info: #aec6ff;
|
||||
--md-sys-color-on-info: #002e6a;
|
||||
--md-sys-color-info-container: #004396;
|
||||
--md-sys-color-on-info-container: #d8e2ff;
|
||||
--md-sys-color-inverse-error: #a8364b;
|
||||
--md-sys-color-inverse-success: #006c45;
|
||||
--md-sys-color-inverse-warning: #7c5800;
|
||||
--md-sys-color-inverse-info: #005ac4;
|
||||
}
|
||||
|
||||
[data-scheme='baseline'],
|
||||
[data-scheme='baseline'][data-theme='light'],
|
||||
[data-scheme='baseline'] [data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
--md-sys-color-background: #fdf7fe;
|
||||
--md-sys-color-on-background: #34313a;
|
||||
--md-sys-color-surface: #fdf7fe;
|
||||
--md-sys-color-surface-dim: #ded8e4;
|
||||
--md-sys-color-surface-bright: #fdf7fe;
|
||||
--md-sys-color-surface-container-lowest: #ffffff;
|
||||
--md-sys-color-surface-container-low: #f8f1fa;
|
||||
--md-sys-color-surface-container: #f2ecf5;
|
||||
--md-sys-color-surface-container-high: #ece6f0;
|
||||
--md-sys-color-surface-container-highest: #e7e0ec;
|
||||
--md-sys-color-on-surface: #34313a;
|
||||
--md-sys-color-on-surface-variant: #615d68;
|
||||
--md-sys-color-outline: #7d7983;
|
||||
--md-sys-color-outline-variant: #b5b0bb;
|
||||
--md-sys-color-inverse-surface: #0f0d12;
|
||||
--md-sys-color-inverse-on-surface: #a09ba1;
|
||||
--md-sys-color-primary: #655789;
|
||||
--md-sys-color-primary-dim: #594b7c;
|
||||
--md-sys-color-on-primary: #fdf7ff;
|
||||
--md-sys-color-primary-container: #d4c3fd;
|
||||
--md-sys-color-on-primary-container: #493c6c;
|
||||
--md-sys-color-primary-fixed: #d4c3fd;
|
||||
--md-sys-color-primary-fixed-dim: #c6b6ee;
|
||||
--md-sys-color-on-primary-fixed: #352857;
|
||||
--md-sys-color-on-primary-fixed-variant: #524576;
|
||||
--md-sys-color-inverse-primary: #d4c3fd;
|
||||
--md-sys-color-secondary: #625c71;
|
||||
--md-sys-color-secondary-dim: #565065;
|
||||
--md-sys-color-on-secondary: #fdf7ff;
|
||||
--md-sys-color-secondary-container: #e8def8;
|
||||
--md-sys-color-on-secondary-container: #554f63;
|
||||
--md-sys-color-secondary-fixed: #e8def8;
|
||||
--md-sys-color-secondary-fixed-dim: #dad0ea;
|
||||
--md-sys-color-on-secondary-fixed: #423c50;
|
||||
--md-sys-color-on-secondary-fixed-variant: #5f586e;
|
||||
--md-sys-color-tertiary: #7b5270;
|
||||
--md-sys-color-tertiary-dim: #6e4664;
|
||||
--md-sys-color-on-tertiary: #fff7f9;
|
||||
--md-sys-color-tertiary-container: #f4bfe3;
|
||||
--md-sys-color-on-tertiary-container: #5f3956;
|
||||
--md-sys-color-tertiary-fixed: #f4bfe3;
|
||||
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
|
||||
--md-sys-color-on-tertiary-fixed: #4a2642;
|
||||
--md-sys-color-on-tertiary-fixed-variant: #694260;
|
||||
--md-sys-color-error: #a8364b;
|
||||
--md-sys-color-error-dim: #6b0221;
|
||||
--md-sys-color-on-error: #fff7f7;
|
||||
--md-sys-color-error-container: #f97386;
|
||||
--md-sys-color-on-error-container: #6e0523;
|
||||
--md-sys-color-success: #006c45;
|
||||
--md-sys-color-on-success: #ffffff;
|
||||
--md-sys-color-success-container: #86f9bc;
|
||||
--md-sys-color-on-success-container: #002112;
|
||||
--md-sys-color-warning: #7c5800;
|
||||
--md-sys-color-on-warning: #ffffff;
|
||||
--md-sys-color-warning-container: #ffdea6;
|
||||
--md-sys-color-on-warning-container: #271900;
|
||||
--md-sys-color-info: #005ac4;
|
||||
--md-sys-color-on-info: #ffffff;
|
||||
--md-sys-color-info-container: #d8e2ff;
|
||||
--md-sys-color-on-info-container: #001a42;
|
||||
--md-sys-color-inverse-error: #f97386;
|
||||
--md-sys-color-inverse-success: #69dca1;
|
||||
--md-sys-color-inverse-warning: #fdbb28;
|
||||
--md-sys-color-inverse-info: #aec6ff;
|
||||
}
|
||||
|
||||
[data-scheme='baseline'][data-theme='dark'],
|
||||
[data-scheme='baseline'] [data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--md-sys-color-background: #0f0d12;
|
||||
--md-sys-color-on-background: #eae3ef;
|
||||
--md-sys-color-surface: #0f0d12;
|
||||
--md-sys-color-surface-dim: #0f0d12;
|
||||
--md-sys-color-surface-bright: #2e2b34;
|
||||
--md-sys-color-surface-container-lowest: #000000;
|
||||
--md-sys-color-surface-container-low: #141218;
|
||||
--md-sys-color-surface-container: #1b181f;
|
||||
--md-sys-color-surface-container-high: #211e26;
|
||||
--md-sys-color-surface-container-highest: #27242d;
|
||||
--md-sys-color-on-surface: #eae3ef;
|
||||
--md-sys-color-on-surface-variant: #aea9b4;
|
||||
--md-sys-color-outline: #78737e;
|
||||
--md-sys-color-outline-variant: #4a4650;
|
||||
--md-sys-color-inverse-surface: #fdf7fe;
|
||||
--md-sys-color-inverse-on-surface: #575459;
|
||||
--md-sys-color-primary: #cdc0ec;
|
||||
--md-sys-color-primary-dim: #bfb2de;
|
||||
--md-sys-color-on-primary: #443a5f;
|
||||
--md-sys-color-primary-container: #574d72;
|
||||
--md-sys-color-on-primary-container: #e9deff;
|
||||
--md-sys-color-primary-fixed: #ded0fe;
|
||||
--md-sys-color-primary-fixed-dim: #d0c3ef;
|
||||
--md-sys-color-on-primary-fixed: #3c3256;
|
||||
--md-sys-color-on-primary-fixed-variant: #594e74;
|
||||
--md-sys-color-inverse-primary: #645980;
|
||||
--md-sys-color-secondary: #cbc2db;
|
||||
--md-sys-color-secondary-dim: #beb5cd;
|
||||
--md-sys-color-on-secondary: #433d51;
|
||||
--md-sys-color-secondary-container: #3e384c;
|
||||
--md-sys-color-on-secondary-container: #c4bbd4;
|
||||
--md-sys-color-secondary-fixed: #e8def8;
|
||||
--md-sys-color-secondary-fixed-dim: #dad0ea;
|
||||
--md-sys-color-on-secondary-fixed: #423c50;
|
||||
--md-sys-color-on-secondary-fixed-variant: #5f586e;
|
||||
--md-sys-color-tertiary: #ffcfef;
|
||||
--md-sys-color-tertiary-dim: #f4bfe3;
|
||||
--md-sys-color-on-tertiary: #69415f;
|
||||
--md-sys-color-tertiary-container: #f4bfe3;
|
||||
--md-sys-color-on-tertiary-container: #5f3956;
|
||||
--md-sys-color-tertiary-fixed: #f4bfe3;
|
||||
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
|
||||
--md-sys-color-on-tertiary-fixed: #4a2642;
|
||||
--md-sys-color-on-tertiary-fixed-variant: #694260;
|
||||
--md-sys-color-error: #f97386;
|
||||
--md-sys-color-error-dim: #c44b5f;
|
||||
--md-sys-color-on-error: #490013;
|
||||
--md-sys-color-error-container: #871c34;
|
||||
--md-sys-color-on-error-container: #ff97a3;
|
||||
--md-sys-color-success: #69dca1;
|
||||
--md-sys-color-on-success: #003822;
|
||||
--md-sys-color-success-container: #005233;
|
||||
--md-sys-color-on-success-container: #86f9bc;
|
||||
--md-sys-color-warning: #fdbb28;
|
||||
--md-sys-color-on-warning: #412d00;
|
||||
--md-sys-color-warning-container: #5e4200;
|
||||
--md-sys-color-on-warning-container: #ffdea6;
|
||||
--md-sys-color-info: #aec6ff;
|
||||
--md-sys-color-on-info: #002e6a;
|
||||
--md-sys-color-info-container: #004396;
|
||||
--md-sys-color-on-info-container: #d8e2ff;
|
||||
--md-sys-color-inverse-error: #a8364b;
|
||||
--md-sys-color-inverse-success: #006c45;
|
||||
--md-sys-color-inverse-warning: #7c5800;
|
||||
--md-sys-color-inverse-info: #005ac4;
|
||||
}
|
||||
|
||||
[data-scheme='teal'],
|
||||
[data-scheme='teal'][data-theme='light'],
|
||||
[data-scheme='teal'] [data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
--md-sys-color-background: #d3fffd;
|
||||
--md-sys-color-on-background: #003534;
|
||||
--md-sys-color-surface: #d3fffd;
|
||||
--md-sys-color-surface-dim: #87e4e1;
|
||||
--md-sys-color-surface-bright: #d3fffd;
|
||||
--md-sys-color-surface-container-lowest: #ffffff;
|
||||
--md-sys-color-surface-container-low: #bafdfa;
|
||||
--md-sys-color-surface-container: #adf5f2;
|
||||
--md-sys-color-surface-container-high: #a2f0ed;
|
||||
--md-sys-color-surface-container-highest: #96ece8;
|
||||
--md-sys-color-on-surface: #003534;
|
||||
--md-sys-color-on-surface-variant: #296463;
|
||||
--md-sys-color-outline: #46807e;
|
||||
--md-sys-color-outline-variant: #7db7b5;
|
||||
--md-sys-color-inverse-surface: #001111;
|
||||
--md-sys-color-inverse-on-surface: #6da7a5;
|
||||
--md-sys-color-primary: #00675c;
|
||||
--md-sys-color-primary-dim: #005a50;
|
||||
--md-sys-color-on-primary: #c0fff3;
|
||||
--md-sys-color-primary-container: #00f7df;
|
||||
--md-sys-color-on-primary-container: #00594f;
|
||||
--md-sys-color-primary-fixed: #00f7df;
|
||||
--md-sys-color-primary-fixed-dim: #00e8d1;
|
||||
--md-sys-color-on-primary-fixed: #00443c;
|
||||
--md-sys-color-on-primary-fixed-variant: #006359;
|
||||
--md-sys-color-inverse-primary: #00fee5;
|
||||
--md-sys-color-secondary: #006765;
|
||||
--md-sys-color-secondary-dim: #005958;
|
||||
--md-sys-color-on-secondary: #bcfffc;
|
||||
--md-sys-color-secondary-container: #38fbf7;
|
||||
--md-sys-color-on-secondary-container: #005c5a;
|
||||
--md-sys-color-secondary-fixed: #38fbf7;
|
||||
--md-sys-color-secondary-fixed-dim: #10ece8;
|
||||
--md-sys-color-on-secondary-fixed: #004746;
|
||||
--md-sys-color-on-secondary-fixed-variant: #006765;
|
||||
--md-sys-color-tertiary: #006386;
|
||||
--md-sys-color-tertiary-dim: #005675;
|
||||
--md-sys-color-on-tertiary: #e7f5ff;
|
||||
--md-sys-color-tertiary-container: #20c0ff;
|
||||
--md-sys-color-on-tertiary-container: #00374d;
|
||||
--md-sys-color-tertiary-fixed: #20c0ff;
|
||||
--md-sys-color-tertiary-fixed-dim: #00b2ee;
|
||||
--md-sys-color-on-tertiary-fixed: #001e2b;
|
||||
--md-sys-color-on-tertiary-fixed-variant: #004059;
|
||||
--md-sys-color-error: #b31b25;
|
||||
--md-sys-color-error-dim: #9f0519;
|
||||
--md-sys-color-on-error: #ffefee;
|
||||
--md-sys-color-error-container: #fb5151;
|
||||
--md-sys-color-on-error-container: #570008;
|
||||
--md-sys-color-success: #006c45;
|
||||
--md-sys-color-on-success: #ffffff;
|
||||
--md-sys-color-success-container: #86f9bc;
|
||||
--md-sys-color-on-success-container: #002112;
|
||||
--md-sys-color-warning: #7c5800;
|
||||
--md-sys-color-on-warning: #ffffff;
|
||||
--md-sys-color-warning-container: #ffdea6;
|
||||
--md-sys-color-on-warning-container: #271900;
|
||||
--md-sys-color-info: #005ac4;
|
||||
--md-sys-color-on-info: #ffffff;
|
||||
--md-sys-color-info-container: #d8e2ff;
|
||||
--md-sys-color-on-info-container: #001a42;
|
||||
--md-sys-color-inverse-error: #ff716c;
|
||||
--md-sys-color-inverse-success: #69dca1;
|
||||
--md-sys-color-inverse-warning: #fdbb28;
|
||||
--md-sys-color-inverse-info: #aec6ff;
|
||||
}
|
||||
|
||||
[data-scheme='teal'][data-theme='dark'],
|
||||
[data-scheme='teal'] [data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--md-sys-color-background: #001111;
|
||||
--md-sys-color-on-background: #b8f3f1;
|
||||
--md-sys-color-surface: #001111;
|
||||
--md-sys-color-surface-dim: #001111;
|
||||
--md-sys-color-surface-bright: #003231;
|
||||
--md-sys-color-surface-container-lowest: #000000;
|
||||
--md-sys-color-surface-container-low: #001716;
|
||||
--md-sys-color-surface-container: #001e1d;
|
||||
--md-sys-color-surface-container-high: #002424;
|
||||
--md-sys-color-surface-container-highest: #002b2a;
|
||||
--md-sys-color-on-surface: #b8f3f1;
|
||||
--md-sys-color-on-surface-variant: #7bb5b3;
|
||||
--md-sys-color-outline: #457f7d;
|
||||
--md-sys-color-outline-variant: #0e504f;
|
||||
--md-sys-color-inverse-surface: #e3fffd;
|
||||
--md-sys-color-inverse-on-surface: #215e5c;
|
||||
--md-sys-color-primary: #b4fff1;
|
||||
--md-sys-color-primary-dim: #00fee5;
|
||||
--md-sys-color-on-primary: #00665b;
|
||||
--md-sys-color-primary-container: #00fee5;
|
||||
--md-sys-color-on-primary-container: #005c53;
|
||||
--md-sys-color-primary-fixed: #00f7df;
|
||||
--md-sys-color-primary-fixed-dim: #00e8d1;
|
||||
--md-sys-color-on-primary-fixed: #00443c;
|
||||
--md-sys-color-on-primary-fixed-variant: #006359;
|
||||
--md-sys-color-inverse-primary: #006b60;
|
||||
--md-sys-color-secondary: #38fbf7;
|
||||
--md-sys-color-secondary-dim: #10ece8;
|
||||
--md-sys-color-on-secondary: #005c5a;
|
||||
--md-sys-color-secondary-container: #006a68;
|
||||
--md-sys-color-on-secondary-container: #dafffd;
|
||||
--md-sys-color-secondary-fixed: #38fbf7;
|
||||
--md-sys-color-secondary-fixed-dim: #10ece8;
|
||||
--md-sys-color-on-secondary-fixed: #004746;
|
||||
--md-sys-color-on-secondary-fixed-variant: #006765;
|
||||
--md-sys-color-tertiary: #68ccff;
|
||||
--md-sys-color-tertiary-dim: #20c0ff;
|
||||
--md-sys-color-on-tertiary: #00415a;
|
||||
--md-sys-color-tertiary-container: #20c0ff;
|
||||
--md-sys-color-on-tertiary-container: #00374d;
|
||||
--md-sys-color-tertiary-fixed: #20c0ff;
|
||||
--md-sys-color-tertiary-fixed-dim: #00b2ee;
|
||||
--md-sys-color-on-tertiary-fixed: #001e2b;
|
||||
--md-sys-color-on-tertiary-fixed-variant: #004059;
|
||||
--md-sys-color-error: #ff716c;
|
||||
--md-sys-color-error-dim: #d7383b;
|
||||
--md-sys-color-on-error: #490006;
|
||||
--md-sys-color-error-container: #9f0519;
|
||||
--md-sys-color-on-error-container: #ffa8a3;
|
||||
--md-sys-color-success: #69dca1;
|
||||
--md-sys-color-on-success: #003822;
|
||||
--md-sys-color-success-container: #005233;
|
||||
--md-sys-color-on-success-container: #86f9bc;
|
||||
--md-sys-color-warning: #fdbb28;
|
||||
--md-sys-color-on-warning: #412d00;
|
||||
--md-sys-color-warning-container: #5e4200;
|
||||
--md-sys-color-on-warning-container: #ffdea6;
|
||||
--md-sys-color-info: #aec6ff;
|
||||
--md-sys-color-on-info: #002e6a;
|
||||
--md-sys-color-info-container: #004396;
|
||||
--md-sys-color-on-info-container: #d8e2ff;
|
||||
--md-sys-color-inverse-error: #b31b25;
|
||||
--md-sys-color-inverse-success: #006c45;
|
||||
--md-sys-color-inverse-warning: #7c5800;
|
||||
--md-sys-color-inverse-info: #005ac4;
|
||||
}
|
||||
|
||||
[data-scheme='rose'],
|
||||
[data-scheme='rose'][data-theme='light'],
|
||||
[data-scheme='rose'] [data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
--md-sys-color-background: #fff4f6;
|
||||
--md-sys-color-on-background: #492136;
|
||||
--md-sys-color-surface: #fff4f6;
|
||||
--md-sys-color-surface-dim: #ffc4de;
|
||||
--md-sys-color-surface-bright: #fff4f6;
|
||||
--md-sys-color-surface-container-lowest: #ffffff;
|
||||
--md-sys-color-surface-container-low: #ffecf2;
|
||||
--md-sys-color-surface-container: #ffe0ec;
|
||||
--md-sys-color-surface-container-high: #ffd8e8;
|
||||
--md-sys-color-surface-container-highest: #ffd0e4;
|
||||
--md-sys-color-on-surface: #492136;
|
||||
--md-sys-color-on-surface-variant: #7c4d64;
|
||||
--md-sys-color-outline: #9b6880;
|
||||
--md-sys-color-outline-variant: #d69db7;
|
||||
--md-sys-color-inverse-surface: #220215;
|
||||
--md-sys-color-inverse-on-surface: #c58da6;
|
||||
--md-sys-color-primary: #ae1d53;
|
||||
--md-sys-color-primary-dim: #9e0b47;
|
||||
--md-sys-color-on-primary: #ffeff0;
|
||||
--md-sys-color-primary-container: #ff7198;
|
||||
--md-sys-color-on-primary-container: #4d001e;
|
||||
--md-sys-color-primary-fixed: #ff7198;
|
||||
--md-sys-color-primary-fixed-dim: #f95a8a;
|
||||
--md-sys-color-on-primary-fixed: #000000;
|
||||
--md-sys-color-on-primary-fixed-variant: #5e0027;
|
||||
--md-sys-color-inverse-primary: #f65787;
|
||||
--md-sys-color-secondary: #99366e;
|
||||
--md-sys-color-secondary-dim: #8a2a62;
|
||||
--md-sys-color-on-secondary: #ffeff3;
|
||||
--md-sys-color-secondary-container: #ffc0dc;
|
||||
--md-sys-color-on-secondary-container: #7f2159;
|
||||
--md-sys-color-secondary-fixed: #ffc0dc;
|
||||
--md-sys-color-secondary-fixed-dim: #ffabd2;
|
||||
--md-sys-color-on-secondary-fixed: #670845;
|
||||
--md-sys-color-on-secondary-fixed-variant: #8b2b63;
|
||||
--md-sys-color-tertiary: #5c4bb4;
|
||||
--md-sys-color-tertiary-dim: #503ea7;
|
||||
--md-sys-color-on-tertiary: #f6f0ff;
|
||||
--md-sys-color-tertiary-container: #b3a5ff;
|
||||
--md-sys-color-on-tertiary-container: #301887;
|
||||
--md-sys-color-tertiary-fixed: #b3a5ff;
|
||||
--md-sys-color-tertiary-fixed-dim: #a595ff;
|
||||
--md-sys-color-on-tertiary-fixed: #170059;
|
||||
--md-sys-color-on-tertiary-fixed-variant: #39248f;
|
||||
--md-sys-color-error: #b31b25;
|
||||
--md-sys-color-error-dim: #9f0519;
|
||||
--md-sys-color-on-error: #ffefee;
|
||||
--md-sys-color-error-container: #fb5151;
|
||||
--md-sys-color-on-error-container: #570008;
|
||||
--md-sys-color-success: #006c45;
|
||||
--md-sys-color-on-success: #ffffff;
|
||||
--md-sys-color-success-container: #86f9bc;
|
||||
--md-sys-color-on-success-container: #002112;
|
||||
--md-sys-color-warning: #7c5800;
|
||||
--md-sys-color-on-warning: #ffffff;
|
||||
--md-sys-color-warning-container: #ffdea6;
|
||||
--md-sys-color-on-warning-container: #271900;
|
||||
--md-sys-color-info: #005ac4;
|
||||
--md-sys-color-on-info: #ffffff;
|
||||
--md-sys-color-info-container: #d8e2ff;
|
||||
--md-sys-color-on-info-container: #001a42;
|
||||
--md-sys-color-inverse-error: #ff716c;
|
||||
--md-sys-color-inverse-success: #69dca1;
|
||||
--md-sys-color-inverse-warning: #fdbb28;
|
||||
--md-sys-color-inverse-info: #aec6ff;
|
||||
}
|
||||
|
||||
[data-scheme='rose'][data-theme='dark'],
|
||||
[data-scheme='rose'] [data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--md-sys-color-background: #220215;
|
||||
--md-sys-color-on-background: #ffdcea;
|
||||
--md-sys-color-surface: #220215;
|
||||
--md-sys-color-surface-dim: #220215;
|
||||
--md-sys-color-surface-bright: #4e1737;
|
||||
--md-sys-color-surface-container-lowest: #000000;
|
||||
--md-sys-color-surface-container-low: #2a041b;
|
||||
--md-sys-color-surface-container: #330822;
|
||||
--md-sys-color-surface-container-high: #3c0d28;
|
||||
--md-sys-color-surface-container-highest: #45122f;
|
||||
--md-sys-color-on-surface: #ffdcea;
|
||||
--md-sys-color-on-surface-variant: #d49bb4;
|
||||
--md-sys-color-outline: #99667e;
|
||||
--md-sys-color-outline-variant: #663a50;
|
||||
--md-sys-color-inverse-surface: #fff8f8;
|
||||
--md-sys-color-inverse-on-surface: #75475d;
|
||||
--md-sys-color-primary: #ff8aa7;
|
||||
--md-sys-color-primary-dim: #ff6b95;
|
||||
--md-sys-color-on-primary: #620029;
|
||||
--md-sys-color-primary-container: #ff7198;
|
||||
--md-sys-color-on-primary-container: #4d001e;
|
||||
--md-sys-color-primary-fixed: #ff7198;
|
||||
--md-sys-color-primary-fixed-dim: #f95a8a;
|
||||
--md-sys-color-on-primary-fixed: #000000;
|
||||
--md-sys-color-on-primary-fixed-variant: #5e0027;
|
||||
--md-sys-color-inverse-primary: #b32257;
|
||||
--md-sys-color-secondary: #ff8ac6;
|
||||
--md-sys-color-secondary-dim: #ef7db9;
|
||||
--md-sys-color-on-secondary: #620241;
|
||||
--md-sys-color-secondary-container: #7f2058;
|
||||
--md-sys-color-on-secondary-container: #ffbedb;
|
||||
--md-sys-color-secondary-fixed: #ffc0dc;
|
||||
--md-sys-color-secondary-fixed-dim: #ffabd2;
|
||||
--md-sys-color-on-secondary-fixed: #670845;
|
||||
--md-sys-color-on-secondary-fixed-variant: #8b2b63;
|
||||
--md-sys-color-tertiary: #aa9bff;
|
||||
--md-sys-color-tertiary-dim: #9f8ffc;
|
||||
--md-sys-color-on-tertiary: #290c80;
|
||||
--md-sys-color-tertiary-container: #9d8cfa;
|
||||
--md-sys-color-on-tertiary-container: #1c0068;
|
||||
--md-sys-color-tertiary-fixed: #b3a5ff;
|
||||
--md-sys-color-tertiary-fixed-dim: #a595ff;
|
||||
--md-sys-color-on-tertiary-fixed: #170059;
|
||||
--md-sys-color-on-tertiary-fixed-variant: #39248f;
|
||||
--md-sys-color-error: #ff716c;
|
||||
--md-sys-color-error-dim: #d7383b;
|
||||
--md-sys-color-on-error: #490006;
|
||||
--md-sys-color-error-container: #9f0519;
|
||||
--md-sys-color-on-error-container: #ffa8a3;
|
||||
--md-sys-color-success: #69dca1;
|
||||
--md-sys-color-on-success: #003822;
|
||||
--md-sys-color-success-container: #005233;
|
||||
--md-sys-color-on-success-container: #86f9bc;
|
||||
--md-sys-color-warning: #fdbb28;
|
||||
--md-sys-color-on-warning: #412d00;
|
||||
--md-sys-color-warning-container: #5e4200;
|
||||
--md-sys-color-on-warning-container: #ffdea6;
|
||||
--md-sys-color-info: #aec6ff;
|
||||
--md-sys-color-on-info: #002e6a;
|
||||
--md-sys-color-info-container: #004396;
|
||||
--md-sys-color-on-info-container: #d8e2ff;
|
||||
--md-sys-color-inverse-error: #b31b25;
|
||||
--md-sys-color-inverse-success: #006c45;
|
||||
--md-sys-color-inverse-warning: #7c5800;
|
||||
--md-sys-color-inverse-info: #005ac4;
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
{
|
||||
"seed": "#6750a4",
|
||||
"variant": "tonal-spot",
|
||||
"spec": "2025",
|
||||
"contrast": 0,
|
||||
"light": {
|
||||
"background": "#fdf7fe",
|
||||
"on-background": "#34313a",
|
||||
"surface": "#fdf7fe",
|
||||
"surface-dim": "#ded8e4",
|
||||
"surface-bright": "#fdf7fe",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"surface-container-low": "#f8f1fa",
|
||||
"surface-container": "#f2ecf5",
|
||||
"surface-container-high": "#ece6f0",
|
||||
"surface-container-highest": "#e7e0ec",
|
||||
"on-surface": "#34313a",
|
||||
"on-surface-variant": "#615d68",
|
||||
"outline": "#7d7983",
|
||||
"outline-variant": "#b5b0bb",
|
||||
"inverse-surface": "#0f0d12",
|
||||
"inverse-on-surface": "#a09ba1",
|
||||
"primary": "#655789",
|
||||
"primary-dim": "#594b7c",
|
||||
"on-primary": "#fdf7ff",
|
||||
"primary-container": "#d4c3fd",
|
||||
"on-primary-container": "#493c6c",
|
||||
"primary-fixed": "#d4c3fd",
|
||||
"primary-fixed-dim": "#c6b6ee",
|
||||
"on-primary-fixed": "#352857",
|
||||
"on-primary-fixed-variant": "#524576",
|
||||
"inverse-primary": "#d4c3fd",
|
||||
"secondary": "#625c71",
|
||||
"secondary-dim": "#565065",
|
||||
"on-secondary": "#fdf7ff",
|
||||
"secondary-container": "#e8def8",
|
||||
"on-secondary-container": "#554f63",
|
||||
"secondary-fixed": "#e8def8",
|
||||
"secondary-fixed-dim": "#dad0ea",
|
||||
"on-secondary-fixed": "#423c50",
|
||||
"on-secondary-fixed-variant": "#5f586e",
|
||||
"tertiary": "#7b5270",
|
||||
"tertiary-dim": "#6e4664",
|
||||
"on-tertiary": "#fff7f9",
|
||||
"tertiary-container": "#f4bfe3",
|
||||
"on-tertiary-container": "#5f3956",
|
||||
"tertiary-fixed": "#f4bfe3",
|
||||
"tertiary-fixed-dim": "#e5b2d5",
|
||||
"on-tertiary-fixed": "#4a2642",
|
||||
"on-tertiary-fixed-variant": "#694260",
|
||||
"error": "#a8364b",
|
||||
"error-dim": "#6b0221",
|
||||
"on-error": "#fff7f7",
|
||||
"error-container": "#f97386",
|
||||
"on-error-container": "#6e0523",
|
||||
"success": "#006c45",
|
||||
"on-success": "#ffffff",
|
||||
"success-container": "#86f9bc",
|
||||
"on-success-container": "#002112",
|
||||
"warning": "#7c5800",
|
||||
"on-warning": "#ffffff",
|
||||
"warning-container": "#ffdea6",
|
||||
"on-warning-container": "#271900",
|
||||
"info": "#005ac4",
|
||||
"on-info": "#ffffff",
|
||||
"info-container": "#d8e2ff",
|
||||
"on-info-container": "#001a42",
|
||||
"inverse-error": "#f97386",
|
||||
"inverse-success": "#69dca1",
|
||||
"inverse-warning": "#fdbb28",
|
||||
"inverse-info": "#aec6ff"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#0f0d12",
|
||||
"on-background": "#eae3ef",
|
||||
"surface": "#0f0d12",
|
||||
"surface-dim": "#0f0d12",
|
||||
"surface-bright": "#2e2b34",
|
||||
"surface-container-lowest": "#000000",
|
||||
"surface-container-low": "#141218",
|
||||
"surface-container": "#1b181f",
|
||||
"surface-container-high": "#211e26",
|
||||
"surface-container-highest": "#27242d",
|
||||
"on-surface": "#eae3ef",
|
||||
"on-surface-variant": "#aea9b4",
|
||||
"outline": "#78737e",
|
||||
"outline-variant": "#4a4650",
|
||||
"inverse-surface": "#fdf7fe",
|
||||
"inverse-on-surface": "#575459",
|
||||
"primary": "#cdc0ec",
|
||||
"primary-dim": "#bfb2de",
|
||||
"on-primary": "#443a5f",
|
||||
"primary-container": "#574d72",
|
||||
"on-primary-container": "#e9deff",
|
||||
"primary-fixed": "#ded0fe",
|
||||
"primary-fixed-dim": "#d0c3ef",
|
||||
"on-primary-fixed": "#3c3256",
|
||||
"on-primary-fixed-variant": "#594e74",
|
||||
"inverse-primary": "#645980",
|
||||
"secondary": "#cbc2db",
|
||||
"secondary-dim": "#beb5cd",
|
||||
"on-secondary": "#433d51",
|
||||
"secondary-container": "#3e384c",
|
||||
"on-secondary-container": "#c4bbd4",
|
||||
"secondary-fixed": "#e8def8",
|
||||
"secondary-fixed-dim": "#dad0ea",
|
||||
"on-secondary-fixed": "#423c50",
|
||||
"on-secondary-fixed-variant": "#5f586e",
|
||||
"tertiary": "#ffcfef",
|
||||
"tertiary-dim": "#f4bfe3",
|
||||
"on-tertiary": "#69415f",
|
||||
"tertiary-container": "#f4bfe3",
|
||||
"on-tertiary-container": "#5f3956",
|
||||
"tertiary-fixed": "#f4bfe3",
|
||||
"tertiary-fixed-dim": "#e5b2d5",
|
||||
"on-tertiary-fixed": "#4a2642",
|
||||
"on-tertiary-fixed-variant": "#694260",
|
||||
"error": "#f97386",
|
||||
"error-dim": "#c44b5f",
|
||||
"on-error": "#490013",
|
||||
"error-container": "#871c34",
|
||||
"on-error-container": "#ff97a3",
|
||||
"success": "#69dca1",
|
||||
"on-success": "#003822",
|
||||
"success-container": "#005233",
|
||||
"on-success-container": "#86f9bc",
|
||||
"warning": "#fdbb28",
|
||||
"on-warning": "#412d00",
|
||||
"warning-container": "#5e4200",
|
||||
"on-warning-container": "#ffdea6",
|
||||
"info": "#aec6ff",
|
||||
"on-info": "#002e6a",
|
||||
"info-container": "#004396",
|
||||
"on-info-container": "#d8e2ff",
|
||||
"inverse-error": "#a8364b",
|
||||
"inverse-success": "#006c45",
|
||||
"inverse-warning": "#7c5800",
|
||||
"inverse-info": "#005ac4"
|
||||
},
|
||||
"default": "baseline",
|
||||
"profiles": {
|
||||
"baseline": {
|
||||
"label": "Baseline",
|
||||
"seed": "#6750a4",
|
||||
"variant": "tonal-spot",
|
||||
"spec": "2025",
|
||||
"contrast": 0,
|
||||
"light": {
|
||||
"background": "#fdf7fe",
|
||||
"on-background": "#34313a",
|
||||
"surface": "#fdf7fe",
|
||||
"surface-dim": "#ded8e4",
|
||||
"surface-bright": "#fdf7fe",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"surface-container-low": "#f8f1fa",
|
||||
"surface-container": "#f2ecf5",
|
||||
"surface-container-high": "#ece6f0",
|
||||
"surface-container-highest": "#e7e0ec",
|
||||
"on-surface": "#34313a",
|
||||
"on-surface-variant": "#615d68",
|
||||
"outline": "#7d7983",
|
||||
"outline-variant": "#b5b0bb",
|
||||
"inverse-surface": "#0f0d12",
|
||||
"inverse-on-surface": "#a09ba1",
|
||||
"primary": "#655789",
|
||||
"primary-dim": "#594b7c",
|
||||
"on-primary": "#fdf7ff",
|
||||
"primary-container": "#d4c3fd",
|
||||
"on-primary-container": "#493c6c",
|
||||
"primary-fixed": "#d4c3fd",
|
||||
"primary-fixed-dim": "#c6b6ee",
|
||||
"on-primary-fixed": "#352857",
|
||||
"on-primary-fixed-variant": "#524576",
|
||||
"inverse-primary": "#d4c3fd",
|
||||
"secondary": "#625c71",
|
||||
"secondary-dim": "#565065",
|
||||
"on-secondary": "#fdf7ff",
|
||||
"secondary-container": "#e8def8",
|
||||
"on-secondary-container": "#554f63",
|
||||
"secondary-fixed": "#e8def8",
|
||||
"secondary-fixed-dim": "#dad0ea",
|
||||
"on-secondary-fixed": "#423c50",
|
||||
"on-secondary-fixed-variant": "#5f586e",
|
||||
"tertiary": "#7b5270",
|
||||
"tertiary-dim": "#6e4664",
|
||||
"on-tertiary": "#fff7f9",
|
||||
"tertiary-container": "#f4bfe3",
|
||||
"on-tertiary-container": "#5f3956",
|
||||
"tertiary-fixed": "#f4bfe3",
|
||||
"tertiary-fixed-dim": "#e5b2d5",
|
||||
"on-tertiary-fixed": "#4a2642",
|
||||
"on-tertiary-fixed-variant": "#694260",
|
||||
"error": "#a8364b",
|
||||
"error-dim": "#6b0221",
|
||||
"on-error": "#fff7f7",
|
||||
"error-container": "#f97386",
|
||||
"on-error-container": "#6e0523",
|
||||
"success": "#006c45",
|
||||
"on-success": "#ffffff",
|
||||
"success-container": "#86f9bc",
|
||||
"on-success-container": "#002112",
|
||||
"warning": "#7c5800",
|
||||
"on-warning": "#ffffff",
|
||||
"warning-container": "#ffdea6",
|
||||
"on-warning-container": "#271900",
|
||||
"info": "#005ac4",
|
||||
"on-info": "#ffffff",
|
||||
"info-container": "#d8e2ff",
|
||||
"on-info-container": "#001a42",
|
||||
"inverse-error": "#f97386",
|
||||
"inverse-success": "#69dca1",
|
||||
"inverse-warning": "#fdbb28",
|
||||
"inverse-info": "#aec6ff"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#0f0d12",
|
||||
"on-background": "#eae3ef",
|
||||
"surface": "#0f0d12",
|
||||
"surface-dim": "#0f0d12",
|
||||
"surface-bright": "#2e2b34",
|
||||
"surface-container-lowest": "#000000",
|
||||
"surface-container-low": "#141218",
|
||||
"surface-container": "#1b181f",
|
||||
"surface-container-high": "#211e26",
|
||||
"surface-container-highest": "#27242d",
|
||||
"on-surface": "#eae3ef",
|
||||
"on-surface-variant": "#aea9b4",
|
||||
"outline": "#78737e",
|
||||
"outline-variant": "#4a4650",
|
||||
"inverse-surface": "#fdf7fe",
|
||||
"inverse-on-surface": "#575459",
|
||||
"primary": "#cdc0ec",
|
||||
"primary-dim": "#bfb2de",
|
||||
"on-primary": "#443a5f",
|
||||
"primary-container": "#574d72",
|
||||
"on-primary-container": "#e9deff",
|
||||
"primary-fixed": "#ded0fe",
|
||||
"primary-fixed-dim": "#d0c3ef",
|
||||
"on-primary-fixed": "#3c3256",
|
||||
"on-primary-fixed-variant": "#594e74",
|
||||
"inverse-primary": "#645980",
|
||||
"secondary": "#cbc2db",
|
||||
"secondary-dim": "#beb5cd",
|
||||
"on-secondary": "#433d51",
|
||||
"secondary-container": "#3e384c",
|
||||
"on-secondary-container": "#c4bbd4",
|
||||
"secondary-fixed": "#e8def8",
|
||||
"secondary-fixed-dim": "#dad0ea",
|
||||
"on-secondary-fixed": "#423c50",
|
||||
"on-secondary-fixed-variant": "#5f586e",
|
||||
"tertiary": "#ffcfef",
|
||||
"tertiary-dim": "#f4bfe3",
|
||||
"on-tertiary": "#69415f",
|
||||
"tertiary-container": "#f4bfe3",
|
||||
"on-tertiary-container": "#5f3956",
|
||||
"tertiary-fixed": "#f4bfe3",
|
||||
"tertiary-fixed-dim": "#e5b2d5",
|
||||
"on-tertiary-fixed": "#4a2642",
|
||||
"on-tertiary-fixed-variant": "#694260",
|
||||
"error": "#f97386",
|
||||
"error-dim": "#c44b5f",
|
||||
"on-error": "#490013",
|
||||
"error-container": "#871c34",
|
||||
"on-error-container": "#ff97a3",
|
||||
"success": "#69dca1",
|
||||
"on-success": "#003822",
|
||||
"success-container": "#005233",
|
||||
"on-success-container": "#86f9bc",
|
||||
"warning": "#fdbb28",
|
||||
"on-warning": "#412d00",
|
||||
"warning-container": "#5e4200",
|
||||
"on-warning-container": "#ffdea6",
|
||||
"info": "#aec6ff",
|
||||
"on-info": "#002e6a",
|
||||
"info-container": "#004396",
|
||||
"on-info-container": "#d8e2ff",
|
||||
"inverse-error": "#a8364b",
|
||||
"inverse-success": "#006c45",
|
||||
"inverse-warning": "#7c5800",
|
||||
"inverse-info": "#005ac4"
|
||||
}
|
||||
},
|
||||
"teal": {
|
||||
"label": "Teal",
|
||||
"seed": "#00897b",
|
||||
"variant": "vibrant",
|
||||
"spec": "2025",
|
||||
"contrast": 0,
|
||||
"light": {
|
||||
"background": "#d3fffd",
|
||||
"on-background": "#003534",
|
||||
"surface": "#d3fffd",
|
||||
"surface-dim": "#87e4e1",
|
||||
"surface-bright": "#d3fffd",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"surface-container-low": "#bafdfa",
|
||||
"surface-container": "#adf5f2",
|
||||
"surface-container-high": "#a2f0ed",
|
||||
"surface-container-highest": "#96ece8",
|
||||
"on-surface": "#003534",
|
||||
"on-surface-variant": "#296463",
|
||||
"outline": "#46807e",
|
||||
"outline-variant": "#7db7b5",
|
||||
"inverse-surface": "#001111",
|
||||
"inverse-on-surface": "#6da7a5",
|
||||
"primary": "#00675c",
|
||||
"primary-dim": "#005a50",
|
||||
"on-primary": "#c0fff3",
|
||||
"primary-container": "#00f7df",
|
||||
"on-primary-container": "#00594f",
|
||||
"primary-fixed": "#00f7df",
|
||||
"primary-fixed-dim": "#00e8d1",
|
||||
"on-primary-fixed": "#00443c",
|
||||
"on-primary-fixed-variant": "#006359",
|
||||
"inverse-primary": "#00fee5",
|
||||
"secondary": "#006765",
|
||||
"secondary-dim": "#005958",
|
||||
"on-secondary": "#bcfffc",
|
||||
"secondary-container": "#38fbf7",
|
||||
"on-secondary-container": "#005c5a",
|
||||
"secondary-fixed": "#38fbf7",
|
||||
"secondary-fixed-dim": "#10ece8",
|
||||
"on-secondary-fixed": "#004746",
|
||||
"on-secondary-fixed-variant": "#006765",
|
||||
"tertiary": "#006386",
|
||||
"tertiary-dim": "#005675",
|
||||
"on-tertiary": "#e7f5ff",
|
||||
"tertiary-container": "#20c0ff",
|
||||
"on-tertiary-container": "#00374d",
|
||||
"tertiary-fixed": "#20c0ff",
|
||||
"tertiary-fixed-dim": "#00b2ee",
|
||||
"on-tertiary-fixed": "#001e2b",
|
||||
"on-tertiary-fixed-variant": "#004059",
|
||||
"error": "#b31b25",
|
||||
"error-dim": "#9f0519",
|
||||
"on-error": "#ffefee",
|
||||
"error-container": "#fb5151",
|
||||
"on-error-container": "#570008",
|
||||
"success": "#006c45",
|
||||
"on-success": "#ffffff",
|
||||
"success-container": "#86f9bc",
|
||||
"on-success-container": "#002112",
|
||||
"warning": "#7c5800",
|
||||
"on-warning": "#ffffff",
|
||||
"warning-container": "#ffdea6",
|
||||
"on-warning-container": "#271900",
|
||||
"info": "#005ac4",
|
||||
"on-info": "#ffffff",
|
||||
"info-container": "#d8e2ff",
|
||||
"on-info-container": "#001a42",
|
||||
"inverse-error": "#ff716c",
|
||||
"inverse-success": "#69dca1",
|
||||
"inverse-warning": "#fdbb28",
|
||||
"inverse-info": "#aec6ff"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#001111",
|
||||
"on-background": "#b8f3f1",
|
||||
"surface": "#001111",
|
||||
"surface-dim": "#001111",
|
||||
"surface-bright": "#003231",
|
||||
"surface-container-lowest": "#000000",
|
||||
"surface-container-low": "#001716",
|
||||
"surface-container": "#001e1d",
|
||||
"surface-container-high": "#002424",
|
||||
"surface-container-highest": "#002b2a",
|
||||
"on-surface": "#b8f3f1",
|
||||
"on-surface-variant": "#7bb5b3",
|
||||
"outline": "#457f7d",
|
||||
"outline-variant": "#0e504f",
|
||||
"inverse-surface": "#e3fffd",
|
||||
"inverse-on-surface": "#215e5c",
|
||||
"primary": "#b4fff1",
|
||||
"primary-dim": "#00fee5",
|
||||
"on-primary": "#00665b",
|
||||
"primary-container": "#00fee5",
|
||||
"on-primary-container": "#005c53",
|
||||
"primary-fixed": "#00f7df",
|
||||
"primary-fixed-dim": "#00e8d1",
|
||||
"on-primary-fixed": "#00443c",
|
||||
"on-primary-fixed-variant": "#006359",
|
||||
"inverse-primary": "#006b60",
|
||||
"secondary": "#38fbf7",
|
||||
"secondary-dim": "#10ece8",
|
||||
"on-secondary": "#005c5a",
|
||||
"secondary-container": "#006a68",
|
||||
"on-secondary-container": "#dafffd",
|
||||
"secondary-fixed": "#38fbf7",
|
||||
"secondary-fixed-dim": "#10ece8",
|
||||
"on-secondary-fixed": "#004746",
|
||||
"on-secondary-fixed-variant": "#006765",
|
||||
"tertiary": "#68ccff",
|
||||
"tertiary-dim": "#20c0ff",
|
||||
"on-tertiary": "#00415a",
|
||||
"tertiary-container": "#20c0ff",
|
||||
"on-tertiary-container": "#00374d",
|
||||
"tertiary-fixed": "#20c0ff",
|
||||
"tertiary-fixed-dim": "#00b2ee",
|
||||
"on-tertiary-fixed": "#001e2b",
|
||||
"on-tertiary-fixed-variant": "#004059",
|
||||
"error": "#ff716c",
|
||||
"error-dim": "#d7383b",
|
||||
"on-error": "#490006",
|
||||
"error-container": "#9f0519",
|
||||
"on-error-container": "#ffa8a3",
|
||||
"success": "#69dca1",
|
||||
"on-success": "#003822",
|
||||
"success-container": "#005233",
|
||||
"on-success-container": "#86f9bc",
|
||||
"warning": "#fdbb28",
|
||||
"on-warning": "#412d00",
|
||||
"warning-container": "#5e4200",
|
||||
"on-warning-container": "#ffdea6",
|
||||
"info": "#aec6ff",
|
||||
"on-info": "#002e6a",
|
||||
"info-container": "#004396",
|
||||
"on-info-container": "#d8e2ff",
|
||||
"inverse-error": "#b31b25",
|
||||
"inverse-success": "#006c45",
|
||||
"inverse-warning": "#7c5800",
|
||||
"inverse-info": "#005ac4"
|
||||
}
|
||||
},
|
||||
"rose": {
|
||||
"label": "Rose",
|
||||
"seed": "#c2185b",
|
||||
"variant": "vibrant",
|
||||
"spec": "2025",
|
||||
"contrast": 0,
|
||||
"light": {
|
||||
"background": "#fff4f6",
|
||||
"on-background": "#492136",
|
||||
"surface": "#fff4f6",
|
||||
"surface-dim": "#ffc4de",
|
||||
"surface-bright": "#fff4f6",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"surface-container-low": "#ffecf2",
|
||||
"surface-container": "#ffe0ec",
|
||||
"surface-container-high": "#ffd8e8",
|
||||
"surface-container-highest": "#ffd0e4",
|
||||
"on-surface": "#492136",
|
||||
"on-surface-variant": "#7c4d64",
|
||||
"outline": "#9b6880",
|
||||
"outline-variant": "#d69db7",
|
||||
"inverse-surface": "#220215",
|
||||
"inverse-on-surface": "#c58da6",
|
||||
"primary": "#ae1d53",
|
||||
"primary-dim": "#9e0b47",
|
||||
"on-primary": "#ffeff0",
|
||||
"primary-container": "#ff7198",
|
||||
"on-primary-container": "#4d001e",
|
||||
"primary-fixed": "#ff7198",
|
||||
"primary-fixed-dim": "#f95a8a",
|
||||
"on-primary-fixed": "#000000",
|
||||
"on-primary-fixed-variant": "#5e0027",
|
||||
"inverse-primary": "#f65787",
|
||||
"secondary": "#99366e",
|
||||
"secondary-dim": "#8a2a62",
|
||||
"on-secondary": "#ffeff3",
|
||||
"secondary-container": "#ffc0dc",
|
||||
"on-secondary-container": "#7f2159",
|
||||
"secondary-fixed": "#ffc0dc",
|
||||
"secondary-fixed-dim": "#ffabd2",
|
||||
"on-secondary-fixed": "#670845",
|
||||
"on-secondary-fixed-variant": "#8b2b63",
|
||||
"tertiary": "#5c4bb4",
|
||||
"tertiary-dim": "#503ea7",
|
||||
"on-tertiary": "#f6f0ff",
|
||||
"tertiary-container": "#b3a5ff",
|
||||
"on-tertiary-container": "#301887",
|
||||
"tertiary-fixed": "#b3a5ff",
|
||||
"tertiary-fixed-dim": "#a595ff",
|
||||
"on-tertiary-fixed": "#170059",
|
||||
"on-tertiary-fixed-variant": "#39248f",
|
||||
"error": "#b31b25",
|
||||
"error-dim": "#9f0519",
|
||||
"on-error": "#ffefee",
|
||||
"error-container": "#fb5151",
|
||||
"on-error-container": "#570008",
|
||||
"success": "#006c45",
|
||||
"on-success": "#ffffff",
|
||||
"success-container": "#86f9bc",
|
||||
"on-success-container": "#002112",
|
||||
"warning": "#7c5800",
|
||||
"on-warning": "#ffffff",
|
||||
"warning-container": "#ffdea6",
|
||||
"on-warning-container": "#271900",
|
||||
"info": "#005ac4",
|
||||
"on-info": "#ffffff",
|
||||
"info-container": "#d8e2ff",
|
||||
"on-info-container": "#001a42",
|
||||
"inverse-error": "#ff716c",
|
||||
"inverse-success": "#69dca1",
|
||||
"inverse-warning": "#fdbb28",
|
||||
"inverse-info": "#aec6ff"
|
||||
},
|
||||
"dark": {
|
||||
"background": "#220215",
|
||||
"on-background": "#ffdcea",
|
||||
"surface": "#220215",
|
||||
"surface-dim": "#220215",
|
||||
"surface-bright": "#4e1737",
|
||||
"surface-container-lowest": "#000000",
|
||||
"surface-container-low": "#2a041b",
|
||||
"surface-container": "#330822",
|
||||
"surface-container-high": "#3c0d28",
|
||||
"surface-container-highest": "#45122f",
|
||||
"on-surface": "#ffdcea",
|
||||
"on-surface-variant": "#d49bb4",
|
||||
"outline": "#99667e",
|
||||
"outline-variant": "#663a50",
|
||||
"inverse-surface": "#fff8f8",
|
||||
"inverse-on-surface": "#75475d",
|
||||
"primary": "#ff8aa7",
|
||||
"primary-dim": "#ff6b95",
|
||||
"on-primary": "#620029",
|
||||
"primary-container": "#ff7198",
|
||||
"on-primary-container": "#4d001e",
|
||||
"primary-fixed": "#ff7198",
|
||||
"primary-fixed-dim": "#f95a8a",
|
||||
"on-primary-fixed": "#000000",
|
||||
"on-primary-fixed-variant": "#5e0027",
|
||||
"inverse-primary": "#b32257",
|
||||
"secondary": "#ff8ac6",
|
||||
"secondary-dim": "#ef7db9",
|
||||
"on-secondary": "#620241",
|
||||
"secondary-container": "#7f2058",
|
||||
"on-secondary-container": "#ffbedb",
|
||||
"secondary-fixed": "#ffc0dc",
|
||||
"secondary-fixed-dim": "#ffabd2",
|
||||
"on-secondary-fixed": "#670845",
|
||||
"on-secondary-fixed-variant": "#8b2b63",
|
||||
"tertiary": "#aa9bff",
|
||||
"tertiary-dim": "#9f8ffc",
|
||||
"on-tertiary": "#290c80",
|
||||
"tertiary-container": "#9d8cfa",
|
||||
"on-tertiary-container": "#1c0068",
|
||||
"tertiary-fixed": "#b3a5ff",
|
||||
"tertiary-fixed-dim": "#a595ff",
|
||||
"on-tertiary-fixed": "#170059",
|
||||
"on-tertiary-fixed-variant": "#39248f",
|
||||
"error": "#ff716c",
|
||||
"error-dim": "#d7383b",
|
||||
"on-error": "#490006",
|
||||
"error-container": "#9f0519",
|
||||
"on-error-container": "#ffa8a3",
|
||||
"success": "#69dca1",
|
||||
"on-success": "#003822",
|
||||
"success-container": "#005233",
|
||||
"on-success-container": "#86f9bc",
|
||||
"warning": "#fdbb28",
|
||||
"on-warning": "#412d00",
|
||||
"warning-container": "#5e4200",
|
||||
"on-warning-container": "#ffdea6",
|
||||
"info": "#aec6ff",
|
||||
"on-info": "#002e6a",
|
||||
"info-container": "#004396",
|
||||
"on-info-container": "#d8e2ff",
|
||||
"inverse-error": "#b31b25",
|
||||
"inverse-success": "#006c45",
|
||||
"inverse-warning": "#7c5800",
|
||||
"inverse-info": "#005ac4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user