14 Commits
Author SHA1 Message Date
Andreas Reinhold / reiniandClaude Opus 5 db179e6b4e Compile the showcase's views with the showcase off
tests / lint (push) Successful in 1m6s
tests / feature (8.4) (push) Successful in 1m18s
tests / feature (8.5) (push) Successful in 1m17s
tests / browser (chrome, chromium) (push) Successful in 3m27s
tests / browser (firefox, firefox) (push) Successful in 4m57s
tests / browser (safari, webkit) (push) Successful in 5m29s
php artisan view:cache compiles every view in the package's namespace,
the showcase's pages included, but the showcase:: component prefix was
only registered while the showcase was enabled. In production, where it
is off, view:cache failed with "Unable to locate a class or view for
component [showcase::example]" and an application caching its views on
start would not boot. The prefix is now always registered; the routes
still are not.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 16:25:35 +02:00
Andreas Reinhold / reiniandClaude Opus 5 d805dbc407 Lay the scheme picker out as tiles and pass its attributes to the group
tests / lint (push) Successful in 1m6s
tests / feature (8.4) (push) Successful in 1m12s
tests / feature (8.5) (push) Successful in 1m16s
tests / browser (chrome, chromium) (push) Successful in 3m17s
tests / browser (firefox, firefox) (push) Successful in 4m43s
tests / browser (safari, webkit) (push) Successful in 5m22s
The check sat beside the name and truncated it at four columns; it now
sits in the tile's corner under the swatch dots. Attributes other than
the binding (data-test, class) reach the fieldset, and the dots' ring
follows the selected tile's colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 14:39:26 +02:00
Andreas Reinhold / reiniandClaude Opus 5 fb42f004b1 Add colour profiles
An application lists named schemes in livewire-material.profiles, and
material:scheme without a seed generates them all into one stylesheet:
the default profile as the plain blocks, every profile under
<html data-scheme>, with descendant selectors so a nested data-theme
panel keeps the page's profile. The JSON keeps the 1.0 top level.

Scheme::resolveProfileUsing() lets the application name the active
profile; it is asked on every use and falls back to the default for an
unknown name or a resolver that throws. The head script writes it before
the first paint and keeps it through wire:navigate, and the mail theme
and the error pages' fallback draw it. <x-scheme-picker> chooses one and
previews it through $store.theme.previewScheme(); the showcase previews
the Workbench's profiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 14:35:49 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7798352cfc Compile unprefixed components to the same view on every machine
tests / lint (push) Successful in 1m3s
tests / feature (8.4) (push) Successful in 1m14s
tests / feature (8.5) (push) Successful in 1m14s
tests / browser (chrome, chromium) (push) Successful in 3m16s
tests / browser (firefox, firefox) (push) Successful in 4m36s
tests / browser (safari, webkit) (push) Successful in 5m8s
Without a prefix, Blade named the components' view namespace after the
folder's absolute path, while compiled views are named relative to the
application. A view compiled on a laptop and served from a container
sharing storage/framework/views (or from another release directory)
printed 'eb5f…::input' as text. The components now register under the
prefix livewire-material when none is configured, which unprefixed tags
still resolve through, and the path's namespace stays registered for
views compiled before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 11:16:52 +02:00
Andreas Reinhold / reiniandClaude Opus 5 cb3aa798a0 Keep a full-screen dialog's subtitle on a phone
tests / lint (push) Successful in 1m20s
tests / feature (8.4) (push) Successful in 1m13s
tests / feature (8.5) (push) Successful in 1m13s
tests / browser (chrome, chromium) (push) Successful in 3m17s
tests / browser (firefox, firefox) (push) Successful in 4m37s
tests / browser (safari, webkit) (push) Successful in 5m10s
The bar names the dialog there, so only the headline hides; the subtitle
used to disappear with it, and a subtitle without a title never showed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 10:57:36 +02:00
Andreas Reinhold / reiniandClaude Opus 5 648a4cfe0f Show a plain form field's errors under its name
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m9s
tests / feature (8.5) (push) Successful in 1m9s
tests / browser (chrome, chromium) (push) Successful in 3m19s
tests / browser (safari, webkit) (push) Successful in 5m0s
tests / browser (firefox, firefox) (push) Successful in 4m34s
Fields read their errors only under the wire:model name, so a Fortify
login form (name="email", no wire:model) never showed "These
credentials do not match". Without wire:model they now read the name,
turning brackets into dots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 10:41:37 +02:00
Andreas Reinhold / reiniandClaude Opus 5 51bf034b74 Add a search to the showcase
tests / feature (8.5) (push) Successful in 1m14s
tests / lint (push) Successful in 1m1s
tests / feature (8.4) (push) Successful in 1m9s
tests / browser (chrome, chromium) (push) Successful in 3m22s
tests / browser (firefox, firefox) (push) Successful in 4m37s
tests / browser (safari, webkit) (push) Successful in 5m4s
A search app bar looks through every section, example and component,
opens the section, or the example at its anchor on its page, and is
reached from anywhere with / or Ctrl+K. Section pages now carry their
own heading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:56:00 +02:00
Andreas Reinhold / reiniandClaude Opus 5 7ebcb0565f Send a carousel scroll on when it comes to rest short of its item
tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
CI's WebKit twice left the carousel on its first item after Next. A
scroll started by the buttons or keys that comes to rest anywhere but
its item is now sent there once more; a scroll the person starts
themselves cancels that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:40:40 +02:00
Andreas Reinhold / reiniandClaude Opus 5 889f8a8aa1 Put the showcase in the app shell, with a page per section
tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
The showcase is now an overview and one page per section behind a
grouped navigation rail, moved between with wire:navigate, instead of
one long page under a scrolling top bar. The switch exposed a WebKit
bug in the menu: inside a focusable region, WebKit moves focus out of
the closing popover before its toggle event, so Escape no longer
returned focus to the trigger. The menu now reads it on beforetoggle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:30:26 +02:00
Andreas Reinhold / reiniandClaude Opus 5 23218431bf Add a clear button to the date picker
`clearable` empties a date, or both ends of a range, closes an open
picker and hands focus back to the field, as the time picker's does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:30:26 +02:00
Andreas Reinhold / reiniandClaude Opus 5 eca6b8fb05 Give browser tests on CI more time and wait for Alpine in the theme test
tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
CI's WebKit job failed twice on a slow runner: a carousel scroll that had
not settled within five seconds, and a press on the theme switch before
Alpine had revealed it. BROWSER_TIMEOUT raises the assertion timeout, and
the workflow sets it to fifteen seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:14:11 +02:00
Andreas Reinhold / reiniandClaude Opus 5 cbf874a5a6 Complete the showcase and record Phase 10
tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
Shows the plain tooltip on its own, checks that every component appears
in the showcase, and records the namespace change and the fresh
application check in the plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:08:35 +02:00
Andreas Reinhold / reiniandClaude Opus 5 1e580c573e Resolve the package's own components through its namespace
Package views now write <x-livewire-material::button>, so a configured
prefix (which Blade spells <x-m::button>) and an application component
of the same name can no longer break or shadow them. The showcase
rewrites its examples to the configured prefix. Adds the README, and
waits for the adaptive rail to hear a resize before the navigation
tests press its menu button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:05:24 +02:00
110 changed files with 3235 additions and 996 deletions
+2
View File
@@ -115,3 +115,5 @@ jobs:
- name: Run browser tests
run: vendor/bin/pest --testsuite=Browser --browser ${{ matrix.browser }}
env:
BROWSER_TIMEOUT: 15000
+3
View File
@@ -7,3 +7,6 @@
/workbench/public/hot
/tests/Browser/Screenshots
.DS_Store
# Planning notes stay local
/docs/plans
+169
View File
@@ -0,0 +1,169 @@
# Livewire Material
Material 3 Expressive components for Laravel and Livewire, built on Tailwind CSS 4.
- Anonymous Blade components for the current M3 Expressive catalogue: buttons and FABs, menus, chips, text fields, selection controls, sliders, pickers, dialogs and sheets, lists, cards, carousels, progress and loading indicators, snackbars, tabs, app bars, toolbars, navigation bars and rails, an adaptive app shell, data tables and pagination.
- A colour scheme generated from one seed colour with Google's colour science (`php artisan material:scheme`), light and dark, and a theme that is chosen before the first paint.
- The full Material Symbols Rounded set and the M3 Expressive shapes, drawn inline without an icon package.
- Error pages and a Markdown mail theme in the same scheme.
- A showcase of every component in the application's own scheme, a design guard for tests, and Laravel Boost guidelines and a skill for AI agents.
No JavaScript libraries beyond the Alpine that ships with Livewire. Browsers: Chrome 125+, Firefox 147+, Safari 18.4+.
## Requirements
PHP 8.4+, Laravel 13, Livewire 4, Tailwind CSS 4 with Vite, and Node (for `material:scheme`).
## Installation
The package is served from Gitea. Add the repository and require it:
```bash
composer config repositories.livewire-material vcs https://gitea.nonameweb.ch/noNameWEB/livewire-material.git
composer require nonameweb/livewire-material
```
### Stylesheet and script
The application's build imports from `vendor/`, so Composer packages must be installed before `npm run build` — in a Dockerfile, copy `composer.json`, run `composer install`, then build the assets.
```css
/* resources/css/app.css */
@import 'tailwindcss';
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
@import './material-scheme.css';
@source '../../vendor/nonameweb/livewire-material/resources/views';
@source '../../vendor/nonameweb/livewire-material/src';
```
```js
// resources/js/app.js
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
```
Do not install Alpine separately; Livewire provides it.
### Layout
The theme script goes in `<head>`, before `@vite`, so the page paints in the visitor's theme:
```blade
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<x-theme-script />
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-surface font-sans text-on-surface antialiased">
{{ $slot }}
<x-toast />
</body>
</html>
```
### Colour scheme
Generate the scheme from a seed colour. It writes `resources/css/material-scheme.css` (imported above) and `material-scheme.json` (read by the mail theme):
```bash
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.
#### Colour profiles
To let an installation switch between several schemes, list them as `profiles` in the config (name ⇒ `label`, `seed`, `variant`) and run `php artisan material:scheme` without a seed: every profile lands in the same stylesheet under `<html data-scheme>`. Tell the package which one is active — `Scheme::resolveProfileUsing(fn () => Setting::get('color_profile'))` in a service provider — and the head script, mails and error pages follow it. `<x-scheme-picker wire:model="colorProfile" />` lets someone choose, previewing each profile on the page.
### Configuration
```bash
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).
- `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`.
- `node` — the Node binary for `material:scheme`.
## Usage
```blade
<x-card title="holiday-photos.zip" subtitle="248 MB · expires in 3 days" variant="outlined">
<x-slot:actions>
<x-button label="Copy link" icon="content_copy" wire:click="copy" />
<x-button label="Delete" danger wire:click="$set('confirming', true)" />
</x-slot:actions>
</x-card>
<x-modal wire:model="confirming" title="Delete this share?" icon="delete">
Recipients lose access at once.
<x-slot:actions>
<x-button label="Cancel" x-on:click="close()" />
<x-button label="Delete" danger wire:click="delete" />
</x-slot:actions>
</x-modal>
```
```php
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
class Shares extends Component
{
use Toasts;
public function copy(): void
{
$this->success('Link copied');
}
}
```
Every component, prop and slot is documented in the Boost skill (`resources/boost/skills/livewire-material-development/SKILL.md`) and shown in the showcase.
## Showcase
While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/material` shows every token and component, in every variant, in the application's own scheme and theme: an overview, and a page per section behind a navigation rail (the package's own app shell), with a search over every section, example and component (press `/`).
## Testing the design
```php
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
it('uses only what compiles', function () {
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
->forbidColours(['tertiary'])
->violations())->toBe([]);
});
```
The guard fails on maryUI tags, daisyUI classes, colours the theme does not declare, unknown Material Symbol names and Blade directives written inside component tags.
## AI agents
With [Laravel Boost](https://github.com/laravel/boost), `php artisan boost:install` (or `boost:update --discover`) picks up the package's guideline and the `livewire-material-development` skill.
## Developing the package
```bash
composer install && npm install
npm run build # the Workbench's assets (or `npm run dev` while working)
composer serve # the showcase at http://127.0.0.1:8000/material
vendor/bin/pest --testsuite=Feature
npx playwright install
vendor/bin/pest --testsuite=Browser --browser chrome # also firefox, safari
```
## Credits
Material Symbols, the M3 Expressive shapes, Google Sans Flex, material-color-utilities and Jetpack Compose Material 3's tokens and algorithms are Google's and the Android Open Source Project's; see `NOTICE`.
## License
MIT. See `LICENSE`.
+22 -2
View File
@@ -8,8 +8,10 @@ return [
|--------------------------------------------------------------------------
|
| Every component is an anonymous Blade component. Without a prefix they are
| <x-button>, <x-card> and so on; set a prefix such as 'm-' when a name
| clashes with one of the application's own components (<x-m-button>).
| <x-button>, <x-card> and so on; set a prefix such as 'm' when a name
| clashes with one of the application's own components, and they become
| <x-m::button>, <x-m::card>. They are always <x-livewire-material::button>
| as well.
|
*/
@@ -105,6 +107,24 @@ 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'. 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
-630
View File
@@ -1,630 +0,0 @@
# Livewire Material: a shared Material 3 Expressive component library
> Split on 2026-09-13 from SealShare's `docs/plans/livewire-material.md`, where the plan was
> made. SealShare's adoption (Phase 11) stays in SealShare's copy.
## Goal
ReStride has just left maryUI and daisyUI for **Material 3 Expressive on its own Blade
components** (`ReStride/docs/plans/material-expressive.md`, done 2026-09-10). Doing that
again, by hand, in every Laravel + Livewire app is what this package avoids.
**`nonameweb/livewire-material`** carries the whole current M3 Expressive component catalogue
as Blade components for Livewire, together with everything around them — colour scheme
generation, tokens, the theme script, Material Symbols, Google Sans Flex, motion, error pages,
a mail theme, a showcase, test helpers and AI guidelines. SealShare converts to it once it is
complete (`1.0.0`); ReStride adopts it later, in a plan of its own.
## Context
**Stacks.** SealShare: Laravel 13.31, Livewire 4.4, maryUI 2.9.10 (no prefix), daisyUI 5.7,
Tailwind 4.3, Pest 5.1, Octane on FrankenPHP, PHP 8.5; public on GitHub under MIT, image
published to `ghcr.io/surtic86/sealshare`. ReStride: same Laravel / Livewire / Tailwind / Pest,
private on `gitea.nonameweb.ch`, CI through Gitea act_runner.
**What ReStride already solved** (and the package generalises):
- 45 anonymous components in `resources/views/components/`, maryUI's names and props, M3
styling; class components only where PHP earns it.
- `resources/css/material/{color,shape,type,motion,elevation,field,menu,table,list}.css`;
`@theme` (never `inline`) mapping M3 roles to utilities; `--color-*: initial` so only tokens
compile; `:root` dark + `[data-theme=light]`, each with its own `color-scheme`.
- Material Symbols Rounded (400/0/24) as local SVGs registered as `blade-icons` sets
(`ms`/`msf`), `config/blade-icons.php` turning off blade-icons' own `<x-icon>`.
- Google Sans Flex, a 63 KB Latin subset (weight 400700, ROND 0100).
- Spring motion as CSS `linear()` curves; press shape-morph; Expressive shapes generated by
formula into `resources/svg/shapes`.
- `App\Livewire\Concerns\Toasts` (protected `success|error|warning|info`, dispatching a window
event) and a snackbar `<x-toast>`.
- ~30 component tests (`$this->blade()`), browser tests (Pest browser plugin), and guard tests
(`DesignLanguageTest`, `MaterialTokensTest`, `MaterialSymbolsTest`).
- The Livewire traps, recorded in `../ReStride/.ai/rules/ui.md`: `wire:ignore.self` on a
`showModal()` dialog; never pass `hidden` or a position to a component (wrap it); Blade
directives do not compile inside a component tag's attributes; `wire('model')->value()` is
`false`, not `null`, without `wire:model`; a field's states are CSS selectors (`:has()`),
marked with `data-invalid` / `data-floated` / `data-readonly` where the control cannot carry
them; the customizable select's traps (`../ReStride/.ai/rules/components-views-components.md`).
- Mail on M3 (`../ReStride/docs/plans/material-mail.md`): one theme CSS inlined onto bare
tags, light only, because `CssToInlineStyles` strips `@media`.
**What in ReStride is app-specific** and does not move: the doctrine (two button weights, no
tertiary / primary-container — enforced by its build), `restride-theme`, `$store.install` /
`$store.connection` in the shell, `--bottom-bar` set by its layout, sport / zone / route
tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`,
`share-button`, the unDraw repaint, the Ace code editor.
**Constraints found.**
- `gitea.nonameweb.ch` requires sign-in to view anything (`/explore/repos` → login, API 403).
A "public" repo there cannot be installed anonymously until that changes.
*(Resolved in step 1.)*
- Laravel's Markdown mail accepts a namespaced view as theme
(`Illuminate/Mail/Markdown.php:114`), so a package can render the theme CSS from data.
- Laravel **replaces** the `errors` view namespace at render time with
`config('view.paths')` + `/errors` and the framework's own
(`Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php`), so error views a package
adds with `addNamespace('errors', …)` are wiped; only a path in `view.paths` survives.
- M3 Expressive deprecates bottom app bar, navigation drawer, the original navigation bar,
segmented button, small FAB and medium/large top app bar (material-components-android docs).
- CSS anchor positioning is in Chrome 125+, Firefox 147+, Safari 18.4+ (flip via
`@position-try` from Safari 18.4).
- Boost loads a package's `resources/boost/guidelines/core.blade.php` and its skills on
`boost:install` / `boost:update --discover`.
## Decisions
- **A shared Composer package, SealShare its first consumer; ReStride adopts later in its own
plan** — SealShare's small surface proves the API; ReStride's three-day-old migration is not
put at risk.
- **Public repo on `gitea.nonameweb.ch`, MIT** — SealShare is public; its CI, Docker build,
manual install and fork PRs must install the package without credentials. SealShare
requires it through a `vcs` repository entry. Material Symbols (Apache-2.0), Google Sans
Flex (OFL) and material-web token values (Apache-2.0) are compatible; attributions ship in
`NOTICE`.
- **Prerequisite: Gitea allows anonymous reads** (`[service] REQUIRE_SIGNIN_VIEW = false`, or
`expensive` on 1.23+) — otherwise "public" is not installable.
- **Name `nonameweb/livewire-material`, namespace `NoNameWeb\LivewireMaterial`,
`config/livewire-material.php`, views `livewire-material::`, commands `material:*`** — says
what it is and what it is for.
- **Components unprefixed by default, prefix configurable** (maryUI's model, via
`Blade::anonymousComponentPath($path, $prefix)`) — ReStride's call sites keep their names;
SealShare's maryUI tags keep theirs. The provider sets `blade-icons.components.default` to
`null`, or blade-icons' class `<x-icon>` beats ours.
- **CSS and JS imported from `vendor/`** (`@import`, `@source`, `import`) — one dependency, one
version; consuming Dockerfiles install Composer packages before the Vite build.
`@source` covers the package's views and PHP, never its SVG folders.
- **The full Material Symbols Rounded set** (400 / 0 / 24, outlined and filled, 4,135 symbols,
5.3 MB), fetched by a maintenance script in the package repo, never at runtime — any name works
in any app. **`<x-icon>` reads the SVG files itself; blade-icons is not used for them** (decided
after Phase 1): blade-icons registers one Blade component per icon whenever the view factory
resolves, ~8,000 registrations per request here. blade-icons stays a dev dependency, only to
test that the provider still keeps its `<x-icon>` from shadowing ours in apps that have it.
- **Google Sans Flex bundled** (ReStride's subset) via `@font-face` in the package CSS; an app
overrides `--font-sans`.
- **Full M3, not ReStride's doctrine** — every variant, colour role (tertiary and
primary-container included) and container is available; each app enforces its own rules
through a configurable guard helper the package ships.
- **`variant` + `color` props** — `variant="filled|tonal|outlined|text|elevated"`,
`color="primary|secondary|tertiary|error|success|warning|info"`, default `text` in primary;
both validated against fixed lists, unknown values fall back to the default. Shorthands
kept: `primary` = filled primary, `danger` = filled error, `caution` = filled warning. The
same pattern for badge, alert, chip, icon button and FAB (`tone` stays an alias of `color`
where ReStride used it).
- **`php artisan material:scheme {seed} --variant=`** — runs Google's
`material-color-utilities` from a single prebundled Node script inside the package; writes
the app's `resources/css/material-scheme.css` (dark and light `--md-sys-color-*`, plus
`success`/`warning`/`info` custom colours, harmonisation off) and
`resources/css/material-scheme.json` (the light hexes, for the mail theme). Output is
committed.
- **Theme: a head script component, `light | dark | system`** — sets `data-theme` before paint;
CSS keys only on the attribute and never asks `prefers-color-scheme`; only the script reads
the OS (and follows its changes while `system`). `$store.theme` is the one state every
toggle shares. Config: `theme.default`, `theme.storage_key`, `theme.legacy_keys` (adopted
once, then removed).
- **The whole M3 Expressive catalogue, current components only** — the six deprecated ones are
skipped; their names alias where free (`<x-group>` renders a connected button group, FAB
`size="sm"` renders medium).
- **Plus the non-M3 pieces apps need** — data table, sort header, pagination views, file input,
password, stat, alert, empty state, collapse, section nav, account menu, theme toggle, an
adaptive app-shell composition, error pages, mail theme.
- **Modern browser floor, no third-party JS** — Chrome 125+, Firefox 147+, Safari 18.4+:
native `<dialog>`, Popover API, CSS anchor positioning, customizable `<select>` as a
progressive enhancement; Alpine (bundled with Livewire) for behaviour. Date and time
pickers, carousel and search are our own.
- **PHP ^8.4, Laravel ^13, Livewire ^4.** No request state in singletons (Octane).
- **Strings through `__()` with an `en` file**, publishable and overridable.
- **Accessibility: WAI-ARIA Authoring Practices patterns, WCAG 2.2 AA contrast, full keyboard.**
- **Showcase in the package, mounted twice** — by the Workbench for development and browser
tests, and opt-in inside an app (`showcase.enabled`, default only when `APP_ENV=local`) so
an app sees every component in its own scheme. Every variant, colour, size and state, a
light/dark/system switch, and the Blade snippet beside each example.
- **Tests: render tests for every component, browser tests against the Workbench showcase in
Chromium, Firefox and WebKit; no screenshot diffs.**
- **Error pages and a mail theme in the package** — the mail theme is the namespaced view
`livewire-material::mail.theme`, rendering CSS from the app's `material-scheme.json`, so
mail colours cannot drift from the app.
- **Boost guideline + `livewire-material-development` skill** in the package, and a test that
fails when a component is missing from the skill.
- **No version tags until the whole catalogue is done; then `1.0.0`** — the waves land on `main` untagged (decided 2026-09-13; replaces "`0.x` per wave").
- **Semantic ink and line utilities carried over from ReStride** (`text-body`, `text-meta`,
`text-quiet`, `border-structure`, `border-chrome`, `border-divider`) — cheap `@theme` names
ReStride's templates already use, derived from M3 roles.
## Out of scope
- ReStride adopting the package — its own plan, after `1.0.0`.
- A brand colour chosen at runtime (e.g. in SealShare's admin), dynamic colour, a PHP port of
the colour maths.
- M3 Expressive's deprecated components (bottom app bar, navigation drawer, original
navigation bar, segmented button, small FAB, medium/large top app bar).
- Screenshot / visual regression tests.
- Blaze optimisation of the components — a follow-up if rendering gets slow.
- ReStride-specific components and tokens (training row, maps, product shot, star rating, share
button, sport/zone/route tokens, unDraw repaint, Ace code editor).
- Publishing on Packagist — the `vcs` repository is enough; revisit if others adopt it.
- Translations beyond `en`.
- Browsers below the floor.
- Non-Livewire stacks (Inertia, React, Vue).
## Implementation steps
### Phase 0 — Prerequisites
1. **Gitea.** *Done 2026-09-13.* `GITEA__service__REQUIRE_SIGNIN_VIEW=false` in the Gitea
compose service (Gitea 1.26.4); `/explore/repos` and the API answer anonymously, and no
other repo or org is public. The package lives in the public org **`noNameWEB`**:
`https://gitea.nonameweb.ch/noNameWEB/livewire-material.git` (public, empty, `main`,
Actions on). SealShare's `vcs` repository entry uses that URL.
2. **Split the plan.** *Done 2026-09-13.* This file; SealShare keeps its adoption steps.
### Phase 1 — Package skeleton
3. **Repository.** `composer.json` (`nonameweb/livewire-material`, PSR-4
`NoNameWeb\LivewireMaterial\`, requires `php ^8.4`, `laravel/framework ^13`,
`livewire/livewire ^4`, `blade-ui-kit/blade-icons ^1.10`; dev: `orchestra/testbench`,
`pestphp/pest`, `pestphp/pest-plugin-laravel`, `pestphp/pest-plugin-browser`,
`laravel/pint`), `LICENSE` (MIT), `NOTICE`, `pint.json`, `phpunit.xml`, `.gitattributes`
excluding `workbench/`, `tests/`, `bin/`, `docs/` and the Node/Vite dev files from dist
archives.
4. **Service provider** `LivewireMaterialServiceProvider`: merge config; register the
anonymous component path with the configured prefix; `loadViewsFrom` (`livewire-material`),
`loadTranslationsFrom`; set `blade-icons.components.default` to `null` in `register()`
(before blade-icons boots and registers its component); register the icon and shape sets;
append the package's error-view root (a directory holding only `errors/`) to
`view.paths` after the app's own, so Laravel's namespace replacement keeps it and an app's
`resources/views/errors` still wins; publishable config, lang, error views; commands; the
showcase routes when enabled. No request state anywhere in the container.
5. **Config** `config/livewire-material.php`: `prefix`, `theme.default`, `theme.storage_key`,
`theme.legacy_keys`, `showcase.enabled` (`env('MATERIAL_SHOWCASE', app()->isLocal())`),
`showcase.path` (`material`), `showcase.middleware` (`['web']`).
6. **Workbench** (`orchestra/workbench`): a Laravel app with Livewire, Vite and the package
CSS/JS built, serving the showcase at `/`; `composer serve` starts it.
7. **CI on Gitea act_runner:** Pint, Pest render tests, browser tests in Chromium, Firefox and
WebKit (Playwright installed with deps), on pushes and pull requests.
8. **Boost resources skeleton:** `resources/boost/guidelines/core.blade.php` and
`resources/boost/skills/livewire-material-development/SKILL.md`; the drift test (every
component file under `resources/views/components` is named in the skill).
**Phase 1 is done (2026-09-13).** What changed from the steps above:
- **Testbench 11.2** runs the Workbench (`testbench.yaml`: providers listed, `start: /material`,
`MATERIAL_SHOWCASE=true`). Vite builds into `workbench/public/build`; `composer serve` links
that into the skeleton's `public/build` through the Workbench `sync` option, and
`tests/TestCase.php` points `public_path()` at `workbench/public` so browser tests find the
manifest without the command. Verified: `composer serve` answers `/material` and its CSS.
- **The showcase is a plain view with `@extends`**, not a component: `<x-livewire-material::…>`
resolves under `components/`, which would have made the layout a public component.
- **blade-icons' `components.default` is set in a `booting` callback**, not in `register()`:
`mergeConfigFrom` is shallow, so a nested key written before blade-icons merges its defaults
wipes the rest of its `components` array. The test fails with the call removed.
- **The showcase switch is tested by rebooting** with `MATERIAL_SHOWCASE` in the environment:
Testbench's per-test attributes do not reach Pest closures.
- **The Boost guideline is wrapped in `@verbatim`**: it is rendered as Blade, and a
`<x-mary-*>` in its prose would compile as a component tag.
- **`phpunit.xml` trap:** a comment containing a double hyphen (`--browser`) is invalid XML and
PHPUnit refuses the file; Pest then reports an unrelated `Pest\Plugins\Tia` error on shutdown.
- **CI** (`.github/workflows/tests.yml`): Pint, feature tests on PHP 8.4 and 8.5, browser tests
per engine — Pest's `--browser chrome|firefox|safari`, Playwright's `chromium|firefox|webkit`.
Locally only Chromium was run; Firefox and WebKit first run on CI.
- **`NOTICE` moves to Phase 2**, when the first third-party assets (Symbols, font, tokens,
colour utilities) arrive — a notice with nothing to attribute would be wrong.
- **The scheme script is not in `bin/`:** `bin/` is export-ignored (maintenance scripts only),
but applications run the scheme script, so it lives at `resources/node/scheme.mjs`.
- **Found for Phase 2:** blade-icons calls `Factory::registerComponents()` whenever the view
factory resolves, registering one Blade component per icon unless `components.disabled`
and without `icons:cache` it scans the folders first. With ~7,800 symbols that is per
request under PHP-FPM. Decided before step 13.
### Phase 2 — Foundation
9. **Scheme command.** `resources/node/scheme.mjs` built once in the package repo (esbuild bundle of
`@material/material-color-utilities`, committed, Apache-2.0 header); `material:scheme {seed}
{--variant=tonal-spot|vibrant|expressive|fidelity|content|neutral|monochrome}
{--success=} {--warning=} {--info=} {--output=}` runs it through `node` and writes
`resources/css/material-scheme.css` and `resources/css/material-scheme.json`. Fails with a
clear message when `node` is missing. A default scheme (M3 baseline `#6750A4`) ships inside
the package so a fresh install renders before the command is run.
10. **Tokens.** `resources/css/material.css` as the single entry, importing `tokens/shape.css`,
`type.css` (typescale utilities incl. emphasized), `motion.css` (six spring `linear()`
curves — spatial/effects × fast/default/slow — and reduced-motion overrides),
`elevation.css`, `state.css` (state layers, focus ring), and the `@theme` block mapping
**every** M3 role (primary/secondary/tertiary and their containers, surfaces, outlines,
inverse, error, custom success/warning/info with `inverse-*`) plus the semantic ink and
line utilities; `--color-*: initial` with white and black re-added. Each theme block
declares `color-scheme`. Values from material-web's token files, attributed.
11. **Font.** `resources/fonts/google-sans-flex/` (woff2 + OFL), `@font-face` and `--font-sans`
in the entry CSS.
12. **Theme.** `<x-theme-script />` (inline, before `@vite`): reads `storage_key`, adopts
`legacy_keys` (their values may be JSON-encoded — maryUI's `$persist` stores `"dark"`
with quotes), resolves `system` through `matchMedia`, writes `data-theme`; `$store.theme`
in `resources/js/material.js` with `set()` and a `matchMedia` listener while `system`.
13. **Icons.** `bin/fetch-symbols` (maintenance only) downloads Material Symbols Rounded
400/0/24 outlined and filled from `google/material-design-icons` into
`resources/svg/symbols/{outlined,filled}` with `fill="currentColor"` and no width/height;
`<x-icon name="…" filled>` resolves `ms`/`msf`; throws on an unknown name.
14. **Shapes.** The Expressive shape set generated by formula (ReStride's
`components/shape.blade.php` method) into `resources/svg/shapes`; `<x-shape>`.
15. **JS entry** `resources/js/material.js`: theme store, snackbar listener, `data-list-row`
rows, `x-figure` count-up directive, shared keyboard helpers. Imported by an app's `app.js`.
16. **`Toasts` concern** `NoNameWeb\LivewireMaterial\Concerns\Toasts`: protected
`success|error|warning|info(string $title, ?string $description = null, ?int $timeout = null, ?string $redirectTo = null)`,
dispatching a browser event and flashing across `redirectTo`.
17. **Guard helpers** (`NoNameWeb\LivewireMaterial\Testing\DesignGuard`, Pest-friendly):
scan given paths for maryUI tags, daisyUI component classes, colour utilities not declared
by the compiled tokens, unknown icon names, and app-configured banned roles/variants.
18. **Showcase shell**: layout, section navigation, theme switch, snippet renderer; a section
per foundation piece (colour roles in both themes, type scale, shapes, motion, icons
search).
**Phase 2 is done (2026-09-13).** What changed from the steps above:
- **Icons without blade-icons** (see Decisions): `NoNameWeb\LivewireMaterial\Support\SvgFile`
reads a symbol or shape file on first use and keeps it for the worker. An unknown name throws,
and a Heroicon-style name (`o-home`) throws with a pointer to the catalogue. `bin/fetch-symbols`
sparse-checks-out google/material-design-icons (5 GB, cloned without blobs) for the
`_24px` and `_fill1_24px` files: 4,135 symbols, byte-identical to ReStride's hand-picked ones.
The `@material-symbols/svg-400` npm package was rejected: its "rounded" SVGs are optical size 48.
- **All 35 M3 Expressive shapes**, ported from androidx `MaterialShapes.kt` and graphics-shapes
into `bin/shapes.mjs` (commit in its header), each fitted to 298 of a 100-unit box; eight had
control points outside the box and are split into more segments along the same outline.
- **The scheme script** lives at `resources/node/scheme.mjs` (93 KB, esbuild bundle of
material-color-utilities 0.4.0, spec 2025 — M3 Expressive's colour; variants the 2025 spec
does not define fall back to 2021). The library's extensionless imports mean it cannot run
unbundled. 65 roles per theme plus `inverse-{error,success,warning,info}`. Default state
sources: success `#22a06b`, warning `#e2a400`, info `#1d7afc`. `config('livewire-material.node')`
names the binary. The package's own default is `#6750A4`, tonal-spot, in `tokens/scheme.css`.
- **Colour utilities are `@theme inline`** — contrary to ReStride's rule that `inline` breaks
theme switching. Verified in Chromium: without `inline`, `--color-primary` resolves once on
`:root`, so a `data-theme="dark"` section inside a light page keeps light colours; with it the
utility reads `var(--md-sys-color-primary)` on the element and both the page toggle and nested
themes work. Only blocks whose values are variables are inline. A browser test fails without it.
- **Tailwind emits only the theme variables that are used**, and cannot see a class assembled in
Blade (`type-{{ $style }}`): the showcase lists every class literally.
- **`dark:`** is keyed on `data-theme`, so it follows the page's theme rather than the OS.
- **Theme script** writes nothing for a visitor who never chose (a later change of
`theme.default` still reaches them); only an adopted legacy value is stored. `data-theme-choice`
and `data-theme-key` on `<html>` hand the state to `$store.theme` (`choice`, `resolved`, `set`,
`toggle`, `value`).
- **Moved out of Phase 2:** the snackbar listener goes to Phase 4 with `<x-toast>`; `data-list-row`
rows (JS and CSS) and the list keyboard go to Phase 5 with `list` and `card`, which they style.
`Toasts` dispatches `toast` with a 4 s default (M3's snackbar range is 410 s; ReStride used 3 s).
- **`DesignGuard`**: `secondary` is a valid role now that the package exposes full M3, so it is no
longer flagged as a daisyUI colour; icon attributes are checked per component tag (`icon` and
`icon-right` on the same tag).
- **Pest browser trap:** a bare `html` selector is taken as text to search for and times out;
assert on `document.documentElement` through `assertScript`. `@name` targets `data-test`.
- **Showcase**: colour roles in both themes side by side, type, corners and shapes, elevation,
springs and `x-figure`, and an icon search drawing matches as CSS masks from a showcase-only
symbol route (relative URLs — an absolute one carries `APP_URL` and misses the dev server's port).
### Phase 3 — Actions
19. Primitives the actions need: `loading` (M3 Expressive loading indicator, contained and
not), plain `tooltip` (from a fine pointer only, not laid out while hidden), `menu` /
`menu-item` / `menu-separator` (Popover API + anchor positioning, Expressive vertical menu,
APG menu keyboard).
20. `button` — five variants × colours × sizes `xs|sm|md|lg|xl`, `icon`, `icon-right`,
`label`, `link` (+ `wire:navigate` unless `external` / `no-wire-navigate`), `spinner`,
`responsive`, `tooltip*`, `disabled` on links (`aria-disabled`), press shape-morph.
21. `icon-button` behaviour inside `button` (icon, no label): standard/filled/tonal/outlined,
`selected` toggle (`aria-pressed`), widths.
22. `button-group` (standard and connected; `<x-group>` alias with `wire:model` options),
`split-button`, `fab` (56px default — M3's deprecated small FAB is gone, so `sm` is the baseline FAB — 80px `md`, 96px `lg`), extended FAB, and the
responsive `fab` prop (extended FAB below `sm`, filled header button above — one element),
`fab-menu`.
**Phase 3 is done (2026-09-13).** What changed from the steps above:
- **Values from androidx Compose Material 3's generated tokens** (`Button*Tokens`,
`*IconButtonTokens`, `Fab*Tokens`, `ExtendedFab*Tokens`, `FabMenuBaselineTokens`,
`ButtonGroupSmallTokens`, `ConnectedButtonGroupSmallTokens`, `SplitButton*Tokens`,
`StandardMenuTokens`, `VibrantMenuTokens`, `SegmentedMenuTokens`, `PlainTooltipTokens`,
`LoadingIndicatorTokens`), cited in each component's header. Two tokens are wrong and Compose
overrides them in code, so the package does too: the text button's label is `primary`, not
`on-surface-variant`, and the extra-small button's padding is 12px, not 16px.
- **`<x-button>` is label button, icon button and toggle in one**, with `data-icon-button` on the
icon-only form. A selected round button squares off; a selected square icon button rounds. A
`corners` prop lets composite components (the split button) draw the corners themselves.
- **Group and split corners are unlayered CSS** (`resources/css/components/groups.css`): a
child button's corners are utilities, and anything in a `@layer` loses to a utility. Inner
corners ride a `--group-corner` variable so pressing and selecting change one value while the
rounded outer corners stay put. The standard group's press expansion is padding moved from the
neighbours to the pressed button (a fixed step per size); icon buttons keep their width.
- **`<x-group>` stays ReStride's native-radio design** (checkboxes with `multiple`), restyled as a
connected button group — `wire:model`, `x-model` and the arrow keys need no script.
- **Tooltips and menus are popovers placed by CSS anchor positioning**, with per-render anchor
names generated in Blade (a morph updates the trigger and the popover together). `popover`
elements must never get a `display` utility (`flex`), which beats the UA's `display: none` for
a closed popover; use `open:flex`.
- **Menu keyboard is WAI-ARIA's menu button**; `aria-expanded` and the first item's focus are set
synchronously in `open()`, because the popover `toggle` event is queued and a test (or a screen
reader) reading in between saw a shut menu. **Bug found by the browser tests:** the guard
against a light-dismiss press reopening the menu compared against `closedAt = 0`, so every click
in the first 250ms after page load was swallowed; it starts at `-Infinity` now, with a test.
- **Anonymous component trap:** every prop is a local variable, so a helper variable in `@php`
must not reuse a prop's name — a local `$corners` array silently replaced the `corners` prop.
- **The loading indicator is androidx's own geometry, in SVG + SMIL** (`bin/loading-indicator.mjs`,
`resources/svg/loading-indicator/`): `Morph` is ported, so each of the seven morphs is a path
whose control points SMIL can interpolate in every engine (CSS `d:` has no WebKit support).
The spring is two keySplines (<1% error), each morph's path shrinks under its successor at the
hand-over, and the per-morph quarter turn runs on its own 2.6s cycle so the 630° per shape
cycle never needs a reset. 21.7 KB. Two deliberate differences from Compose: the spring settles
inside the 650ms instead of snapping back from 9% past, and frames centre on exact curve bounds,
so there is no 0.10.18 unit jump at three hand-overs. Reduced motion shows `static.svg`.
- **Showcase examples are Blade strings rendered with `Blade::render()` beside their source**
(`<x-showcase::example>`, an anonymous component path registered only when the showcase is
enabled), so the snippet can never disagree with what is drawn.
- **Browser tests in three engines, locally too** (Playwright's Firefox and WebKit are installed):
- WebKit, like Safari on macOS, leaves buttons out of the Tab order; focus them directly.
- Firefox counts a scripted focus as `:focus-visible` only after a key press.
- Firefox flaked ~3 runs in 4 with **HTTP 431 from Pest's in-process Amp server** on
`livewire.js`, so Alpine never started. It appeared once the showcase inlined a 60 KB list of
symbol names; the icon search now fetches `symbols.json` on `x-intersect.once`, and the suite
passed 4 of 4. Keep showcase pages lean.
- A click that lands before Alpine starts does nothing; tests wait for `networkidle`, and the
showcase's theme switch is `x-cloak` so Playwright's click waits for it.
### Phase 4 — Communication
23. `badge` (dot, count, label; variant/colour), `progress` (linear, circular, **wavy**,
determinate and indeterminate), `toast` (M3 snackbar, action, timeout, stacked), rich
`tooltip`, `alert` (tinted container, icon, actions slot), `stat` (figure with
`x-figure`), `empty-state`.
**Phase 4 is done (2026-09-13).** What changed from the steps above:
- **`<x-progress>` was built by a separate agent** from Compose's `ProgressIndicator.kt` and
`WavyProgressIndicator.kt`: server-rendered SVG first frame, then an Alpine component that ports
the drawing and keyframes and animates only while something moves and it is on screen; the SVG
is `wire:ignore` and a MutationObserver on the root's `data-value` turns a morph or a `bind`
expression into motion. Flat circular indeterminate has no track, as in Compose.
- **`<x-toast>` listens from the moment `snackbar.js` loads**, not on its Alpine component, and holds
toasts until the host registers — a toast dispatched before Alpine starts is shown, not lost.
`@persist` keeps the host across `wire:navigate`.
- **`<x-badge>` is M3's dot and count** (`floating` pins it to an icon) **plus a status label**
(`tonal`, `outline`), which M3 lacks and every app needs. `<x-alert>`, `<x-stat>` and
`<x-empty-state>` are built from M3's parts; `<x-rich-tooltip>` is transient or `persistent`.
- **A commit went out broken and was fixed forward:** `648ad8e` staged `material.js` and the
showcase index while they held the progress agent's temporary lines, without its files.
**With agents in the same tree, stage by explicit path and read the diff of shared files first.**
- **Browser test lessons (all three engines, locally):**
- `waitForEvent('networkidle')` can return before a repeated visit has loaded in Firefox (an
empty document, then a page still streaming in). Every helper now also asserts
`document.readyState === 'complete'` and that Alpine and Livewire exist; assertions retry.
- Firefox runs Playwright's evaluate in a sandbox: an event built there has a `detail` the page
cannot read, and assignments to Alpine's reactive proxies do not trigger. Go through the page's
realm with `window.eval("…")` (or `Livewire.dispatch`).
- Pest retries a failing `assertScript`; a script that changes state must not be written so
that a retry starts from the changed state.
### Phase 5 — Containment
24. `card` (elevated, filled, outlined; `title`, `subtitle`, `actions` slot; clickable row
contract), `divider`, `list` / `list-item` (one-, two-, three-line; leading/trailing;
selectable), `modal` (native `<dialog>`, `showModal()`, `wire:ignore.self`, writes back
`false`/`null`, `fullscreen` below `sm`, basic dialog with icon/headline/actions),
`bottom-sheet` (modal and standard, drag handle), `drawer` (side sheet: modal and
standard; `pane` for list-detail from `xl`; `width` prop), `carousel` (multi-browse, uncontained, hero,
full-screen on CSS scroll-snap), `collapse`.
**Phase 5 is done (2026-09-13).** What changed from the steps above:
- **`<x-carousel>` was built by a separate agent in its own worktree**, porting Compose's keyline
maths (`Arrangement`, `Keylines`, `Strategy`, `KeylineSnapPosition`, androidx
`7ac433e44e797de53af85226797862687f37735f`; checked against 43 values from androidx's unit tests).
Items are laid out at the large size on a native scroll-snap row and masked each frame with a
`clip-path` inset; snap positions are `scroll-margin-inline-start`. Full-screen follows
material-components-android, which Compose lacks; one item per swipe is `scroll-snap-stop`, not
fling physics. Hooks are `data-material-carousel*`: the design guard rejects `carousel*` classes
as daisyUI's.
- **The drawer is the side sheet**, with `pane` for list-detail from `xl`, and the bottom sheet is its
own component (modal or `standard`) with drag-to-dismiss.
- **`<x-list-item>`'s trailing slot is `end`**: a slot named like the `trailing` prop replaced it.
- **The design guard also rejects Blade directives inside component tags** (`@class` on
`<x-icon>`), which reach the browser as text.
- **`x-trap.inert` hides the page with `aria-hidden`**, not `inert`; tests assert what it sets.
- **WebKit returns focus from a dialog only to an element that had focus**, and a click does not
focus a button there: tests open dialogs from the keyboard.
### Phase 6 — Text inputs and selection
25. `form`, `field` (the shared shell: **outlined and filled**, floating label via `:has()`,
notch, `hint` replaced by error, `aria-invalid` / `aria-describedby`, `data-*` state
marks), `input` (prefix/suffix, icons, `copyable` trailing button with a snackbar),
`password` (reveal toggle), `textarea` (auto-grow), `select` (native, customizable-select
enhancement with ReStride's traps), `checkbox` (incl. indeterminate), `radio`, `toggle`
(M3 switch, icons), `file` (native input inside the field, batch and per-file errors).
26. `chip` (assist, filter, input, suggestion), `choices` (filter chips, or a searchable
combobox with a menu when `searchable`), `slider` (standard, centered, range; native range
inputs, value label), `search` (search bar and search view, results through a Livewire
property).
**Phase 6 is done (2026-09-13).** What changed from the steps above:
- **The field family is ReStride's, extended with M3's filled text field** (`variant`, default from
`config('livewire-material.fields.variant')`): a surface-container-highest box, extra-small top
corners, the outline turned into an indicator line and the label floating inside. A textarea's
top offset is half margin, so text scrolled up in a full textarea disappears under the edge
instead of running through the label; it grows with `field-sizing: content` from `rows` to
`max-rows`, with a script fallback (`field.js`) that no current engine needs.
- **Checkbox, radio and switch are CSS on native inputs** (`components/selection.css`), hooked by
`data-checkbox`, `data-radio`, `data-switch`: `checkbox`, `radio` and `toggle` are daisyUI class
names the design guard rejects. The indeterminate checkbox is `data-indeterminate`, kept in step
with the property by a MutationObserver, since HTML has no attribute for it.
- **`<x-chip>` and `<x-slider>` were built by separate agents in their own worktrees.** Chips: one
component for four types; a filter chip is a native checkbox when bound and a toggle button
otherwise; removable input chips hand focus on by `wire:key` after a morph. Slider: native range
inputs under a drawing ported from Compose (`wire:ignore`); `.number` is added to the binding,
because a string sent and an integer returned made Livewire move the handle back mid-drag.
- **`<x-choices>` keeps typed values in Alpine** (filter chips as toggle buttons, or a searchable
combobox whose list is a `popover="manual"` placed by CSS anchor positioning, so a card's
`overflow-hidden` never clips it). Selecting the text on focus has to happen on the `click` that
follows the press: Chromium collapses a selection made in the focus handler.
- **`<x-search>` is the search bar and view**: docked from `sm`, full screen and trapped below it.
It closes on `pointerdown` outside, not `click` (the bar moves under the pointer as it goes full
screen, and the click then lands outside), and focus returned to the input within 250ms of a close
does not reopen it (Escape, and the trap letting go).
- **A slot rendered by a `@foreach` with no items is not empty** to `isNotEmpty()`; use
`hasActualContent()`.
### Phase 7 — Pickers
27. `datepicker` (docked, modal, modal input; `Intl` month/day names and week start from the
app locale; `min`/`max`; single and range; `wire:model` stores `Y-m-d`), `timepicker`
(dial and input; 12/24h from locale; stores `H:i`). APG grid keyboard for the calendar.
**Phase 7 is done (2026-09-13).** Both pickers were built by separate agents in their own
worktrees. What changed from the step above:
- **`<x-datepicker>` is one `<dialog wire:ignore>` for every mode**: docked opens it as a
`popover="manual"` under the field (CSS anchor positioning, flipping above), modal and input
with `showModal()`, and docked becomes modal below `sm`. Dates are `Y-m-d` strings computed in
UTC; only "today" reads the browser's zone. The typed format is androidx's
`datePatternAsInputFormat` from `Intl` (`de``dd.MM.yyyy`). **A range binds one array**,
`['start' => …, 'end' => …]`, not two models: one update, so `after_or_equal:trip.start`
validates against the matching end, and all its errors land on one field. Docked values come from
material-web's docked tokens, which Compose lacks.
- **`<x-timepicker>` follows current Compose, not older Android**: a 24-hour dial puts 0011 outside
and 1223 inside, and the period selector is two separate toggles. The selector angle is a
registered custom property, so one transition turns the line and moves the handle.
- **Alpine's `x-show` reveals an element on the next animation frame**: focusing into it from
`$nextTick` failed in Firefox and Safari. Wait a frame, or switch views with an attribute and CSS.
- **A Livewire public property named `$slot` renders empty** in the component's view.
### Phase 8 — Navigation
28. `app-bar` (small, center-aligned, medium flexible, large flexible, search app bar; sticky,
scroll-elevation), `navigation-bar` (flexible), `navigation-rail` (collapsed, expanded,
modal; badges), `tabs` / `tab` (primary and secondary; server-rendered tablist, roving
tabindex), `toolbar` (docked and floating), `section-nav` (secondary tabs from `sm`, menu
picker below), `account-menu` (avatar trigger, slot for items, theme row), `theme-toggle`
(cycles or picks light/dark/system through `$store.theme`).
29. `app-shell` — a slot-based adaptive composition: app bar + navigation bar below `sm`, rail
`sm``lg`, expanded collapsible rail from `lg` (state in the store, applied before paint by
the theme script), content region with `wire:transition.navigate`, snackbar host. Nothing
app-specific inside; apps pass destinations and extra chrome as slots.
**Phase 8 is done (2026-09-13).** What changed from the steps above:
- **App bars, toolbars, tabs, section nav, account menu and theme toggle were built in main; the
navigation bar, rail and app shell by an agent in its own worktree.**
- **A medium or large app bar collapses without script moving anything**: the bar is sticky at a
negative top (its measured height less the 64px row, so a wrapped title still fits) and its row
is sticky at 0 inside it; script only reports `scrolled` and `collapsed`.
- **The tab indicator moves in a view transition** (`view-transition-name` from a per-tablist
custom property, `view-transition-class` for the timing); every tab draws its own indicator, so it
is right before Alpine starts.
- **Livewire 4.4's `wire:navigate` gives `<html>` the next page's attributes and removes the rest**,
which dropped `data-theme` on every in-app navigation. The head script saves the theme and rail
attributes on `livewire:navigating` and puts them back in `onSwap`, before anything paints.
- **The rail's collapsed state is applied before first paint** by the head script
(`<html data-rail>`, `rail.default`, `rail.storage_key`) and read by a `rail-collapsed:` variant;
`$store.rail` changes it. The shell draws no phone menu button: the app bar in `top` calls
`$store.rail.show()`.
- **Playwright's locators are strict**: `assertAttribute` on a selector matching two elements fails.
- **Tailwind only compiles classes it can see**: a class written only in a test probe (`h-[200vh]`)
does not exist in the Workbench build; use inline styles there.
- **A Tailwind `@variant` nested under a pseudo-element or a `* +` selector compiles to broken CSS.**
### Phase 9 — Data, pages, mail
30. `table` (`.data-table`, descendant selectors, fine-pointer density, `position: relative`),
`sort-header` (`sortBy` array shape, `aria-sort`), Livewire and Laravel pagination views
(current page in `secondary-container`, "Page 2 of 7" on a phone).
31. Error pages: a layout and `403, 404, 419, 429, 500, 503` in the package's error-view root
(wired through `view.paths` in step 4); publishable for per-app wording.
32. Mail theme: `livewire-material::mail.theme` renders CSS from the app's
`material-scheme.json` (falling back to the default scheme); `html/header` and
`html/message` overrides; typescale on bare tags; filled primary button.
**Steps 3032 are done (2026-09-13).** Tables, sort headers and pagination were built in main; error
pages and the mail theme by an agent in its own worktree. What changed from the steps above:
- **The paginators are prepended to the `pagination` and `livewire` view namespaces** rather than
set as the default view, because Livewire sets its own default on every render; published
`vendor/pagination` and `vendor/livewire` views still win (`livewire-material.pagination`).
- **The error-view root is `resources/views/error-pages`** (inside `views`, so an application's
`@source` line already covers its classes), appended to `view.paths` in `register()`, before the
view finder is built. The layout is `errors::minimal`, so the framework's 401 and 402 use it too.
Without a Vite build the pages fall back to an inline stylesheet from the scheme JSON. Testbench's
skeleton ships its own `errors/503`, which wins in the Workbench.
- **Mail is light only**: the CSS inliner strips `@media`. The header and message components are
opt-in (`livewire-material.mail.components`) or published, because they change every Markdown mail.
### Phase 10 — `1.0.0`
33. Showcase complete (every component, variant, colour, size and state, both themes); the
in-app mount verified inside a fresh Laravel app; Boost guideline and skill complete
(drift test green); README (install, CSS/JS wiring, scheme, theme, prefix, showcase,
guard, Docker ordering note); tag `1.0.0`.
### Phase 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.
+1 -1
View File
@@ -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
@@ -46,6 +46,34 @@ 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.
### 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
```
- 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
Tailwind's default palette is cleared: every colour class names an M3 role. `text-red-600`, `bg-base-200` or `text-gray-500` compile to nothing.
@@ -62,7 +90,7 @@ 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`. 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.
## Toasts
@@ -467,7 +495,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 (the error replaces the hint, 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 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`.
@@ -523,7 +551,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
```blade
<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" />
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" clearable />
```
| Prop | Default | |
@@ -534,6 +562,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
| `label`, `hint`, `icon`, `variant`, `size` | | the field's |
| `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 |
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.
@@ -723,6 +752,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"`.
@@ -764,7 +801,7 @@ It fails on maryUI tags, daisyUI classes, colours the theme does not declare, un
## Conventions
- Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}name>` when `config('livewire-material.prefix')` is set.
- Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}::name>` when `config('livewire-material.prefix')` is set; `<x-livewire-material::name>` always works.
- Write class names out whole. Tailwind cannot compile `'text-'.$tone` or `type-{{ $size }}`, and the design guard cannot read them.
- The showcase at `/material` (local only, `MATERIAL_SHOWCASE=true` to force it) renders every token and component.
+38 -1
View File
@@ -69,6 +69,9 @@ const MEDIUM_ITEM_FLEX_PERCENTAGE = 0.1
/** A programmatic scroll still counts as where the carousel is going for this long. */
const TARGET_MS = 700
// How long the row must go without a scroll event to count as having come to rest.
const SETTLE_MS = 150
const ITEM = '[data-material-carousel-item]'
const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable], [tabindex]:not([tabindex="-1"])'
@@ -805,6 +808,7 @@ document.addEventListener('alpine:init', () => {
frame: null,
target: null,
targetAt: 0,
settle: null,
listeners: [],
mutations: null,
resizes: null,
@@ -817,7 +821,12 @@ document.addEventListener('alpine:init', () => {
state.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
this.listen(scroller, 'scroll', () => this.schedule(), { passive: true })
this.listen(scroller, 'scroll', () => this.scrolled(), { passive: true })
// A scroll the person makes themselves is theirs to end wherever it ends.
for (const type of ['pointerdown', 'wheel', 'touchstart']) {
this.listen(scroller, type, () => (state.target = null), { passive: true })
}
this.listen(scroller, 'keydown', (event) => this.navigate(event))
this.listen(scroller, 'focusin', (event) => this.reveal(event))
this.listen(scroller, 'click', (event) => this.open(event))
@@ -913,6 +922,33 @@ document.addEventListener('alpine:init', () => {
state.frame ??= requestAnimationFrame(() => this.render())
},
/**
* Each scroll frame, and once the row comes to rest: a scroll the buttons or keys started
* must end on its item. WebKit on Linux can re-snap a smooth scroll to the item it left
* when the masks change the layout under it, so an arrival somewhere else is sent on
* again, once, at once.
*/
scrolled() {
this.schedule()
clearTimeout(state.settle)
state.settle = setTimeout(() => {
const target = state.target
if (target === null || performance.now() - state.targetAt > TARGET_MS * 3) {
return
}
state.target = null
const left = state.snaps[target]
if (left !== undefined && Math.abs(this.scrollOffset() - left) > 1) {
this.$refs.scroller.scrollTo({ left: state.rtl ? -left : left, behavior: 'instant' })
}
}, SETTLE_MS)
},
/** Carousel.kt's carouselItem layer block, for every item. */
render() {
cancelAnimationFrame(state.frame)
@@ -1076,6 +1112,7 @@ document.addEventListener('alpine:init', () => {
destroy() {
cancelAnimationFrame(state.frame)
clearTimeout(state.settle)
state.listeners.forEach((remove) => remove())
state.resizes?.disconnect()
state.mutations?.disconnect()
+12
View File
@@ -366,6 +366,18 @@ document.addEventListener('alpine:init', () => {
}
},
/** The clear button: no date (no start and no end), closed, and focus back in the field. */
clear() {
if (this.open) {
this.cancel(false)
}
this.fieldError = ''
this.text = ''
this.write(null)
this.$refs.input.focus()
},
fieldProblem(value) {
if (!config.range) {
return this.problem(value)
+11 -1
View File
@@ -15,6 +15,7 @@ document.addEventListener('alpine:init', () => {
window.Alpine.data('materialMenu', () => ({
closedAt: -Infinity,
returnFocus: true,
focusWasInside: false,
listeners: [],
init() {
@@ -26,6 +27,13 @@ document.addEventListener('alpine:init', () => {
// and close() have already done their part, synchronously, because this event is
// queued and a screen reader or a test reading aria-expanded in between would be told
// the menu is shut.
// Whether focus was in the menu is read before it closes: once closed, a browser may
// already have handed focus to what had it before the menu opened (WebKit does, when
// that was a focusable region around the trigger).
this.listen(menu, 'beforetoggle', (event) => {
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
})
this.listen(menu, 'toggle', (event) => {
const opened = event.newState === 'open'
@@ -37,9 +45,11 @@ document.addEventListener('alpine:init', () => {
this.closedAt = performance.now()
if (this.returnFocus && menu.contains(document.activeElement)) {
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
this.control()?.focus()
}
this.focusWasInside = false
})
// A press outside closes the menu without pulling focus back to the trigger.
+14
View File
@@ -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
@@ -32,7 +32,7 @@
$initials = $image ? null : ($avatar ?? \Illuminate\Support\Str::of((string) $name)->explode(' ')->filter()->take(2)->map(fn (string $word): string => mb_strtoupper(mb_substr($word, 0, 1)))->join(''));
@endphp
<x-menu :$label :$position {{ $attributes }}>
<x-livewire-material::menu :$label :$position {{ $attributes }}>
<x-slot:trigger>
<button
type="button"
@@ -45,7 +45,7 @@
@elseif (filled($initials))
{{ $initials }}
@else
<x-icon name="person" />
<x-livewire-material::icon name="person" />
@endif
</button>
</x-slot:trigger>
@@ -60,20 +60,20 @@
@endif
</div>
<x-menu-separator />
<x-livewire-material::menu-separator />
@endif
{{ $slot }}
@if ($theme)
<x-menu-item icon="contrast" x-on:click="$store.theme.toggle()" data-account-theme>
<x-livewire-material::menu-item icon="contrast" x-on:click="$store.theme.toggle()" data-account-theme>
<span x-text="$store.theme.resolved === 'dark' ? @js(__('Light theme')) : @js(__('Dark theme'))">{{ __('Theme') }}</span>
</x-menu-item>
</x-livewire-material::menu-item>
@endif
@isset($footer)
<x-menu-separator />
<x-livewire-material::menu-separator />
{{ $footer }}
@endisset
</x-menu>
</x-livewire-material::menu>
+2 -2
View File
@@ -40,7 +40,7 @@
{{ $attributes->class(['flex items-start gap-3 rounded-corner-md p-4', $colours]) }}
>
@if ($icon)
<x-icon :name="$icon" filled class="size-6" />
<x-livewire-material::icon :name="$icon" filled class="size-6" />
@endif
<div class="min-w-0 flex-1 space-y-1 self-center">
@@ -59,7 +59,7 @@
@if ($dismissible)
<button type="button" class="state-layer focus-ring -m-2 inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="shown = false">
<x-icon name="close" class="size-5" />
<x-livewire-material::icon name="close" class="size-5" />
</button>
@endif
</div>
+10 -10
View File
@@ -88,7 +88,7 @@
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"
>{{ __('Skip to content') }}</a>
<x-navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
@isset($brand)
<x-slot:brand>{{ $brand }}</x-slot:brand>
@endisset
@@ -99,14 +99,14 @@
@foreach ($groups as $group)
@if ($group->first()['section'] !== null)
<x-navigation-rail-section :label="$group->first()['section']">
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
@foreach ($group as $item)
<x-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']" :no-wire-navigate="! $item['navigate']" />
@endforeach
</x-navigation-rail-section>
</x-livewire-material::navigation-rail-section>
@else
@foreach ($group as $item)
<x-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']" :no-wire-navigate="! $item['navigate']" />
@endforeach
@endif
@endforeach
@@ -122,7 +122,7 @@
@endisset
</x-slot:footer>
@endif
</x-navigation-rail>
</x-livewire-material::navigation-rail>
<div class="flex min-w-0 flex-1 flex-col">
{{ $top ?? '' }}
@@ -134,13 +134,13 @@
@if ($barItems->isNotEmpty())
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
<x-navigation-bar :label="$label">
<x-livewire-material::navigation-bar :label="$label">
@foreach ($barItems as $item)
<x-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']" :no-wire-navigate="! $item['navigate']" />
@endforeach
</x-navigation-bar>
</x-livewire-material::navigation-bar>
</div>
@endif
<x-toast />
<x-livewire-material::toast />
</div>
+4 -4
View File
@@ -202,13 +202,13 @@
<{{ $tag }} {{ $attributes }}>
@if ($spinnerTarget)
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center">
<x-loading :class="$iconSize" :label="false" />
<x-livewire-material::loading :class="$iconSize" :label="false" />
</span>
@endif
@if ($icon)
<span class="contents" @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif>
<x-icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-sm:size-6' => $fab])" />
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-sm:size-6' => $fab])" />
</span>
@endif
@@ -217,10 +217,10 @@
@endunless
@if ($iconRight)
<x-icon :name="$iconRight" :class="$iconSize" />
<x-livewire-material::icon :name="$iconRight" :class="$iconSize" />
@endif
@if ($tip !== null)
<x-tooltip :text="$tip" :side="$tipSide" :anchor="$anchor" />
<x-livewire-material::tooltip :text="$tip" :side="$tipSide" :anchor="$anchor" />
@endif
</{{ $tag }}>
+1 -1
View File
@@ -60,7 +60,7 @@
</div>
@if ($separator)
<x-divider class="mt-4" />
<x-livewire-material::divider class="mt-4" />
@endif
</div>
@endif
@@ -136,7 +136,7 @@
'hidden pointer-fine:flex' => $controls === null,
'flex' => $controls === true,
])>
<x-button
<x-livewire-material::button
icon="chevron_left"
variant="tonal"
:tooltip="__('Previous')"
@@ -145,7 +145,7 @@
x-on:click="previous()"
class="rtl:-scale-x-100"
/>
<x-button
<x-livewire-material::button
icon="chevron_right"
variant="tonal"
:tooltip="__('Next')"
@@ -18,7 +18,9 @@
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'check-'.substr(md5($model.'|'.$label.'|'.$attributes->get('value')), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
// A plain form's field is named, not bound: its errors are under its name (`files[]` `files`, `a[b]` `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}>
@@ -36,8 +38,8 @@
@if ($indeterminate) data-indeterminate @endif
@if ($messages !== []) aria-invalid="true" aria-describedby="{{ $id }}-support" @endif
/>
<x-icon name="check" class="size-4" data-check />
<x-icon name="remove" class="size-4" data-mixed />
<x-livewire-material::icon name="check" class="size-4" data-check />
<x-livewire-material::icon name="remove" class="size-4" data-mixed />
</span>
@if (filled($label) || filled($hint))
+8 -8
View File
@@ -230,7 +230,7 @@
<img src="{{ $avatar }}" alt="" @class(['me-2 size-6 shrink-0 rounded-corner-full object-cover', 'opacity-38' => $disabled]) />
@endif
@elseif ($icon)
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
<x-livewire-material::icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
@endif
<span data-chip-label class="truncate">{{ $label ?? $slot }}</span>
@@ -251,7 +251,7 @@
'cursor-not-allowed' => $disabled,
])
>
<x-icon name="close" class="size-4.5" />
<x-livewire-material::icon name="close" class="size-4.5" />
</button>
@endif
@@ -260,7 +260,7 @@
@endif
@if ($tooltip !== null)
<x-tooltip :text="$tooltip" :anchor="$anchor" />
<x-livewire-material::tooltip :text="$tooltip" :anchor="$anchor" />
@endif
</span>
@else
@@ -277,22 +277,22 @@
'h-4.5 w-0 transition-[width] duration-(--md-sys-motion-effects-default-duration) ease-effects-default group-has-checked/chip:w-4.5 group-has-checked/chip:duration-(--md-sys-motion-spatial-fast-duration) group-has-checked/chip:ease-spatial-fast group-aria-pressed/chip:w-4.5 group-aria-pressed/chip:duration-(--md-sys-motion-spatial-fast-duration) group-aria-pressed/chip:ease-spatial-fast' => blank($icon),
])>
@if ($icon)
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-4.5 transition-opacity duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-has-checked/chip:opacity-0 group-aria-pressed/chip:opacity-0', $leadingInk])" />
<x-livewire-material::icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-4.5 transition-opacity duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-has-checked/chip:opacity-0 group-aria-pressed/chip:opacity-0', $leadingInk])" />
@endif
<x-icon name="check" data-chip-check :class="$check" />
<x-livewire-material::icon name="check" data-chip-check :class="$check" />
</span>
@elseif ($icon)
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
<x-livewire-material::icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
@endif
<span class="truncate">{{ $label ?? $slot }}</span>
@if ($iconRight)
<x-icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['ms-2 size-4.5', $trailingInk])" />
<x-livewire-material::icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['ms-2 size-4.5', $trailingInk])" />
@endif
@if ($tooltip !== null)
<x-tooltip :text="$tooltip" :anchor="$anchor" />
<x-livewire-material::tooltip :text="$tooltip" :anchor="$anchor" />
@endif
</{{ $tag }}>
@endif
+11 -9
View File
@@ -35,8 +35,10 @@
@php
$model = $attributes->wire('model')->value() ?: null;
$messages = $model !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
: [];
$choices = collect($options)->map(fn ($option): array => [
'value' => data_get($option, $optionValue),
@@ -115,7 +117,7 @@
@if ($model === null) x-modelable="value" @endif
>
<div style="anchor-name: {{ $anchor }}">
<x-field :$id :$label :$hint :$messages :$icon :$variant>
<x-livewire-material::field :$id :$label :$hint :$messages :$icon :$variant>
<input
id="{{ $id }}"
type="text"
@@ -144,9 +146,9 @@
/>
<x-slot:trailing>
<x-icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
<x-livewire-material::icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
</x-slot:trailing>
</x-field>
</x-livewire-material::field>
</div>
<ul
@@ -170,7 +172,7 @@
class="field-option"
>
<span x-text="option.label"></span>
<x-icon name="check" class="field-check size-6" />
<x-livewire-material::icon name="check" class="field-check size-6" />
</li>
</template>
<li x-show="filtered.length === 0" class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</li>
@@ -202,9 +204,9 @@
}"
@if ($model === null) x-modelable="selection" @endif
>
<x-chip-set :$label :$hint :error-field="$model">
<x-livewire-material::chip-set :$label :$hint :error-field="$model">
@foreach ($choices as $index => $choice)
<x-chip
<x-livewire-material::chip
type="filter"
:label="$choice['label']"
:selected="$isSelected($choice['value'])"
@@ -213,6 +215,6 @@
x-on:click="toggle({{ $index }})"
/>
@endforeach
</x-chip-set>
</x-livewire-material::chip-set>
</div>
@endif
@@ -28,12 +28,12 @@
'px-4' => $variant === 'filled',
])>
@if ($icon)
<x-icon :name="$icon" class="size-6 text-on-surface-variant" />
<x-livewire-material::icon :name="$icon" class="size-6 text-on-surface-variant" />
@endif
<span class="min-w-0 flex-1">{{ $heading ?? $title }}</span>
<x-icon name="expand_more" class="size-6 text-on-surface-variant transition-[rotate] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast group-open/collapse:rotate-180" />
<x-livewire-material::icon name="expand_more" class="size-6 text-on-surface-variant transition-[rotate] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast group-open/collapse:rotate-180" />
</summary>
<div @class(['pb-4 type-body-md text-on-surface-variant', 'px-4' => $variant === 'filled', 'pt-1'])>
+42 -25
View File
@@ -21,7 +21,8 @@
with, and the errors for `period`, `period.start` and `period.end` all belong to this field.
`min` and `max` (`Y-m-d` or a date object) disable the days outside them and keep the
keyboard inside them. `label`, `hint`, `icon`, `variant` (`outlined`, `filled`) and `size`
are the field's; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every
are the field's; `clearable` adds a button that empties it (a date, or both ends of a range)
once it holds one; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every
other attribute (`required`, `disabled`, `readonly`) reaches the text field. Errors under
the `wire:model` name replace the hint, and so does a typed date that cannot be read.
@@ -48,6 +49,7 @@
'min' => null,
'max' => null,
'value' => null,
'clearable' => false,
])
@php
@@ -55,8 +57,10 @@
$mode = in_array($mode, ['docked', 'modal', 'input'], true) ? $mode : 'docked';
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|datepicker'), 0, 12);
$anchor = '--material-datepicker-'.preg_replace('/[^A-Za-z0-9_-]/', '-', $id);
$messages = $model !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
: [];
$locale = str_replace('_', '-', app()->getLocale());
@@ -133,7 +137,7 @@
data-datepicker
>
<div style="anchor-name: {{ $anchor }}">
<x-field
<x-livewire-material::field
:$id
:$label
:$icon
@@ -179,6 +183,19 @@
/>
<x-slot:trailing>
@if ($clearable)
<button
type="button"
class="field-trailing field-clear field-button"
aria-label="{{ __('Clear') }}"
x-on:click="clear()"
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
data-field-clear
>
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
</button>
@endif
<button
type="button"
class="field-trailing field-button"
@@ -191,10 +208,10 @@
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
data-datepicker-toggle
>
<x-icon name="calendar_today" class="size-(--field-icon)" />
<x-livewire-material::icon name="calendar_today" class="size-(--field-icon)" />
</button>
</x-slot:trailing>
</x-field>
</x-livewire-material::field>
</div>
<div id="{{ $id }}-support" data-datepicker-support data-size="{{ in_array($size, ['sm', 'xs'], true) ? $size : 'md' }}">
@@ -241,10 +258,10 @@
<p data-datepicker-headline aria-live="polite" x-text="headline"></p>
<span x-show="! typing">
<x-button icon="edit" :tooltip="__('Switch to text input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
<x-livewire-material::button icon="edit" :tooltip="__('Switch to text input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
</span>
<span x-show="typing">
<x-button icon="date_range" :tooltip="__('Switch to calendar input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
<x-livewire-material::button icon="date_range" :tooltip="__('Switch to calendar input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
</span>
</div>
</div>
@@ -261,19 +278,19 @@
x-bind:aria-label="monthYear + ', ' + @js(__('Switch to selecting a year'))"
>
<span x-text="monthYear"></span>
<x-icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
<x-livewire-material::icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
</button>
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
<x-button icon="chevron_left" :tooltip="__('Previous month')" x-on:click="step(-1)" x-bind:disabled="view !== 'days' || ! canStep(-1)" data-datepicker-previous />
<x-button icon="chevron_right" :tooltip="__('Next month')" x-on:click="step(1)" x-bind:disabled="view !== 'days' || ! canStep(1)" data-datepicker-next />
<x-livewire-material::button icon="chevron_left" :tooltip="__('Previous month')" x-on:click="step(-1)" x-bind:disabled="view !== 'days' || ! canStep(-1)" data-datepicker-previous />
<x-livewire-material::button icon="chevron_right" :tooltip="__('Next month')" x-on:click="step(1)" x-bind:disabled="view !== 'days' || ! canStep(1)" data-datepicker-next />
</span>
</div>
<div data-datepicker-nav data-docked x-show="presentation === 'docked'">
<span data-datepicker-stepper>
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
<x-button icon="chevron_left" :tooltip="__('Previous month')" x-on:click="step(-1)" x-bind:disabled="view !== 'days' || ! canStep(-1)" />
<x-livewire-material::button icon="chevron_left" :tooltip="__('Previous month')" x-on:click="step(-1)" x-bind:disabled="view !== 'days' || ! canStep(-1)" />
</span>
<button
type="button"
@@ -283,16 +300,16 @@
x-bind:aria-label="monthLabel + ', ' + @js(__('Switch to selecting a month'))"
>
<span x-text="monthLabel"></span>
<x-icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
<x-livewire-material::icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
</button>
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
<x-button icon="chevron_right" :tooltip="__('Next month')" x-on:click="step(1)" x-bind:disabled="view !== 'days' || ! canStep(1)" />
<x-livewire-material::button icon="chevron_right" :tooltip="__('Next month')" x-on:click="step(1)" x-bind:disabled="view !== 'days' || ! canStep(1)" />
</span>
</span>
<span data-datepicker-stepper>
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
<x-button icon="chevron_left" :tooltip="__('Previous year')" x-on:click="step(-12)" x-bind:disabled="view !== 'days' || ! canStep(-12)" />
<x-livewire-material::button icon="chevron_left" :tooltip="__('Previous year')" x-on:click="step(-12)" x-bind:disabled="view !== 'days' || ! canStep(-12)" />
</span>
<button
type="button"
@@ -302,10 +319,10 @@
x-bind:aria-label="yearLabel + ', ' + @js(__('Switch to selecting a year'))"
>
<span x-text="yearLabel"></span>
<x-icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
<x-livewire-material::icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
</button>
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
<x-button icon="chevron_right" :tooltip="__('Next year')" x-on:click="step(12)" x-bind:disabled="view !== 'days' || ! canStep(12)" />
<x-livewire-material::button icon="chevron_right" :tooltip="__('Next year')" x-on:click="step(12)" x-bind:disabled="view !== 'days' || ! canStep(12)" />
</span>
</span>
</div>
@@ -372,7 +389,7 @@
x-on:click="month.disabled || showMonthOf(+shown.slice(0, 4), month.value)"
data-datepicker-option
>
<x-icon name="check" data-datepicker-check />
<x-livewire-material::icon name="check" data-datepicker-check />
<span x-text="month.label"></span>
</button>
</template>
@@ -388,7 +405,7 @@
x-on:click="showMonthOf(year.value, +shown.slice(5, 7))"
data-datepicker-option
>
<x-icon name="check" data-datepicker-check />
<x-livewire-material::icon name="check" data-datepicker-check />
<span x-text="year.label"></span>
</button>
</template>
@@ -397,7 +414,7 @@
<div x-show="typing" data-datepicker-entry>
<div @if ($range) data-range @endif data-datepicker-entry-fields>
<x-field id="{{ $id }}-entry" :label="$range ? __('Start date') : __('Date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null">
<x-livewire-material::field id="{{ $id }}-entry" :label="$range ? __('Start date') : __('Date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null">
<input
id="{{ $id }}-entry"
type="text"
@@ -412,10 +429,10 @@
x-on:keydown.enter.prevent="confirm()"
class="field-control"
/>
</x-field>
</x-livewire-material::field>
@if ($range)
<x-field id="{{ $id }}-entry-end" :label="__('End date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null">
<x-livewire-material::field id="{{ $id }}-entry-end" :label="__('End date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null">
<input
id="{{ $id }}-entry-end"
type="text"
@@ -429,7 +446,7 @@
x-on:keydown.enter.prevent="confirm()"
class="field-control"
/>
</x-field>
</x-livewire-material::field>
@endif
</div>
@@ -439,8 +456,8 @@
</div>
<div x-show="view === 'days' || typing || presentation === 'modal'" data-datepicker-actions>
<x-button :label="__('Cancel')" x-on:click="cancel()" data-datepicker-cancel />
<x-button :label="__('OK')" x-on:click="confirm()" data-datepicker-confirm />
<x-livewire-material::button :label="__('Cancel')" x-on:click="cancel()" data-datepicker-cancel />
<x-livewire-material::button :label="__('OK')" x-on:click="confirm()" data-datepicker-confirm />
</div>
</div>
</dialog>
+2 -2
View File
@@ -113,13 +113,13 @@
@if ($withCloseButton)
<span class="-me-3 -mt-2 inline-flex shrink-0">
<x-button icon="close" :tooltip-left="__('Close')" x-on:click="close()" />
<x-livewire-material::button icon="close" :tooltip-left="__('Close')" x-on:click="close()" />
</span>
@endif
</div>
@if ($separator)
<x-divider class="mt-4" />
<x-livewire-material::divider class="mt-4" />
@endif
</div>
@endif
@@ -19,8 +19,8 @@
<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-shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" />
<x-icon :name="$icon" class="relative size-12 text-on-secondary-container" />
<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 ($title)
@@ -40,7 +40,7 @@
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-icon :name="$icon" class="size-6" />
<x-livewire-material::icon :name="$icon" class="size-6" />
@endif
<span>{{ $label ?? $slot }}</span>
@@ -49,8 +49,8 @@
$colours,
])
>
<x-icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" />
<x-icon name="close" class="hidden size-5 group-aria-expanded/fab:block" />
<x-livewire-material::icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" />
<x-livewire-material::icon name="close" class="hidden size-5 group-aria-expanded/fab:block" />
</button>
</span>
+2 -2
View File
@@ -65,7 +65,7 @@
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-icon :name="$icon" :class="$iconSize" />
<x-livewire-material::icon :name="$icon" :class="$iconSize" />
@endif
@if ($extended)
@@ -73,6 +73,6 @@
@endif
@if ($tooltip !== null)
<x-tooltip :text="$tooltip" :anchor="$anchor" />
<x-livewire-material::tooltip :text="$tooltip" :anchor="$anchor" />
@endif
</{{ $tag }}>
+1 -1
View File
@@ -46,7 +46,7 @@
>
<div class="field-box">
@if ($icon)
<x-icon :name="$icon" class="field-icon size-(--field-icon)" />
<x-livewire-material::icon :name="$icon" class="field-icon size-(--field-icon)" />
@endif
@if (filled($prefix))
+6 -4
View File
@@ -21,12 +21,14 @@
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'field-'.substr(md5('file|'.$model.'|'.$label), 0, 12);
$messages = $model !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
: [];
@endphp
<x-field :$id :$label :$hint :$messages :$variant floated :class="$attributes->get('class')">
<x-livewire-material::field :$id :$label :$hint :$messages :$variant floated :class="$attributes->get('class')">
<input
{{ $attributes->except(['class', 'id', 'type']) }}
type="file"
@@ -35,4 +37,4 @@
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control"
/>
</x-field>
</x-livewire-material::field>
+1 -1
View File
@@ -16,7 +16,7 @@
@isset($actions)
@if ($separator)
<x-divider />
<x-livewire-material::divider />
@endif
<div {{ $actions->attributes->class(['flex flex-wrap items-center justify-end gap-2']) }}>
+4 -2
View File
@@ -33,7 +33,9 @@
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$name ??= $model;
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
$segment = [
@@ -79,7 +81,7 @@
/>
@if (filled(data_get($option, $optionIcon)))
<x-icon :name="data_get($option, $optionIcon)" :class="$iconSize" />
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :class="$iconSize" />
@endif
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
+8 -6
View File
@@ -30,10 +30,12 @@
$model = $attributes->whereStartsWith('wire:model')->first();
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|'.$placeholder), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp
<x-field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
<input
{{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }}
id="{{ $id }}"
@@ -54,7 +56,7 @@
aria-label="{{ __('Clear') }}"
data-field-clear
>
<x-icon name="close" class="size-(--field-icon)" />
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
</button>
@endif
@@ -67,13 +69,13 @@
aria-label="{{ __('Copy') }}"
data-field-copy
>
<x-icon name="content_copy" class="size-(--field-icon)" />
<x-livewire-material::icon name="content_copy" class="size-(--field-icon)" />
</button>
@endif
@if ($iconRight)
<x-icon :name="$iconRight" class="field-trailing size-(--field-icon)" />
<x-livewire-material::icon :name="$iconRight" class="field-trailing size-(--field-icon)" />
@endif
</x-slot:trailing>
@endif
</x-field>
</x-livewire-material::field>
@@ -61,7 +61,7 @@
@elseif ($image)
<img src="{{ $image }}" alt="" class="size-14 shrink-0 rounded-corner-sm object-cover" />
@elseif ($icon)
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
<x-livewire-material::icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
@endif
<div class="min-w-0 flex-1">
@@ -94,6 +94,6 @@
@endif
@if ($iconRight)
<x-icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
<x-livewire-material::icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
@endif
</div>
@@ -60,7 +60,7 @@
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-icon :name="$icon" :filled="$selected === true" :class="'size-5 '.$iconInk" />
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="'size-5 '.$iconInk" />
@endif
<span class="min-w-0 flex-1">
@@ -76,6 +76,6 @@
@endif
@if ($iconRight)
<x-icon :name="$iconRight" :class="'size-5 '.$iconInk" />
<x-livewire-material::icon :name="$iconRight" :class="'size-5 '.$iconInk" />
@endif
</{{ $tag }}>
+8 -7
View File
@@ -69,7 +69,7 @@
])>
@if ($fullscreen)
<div class="flex h-16 shrink-0 items-center gap-1 px-1 sm:hidden">
<x-button icon="close" :tooltip="__('Close')" x-on:click="close()" />
<x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="close()" />
@if (filled($title))
<span class="truncate type-title-lg">{{ $title }}</span>
@@ -78,22 +78,23 @@
@endif
<div @class(['min-h-0 flex-1', 'max-sm:overflow-y-auto max-sm:px-6 max-sm:pb-6' => $fullscreen])>
@if (filled($title) || $icon)
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon, 'text-center' => $icon])>
@if (filled($title) || filled($subtitle) || $icon)
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])>
@if ($icon)
<x-icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
@endif
@if (filled($title))
<h2 id="{{ $id }}-title" class="type-headline-sm">{{ $title }}</h2>
<h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-sm:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
@endif
@if (filled($subtitle))
<p class="mt-4 type-body-md text-on-surface-variant">{{ $subtitle }}</p>
<p @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-sm:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p>
@endif
@if ($separator)
<x-divider class="mt-4" />
<x-livewire-material::divider class="mt-4" />
@endif
</div>
@endif
@@ -49,13 +49,13 @@
<span data-navigation-indicator>
<span class="relative inline-flex">
@if ($icon)
<x-icon :name="$icon" :filled="$active" class="size-6" />
<x-livewire-material::icon :name="$icon" :filled="$active" class="size-6" />
@endif
@if ($dot)
<x-badge floating />
<x-livewire-material::badge floating />
@elseif ($count)
<x-badge :value="$badge" max="999" floating />
<x-livewire-material::badge :value="$badge" max="999" floating />
@endif
</span>
</span>
@@ -51,13 +51,13 @@
<span data-navigation-indicator>
<span class="relative inline-flex">
@if ($icon)
<x-icon :name="$icon" :filled="$active" class="size-6" />
<x-livewire-material::icon :name="$icon" :filled="$active" class="size-6" />
@endif
@if ($dot)
<x-badge floating />
<x-livewire-material::badge floating />
@elseif ($count)
<span class="hidden rail-collapsed:contents"><x-badge :value="$badge" max="999" floating /></span>
<span class="hidden rail-collapsed:contents"><x-livewire-material::badge :value="$badge" max="999" floating /></span>
@endif
</span>
</span>
@@ -65,7 +65,7 @@
<span data-navigation-label>{{ $label ?? $slot }}</span>
@if ($count)
<span class="flex shrink-0 rail-collapsed:hidden"><x-badge :value="$badge" max="999" /></span>
<span class="flex shrink-0 rail-collapsed:hidden"><x-livewire-material::badge :value="$badge" max="999" /></span>
@endif
@if ($spoken !== null)
@@ -107,8 +107,8 @@
x-bind:aria-expanded="expanded.toString()"
class="state-layer focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant after:absolute after:top-1/2 after:left-1/2 after:size-12 after:-translate-x-1/2 after:-translate-y-1/2"
>
<span class="contents rail-collapsed:hidden"><x-icon name="menu_open" class="size-6" /></span>
<span class="hidden rail-collapsed:contents"><x-icon name="menu" class="size-6" /></span>
<span class="contents rail-collapsed:hidden"><x-livewire-material::icon name="menu_open" class="size-6" /></span>
<span class="hidden rail-collapsed:contents"><x-livewire-material::icon name="menu" class="size-6" /></span>
</button>
@endif
@@ -17,10 +17,12 @@
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|password'), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp
<x-field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" x-data="{ shown: false }">
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" x-data="{ shown: false }">
<input
{{ $attributes->except(['class', 'id', 'type', 'placeholder']) }}
id="{{ $id }}"
@@ -43,8 +45,8 @@
class="field-trailing field-button"
data-field-reveal
>
<x-icon name="visibility" class="size-(--field-icon)" x-show="! shown" />
<x-icon name="visibility_off" class="size-(--field-icon)" x-show="shown" x-cloak />
<x-livewire-material::icon name="visibility" class="size-(--field-icon)" x-show="! shown" />
<x-livewire-material::icon name="visibility_off" class="size-(--field-icon)" x-show="shown" x-cloak />
</button>
</x-slot:trailing>
</x-field>
</x-livewire-material::field>
+3 -1
View File
@@ -23,7 +23,9 @@
$model = $attributes->whereStartsWith('wire:model')->first();
$name = $attributes->get('name') ?? $model ?? 'radio-'.substr(md5($label.'|'.json_encode($options)), 0, 12);
$id = 'radio-'.substr(md5($name.'|'.$label), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp
<fieldset
@@ -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
+3 -3
View File
@@ -37,11 +37,11 @@
>
<div data-search-bar role="search" x-on:click="if ($event.target === $el) $refs.input.focus()">
<span data-search-leading x-show="! fullScreen">
<x-icon :name="$icon" />
<x-livewire-material::icon :name="$icon" />
</span>
<button type="button" data-search-leading data-search-back x-show="fullScreen" x-cloak x-on:click="close()" aria-label="{{ __('Back') }}">
<x-icon name="arrow_back" />
<x-livewire-material::icon name="arrow_back" />
</button>
<input
@@ -64,7 +64,7 @@
/>
<button type="button" data-search-clear x-on:click="clear()" aria-label="{{ __('Clear') }}">
<x-icon name="close" />
<x-livewire-material::icon name="close" />
</button>
@isset($trailing)
@@ -33,21 +33,21 @@
<div {{ $attributes->class(['min-w-0']) }} data-section-nav>
@if ($current)
<div class="sm:hidden" data-section-picker>
<x-menu :$label position="bottom-start">
<x-livewire-material::menu :$label position="bottom-start">
<x-slot:trigger>
<button type="button" class="focus-ring flex h-12 w-[min(20rem,calc(100vw-2rem))] cursor-pointer items-center gap-3 rounded-corner-xs border border-outline px-4 text-start type-body-lg text-on-surface">
@isset($current['icon'])
<x-icon :name="$current['icon']" class="size-5 text-on-surface-variant" />
<x-livewire-material::icon :name="$current['icon']" class="size-5 text-on-surface-variant" />
@endisset
<span class="min-w-0 flex-1 truncate">{{ $current['title'] }}</span>
<x-icon name="arrow_drop_down" class="size-6 text-on-surface-variant" />
<x-livewire-material::icon name="arrow_drop_down" class="size-6 text-on-surface-variant" />
</button>
</x-slot:trigger>
@foreach ($items as $item)
<x-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']" :selected="$isCurrent($item)" :no-wire-navigate="$noWireNavigate" />
@endforeach
</x-menu>
</x-livewire-material::menu>
</div>
@endif
@@ -65,11 +65,11 @@
>
<span data-tab-content>
@isset($item['icon'])
<x-icon :name="$item['icon']" :filled="$on" class="size-5" />
<x-livewire-material::icon :name="$item['icon']" :filled="$on" class="size-5" />
@endisset
<span class="truncate">{{ $item['title'] }}</span>
@if (filled($item['badge'] ?? null))
<x-badge :value="$item['badge']" />
<x-livewire-material::badge :value="$item['badge']" />
@endif
<span data-tab-indicator aria-hidden="true"></span>
</span>
+6 -4
View File
@@ -29,10 +29,12 @@
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|select'), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp
<x-field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant floated :class="$attributes->get('class')">
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant floated :class="$attributes->get('class')">
<select
{{ $attributes->except(['class', 'id']) }}
id="{{ $id }}"
@@ -52,6 +54,6 @@
</select>
<x-slot:trailing>
<x-icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
<x-livewire-material::icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
</x-slot:trailing>
</x-field>
</x-livewire-material::field>
+1 -1
View File
@@ -321,7 +321,7 @@
@class(['absolute top-1/2 flex -translate-y-1/2 rtl:-scale-x-100', $iconInk[$color], 'group-has-disabled/slider:text-on-surface/38'])
style="left: {{ $calc($iconAt) }}"
>
<x-icon :name="$icon" :class="$iconSize === 32 ? 'size-8' : 'size-6'" />
<x-livewire-material::icon :name="$icon" :class="$iconSize === 32 ? 'size-8' : 'size-6'" />
</span>
@endif
</div>
@@ -29,7 +29,7 @@
])
>
{{ $slot }}
<x-icon :name="$direction === 'desc' ? 'arrow_downward' : 'arrow_upward'" :class="\Illuminate\Support\Arr::toCssClasses([
<x-livewire-material::icon :name="$direction === 'desc' ? 'arrow_downward' : 'arrow_upward'" :class="\Illuminate\Support\Arr::toCssClasses([
'size-4 transition-opacity duration-(--md-sys-motion-effects-fast-duration)',
'opacity-0 group-hover/sort:opacity-60 group-focus-visible/sort:opacity-60' => ! $active,
])" />
@@ -37,7 +37,7 @@
@endphp
<div data-button-group="split" data-size="{{ $size }}" {{ $attributes->only('class')->class('inline-flex items-center gap-0.5') }}>
<x-button
<x-livewire-material::button
{{ $attributes->except('class') }}
:label="$label"
:icon="$icon"
@@ -51,9 +51,9 @@
:class="$leadingPadding"
/>
<x-menu :position="$position" :label="$menuLabel">
<x-livewire-material::menu :position="$position" :label="$menuLabel">
<x-slot:trigger>
<x-button
<x-livewire-material::button
icon="keyboard_arrow_down"
:aria-label="$menuLabel"
:variant="$variant"
@@ -67,5 +67,5 @@
</x-slot:trigger>
{{ $slot }}
</x-menu>
</x-livewire-material::menu>
</div>
+1 -1
View File
@@ -17,7 +17,7 @@
<div {{ $attributes->class('flex flex-col gap-1 rounded-corner-lg bg-surface-container p-4') }}>
<div class="flex items-center gap-2 type-label-lg text-on-surface-variant">
@if ($icon)
<x-icon :name="$icon" class="size-5" />
<x-livewire-material::icon :name="$icon" class="size-5" />
@endif
<span>{{ $title }}</span>
</div>
+2 -2
View File
@@ -72,12 +72,12 @@
>
<span data-tab-content>
@isset($tab['icon'])
<x-icon :name="$tab['icon']" class="size-6" />
<x-livewire-material::icon :name="$tab['icon']" class="size-6" />
@endisset
<span class="inline-flex items-center gap-1.5">
{{ $tab['label'] }}
@if (filled($tab['badge'] ?? null))
<x-badge :value="$tab['badge']" />
<x-livewire-material::badge :value="$tab['badge']" />
@endif
</span>
<span data-tab-indicator aria-hidden="true"></span>
@@ -19,10 +19,12 @@
$model = $attributes->whereStartsWith('wire:model')->first();
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|textarea'), 0, 12);
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp
<x-field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
<textarea
{{ $attributes->except(['class', 'id', 'placeholder']) }}
id="{{ $id }}"
@@ -36,4 +38,4 @@
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control"
></textarea>
</x-field>
</x-livewire-material::field>
@@ -11,6 +11,10 @@
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
@@ -27,6 +31,7 @@
$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'] ?? []),
@@ -78,6 +83,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);
@@ -87,7 +96,7 @@
media.addEventListener('change', apply);
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)];
});
@@ -40,8 +40,8 @@
data-theme-option="{{ $choice }}"
class="state-layer focus-ring inline-flex min-w-0 flex-1 cursor-pointer items-center justify-center gap-2 border-outline px-3 type-label-lg text-on-surface not-first:border-s first:rounded-s-corner-full last:rounded-e-corner-full aria-checked:bg-secondary-container aria-checked:text-on-secondary-container"
>
<x-icon name="check" class="hidden size-4.5 in-aria-checked:block" />
<x-icon :name="$icon" class="size-4.5 in-aria-checked:hidden" />
<x-livewire-material::icon name="check" class="hidden size-4.5 in-aria-checked:block" />
<x-livewire-material::icon :name="$icon" class="size-4.5 in-aria-checked:hidden" />
{{ __($text) }}
</button>
@endforeach
@@ -65,12 +65,12 @@
{{ $attributes->class(['state-layer focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant transition-[border-radius] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast active:rounded-corner-sm']) }}
>
@if ($mode === 'cycle')
<x-icon name="light_mode" x-cloak x-show="$store.theme.choice === 'light'" />
<x-icon name="dark_mode" x-cloak x-show="$store.theme.choice === 'dark'" />
<x-icon name="brightness_auto" x-cloak x-show="$store.theme.choice === 'system'" />
<x-livewire-material::icon name="light_mode" x-cloak x-show="$store.theme.choice === 'light'" />
<x-livewire-material::icon name="dark_mode" x-cloak x-show="$store.theme.choice === 'dark'" />
<x-livewire-material::icon name="brightness_auto" x-cloak x-show="$store.theme.choice === 'system'" />
@else
<x-icon name="light_mode" x-cloak x-show="$store.theme.resolved === 'dark'" />
<x-icon name="dark_mode" x-cloak x-show="$store.theme.resolved !== 'dark'" />
<x-livewire-material::icon name="light_mode" x-cloak x-show="$store.theme.resolved === 'dark'" />
<x-livewire-material::icon name="dark_mode" x-cloak x-show="$store.theme.resolved !== 'dark'" />
@endif
</button>
@endif
@@ -58,7 +58,9 @@
@php
$model = $attributes->wire('model')->value() ?: null;
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($name) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $name) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$id = $attributes->get('id') ?? 'timepicker-'.substr(md5($model.'|'.$label.'|'.$name.'|timepicker'), 0, 12);
$cycle = in_array((string) $format, ['12', '24'], true) ? (int) $format : null;
@@ -138,7 +140,7 @@
x-data="materialTimepicker(@if ($model !== null) @entangle($attributes->wire('model')) @else @js($current) @endif, @js($config))"
@if ($model === null) x-modelable="value" @endif
>
<x-field :$id :$label :$hint :$messages :$icon :$size :$variant data-timepicker-field>
<x-livewire-material::field :$id :$label :$hint :$messages :$icon :$size :$variant data-timepicker-field>
<input
{{ $inputAttributes }}
id="{{ $id }}"
@@ -172,7 +174,7 @@
data-field-clear
@disabled($disabled)
>
<x-icon name="close" class="size-(--field-icon)" />
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
</button>
@endif
@@ -185,10 +187,10 @@
data-timepicker-open
@disabled($disabled)
>
<x-icon name="schedule" class="size-(--field-icon)" />
<x-livewire-material::icon name="schedule" class="size-(--field-icon)" />
</button>
</x-slot:trailing>
</x-field>
</x-livewire-material::field>
@if (filled($name))
<input type="hidden" name="{{ $name }}" value="{{ $current }}" x-bind:value="value ?? ''" />
@@ -348,14 +350,14 @@
<div data-timepicker-actions>
<span data-timepicker-when="dial">
<x-button icon="keyboard" :tooltip="__('Switch to text input mode')" x-on:click="toggleMode()" data-timepicker-mode="input" />
<x-livewire-material::button icon="keyboard" :tooltip="__('Switch to text input mode')" x-on:click="toggleMode()" data-timepicker-mode="input" />
</span>
<span data-timepicker-when="input">
<x-button icon="schedule" :tooltip="__('Switch to clock mode')" x-on:click="toggleMode()" data-timepicker-mode="dial" />
<x-livewire-material::button icon="schedule" :tooltip="__('Switch to clock mode')" x-on:click="toggleMode()" data-timepicker-mode="dial" />
</span>
<span data-timepicker-spacer></span>
<x-button :label="__('Cancel')" x-on:click="cancel()" data-timepicker-cancel />
<x-button :label="__('OK')" x-on:click="confirm()" data-timepicker-confirm />
<x-livewire-material::button :label="__('Cancel')" x-on:click="cancel()" data-timepicker-cancel />
<x-livewire-material::button :label="__('OK')" x-on:click="confirm()" data-timepicker-confirm />
</div>
</div>
</dialog>
+5 -5
View File
@@ -47,10 +47,10 @@
'text-inverse-warning': current.type === 'warning',
'text-inverse-info': current.type === 'info',
}">
<span x-show="current.type === 'success'"><x-icon name="check_circle" filled class="size-6" /></span>
<span x-show="current.type === 'error'"><x-icon name="error" filled class="size-6" /></span>
<span x-show="current.type === 'warning'"><x-icon name="warning" filled class="size-6" /></span>
<span x-show="current.type === 'info'"><x-icon name="info" filled class="size-6" /></span>
<span x-show="current.type === 'success'"><x-livewire-material::icon name="check_circle" filled class="size-6" /></span>
<span x-show="current.type === 'error'"><x-livewire-material::icon name="error" filled class="size-6" /></span>
<span x-show="current.type === 'warning'"><x-livewire-material::icon name="warning" filled class="size-6" /></span>
<span x-show="current.type === 'info'"><x-livewire-material::icon name="info" filled class="size-6" /></span>
</span>
</template>
@@ -65,7 +65,7 @@
<template x-if="current.action || ! current.timeout">
<button type="button" class="state-layer focus-ring inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="dismiss()">
<x-icon name="close" class="size-5" />
<x-livewire-material::icon name="close" class="size-5" />
</button>
</template>
</div>
+2 -2
View File
@@ -36,8 +36,8 @@
/>
<span data-handle>
@if ($icons)
<x-icon name="check" class="size-4" data-on />
<x-icon name="close" class="size-4" data-off />
<x-livewire-material::icon name="check" class="size-4" data-on />
<x-livewire-material::icon name="close" class="size-4" data-off />
@endif
</span>
</span>
@@ -11,6 +11,6 @@
@section('message', __('It was open for a while. Refresh it, then try again.'))
@section('actions')
<x-button :link="url()->previous()" :label="__('Refresh the page')" variant="filled" size="md" no-wire-navigate />
<x-button :link="url('/')" :label="__('Go home')" variant="text" size="md" no-wire-navigate />
<x-livewire-material::button :link="url()->previous()" :label="__('Refresh the page')" variant="filled" size="md" no-wire-navigate />
<x-livewire-material::button :link="url('/')" :label="__('Go home')" variant="text" size="md" no-wire-navigate />
@endsection
@@ -18,5 +18,5 @@
@section('message', $reason !== '' && $reason !== 'Service Unavailable' ? __($reason) : __('Were making some improvements. Please check back soon.'))
@section('actions')
<x-button :label="__('Try again')" variant="filled" size="md" onclick="location.reload()" />
<x-livewire-material::button :label="__('Try again')" variant="filled" size="md" onclick="location.reload()" />
@endsection
@@ -32,7 +32,7 @@
<title>@yield('title') · {{ config('app.name') }}</title>
<x-theme-script />
<x-livewire-material::theme-script />
@if ($assets !== null)
{{ $assets }}
@@ -49,7 +49,7 @@
<main data-error-page class="mx-auto flex min-h-dvh max-w-xl flex-col items-center justify-center px-6 py-12 text-center">
<div data-error-art class="relative grid size-48 shrink-0 place-items-center sm:size-60">
<div data-error-shape class="absolute inset-0">
<x-shape :name="trim($__env->yieldContent('shape', 'cookie-7'))" class="size-full text-primary-container" />
<x-livewire-material::shape :name="trim($__env->yieldContent('shape', 'cookie-7'))" class="size-full text-primary-container" />
</div>
<p data-error-code class="relative type-emphasized-display-lg text-on-primary-container tabular-nums">@yield('code')</p>
@@ -71,10 +71,10 @@
@hasSection('actions')
@yield('actions')
@else
<x-button :link="url('/')" :label="__('Go home')" variant="filled" size="md" no-wire-navigate />
<x-livewire-material::button :link="url('/')" :label="__('Go home')" variant="filled" size="md" no-wire-navigate />
@if ($back !== null)
<x-button :link="$back" :label="__('Go back')" variant="text" size="md" no-wire-navigate />
<x-livewire-material::button :link="$back" :label="__('Go back')" variant="text" size="md" no-wire-navigate />
@endif
@endif
</div>
@@ -4,15 +4,15 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-pagination class="flex items-center justify-between gap-4">
@if ($paginator->onFirstPage())
<x-button variant="outlined" icon="chevron_left" :label="__('Previous')" disabled />
<x-livewire-material::button variant="outlined" icon="chevron_left" :label="__('Previous')" disabled />
@else
<x-button variant="outlined" icon="chevron_left" :label="__('Previous')" :link="$paginator->previousPageUrl()" no-wire-navigate rel="prev" />
<x-livewire-material::button variant="outlined" icon="chevron_left" :label="__('Previous')" :link="$paginator->previousPageUrl()" no-wire-navigate rel="prev" />
@endif
@if ($paginator->hasMorePages())
<x-button variant="outlined" icon-right="chevron_right" :label="__('Next')" :link="$paginator->nextPageUrl()" no-wire-navigate rel="next" />
<x-livewire-material::button variant="outlined" icon-right="chevron_right" :label="__('Next')" :link="$paginator->nextPageUrl()" no-wire-navigate rel="next" />
@else
<x-button variant="outlined" icon-right="chevron_right" :label="__('Next')" disabled />
<x-livewire-material::button variant="outlined" icon-right="chevron_right" :label="__('Next')" disabled />
@endif
</nav>
@endif
@@ -19,11 +19,11 @@
<div class="flex items-center gap-1">
@if ($paginator->onFirstPage())
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Previous') }}">
<x-icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
</span>
@else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" class="{{ $live }}" aria-label="{{ __('Previous') }}">
<x-icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
</a>
@endif
@@ -45,11 +45,11 @@
@if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next" class="{{ $live }}" aria-label="{{ __('Next') }}">
<x-icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
</a>
@else
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Next') }}">
<x-icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
</span>
@endif
</div>
@@ -19,21 +19,21 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-pagination class="flex items-center justify-between gap-4">
@if ($paginator->onFirstPage())
<x-button variant="outlined" icon="chevron_left" :label="__('Previous')" disabled />
<x-livewire-material::button variant="outlined" icon="chevron_left" :label="__('Previous')" disabled />
@elseif ($cursor)
@php($previousCursor = $paginator->previousCursor() ?? $paginator->cursor())
<x-button variant="outlined" icon="chevron_left" :label="__('Previous')" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $previousCursor?->encode() }}" wire:click="setPage('{{ $previousCursor?->encode() }}', '{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage" />
<x-livewire-material::button variant="outlined" icon="chevron_left" :label="__('Previous')" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $previousCursor?->encode() }}" wire:click="setPage('{{ $previousCursor?->encode() }}', '{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage" />
@else
<x-button variant="outlined" icon="chevron_left" :label="__('Previous')" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage" />
<x-livewire-material::button variant="outlined" icon="chevron_left" :label="__('Previous')" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage" />
@endif
@if (! $paginator->hasMorePages())
<x-button variant="outlined" icon-right="chevron_right" :label="__('Next')" disabled />
<x-livewire-material::button variant="outlined" icon-right="chevron_right" :label="__('Next')" disabled />
@elseif ($cursor)
@php($nextCursor = $paginator->nextCursor() ?? $paginator->cursor())
<x-button variant="outlined" icon-right="chevron_right" :label="__('Next')" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $nextCursor?->encode() }}" wire:click="setPage('{{ $nextCursor?->encode() }}', '{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage" />
<x-livewire-material::button variant="outlined" icon-right="chevron_right" :label="__('Next')" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $nextCursor?->encode() }}" wire:click="setPage('{{ $nextCursor?->encode() }}', '{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage" />
@else
<x-button variant="outlined" icon-right="chevron_right" :label="__('Next')" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage" />
<x-livewire-material::button variant="outlined" icon-right="chevron_right" :label="__('Next')" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage" />
@endif
</nav>
@endif
@@ -35,11 +35,11 @@
<div class="flex items-center gap-1">
@if ($paginator->onFirstPage())
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Previous') }}">
<x-icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
</span>
@else
<button type="button" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.'.$paginator->getPageName() }}" class="{{ $live }}" aria-label="{{ __('Previous') }}">
<x-icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
</button>
@endif
@@ -63,11 +63,11 @@
@if ($paginator->hasMorePages())
<button type="button" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.'.$paginator->getPageName() }}" class="{{ $live }}" aria-label="{{ __('Next') }}">
<x-icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
</button>
@else
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Next') }}">
<x-icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
<x-livewire-material::icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
</span>
@endif
</div>
@@ -3,7 +3,22 @@
@props(['title' => null, 'code', 'stack' => false])
<div class="space-y-4 rounded-corner-lg bg-surface-container p-4">
@php
// Written as an application without a prefix writes it; under a configured prefix the tags are
// rewritten to it, so what renders and what is shown is what this application would write.
$prefix = config('livewire-material.prefix');
if (filled($prefix)) {
$names = collect(glob(\NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider::componentPath().'/*.blade.php'))
->map(fn (string $path): string => preg_quote(basename($path, '.blade.php'), '/'))
->sortByDesc(fn (string $name): int => strlen($name))
->implode('|');
$code = preg_replace('/<(\/?)x-('.$names.')(?=[\s\/>])/', '<$1x-'.$prefix.'::$2', $code);
}
@endphp
<div @if ($title) id="{{ \NoNameWeb\LivewireMaterial\Showcase\Sections::anchor($title) }}" @endif class="scroll-mt-24 space-y-4 rounded-corner-lg bg-surface-container p-4">
@if ($title)
<h3 class="type-title-md">{{ $title }}</h3>
@endif
+31 -26
View File
@@ -1,31 +1,36 @@
@extends('livewire-material::showcase.layout')
@section('content')
<main class="mx-auto max-w-6xl space-y-16 px-4 py-10">
<p class="max-w-3xl type-body-lg text-on-surface-variant">
Every token and component, in this application's own scheme. Components appear here as they land.
</p>
<div class="mx-auto w-full max-w-6xl space-y-12 px-4 pt-4 pb-16 sm:px-6">
<div class="max-w-3xl space-y-3">
<h1 class="type-headline-lg">Livewire Material</h1>
<p class="type-title-lg">Material 3 Expressive for Laravel and Livewire.</p>
<p class="type-body-lg text-on-surface-variant">
Every token and component, rendered in this application's own scheme and theme. Pick a section in the
navigation, search for one above (or press <kbd class="rounded-corner-xs bg-surface-container-highest px-1.5 type-label-md">/</kbd>), or start below.
</p>
</div>
@include('livewire-material::showcase.sections.colour')
@include('livewire-material::showcase.sections.type')
@include('livewire-material::showcase.sections.shape')
@include('livewire-material::showcase.sections.elevation')
@include('livewire-material::showcase.sections.motion')
@include('livewire-material::showcase.sections.icons')
@include('livewire-material::showcase.sections.buttons')
@include('livewire-material::showcase.sections.menus')
@include('livewire-material::showcase.sections.communication')
@include('livewire-material::showcase.sections.progress')
@include('livewire-material::showcase.sections.containment')
@include('livewire-material::showcase.sections.carousel')
@include('livewire-material::showcase.sections.fields')
@include('livewire-material::showcase.sections.chips')
@include('livewire-material::showcase.sections.sliders')
@include('livewire-material::showcase.sections.pickers')
@include('livewire-material::showcase.sections.timepickers')
@include('livewire-material::showcase.sections.bars')
@include('livewire-material::showcase.sections.navigation')
@include('livewire-material::showcase.sections.data')
@include('livewire-material::showcase.sections.pages')
</main>
@foreach (collect($sections)->groupBy('group', preserveKeys: true) as $group => $entries)
<section class="space-y-4" aria-labelledby="group-{{ \Illuminate\Support\Str::slug($group) }}">
<h2 id="group-{{ \Illuminate\Support\Str::slug($group) }}" class="type-title-lg">{{ $group }}</h2>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($entries as $key => $entry)
<x-livewire-material::card variant="outlined" data-list-row wire:key="section-{{ $key }}">
<div class="flex items-start gap-4">
<span class="grid size-12 shrink-0 place-items-center rounded-corner-lg bg-secondary-container text-on-secondary-container">
<x-livewire-material::icon :name="$entry['icon']" />
</span>
<div class="min-w-0">
<a href="{{ route('livewire-material.section', $key) }}" wire:navigate data-list-open class="type-title-md">{{ $entry['title'] }}</a>
<p class="mt-1 type-body-md text-on-surface-variant">{{ $entry['description'] }}</p>
</div>
</div>
</x-livewire-material::card>
@endforeach
</div>
</section>
@endforeach
</div>
@endsection
+160 -28
View File
@@ -1,47 +1,179 @@
{{-- The showcase's frame, and the package's own app shell at work: the rail groups every section,
collapses and expands from `lg`, and opens as a modal from the app bar's menu button on a phone.
Pages move with wire:navigate, so the rail keeps its place and the theme stays. --}}
@php
$sections ??= \NoNameWeb\LivewireMaterial\Showcase\Sections::all();
$section ??= null;
$title = $section !== null ? $sections[$section]['title'] : 'Livewire Material';
$destinations = [[
'title' => 'Overview',
'icon' => 'home',
'url' => route('livewire-material.showcase'),
'active' => $section === null,
'bar' => false,
]];
foreach ($sections as $key => $entry) {
$destinations[] = [
'title' => $entry['title'],
'icon' => $entry['icon'],
'url' => route('livewire-material.section', $key),
'active' => $key === $section,
'section' => $entry['group'],
'bar' => false,
];
}
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<title>@yield('title', 'Livewire Material')</title>
<title>{{ $section !== null ? $title.' · Livewire Material' : 'Livewire Material' }}</title>
<x-theme-script />
<x-livewire-material::theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="min-h-screen bg-surface font-sans text-on-surface antialiased">
<header class="sticky top-0 z-10 border-b border-divider bg-surface-container">
<div class="mx-auto flex max-w-6xl items-center gap-x-6 px-4 py-3">
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
<body class="bg-surface font-sans text-on-surface antialiased">
<x-livewire-material::app-shell :destinations="$destinations" label="Showcase" rail-width="17rem">
<x-slot:brand>
<a href="{{ route('livewire-material.showcase') }}" wire:navigate class="block truncate rounded-corner-xs type-title-lg focus-ring">Livewire Material</a>
</x-slot:brand>
<nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections">
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'pickers' => 'Date pickers', 'timepickers' => 'Time pickers', 'bars' => 'Bars', 'navigation' => 'Navigation', 'data' => 'Data', 'pages' => 'Pages'] as $anchor => $section)
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
@endforeach
</nav>
<x-slot:top>
<x-livewire-material::app-bar variant="search">
<x-slot:navigation>
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="showcase-menu" /></span>
</x-slot:navigation>
<div class="ms-auto flex shrink-0 rounded-corner-full border border-outline" role="group" aria-label="Theme" x-data x-cloak>
@foreach (['light' => 'Light', 'dark' => 'Dark', 'system' => 'System'] as $choice => $label)
<button
type="button"
data-test="theme-{{ $choice }}"
class="state-layer focus-ring px-4 py-1.5 type-label-lg first:rounded-s-corner-full last:rounded-e-corner-full"
x-on:click="$store.theme.set('{{ $choice }}')"
x-bind:class="$store.theme.choice === '{{ $choice }}' && 'bg-secondary-container text-on-secondary-container'"
x-bind:aria-pressed="($store.theme.choice === '{{ $choice }}').toString()"
>{{ $label }}</button>
@endforeach
</div>
</div>
</header>
{{-- The search looks through every section, example and component: the index is
fetched on first focus, and / or Ctrl+K (⌘K) reaches it from anywhere. Its names
keep clear of <x-search>'s own (`results`, `open`), which the slot also sees. --}}
<div
class="mx-auto w-full max-w-2xl"
x-data="{
query: '',
entries: null,
async load() {
this.entries ??= await (await fetch(@js(route('livewire-material.search', [], false)))).json();
},
get matches() {
const words = this.query.toLowerCase().split(/\s+/).filter(Boolean);
if (! this.entries || words.length === 0) return [];
const phrase = words.join(' ');
return this.entries
.map((entry) => {
const title = entry.title.toLowerCase().replace(/[<>]/g, '');
const text = `${title} ${entry.context} ${entry.kind} ${entry.keywords}`.toLowerCase();
if (! words.every((word) => text.includes(word))) return null;
const rank = title === phrase ? 0 : title.startsWith(phrase) ? 1 : title.includes(phrase) ? 2 : 3;
return { ...entry, rank };
})
.filter(Boolean)
.sort((a, b) => a.rank - b.rank)
.slice(0, 30);
},
go(event, result) {
if (! result || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || (event.button ?? 0) !== 0) return;
event.preventDefault();
const url = new URL(result.url, window.location.href);
const search = this.$root.querySelector('[data-search]');
this.query = '';
if (search) window.Alpine.$data(search).close();
if (url.pathname === window.location.pathname) {
window.location.hash = url.hash;
document.getElementById(url.hash.slice(1))?.scrollIntoView({ block: 'start' });
} else {
window.Livewire.navigate(url.pathname + url.hash);
}
},
shortcut(event) {
const typing = event.target.closest('input, textarea, select, [contenteditable]');
if ((event.key === '/' && ! typing) || (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey))) {
event.preventDefault();
document.getElementById('showcase-search').focus();
}
},
}"
x-on:keydown.window="shortcut($event)"
>
<x-livewire-material::search
id="showcase-search"
placeholder="Search components, examples and sections"
x-model="query"
x-on:focus.once="load()"
x-on:keydown.enter.prevent="go($event, matches[0])"
>
<p x-show="query.trim() === ''" class="px-4 py-3 type-body-md text-on-surface-variant">
Type a component (<code>datepicker</code>), an example or a section. Press <kbd class="rounded-corner-xs bg-surface-container-highest px-1.5 type-label-md">/</kbd> to search from anywhere.
</p>
<p x-cloak x-show="query.trim() !== '' && entries !== null && matches.length === 0" class="px-4 py-3 type-body-md text-on-surface-variant">
Nothing matches <span x-text="query.trim()"></span>.
</p>
<template x-for="result in matches" x-bind:key="result.url + result.title">
<a
x-bind:href="result.url"
x-on:click="go($event, result)"
class="state-layer focus-ring flex min-h-14 flex-col justify-center px-4 py-2 outline-none"
data-showcase-result
>
<span class="truncate type-body-lg text-on-surface" x-text="result.title"></span>
<span class="truncate type-body-sm text-on-surface-variant" x-text="`${result.kind} · ${result.context}`"></span>
</a>
</template>
</x-livewire-material::search>
</div>
@yield('content')
<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>
<x-toast />
@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>
</x-livewire-material::app-bar>
</x-slot:top>
@yield('content')
</x-livewire-material::app-shell>
@livewireScripts
{{-- wire:navigate keeps the URL's hash but not the scroll to it: a search result that opens an
example on another page lands on the example. --}}
<script data-navigate-once>
document.addEventListener('livewire:navigated', () => {
const target = () => (window.location.hash.length > 1 ? document.getElementById(decodeURIComponent(window.location.hash.slice(1))) : null);
const land = () => target()?.scrollIntoView({ block: 'start' });
// After the swap's own scroll to the top and the page transition, and once more when
// the page's components have settled their size.
requestAnimationFrame(() => requestAnimationFrame(land));
setTimeout(() => {
const box = target()?.getBoundingClientRect();
if (box && (box.top < 0 || box.top > window.innerHeight / 2)) {
land();
}
}, 400);
});
</script>
</body>
</html>
@@ -0,0 +1,36 @@
{{-- One section of the showcase on a page of its own, with the way on to the next. The page's
heading names the section, so the section's own heading is only for a screen reader. --}}
@extends('livewire-material::showcase.layout')
@php
$keys = array_keys($sections);
$at = array_search($section, $keys, true);
$previous = $keys[$at - 1] ?? null;
$next = $keys[$at + 1] ?? null;
@endphp
@section('content')
<div class="mx-auto w-full max-w-6xl px-4 pt-4 pb-16 sm:px-6">
<header class="mb-6 space-y-1">
<p class="type-label-lg text-on-surface-variant">{{ $sections[$section]['group'] }}</p>
<h1 class="type-headline-lg">{{ $sections[$section]['title'] }}</h1>
</header>
<div class="space-y-16 [&>section>h2]:sr-only">
@include('livewire-material::showcase.sections.'.$section)
<nav aria-label="Sections" class="flex flex-wrap items-center justify-between gap-4 border-t border-divider pt-6">
@if ($previous)
<x-livewire-material::button variant="text" icon="arrow_back" :label="$sections[$previous]['title']" :link="route('livewire-material.section', $previous)" data-test="previous-section" />
@else
<x-livewire-material::button variant="text" icon="arrow_back" label="Overview" :link="route('livewire-material.showcase')" data-test="previous-section" />
@endif
@if ($next)
<x-livewire-material::button variant="tonal" icon-right="arrow_forward" :label="$sections[$next]['title']" :link="route('livewire-material.section', $next)" data-test="next-section" />
@endif
</nav>
</div>
</div>
@endsection
@@ -76,7 +76,7 @@
<x-button icon="format_underlined" aria-label="Underline" variant="tonal" :selected="false" />
</x-button-group>
BLADE,
'A choice as a connected group (<x-group>)' => <<<'BLADE'
'A choice as a connected group' => <<<'BLADE'
<div x-data="{ theme: 'system', days: ['mon'] }" class="grid w-full gap-6 md:grid-cols-2">
<x-group label="Theme" name="showcase-theme" x-model="theme" :options="[
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
@@ -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>&lt;html data-scheme&gt;</code>, and <code>&lt;x-scheme-picker&gt;</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">
@@ -15,6 +15,13 @@
<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 })" />
BLADE,
'Plain tooltips' => <<<'BLADE'
<x-button icon="content_copy" tooltip="Copy link" />
<x-button icon="qr_code_2" tooltip-bottom="Show QR code" />
<x-tooltip text="Expires in 3 days" side="right">
<span tabindex="0" class="focus-ring rounded-corner-xs type-body-md text-on-surface-variant">holiday-photos.zip</span>
</x-tooltip>
BLADE,
'Rich tooltips' => <<<'BLADE'
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time. Admins can change the longest time allowed.">
<x-button icon="help" aria-label="About expiry" />
@@ -59,7 +66,7 @@
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-badge&gt;</code>, <code>&lt;x-toast&gt;</code> (the snackbar host, fed by the <code>Toasts</code> concern or <code>materialToast()</code>),
<code>&lt;x-rich-tooltip&gt;</code>, <code>&lt;x-alert&gt;</code>, <code>&lt;x-stat&gt;</code> and <code>&lt;x-empty-state&gt;</code>.
<code>&lt;x-tooltip&gt;</code>, <code>&lt;x-rich-tooltip&gt;</code>, <code>&lt;x-alert&gt;</code>, <code>&lt;x-stat&gt;</code> and <code>&lt;x-empty-state&gt;</code>.
</p>
@foreach ($examples as $title => $code)
@@ -24,7 +24,7 @@
<div class="flex flex-wrap items-center gap-4">
<label class="flex min-w-72 flex-1 items-center gap-2 rounded-corner-xs border border-outline px-3 py-2 focus-within:outline-3 focus-within:outline-secondary">
<x-icon name="search" class="size-5 text-on-surface-variant" />
<x-livewire-material::icon name="search" class="size-5 text-on-surface-variant" />
<input type="search" x-model.debounce.150ms="query" placeholder="Search {{ number_format(count(\NoNameWeb\LivewireMaterial\Support\SvgFile::symbolNames())) }} symbols" class="w-full bg-transparent type-body-lg outline-none" />
</label>
@@ -8,9 +8,9 @@
<div class="flex flex-wrap items-center gap-2">
@foreach (['401', '402', '403', '404', '419', '429', '500', '503'] as $code)
<x-button :link="route('livewire-material.error', $code)" :label="$code" variant="tonal" no-wire-navigate />
<x-livewire-material::button :link="route('livewire-material.error', $code)" :label="$code" variant="tonal" no-wire-navigate />
@endforeach
<x-button :link="route('livewire-material.mail')" label="Sample mail" icon="mail" variant="outlined" no-wire-navigate />
<x-livewire-material::button :link="route('livewire-material.mail')" label="Sample mail" icon="mail" variant="outlined" no-wire-navigate />
</div>
</section>
@@ -1,9 +1,9 @@
@php
$examples = [
'docked' => <<<'BLADE'
'Docked' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ expires: '{{ now()->addWeek()->format('Y-m-d') }}', starts: null }">
<div class="grid content-start gap-4">
<x-datepicker label="Expires on" x-model="expires" hint="Type a date or pick one" />
<x-datepicker label="Expires on" clearable x-model="expires" hint="Type a date or pick one" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(expires)"></code></p>
</div>
@@ -13,7 +13,7 @@
</div>
</div>
BLADE,
'modal and modal input' => <<<'BLADE'
'Modal and modal input' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ birthday: '1990-05-17', delivery: null }">
<div class="grid content-start gap-4">
<x-datepicker label="Birthday" mode="modal" x-model="birthday" :max="now()" />
@@ -26,7 +26,7 @@
</div>
</div>
BLADE,
'range' => <<<'BLADE'
'Range' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ trip: { start: '{{ now()->addDays(3)->format('Y-m-d') }}', end: '{{ now()->addDays(9)->format('Y-m-d') }}' }, leave: { start: null, end: null } }">
<div class="grid content-start gap-4">
<x-datepicker label="Trip" range x-model="trip" />
@@ -39,7 +39,7 @@
</div>
</div>
BLADE,
'limits, errors and states' => <<<'BLADE'
'Limits, errors and states' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2">
<div class="grid content-start gap-4">
<x-datepicker label="Within the next 30 days" :min="now()" :max="now()->addDays(30)" hint="Days outside are disabled" />
@@ -17,7 +17,7 @@
<div class="grid grid-cols-3 gap-4 sm:grid-cols-5 lg:grid-cols-7">
@foreach (\NoNameWeb\LivewireMaterial\Support\SvgFile::shapeNames() as $shape)
<div class="space-y-2 text-center">
<x-shape :name="$shape" class="mx-auto size-20 text-secondary-container" />
<x-livewire-material::shape :name="$shape" class="mx-auto size-20 text-secondary-container" />
<code class="type-label-md text-on-surface-variant">{{ $shape }}</code>
</div>
@endforeach
@@ -1,6 +1,6 @@
@php
$examples = [
'Time pickers: 12 and 24 hours, outlined and filled' => <<<'BLADE'
'12 and 24 hours, outlined and filled' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2">
<div class="grid content-start gap-4">
<x-timepicker label="Meeting starts" value="09:30" hint="The locale's clock" />
@@ -15,7 +15,7 @@
</div>
</div>
BLADE,
'Time pickers: steps and limits' => <<<'BLADE'
'Steps and limits' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2">
<div class="grid content-start gap-4">
<x-timepicker label="Appointment" value="10:00" format="24" step="15" min="08:00" max="17:30" hint="Quarter hours from 08:00 to 17:30" />
+15 -15
View File
@@ -33,54 +33,54 @@
<title>{{ $current['title'] }} · App shell · Livewire Material</title>
<x-theme-script />
<x-livewire-material::theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface font-sans text-on-surface antialiased">
<x-app-shell :destinations="$destinations">
<x-livewire-material::app-shell :destinations="$destinations">
<x-slot:brand>
<a href="{{ route('livewire-material.showcase') }}" class="block truncate rounded-corner-xs type-title-lg focus-ring">Livewire Material</a>
</x-slot:brand>
<x-slot:rail-header>
<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" x-on:click="materialToast('Compose opens here')" /></span>
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" x-on:click="materialToast('Compose opens here')" /></span>
<span class="rail-collapsed:hidden"><x-livewire-material::fab label="Compose" icon="edit" x-on:click="materialToast('Compose opens here')" /></span>
<span class="hidden rail-collapsed:inline-flex"><x-livewire-material::fab icon="edit" tooltip-right="Compose" x-on:click="materialToast('Compose opens here')" /></span>
</x-slot:rail-header>
<x-slot:rail-footer>
<x-navigation-rail-item label="Settings" icon="settings" link="#settings" no-wire-navigate />
<x-livewire-material::navigation-rail-item label="Settings" icon="settings" link="#settings" no-wire-navigate />
</x-slot:rail-footer>
<x-slot:actions>
<x-button icon="dark_mode" tooltip-right="Switch theme" x-data x-on:click="$store.theme.toggle()" />
<x-button icon="help" tooltip-right="Help" x-on:click="materialToast('Help opens here')" />
<x-livewire-material::button icon="dark_mode" tooltip-right="Switch theme" x-data x-on:click="$store.theme.toggle()" />
<x-livewire-material::button icon="help" tooltip-right="Help" x-on:click="materialToast('Help opens here')" />
</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">
<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="shell-menu" /></span>
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="shell-menu" /></span>
<h1 class="min-w-0 flex-1 truncate px-3 type-title-lg sm:px-0">{{ $current['title'] }}</h1>
<x-button icon="search" tooltip="Search" />
<x-livewire-material::button icon="search" tooltip="Search" />
</header>
</x-slot:top>
<div class="mx-auto w-full max-w-5xl space-y-4 px-4 pb-6 sm:px-6">
<p class="type-body-md text-on-surface-variant" data-test="shell-page">This is the {{ strtolower($current['title']) }} page.</p>
<x-list segmented>
<x-livewire-material::list segmented>
@foreach (range(1, 14) as $index)
<x-list-item :title="$current['title'].' item '.$index" description="A line of supporting text for this item" :icon="$current['icon']" trailing="{{ $index }}h" wire:key="item-{{ $index }}" />
<x-livewire-material::list-item :title="$current['title'].' item '.$index" description="A line of supporting text for this item" :icon="$current['icon']" trailing="{{ $index }}h" wire:key="item-{{ $index }}" />
@endforeach
</x-list>
</x-livewire-material::list>
<div class="flex flex-wrap gap-2">
<x-button label="Show a toast" variant="tonal" x-data x-on:click="materialToast('Moved to archive', { action: { label: 'Undo', handler: () => {} } })" />
<x-button label="Back to the showcase" link="{{ route('livewire-material.showcase') }}#navigation" no-wire-navigate />
<x-livewire-material::button label="Show a toast" variant="tonal" x-data x-on:click="materialToast('Moved to archive', { action: { label: 'Undo', handler: () => {} } })" />
<x-livewire-material::button label="Back to the showcase" link="{{ route('livewire-material.section', 'navigation') }}" no-wire-navigate />
</div>
</div>
</x-app-shell>
</x-livewire-material::app-shell>
@livewireScripts
</body>
+9 -1
View File
@@ -1,10 +1,12 @@
<?php
use Illuminate\Support\Facades\Route;
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseController;
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcasePageController;
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseSymbolController;
use NoNameWeb\LivewireMaterial\Showcase\Sections;
Route::view('/', 'livewire-material::showcase.index')->name('showcase');
Route::get('/', [ShowcaseController::class, 'index'])->name('showcase');
Route::get('symbols.json', [ShowcaseSymbolController::class, 'index'])->name('symbols');
Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol');
@@ -17,3 +19,9 @@ Route::get('errors/{code}', [ShowcasePageController::class, 'error'])
->whereIn('code', ['401', '402', '403', '404', '419', '429', '500', '503'])
->name('error');
Route::get('mail', [ShowcasePageController::class, 'mail'])->name('mail');
Route::get('search.json', [ShowcaseController::class, 'search'])->name('search');
Route::get('{section}', [ShowcaseController::class, 'section'])
->whereIn('section', array_keys(Sections::all()))
->name('section');
+142 -28
View File
@@ -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,7 +16,7 @@ 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}
{--contrast=0 : The contrast level, from -1 to 1}
{--success=#22a06b : The source of the success colour}
@@ -26,20 +27,73 @@ 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';
public function handle(Filesystem $files): int
{
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
$data = preg_replace('/\.css$/', '', $stylesheet).'.json';
if (filled($this->argument('seed'))) {
$scheme = $this->generate((string) $this->argument('seed'), (string) $this->option('variant'), (float) $this->option('contrast'));
if ($scheme === null) {
return self::FAILURE;
}
return $this->write($files, $stylesheet, $data, $this->stylesheet($scheme), $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;
}
$scheme = $this->generate((string) ($profile['seed'] ?? ''), (string) ($profile['variant'] ?? 'tonal-spot'), (float) ($profile['contrast'] ?? 0), "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.
*
* @return array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}|null
*/
protected function generate(string $seed, string $variant, float $contrast, string $context = ''): ?array
{
$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'),
'seed' => $seed,
'variant' => $variant,
'contrast' => $contrast,
'success' => $this->option('success'),
'warning' => $this->option('warning'),
'info' => $this->option('info'),
@@ -47,22 +101,27 @@ class SchemeCommand extends Command
]);
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}.");
@@ -82,9 +141,7 @@ class SchemeCommand extends Command
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
);
$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 +154,76 @@ 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',
$name,
$profile['seed'],
$profile['variant'],
$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";
}
}
@@ -0,0 +1,34 @@
<?php
namespace NoNameWeb\LivewireMaterial\Http\Controllers;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use NoNameWeb\LivewireMaterial\Showcase\Sections;
/**
* The showcase: an overview of every section, and a page for each.
*/
class ShowcaseController
{
public function index(): View
{
return view('livewire-material::showcase.index', ['sections' => Sections::all()]);
}
public function section(string $section): View
{
return view('livewire-material::showcase.section', [
'sections' => Sections::all(),
'section' => $section,
]);
}
/**
* The search's index, fetched when the search is first focused rather than written into every page.
*/
public function search(): JsonResponse
{
return response()->json(Sections::index());
}
}
+31 -5
View File
@@ -5,6 +5,7 @@ namespace NoNameWeb\LivewireMaterial;
use Illuminate\Contracts\View\Factory;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class LivewireMaterialServiceProvider extends ServiceProvider
@@ -47,6 +48,15 @@ class LivewireMaterialServiceProvider extends ServiceProvider
}
}
/**
* The anonymous components. Blade names their view namespace after a hash of this string, and
* compiled views keep that name, so it stays written the way it always was.
*/
public static function componentPath(): string
{
return __DIR__.'/../resources/views/components';
}
/**
* The error-view root: a folder holding only `errors/`.
*/
@@ -104,14 +114,28 @@ class LivewireMaterialServiceProvider extends ServiceProvider
}
/**
* Register the components as anonymous Blade components under the configured prefix.
* Register the components as anonymous Blade components under the configured prefix, or
* under `livewire-material` without one.
*
* A compiled `<x-button>` names the view namespace Blade derives from the prefix, or from the
* component folder's absolute path when there is none. That path differs between a laptop and
* a container sharing `storage/framework/views`, or between release directories, while the
* compiled file's name does not so a view compiled on one showed `a1b2…::button` as text on
* the other. A prefix does not stop an unprefixed tag resolving. The path's namespace stays
* registered, so views compiled before this release still render until they are recompiled.
*
* The package's own views never go through this registration: they write
* `<x-livewire-material::button>`, which resolves through the view namespace whatever the
* prefix, and which an application's own `<x-button>` cannot shadow.
*/
protected function registerComponents(): void
{
Blade::anonymousComponentPath(
__DIR__.'/../resources/views/components',
filled(config('livewire-material.prefix')) ? config('livewire-material.prefix') : null,
static::componentPath(),
filled(config('livewire-material.prefix')) ? config('livewire-material.prefix') : 'livewire-material',
);
View::addNamespace(hash('xxh128', static::componentPath()), static::componentPath());
}
/**
@@ -151,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;
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace NoNameWeb\LivewireMaterial\Showcase;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
/**
* The showcase's pages: one per section, grouped as the rail groups them.
*/
class Sections
{
/**
* What the showcase's search looks through: every section, every example in it (linked to the
* example's anchor), and every component, linked to the first section that introduces it.
*
* @return list<array{title: string, kind: string, context: string, url: string, keywords: string}>
*/
public static function index(): array
{
$sections = static::all();
$components = collect(glob(LivewireMaterialServiceProvider::componentPath().'/*.blade.php'))
->map(fn (string $path): string => basename($path, '.blade.php'))
->all();
$sources = collect($sections)->map(fn (array $section, string $key): string => (string) file_get_contents(static::path($key)));
$entries = [];
$homes = [];
foreach ($sections as $key => $section) {
$url = route('livewire-material.section', $key, false);
$entries[] = ['title' => $section['title'], 'kind' => 'Section', 'context' => $section['group'], 'url' => $url, 'keywords' => $section['description']];
preg_match_all("/^\\s*'((?:[^'\\\\]|\\\\.)*)'\\s*=>\\s*<<<'BLADE'/m", $sources[$key], $examples);
foreach ($examples[1] as $title) {
$title = stripslashes($title);
$entries[] = ['title' => $title, 'kind' => 'Example', 'context' => $section['title'], 'url' => $url.'#'.static::anchor($title), 'keywords' => ''];
}
}
// A section that names a component in its introduction is its home; otherwise the first that uses it.
foreach (['/&lt;x-([a-z0-9-]+)&gt;/', '/<x-(?:livewire-material::)?([a-z0-9-]+)(?=[\\s\\/>])/'] as $pattern) {
foreach ($sources as $key => $source) {
preg_match_all($pattern, $source, $tags);
foreach ($tags[1] as $tag) {
if (in_array($tag, $components, true)) {
$homes[$tag] ??= $key;
}
}
}
}
foreach ($homes as $tag => $key) {
$entries[] = ['title' => "<x-{$tag}>", 'kind' => 'Component', 'context' => $sections[$key]['title'], 'url' => route('livewire-material.section', $key, false), 'keywords' => str_replace('-', ' ', $tag)];
}
return $entries;
}
/**
* The anchor of an example on its section's page.
*/
public static function anchor(string $title): string
{
return 'example-'.Str::slug($title);
}
/**
* A section's view file.
*/
public static function path(string $key): string
{
return dirname(__DIR__, 2).'/resources/views/showcase/sections/'.$key.'.blade.php';
}
/**
* @return array<string, array{title: string, icon: string, group: string, description: string}>
*/
public static function all(): array
{
return [
'colour' => ['title' => 'Colour', 'icon' => 'palette', 'group' => 'Foundations', 'description' => 'Every colour role of the generated scheme, light and dark.'],
'type' => ['title' => 'Type', 'icon' => 'text_fields', 'group' => 'Foundations', 'description' => 'The typescale, emphasized styles included, in Google Sans Flex.'],
'shape' => ['title' => 'Shape', 'icon' => 'interests', 'group' => 'Foundations', 'description' => 'Corner radii and the 35 M3 Expressive shapes.'],
'elevation' => ['title' => 'Elevation', 'icon' => 'layers', 'group' => 'Foundations', 'description' => 'Shadows for what floats over content.'],
'motion' => ['title' => 'Motion', 'icon' => 'animation', 'group' => 'Foundations', 'description' => 'Spatial springs and effects easings.'],
'icons' => ['title' => 'Icons', 'icon' => 'emoji_symbols', 'group' => 'Foundations', 'description' => 'Every Material Symbol, searchable, outlined and filled.'],
'buttons' => ['title' => 'Buttons', 'icon' => 'smart_button', 'group' => 'Actions', 'description' => 'Buttons, icon buttons, groups, split buttons and FABs.'],
'menus' => ['title' => 'Menus', 'icon' => 'menu_open', 'group' => 'Actions', 'description' => 'Menus with items, groups, choices and shortcuts.'],
'chips' => ['title' => 'Chips', 'icon' => 'sell', 'group' => 'Actions', 'description' => 'Assist, filter, input and suggestion chips.'],
'communication' => ['title' => 'Communication', 'icon' => 'notifications', 'group' => 'Communication', 'description' => 'Badges, snackbars, tooltips, alerts, stats and empty states.'],
'progress' => ['title' => 'Progress', 'icon' => 'progress_activity', 'group' => 'Communication', 'description' => 'Linear, circular and wavy progress, and the loading indicator.'],
'containment' => ['title' => 'Containment', 'icon' => 'web_asset', 'group' => 'Containment', 'description' => 'Cards, lists, dividers, dialogs and sheets.'],
'carousel' => ['title' => 'Carousel', 'icon' => 'view_carousel', 'group' => 'Containment', 'description' => 'Multi-browse, hero, uncontained and full-screen carousels.'],
'fields' => ['title' => 'Text fields', 'icon' => 'edit_note', 'group' => 'Inputs', 'description' => 'Text fields, selects, checkboxes, radios, switches, choices and search.'],
'sliders' => ['title' => 'Sliders', 'icon' => 'tune', 'group' => 'Inputs', 'description' => 'Standard, centered and range sliders in five sizes.'],
'pickers' => ['title' => 'Date pickers', 'icon' => 'calendar_month', 'group' => 'Inputs', 'description' => 'Docked, modal and input date pickers, single and range.'],
'timepickers' => ['title' => 'Time pickers', 'icon' => 'schedule', 'group' => 'Inputs', 'description' => 'The dial and input time picker.'],
'bars' => ['title' => 'App bars and tabs', 'icon' => 'toolbar', 'group' => 'Navigation', 'description' => 'Top app bars, toolbars, tabs, section navigation and the account menu.'],
'navigation' => ['title' => 'Navigation', 'icon' => 'explore', 'group' => 'Navigation', 'description' => 'The navigation bar, the navigation rail and the app shell.'],
'data' => ['title' => 'Data', 'icon' => 'table_chart', 'group' => 'Data and pages', 'description' => 'Data tables, sort headers and pagination.'],
'pages' => ['title' => 'Error pages and mail', 'icon' => 'page_info', 'group' => 'Data and pages', 'description' => 'The HTTP error pages and the Markdown mail theme.'],
];
}
}
+132 -14
View File
@@ -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)];
}
/**
+7 -7
View File
@@ -2,9 +2,9 @@
const MORE = '#menus [aria-label="More"]';
function showcase()
function showcase(string $section = 'buttons')
{
return visit('/material')->waitForEvent('networkidle')
return visit("/material/{$section}")->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
@@ -14,7 +14,7 @@ function focused(string $expression): string
}
it('opens a menu on the first item and walks it with the keyboard', function () {
$page = showcase()
$page = showcase('menus')
->assertNoJavaScriptErrors()
->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true')
@@ -38,7 +38,7 @@ it('opens a menu on the first item and walks it with the keyboard', function ()
});
it('opens a menu from the keyboard, on the last item with ArrowUp', function () {
$page = showcase();
$page = showcase('menus');
$page->script("document.querySelector('".MORE."').focus()");
@@ -50,13 +50,13 @@ it('opens a menu from the keyboard, on the last item with ArrowUp', function ()
it('opens a menu the moment the page can be used', function () {
// The guard against a light-dismiss press reopening the menu once measured from the page's
// time origin, and swallowed every click in the first quarter second.
visit('/material')
visit('/material/menus')
->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true');
});
it('closes a menu when an item is chosen, but not one that keeps it open', function () {
$page = showcase();
$page = showcase('menus');
$page->click(MORE)
->click('#menus [role="menuitem"]:has-text("Download")')
@@ -76,7 +76,7 @@ it('shows a tooltip on keyboard focus and hides it on Escape', function () {
// Firefox only counts a scripted focus as :focus-visible after one. Then focused directly
// rather than tabbed to: WebKit, like Safari on macOS, leaves buttons out of the Tab order
// unless full keyboard access is on.
$page->keys('body', 'Tab');
$page->keys('#content', 'Tab');
$page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()");
$page->assertScript(focused("getAttribute('aria-label') === 'Tonal'"))
->assertScript("{$tooltip}.matches(':popover-open')");
+1 -1
View File
@@ -64,7 +64,7 @@ const FIRST_SCROLLER = '#carousel [role="region"] >> nth=0';
function carouselShowcase(array $options = [])
{
return visit('/material', $options)
return visit('/material/carousel', $options)
->waitForEvent('networkidle')
->assertScript("typeof window.Alpine !== 'undefined'");
}
+2 -2
View File
@@ -74,7 +74,7 @@ function chipProbe()
function chipShowcase()
{
return visit('/material')->waitForEvent('networkidle')
return visit('/material/chips')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
@@ -99,7 +99,7 @@ it('toggles a filter chip with a click and with Space, and grows its check in',
// A key press first, so the browser is in keyboard modality; focused directly, not tabbed to,
// because WebKit leaves form controls out of the Tab order unless full keyboard access is on.
$page->keys('body', 'Tab');
$page->keys('#content', 'Tab');
$page->script(filterInput('archives').'.focus()');
$page->keys(':focus', 'Space')
->assertScript(filterInput('archives').'.checked')
+88
View File
@@ -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']}'");
});
+4 -4
View File
@@ -3,7 +3,7 @@
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
it('shows a toast as a snackbar, then the next in turn', function () {
$page = visit('/material')->waitForEvent('networkidle')
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("materialToast('First', { type: 'success', timeout: 600 }); materialToast('Second', { type: 'error' })");
@@ -18,7 +18,7 @@ it('shows a toast as a snackbar, then the next in turn', function () {
});
it('shows a toast dispatched as a browser event, as the Toasts concern does', function () {
$page = visit('/material')->waitForEvent('networkidle')
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
// Through Livewire, in the page's own realm: an event built inside Playwright's evaluate
@@ -29,7 +29,7 @@ it('shows a toast dispatched as a browser event, as the Toasts concern does', fu
});
it('runs a toast\'s action and dismisses it', function () {
$page = visit('/material')->waitForEvent('networkidle')
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })");
@@ -42,7 +42,7 @@ it('runs a toast\'s action and dismisses it', function () {
it('opens a persistent rich tooltip on press', function () {
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
visit('/material')->waitForEvent('networkidle')
visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
->click('#communication button:has-text("Press for details")')
->assertScript("{$bubble}.matches(':popover-open')");
+1 -1
View File
@@ -66,7 +66,7 @@ function overlayProbe()
function containment()
{
return visit('/material')->waitForEvent('networkidle')
return visit('/material/containment')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
+17 -2
View File
@@ -33,10 +33,10 @@ class DateProbe extends Component
<p>trip: <span id="trip">{{ json_encode($trip) }}</span></p>
<p>renders: <span id="renders">{{ $renders }}</span></p>
<x-datepicker id="expires-field" label="Expires" wire:model.live="expires" min="2026-09-10" max="2026-10-20" />
<x-datepicker id="expires-field" label="Expires" wire:model.live="expires" min="2026-09-10" max="2026-10-20" clearable />
<x-datepicker id="birthday-field" label="Birthday" mode="modal" wire:model.live="birthday" />
<x-datepicker id="delivery-field" label="Delivery" mode="input" wire:model.live="delivery" min="2026-01-01" />
<x-datepicker id="trip-field" label="Trip" range wire:model.live="trip" />
<x-datepicker id="trip-field" label="Trip" range wire:model.live="trip" clearable />
</div>
BLADE;
}
@@ -296,3 +296,18 @@ it('opens a docked picker as a modal one on a compact window', function () {
->assertScript("document.querySelector('#expires-field-picker').matches(':modal')")
->assertScript("getComputedStyle(document.querySelector('#expires-field-picker [data-datepicker-header]')).display !== 'none'");
});
it('empties a date, or both ends of a range, with its clear button', function () {
$page = dateProbe()
->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('.field').querySelector('[data-field-clear]')).display !== 'none'");
$page->click('[data-datepicker]:has(#expires-field) [data-field-clear]')
->assertScript("document.querySelector('#expires').textContent === ''")
->assertValue('#expires-field', '')
->assertScript("document.activeElement.id === 'expires-field'")
->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-field-clear]')).display === 'none'");
$page->click('[data-datepicker]:has(#trip-field) [data-field-clear]')
->assertSeeIn('#trip', '{"start":null,"end":null}')
->assertValue('#trip-field', '');
});
+4 -1
View File
@@ -15,7 +15,10 @@ function navigationReady(mixed $page): mixed
function shellPage(int $width = 1280, int $height = 900, string $path = '/material/shell'): mixed
{
return navigationReady(visit($path)->resize($width, $height));
// The window is resized after the page starts loading, and an adaptive rail hears of the new
// width from a media query's change event, which can arrive after the first key press.
return navigationReady(visit($path)->resize($width, $height))
->assertScript("window.eval(\"(() => { const rail = document.querySelector('[data-navigation-rail]'); return ! rail || rail.dataset.navigationRail !== 'adaptive' || Alpine.\$data(rail).wide === window.matchMedia('(min-width: 64rem)').matches; })()\")");
}
/**
+1 -1
View File
@@ -55,7 +55,7 @@ function progressShowcase(array $options = [])
{
// networkidle alone can return before a repeated visit has even loaded in Firefox; the
// assertion retries until the page is complete and Alpine has started.
return visit('/material', $options)->waitForEvent('networkidle')
return visit('/material/progress', $options)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
+48
View File
@@ -5,3 +5,51 @@ it('opens the showcase in a real browser', function () {
->assertSee('Livewire Material')
->assertNoJavaScriptErrors();
});
it('moves between sections through the rail and the next-section link, keeping the rail', function () {
$page = visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->click('[data-navigation-rail-panel] a[href$="/material/buttons"]')
->assertSee('Variants')
->assertScript("document.querySelector('[data-navigation-rail-panel] [aria-current=\"page\"]').getAttribute('href').endsWith('/material/buttons')")
->assertScript("document.title === 'Buttons · Livewire Material'");
$page->click('[data-test="next-section"]')
->assertScript("location.pathname.endsWith('/material/menus')")
->assertNoJavaScriptErrors();
});
it('finds a component from anywhere and opens its section', function () {
$page = visit('/material/buttons')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->keys('#content', '/')
->assertScript("document.activeElement.id === 'showcase-search'");
$page->type('#showcase-search', 'datepicker')
->assertSeeIn('[data-search-view]', '<x-datepicker>');
$page->keys('#showcase-search', 'Enter')
->assertScript("location.pathname.endsWith('/material/pickers')")
->assertScript("document.title === 'Date pickers · Livewire Material'");
});
it('opens an example on another page at the example', function () {
$page = visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->click('#showcase-search')
->type('#showcase-search', 'split button')
->click('[data-showcase-result]:has-text("Example")')
->assertScript("location.pathname.endsWith('/material/buttons') && location.hash.startsWith('#example-')")
->assertScript('(() => { const box = document.getElementById(decodeURIComponent(location.hash.slice(1))).getBoundingClientRect(); return box.top >= 0 && box.top < innerHeight; })()');
});
it('says so when nothing matches', function () {
visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
->click('#showcase-search')
->type('#showcase-search', 'zzzz')
->assertSeeIn('[data-search-view]', 'Nothing matches');
});
+1 -1
View File
@@ -59,7 +59,7 @@ function sliderProbe()
function sliderShowcase()
{
return visit('/material')->waitForEvent('networkidle')
return visit('/material/sliders')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
+5 -3
View File
@@ -19,13 +19,15 @@ it('follows the operating system while the visitor has not chosen', function ()
});
it('keeps the visitor\'s choice over the operating system', function () {
// The theme switch waits for Alpine (x-cloak); a press before then would find nothing to press.
$page = visit('/material')->inDarkMode()
->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
->assertScript(theme('data-theme', 'dark'));
$page->click('@theme-light')
$page->click('[data-theme-option="light"]')
->assertScript(theme('data-theme', 'light'))
->assertAttribute('@theme-light', 'aria-pressed', 'true')
->assertAttribute('[data-theme-option="light"]', 'aria-checked', 'true')
->assertScript("localStorage.getItem('material-theme') === 'light'");
$page->refresh()
@@ -50,7 +52,7 @@ it('adopts an earlier toggle\'s choice once', function () {
it('repaints a section that sets its own theme', function () {
$swatch = fn (int $index): string => "getComputedStyle(document.querySelectorAll('#colour [data-theme] .bg-primary')[{$index}]).backgroundColor";
visit('/material')->inLightMode()
visit('/material/colour')->inLightMode()
->assertScript(theme('data-theme', 'light'))
->assertScript("{$swatch(0)} !== {$swatch(1)}");
});
@@ -163,3 +163,12 @@ it('replaces the hint with the errors for the property and its start and end', f
->toContain('data-datepicker-error')
->and(substr_count($html, 'data-invalid=""'))->toBe(2);
});
it('offers a clear button on request', function () {
expect((string) $this->blade('<x-datepicker label="Expires" clearable />'))
->toContain('data-field-clear')
->toContain('x-on:click="clear()"')
->toContain('aria-label="Clear"')
->and((string) $this->blade('<x-datepicker label="Expires" />'))
->not->toContain('data-field-clear');
});
+11
View File
@@ -44,6 +44,17 @@ it('uses the surrounding Alpine scope without wire:model, and stays open when pe
->toContain('aria-label="Close"');
});
it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the title', function () {
$html = (string) $this->blade('<x-modal title="Enable 2FA" subtitle="Scan the code" fullscreen>Text</x-modal>');
expect($html)
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-sm:hidden">/')
->toContain('<p class="type-body-md text-on-surface-variant mt-4 max-sm:mt-0">Scan the code</p>')
->not->toContain('<div class="mb-4 max-sm:hidden">')
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="mb-4 max-sm:hidden">')
->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('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,24 @@
<?php
it('shows the errors of a plain form field under its name', function (string $blade, string $key) {
$html = (string) $this->withViewErrors([$key => ['Something is wrong here.']])->blade($blade);
expect($html)
->toContain('Something is wrong here.')
->toContain('aria-invalid="true"');
})->with([
'input' => ['<x-input name="email" label="Email" />', 'email'],
'password' => ['<x-password name="password" label="Password" />', 'password'],
'textarea' => ['<x-textarea name="message" label="Message" />', 'message'],
'select' => ['<x-select name="hours" label="Expires" :options="[[\'id\' => 1, \'name\' => \'1 hour\']]" />', 'hours'],
'checkbox' => ['<x-checkbox name="terms" label="Terms" />', 'terms'],
'radio' => ['<x-radio name="audience" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />', 'audience'],
'file with brackets' => ['<x-file name="photos[]" label="Photos" multiple />', 'photos'],
'nested name' => ['<x-input name="address[city]" label="City" />', 'address.city'],
'timepicker' => ['<x-timepicker name="starts_at" label="Starts at" />', 'starts_at'],
]);
it('keeps reading the wire:model name when both are there', function () {
expect((string) $this->withViewErrors(['form.email' => ['Taken.']])->blade('<x-input name="email" wire:model="form.email" label="Email" />'))
->toContain('Taken.');
});
@@ -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('');
});
+27 -2
View File
@@ -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,28 @@ 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);
}
});
+28
View File
@@ -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();
+74
View File
@@ -0,0 +1,74 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
/**
* The package's own component names, longest first.
*
* @return list<string>
*/
function packageComponentNames(): array
{
return collect(File::files(LivewireMaterialServiceProvider::componentPath()))
->map(fn (SplFileInfo $file): string => $file->getBasename('.blade.php'))
->sortByDesc(fn (string $name): int => strlen($name))
->values()
->all();
}
it('refers to its own components through its namespace, which no prefix or app component changes', function () {
$names = implode('|', array_map(fn (string $name): string => preg_quote($name, '/'), packageComponentNames()));
$unqualified = collect(File::allFiles(__DIR__.'/../../resources/views'))
->filter(fn (SplFileInfo $file): bool => str_ends_with($file->getFilename(), '.blade.php'))
->flatMap(function (SplFileInfo $file) use ($names): array {
// Comments document usage as an application writes it, and the showcase's examples
// are that usage; neither is compiled as the package's own markup.
$source = preg_replace(['/\{\{--.*?--\}\}/s', "/<<<'BLADE'.*?^\\s*BLADE,/sm"], '', $file->getContents());
preg_match_all('/<\/?x-('.$names.')(?=[\s\/>])/', $source, $matches);
return array_map(fn (string $tag): string => $file->getRelativePathname().': '.$tag, $matches[0]);
})
->values();
expect($unqualified->all())->toBe([]);
});
it('renders components that use others under a prefix', function () {
config(['livewire-material.prefix' => 'm']);
Blade::anonymousComponentPath(LivewireMaterialServiceProvider::componentPath(), 'm');
expect(Blade::render('<x-m::drawer title="Share" with-close-button separator><x-m::input label="Name" clearable /></x-m::drawer>'))
->toContain('aria-label="Close"')
->toContain('role="separator"')
->toContain('data-field-clear');
});
it('is not shadowed by an application component of the same name', function () {
$views = sys_get_temp_dir().'/livewire-material-shadow-'.uniqid();
File::ensureDirectoryExists($views.'/components');
File::put($views.'/components/button.blade.php', '<span>the application\'s button</span>');
View::getFinder()->prependLocation($views);
try {
expect(Blade::render('<x-button />'))->toContain('the application\'s button')
->and(Blade::render('<x-drawer title="Share" with-close-button />'))->not->toContain('the application\'s button')
->toContain('aria-label="Close"');
} finally {
File::deleteDirectory($views);
}
});
it('shows the showcase\'s examples as an application with a prefix writes them', function () {
config(['livewire-material.prefix' => 'm']);
Blade::anonymousComponentPath(LivewireMaterialServiceProvider::componentPath(), 'm');
expect(Blade::render('<x-showcase::example title="Buttons" :code="$code" />', ['code' => '<x-button label="Save" />']))
->toContain('&lt;x-m::button label=&quot;Save&quot; /&gt;')
->toContain('Save</span>');
});

Some files were not shown because too many files have changed in this diff Show More