18 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
Andreas Reinhold / reiniandClaude Opus 5 8f174520eb Add M3 error pages and the Markdown mail theme
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (safari, webkit) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m11s
tests / browser (chrome, chromium) (push) Failing after 6m9s
Error pages for 401 to 503 in an error-view root appended to view.paths,
with an inline fallback when the Vite build is missing, and a mail theme
rendered from the application's scheme JSON, with opt-in header and
message components. Completes Phase 9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 08:55:58 +02:00
Andreas Reinhold / reiniandClaude Opus 5 ab692b66bb Add the navigation bar, navigation rail and app shell
M3 Expressive's flexible navigation bar, the collapsed, expanded and
modal navigation rail with its state applied before the first paint,
and an adaptive app shell composing them. The head script now restores
the theme and rail attributes that wire:navigate strips from <html>.
Completes Phase 8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 08:54:51 +02:00
Andreas Reinhold / reiniandClaude Opus 5 f4be9848e2 Add the M3 date pickers
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m10s
tests / feature (8.5) (push) Successful in 1m7s
tests / browser (firefox, firefox) (push) Failing after 8m28s
tests / browser (chrome, chromium) (push) Failing after 5m58s
tests / browser (safari, webkit) (push) Failing after 7m24s
Docked, modal and modal input date pickers for single dates and ranges,
with locale month names, week start and typed format from Intl, min and
max, and the APG grid keyboard. Completes Phase 7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 08:48:56 +02:00
Andreas Reinhold / reiniandClaude Opus 5 b8239dd215 Add the M3 time picker
The dial and input time picker in a modal dialog, with the hour cycle
from the locale, steps, a min and max that may span midnight, and a
landscape layout, ported from Compose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 08:47:42 +02:00
138 changed files with 11120 additions and 838 deletions
+2
View File
@@ -115,3 +115,5 @@ jobs:
- name: Run browser tests - name: Run browser tests
run: vendor/bin/pest --testsuite=Browser --browser ${{ matrix.browser }} run: vendor/bin/pest --testsuite=Browser --browser ${{ matrix.browser }}
env:
BROWSER_TIMEOUT: 15000
+3
View File
@@ -7,3 +7,6 @@
/workbench/public/hot /workbench/public/hot
/tests/Browser/Screenshots /tests/Browser/Screenshots
.DS_Store .DS_Store
# Planning notes stay local
/docs/plans
+14
View File
@@ -21,6 +21,20 @@ M3 Expressive carousel keylines (resources/js/carousel.js)
Ported from androidx Compose Material 3 (carousel/*.kt), as recorded at the top of the file. Ported from androidx Compose Material 3 (carousel/*.kt), as recorded at the top of the file.
Copyright The Android Open Source Project. Apache License 2.0. Copyright The Android Open Source Project. Apache License 2.0.
M3 date pickers (resources/views/components/datepicker.blade.php, resources/js/datepicker.js,
resources/css/components/datepicker.css)
Token values and layout from androidx Compose Material 3 (DatePickerModalTokens,
DateInputModalTokens, DatePicker.kt, DateRangePicker.kt, DateInput.kt; the input-format
derivation ported from datePatternAsInputFormat), commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326,
and the docked picker's values from @material/web's md-comp-date-picker-docked tokens (v0_192).
Copyright The Android Open Source Project; Copyright Google LLC. Apache License 2.0.
M3 time picker (resources/js/timepicker.js, resources/css/components/timepicker.css)
Behaviour and geometry ported from androidx Compose Material 3 (TimePicker.kt,
TimePickerDialog.kt, tokens/TimePickerTokens.kt, tokens/TimeInputTokens.kt), as recorded at
the top of resources/js/timepicker.js.
Copyright The Android Open Source Project. Apache License 2.0.
Google Sans Flex (resources/fonts/google-sans-flex) Google Sans Flex (resources/fonts/google-sans-flex)
Copyright Google LLC. SIL Open Font License 1.1 (resources/fonts/google-sans-flex/OFL.txt). Copyright Google LLC. SIL Open Font License 1.1 (resources/fonts/google-sans-flex/OFL.txt).
Subset: Latin and Latin Extended, weight 400700, roundness 0100. Subset: Latin and Latin Extended, weight 400700, roundness 0100.
+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`.
+77 -3
View File
@@ -8,8 +8,10 @@ return [
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Every component is an anonymous Blade component. Without a prefix they are | 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 | <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>). | 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.
| |
*/ */
@@ -34,6 +36,22 @@ return [
'legacy_keys' => [], 'legacy_keys' => [],
], ],
/*
|--------------------------------------------------------------------------
| Navigation rail
|--------------------------------------------------------------------------
|
| Whether a collapsible navigation rail starts 'expanded' or 'collapsed'
| until the visitor toggles it. The head script applies the choice before
| the first paint, from localStorage under 'storage_key'.
|
*/
'rail' => [
'default' => 'expanded',
'storage_key' => 'material-rail',
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Fields | Fields
@@ -75,6 +93,61 @@ return [
'node' => env('MATERIAL_NODE', 'node'), 'node' => env('MATERIAL_NODE', 'node'),
/*
|--------------------------------------------------------------------------
| Scheme data
|--------------------------------------------------------------------------
|
| The light and dark hexes `php artisan material:scheme` writes beside the
| stylesheet. The mail theme reads its colours here, and so does an error
| page when the build is missing; without the file both use the package's
| default scheme.
|
*/
'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
|--------------------------------------------------------------------------
|
| Markdown mail takes the theme when `mail.markdown.theme` (MAIL_MARKDOWN_THEME)
| is 'livewire-material::mail.theme'. 'components' puts this package's mail
| header and message after the application's own mail components — or
| publish them with `vendor:publish --tag=livewire-material-mail` instead.
| 'logo' replaces the app name in that header with an image: an absolute
| 'src', with 'width' and 'height' in pixels, which Outlook sizes it by.
|
*/
'mail' => [
'components' => (bool) env('MATERIAL_MAIL_COMPONENTS', false),
'logo' => [
'src' => null,
'width' => null,
'height' => null,
],
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Showcase | Showcase
@@ -82,7 +155,8 @@ return [
| |
| Every component in every variant, rendered in the application's own | Every component in every variant, rendered in the application's own
| scheme. Off unless the application runs locally. 'vite' names the entry | scheme. Off unless the application runs locally. 'vite' names the entry
| points that import this package's CSS and JavaScript. | points that import this package's CSS and JavaScript; the error pages
| load them too, showcase or not.
| |
*/ */
-576
View File
@@ -1,576 +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 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 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.
### 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.
+2 -1
View File
@@ -5,6 +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. - 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. - 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). - 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 @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. 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 ## 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. 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 ## 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 ## Toasts
@@ -85,6 +113,59 @@ class Settings extends Component
The methods are protected. They dispatch a `toast` browser event (`assertDispatched('toast', type: 'success', title: 'Settings saved')` in tests). The methods are protected. They dispatch a `toast` browser event (`assertDispatched('toast', type: 'success', title: 'Settings saved')` in tests).
## Error pages
Laravel's HTTP error pages — 403, 404, 419, 429, 500, 503, and the framework's own 401 and 402 — render in M3 without setup. The provider appends the package's error views to `view.paths` after the application's, so a file in `resources/views/errors/` always wins.
- The pages load `config('livewire-material.showcase.vite')` and `<x-theme-script />`, so they use the app's scheme, font and theme. While the build is missing (a deploy in progress) they fall back to an inline stylesheet coloured from `resources/css/material-scheme.json`.
- `abort(403, 'Only the owner can open this share.')` and `abort(503, '…')` show the message as the sentence. Every other string goes through `__()`; translate them in `lang/{locale}.json`.
- To change wording or design, run `php artisan vendor:publish --tag=livewire-material-errors`, which copies the layout and pages to `resources/views/errors`. A page extends `errors::minimal` and sets `title`, `code`, `headline`, `message`, `shape` (an `<x-shape>` name) and optionally `actions`:
```blade
@extends('errors::minimal')
@section('title', __('Payment Required'))
@section('code', '402')
@section('shape', 'cookie-4')
@section('headline', __('Your plan has ended'))
@section('message', __('Choose a plan to keep using the app.'))
@section('actions')
<x-button :link="route('billing')" :label="__('Choose a plan')" variant="filled" size="md" no-wire-navigate />
@endsection
```
- Maintenance mode: `php artisan down --render="errors::503"`.
- The showcase previews each page at `/material/errors/{code}`.
## Mail
Markdown mail (notifications and `markdown:` mailables) wears M3 once the application selects the theme:
```dotenv
MAIL_MARKDOWN_THEME=livewire-material::mail.theme
```
or per mail: `(new MailMessage)->theme('livewire-material::mail.theme')`, or `public $theme = 'livewire-material::mail.theme';` on a mailable.
- Colours are the light scheme from `resources/css/material-scheme.json` (`livewire-material.scheme`), inlined as hexes; regenerate the scheme and mail follows. Without the file, the package's default scheme applies.
- Write the body as Markdown; the theme styles the bare tags (`#` headings, prose, lists, tables) with M3's typescale. `<x-mail::button :url="…">` is a filled pill in `primary`; `color` also takes `secondary`, `tertiary`, `error`, `success`, `warning` and `info`. `<x-mail::panel>` is a tinted container.
- There is no dark mail. Never put `@media` rules, CSS variables or `color-mix()` in mail CSS: the inliner strips media queries and mail clients resolve no variables.
- The package's mail header (the app name, or a logo) and message (with a replaceable footer) are opt-in: set `MATERIAL_MAIL_COMPONENTS=true`, or `php artisan vendor:publish --tag=livewire-material-mail` to copy them into `resources/views/vendor/mail`. For a logo set `livewire-material.mail.logo` to `['src' => 'https://example.com/logo.png', 'width' => 160, 'height' => 40]`: an absolute URL, with the image at twice those dimensions.
- With those components, a mail can replace the footer:
```blade
<x-mail::message>
Your export is ready.
<x-slot:footer>
© {{ date('Y') }} {{ config('app.name') }} · [Unsubscribe]({{ $unsubscribeUrl }})
</x-slot:footer>
</x-mail::message>
```
- The showcase renders a sample mail at `/material/mail`.
## Components ## Components
### `<x-icon>` ### `<x-icon>`
@@ -414,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>` ### `<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-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
- `<x-password>`: a reveal button; `icon`, `size`. - `<x-password>`: a reveal button; `icon`, `size`.
@@ -463,6 +544,50 @@ M3 Expressive's slider on native `<input type="range">`s (one per handle), so th
Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless a `w-*` class is passed. Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless a `w-*` class is passed.
### `<x-datepicker>`
M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model` without Livewire).
```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" clearable />
```
| Prop | Default | |
|---|---|---|
| `mode` | `docked` | `docked`: type a date (in the locale's numeric format) or pick one from a calendar under the field, which opens as a dialog below `sm`; `modal`: the field opens a calendar dialog; `input`: the dialog opens on a text field. Both dialogs switch between calendar and typing |
| `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field |
| `min`, `max` | `null` | `Y-m-d` or a date; days outside are disabled and the keyboard stays inside |
| `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.
### `<x-timepicker>`
M3's time picker in a modal dialog, opened from a read-only text field (a press, Enter, Space, ArrowDown or its clock icon). The dial picks the hour, then the minutes, by press or drag; a 24-hour clock puts 1223 on the inner ring. The keyboard icon switches to two text fields. The arrow keys change the focused dial's value, Home and End go to the ends, Enter confirms; Escape, Cancel or the scrim close it unchanged and give focus back to the field. In a landscape window the dial lies on its side.
```blade
<x-timepicker label="Starts at" wire:model="startsAt" />
<x-timepicker label="Appointment" wire:model.live="appointmentAt" format="24" step="15" min="08:00" max="17:30" clearable />
```
| Prop | Default | |
|---|---|---|
| `wire:model` / `x-model` | | the time as `H:i`, null until chosen; `H:i:s` (a `time` column) is read and written back as `H:i`; nothing is written until OK |
| `format` | the locale's | `12` or `24`; otherwise the hour cycle of `locale` as `Intl.DateTimeFormat` reports it |
| `locale` | app locale | the hour cycle, and how the field writes the time |
| `step` | `1` | minutes between choices (a tap picks fives, or steps when five is not a multiple of the step) |
| `min`, `max` | | `H:i`, inclusive; `min` later than `max` spans midnight. Outside values are greyed out and refused in the picker — validate on the server as well |
| `clearable` | `false` | a button that empties the field |
| `name` | | posts the value from a hidden input |
| `label`, `hint`, `icon`, `variant`, `size` | | the field's; `required`, `disabled` and `placeholder` reach its input |
Errors under the `wire:model` name replace the hint. The dialog is `wire:ignore`: a Livewire render leaves an open picker open with its draft. Never name a Livewire property `$slot`: it renders empty in the component's view.
### `<x-choices>` ### `<x-choices>`
Choosing from a list, with typed values (an array of integers stays integers). `options` (`id`, `name`, `disabled`; `option-value`, `option-label`), `label`, `hint`, `single`. Errors for the property and its items replace the hint. Choosing from a list, with typed values (an array of integers stays integers). `options` (`id`, `name`, `disabled`; `option-value`, `option-label`), `label`, `hint`, `single`. Errors for the property and its items replace the hint.
@@ -492,6 +617,84 @@ M3 search bar that opens into a search view: docked under the bar from `sm`, ful
The docked view overlaps what is under it; never place a search inside an element with `overflow-hidden` (a card), which clips it. The docked view overlaps what is under it; never place a search inside an element with `overflow-hidden` (a card), which clips it.
### `<x-app-shell>`
The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a collapsed rail that opens as a modal to `lg`, an expanded rail the visitor can collapse from `lg`, the page as `<main id="content" wire:transition.navigate>` behind a skip link, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`.
```blade
<x-app-shell :destinations="[
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares.index'), 'active' => request()->routeIs('shares.*'), 'badge' => $expiringCount],
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
]">
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
<x-slot:rail-header>
<span class="rail-collapsed:hidden"><x-fab label="New share" icon="add" link="{{ route('upload') }}" /></span>
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="add" tooltip-right="New share" link="{{ route('upload') }}" /></span>
</x-slot:rail-header>
<x-slot:rail-footer>
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
</x-slot:rail-footer>
<x-slot:top>
{{-- the page's app bar; its menu button opens the modal rail on a phone --}}
<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>
</x-slot:top>
{{ $slot }}
</x-app-shell>
```
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the current one), `badge` (`true` for a dot, or a count), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`).
- Below `sm` the shell sets `--material-bottom-bar`, so the snackbar and a `fab` button clear the bar; pad anything else you pin to the bottom with it.
- The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`<x-app-shell>` does):
```blade
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
<x-navigation-bar>
<x-navigation-bar-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" :active="request()->routeIs('shares.*')" badge="3" />
<x-navigation-bar-item label="Upload" icon="upload" link="{{ route('upload') }}" />
</x-navigation-bar>
</div>
```
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
### `<x-navigation-rail>`, `<x-navigation-rail-item>`, `<x-navigation-rail-section>`
M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (a 56px full-width pill, icon beside label, count at the end).
```blade
<div class="flex min-h-dvh">
<x-navigation-rail mode="collapsible">
<x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
<x-slot:header><x-fab icon="add" tooltip-right="New share" /></x-slot:header>
<x-navigation-rail-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" active badge="3" />
<x-navigation-rail-section label="Admin">
<x-navigation-rail-item label="Users" icon="group" link="{{ route('users') }}" />
</x-navigation-rail-section>
<x-slot:footer>
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" />
</x-slot:footer>
</x-navigation-rail>
<main class="min-w-0 flex-1">…</main>
</div>
```
- `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (`<x-app-shell>`'s: hidden and opened as a modal below `sm`, collapsed and opened as a modal to `lg`, collapsible from `lg`).
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`). Slots: `brand` (beside the menu button, expanded only), `header` (a FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
- Anything inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
- `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way.
- `$store.rail`: `collapsed`, `toggle()`, `collapse()`, `expand()` (the remembered choice), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php``rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
### `<x-app-bar>` ### `<x-app-bar>`
M3 Expressive top app bar, sticky by default (`:sticky="false"` to scroll away), turning surface-container once content scrolls under it. `variant`: `small` (default), `center`, `medium` and `large` (a big title that collapses into the row as the page scrolls — CSS sticky, no layout shift), `search` (put an `<x-search>` in the slot). Props: `title`, `subtitle`, `heading` (`h1` default). Slots: `navigation` (leading icon button), `actions` (trailing icon buttons, avatar). M3 Expressive top app bar, sticky by default (`:sticky="false"` to scroll away), turning surface-container once content scrolls under it. `variant`: `small` (default), `center`, `medium` and `large` (a big title that collapses into the row as the page scrolls — CSS sticky, no layout shift), `search` (put an `<x-search>` in the slot). Props: `title`, `subtitle`, `heading` (`h1` default). Slots: `navigation` (leading icon button), `actions` (trailing icon buttons, avatar).
@@ -549,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. 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>` ### `<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"`. 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"`.
@@ -590,7 +801,7 @@ It fails on maryUI tags, daisyUI classes, colours the theme does not declare, un
## Conventions ## 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. - 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. - The showcase at `/material` (local only, `MATERIAL_SHOWCASE=true` to force it) renders every token and component.
+508
View File
@@ -0,0 +1,508 @@
/*
* M3's date pickers — docked, modal and modal input (resources/views/components/datepicker.blade.php).
*
* One `<dialog>` wears both: docked it is a popover hung under the field by CSS anchor positioning
* (large corner), modal it is opened with `showModal()` over a scrim (extra-large corner, with M3's
* header: a title, the chosen date as the headline, and the switch between calendar and typing).
* Both are 360px of surface-container-high at elevation 3.
*
* Values from DatePickerModalTokens and DateInputModalTokens (androidx Compose Material 3, commit
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326) and the layout of DatePicker.kt, DateRangePicker.kt and
* DateInput.kt; the docked picker's from material-web's md-comp-date-picker-docked tokens (v0_192).
* Apache-2.0.
*
* [data-datepicker] the root: the field, its support line, the dialog
* [data-datepicker-support] the hint, a server error, or what is wrong with the typed date
* [data-datepicker-picker] the <dialog>; data-presentation="docked" | "modal"
* [data-datepicker-surface]
* [data-datepicker-header] modal only: title, headline, switch
* [data-datepicker-calendar]
* [data-datepicker-nav] month and year: one menu button and arrows (modal),
* or a month and a year stepper (docked, data-docked)
* [data-datepicker-grid] the weekdays and six weeks of [data-datepicker-day] cells:
* data-today, data-selected, data-start, data-end,
* data-between, data-outside, data-blank, aria-disabled
* [data-datepicker-years] the modal year grid
* [data-datepicker-menu] a docked month or year list of [data-datepicker-option]s
* [data-datepicker-entry] the text field(s) of the modal input
* [data-datepicker-actions] Cancel and OK
*
* Every state layer is the content colour at 8% on hover (only where a pointer can hover) and 10% on
* focus and press; keyboard focus adds the package's 3px secondary ring.
*/
@layer components {
/* ---- The support line under the field ------------------------------------------------------ */
/* The padding is on the lines, not the box, so a box with nothing to say takes no room. */
[data-datepicker-support] {
--datepicker-pad: 1rem;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
[data-datepicker-support] > * {
padding: 0.25rem var(--datepicker-pad) 0;
}
[data-datepicker-support][data-size="sm"] {
--datepicker-pad: 0.75rem;
}
[data-datepicker-support][data-size="xs"] {
--datepicker-pad: 0.625rem;
}
[data-datepicker-error] {
color: var(--md-sys-color-error);
}
/* ---- The dialog ---------------------------------------------------------------------------- */
[data-datepicker-picker] {
inset: auto;
width: 22.5rem;
max-width: calc(100vw - 1rem);
max-height: calc(100dvh - 1rem);
margin: 0;
padding: 0;
overflow: visible;
border: 0;
background: transparent;
color: var(--md-sys-color-on-surface);
}
[data-datepicker-picker]:popover-open {
top: anchor(bottom);
left: anchor(left);
margin-block: 0.25rem;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
opacity: 1;
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
@starting-style {
[data-datepicker-picker]:popover-open {
opacity: 0;
}
}
[data-datepicker-picker]:modal {
inset: 0;
margin: auto;
opacity: 1;
scale: 1;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
@starting-style {
[data-datepicker-picker]:modal {
opacity: 0;
scale: 0.95;
}
}
[data-datepicker-picker]:modal::backdrop {
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
}
[data-datepicker-surface] {
display: flex;
flex-direction: column;
max-height: inherit;
overflow-x: hidden;
overflow-y: auto;
border-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-sys-color-surface-container-high);
box-shadow: var(--md-sys-elevation-3);
outline: none;
}
[data-datepicker-picker][data-presentation="modal"] [data-datepicker-surface] {
border-radius: var(--md-sys-shape-corner-xl);
}
/* ---- The modal header ---------------------------------------------------------------------- */
[data-datepicker-header] {
display: flex;
flex-direction: column;
justify-content: space-between;
flex: none;
min-height: 7.5rem;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
color: var(--md-sys-color-on-surface-variant);
}
[data-datepicker-header][data-range] {
min-height: 8rem;
}
[data-datepicker-title] {
padding: 1rem 0.75rem 0 1.5rem;
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
}
[data-datepicker-headline-row] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0 0.75rem 0.75rem 1.5rem;
}
[data-datepicker-headline] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--md-sys-typescale-headline-lg);
letter-spacing: var(--md-sys-typescale-headline-lg-tracking);
}
[data-datepicker-header][data-range] [data-datepicker-headline] {
font: var(--md-sys-typescale-title-lg);
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
}
/* ---- Month and year navigation -------------------------------------------------------------- */
[data-datepicker-nav] {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
height: 3.5rem;
padding-inline: 0.75rem;
color: var(--md-sys-color-on-surface-variant);
}
[data-datepicker-nav][data-docked] {
height: 4rem;
padding-inline: 0.25rem;
}
[data-datepicker-stepper],
[data-datepicker-arrows] {
display: flex;
align-items: center;
}
/* While a year or month list is open the arrows keep their place but are gone, as androidx hides them. */
[data-datepicker-arrows][data-concealed] {
visibility: hidden;
}
[data-datepicker-menu-button] {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
height: 2.5rem;
padding-inline: 0.75rem 0.5rem;
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
white-space: nowrap;
cursor: pointer;
outline: none;
isolation: isolate;
}
[data-datepicker-nav][data-docked] [data-datepicker-menu-button] {
padding-inline: 0.5rem 0.25rem;
gap: 0.25rem;
}
[data-datepicker-menu-arrow] {
transition: rotate var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-datepicker-menu-button][aria-expanded="true"] [data-datepicker-menu-arrow] {
rotate: 180deg;
}
/* ---- State layers shared by days, years, menu buttons and list rows ------------------------ */
[data-datepicker-menu-button]::before,
[data-datepicker-day] > span::before,
[data-datepicker-year]::before,
[data-datepicker-option]::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: var(--datepicker-layer, currentColor);
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
[data-datepicker-menu-button]:hover::before,
[data-datepicker-day]:not([aria-disabled="true"], [data-blank]):hover > span::before,
[data-datepicker-year]:hover::before,
[data-datepicker-option]:not([aria-disabled="true"]):hover::before {
opacity: 0.08;
}
}
[data-datepicker-menu-button]:focus-visible::before,
[data-datepicker-menu-button]:active::before,
[data-datepicker-day]:focus-visible > span::before,
[data-datepicker-day]:not([aria-disabled="true"], [data-blank]):active > span::before,
[data-datepicker-year]:focus-visible::before,
[data-datepicker-year]:active::before,
[data-datepicker-option]:focus-visible::before,
[data-datepicker-option]:not([aria-disabled="true"]):active::before {
opacity: 0.1;
}
[data-datepicker-menu-button]:focus-visible,
[data-datepicker-day]:focus-visible > span,
[data-datepicker-year]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-datepicker-option]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px;
}
/* ---- The days ----------------------------------------------------------------------------- */
[data-datepicker-grid] {
width: calc(100% - 1.5rem);
margin-inline: 0.75rem;
border-collapse: collapse;
table-layout: fixed;
}
[data-datepicker-grid] th {
height: 3rem;
padding: 0;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
text-align: center;
}
[data-datepicker-day] {
--datepicker-layer: var(--md-sys-color-on-surface-variant);
position: relative;
height: 3rem;
padding: 0;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
text-align: center;
cursor: pointer;
outline: none;
isolation: isolate;
-webkit-tap-highlight-color: transparent;
}
[data-datepicker-day] > span {
position: relative;
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
margin: auto;
border-radius: var(--md-sys-shape-corner-full);
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-datepicker-day][data-blank] {
cursor: default;
}
/* A docked picker shows the neighbouring months' days, quieter. */
[data-datepicker-day][data-outside] {
color: var(--md-sys-color-on-surface-variant);
}
[data-datepicker-day][data-today] {
--datepicker-layer: var(--md-sys-color-primary);
color: var(--md-sys-color-primary);
}
[data-datepicker-day][data-today] > span {
box-shadow: inset 0 0 0 1px var(--md-sys-color-primary);
}
/* A range's band: secondary-container behind the days between its ends, from the middle of each end. */
[data-datepicker-day][data-between]::before,
[data-datepicker-day][data-start]::before,
[data-datepicker-day][data-end]::before {
content: "";
position: absolute;
inset-block: 0.25rem;
inset-inline: 0;
z-index: -1;
background-color: var(--md-sys-color-secondary-container);
}
[data-datepicker-day][data-start]::before {
inset-inline-start: 50%;
}
[data-datepicker-day][data-end]::before {
inset-inline-end: 50%;
}
[data-datepicker-day][data-between] {
--datepicker-layer: var(--md-sys-color-on-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
[data-datepicker-day][data-selected] {
--datepicker-layer: var(--md-sys-color-on-primary);
color: var(--md-sys-color-on-primary);
}
[data-datepicker-day][data-selected] > span {
background-color: var(--md-sys-color-primary);
box-shadow: none;
}
[data-datepicker-day][aria-disabled="true"] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
[data-datepicker-day][aria-disabled="true"][data-today] > span {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
/* ---- The modal year grid ----------------------------------------------------------------- */
[data-datepicker-years] {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-content: start;
justify-items: center;
row-gap: 1rem;
height: 20.9375rem;
padding: 0.5rem 0.75rem;
overflow-y: auto;
overscroll-behavior: contain;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
[data-datepicker-year] {
--datepicker-layer: var(--md-sys-color-on-surface-variant);
position: relative;
width: 4.5rem;
height: 2.25rem;
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
cursor: pointer;
outline: none;
isolation: isolate;
scroll-margin-block: 0.5rem;
}
[data-datepicker-year][data-current] {
--datepicker-layer: var(--md-sys-color-primary);
color: var(--md-sys-color-primary);
box-shadow: inset 0 0 0 1px var(--md-sys-color-primary);
}
[data-datepicker-year][aria-selected="true"] {
--datepicker-layer: var(--md-sys-color-on-primary);
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
box-shadow: none;
}
/* ---- The docked month and year lists ------------------------------------------------------ */
[data-datepicker-menu] {
height: 21.5rem;
padding-block: 0.5rem;
overflow-y: auto;
overscroll-behavior: contain;
border-top: 1px solid var(--md-sys-color-outline-variant);
}
[data-datepicker-option] {
--datepicker-layer: var(--md-sys-color-on-surface);
position: relative;
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
height: 3rem;
padding-inline: 1rem;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
text-align: start;
cursor: pointer;
outline: none;
isolation: isolate;
}
[data-datepicker-option][aria-selected="true"] {
background-color: var(--md-sys-color-surface-container-highest);
}
[data-datepicker-option][aria-disabled="true"] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
[data-datepicker-check] {
color: var(--md-sys-color-on-surface);
}
[data-datepicker-option]:not([aria-selected="true"]) [data-datepicker-check] {
visibility: hidden;
}
/* ---- The modal input ------------------------------------------------------------------------ */
[data-datepicker-entry] {
padding: 0.625rem 1.5rem 1rem;
}
[data-datepicker-entry-fields][data-range] {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
/* ---- Cancel and OK ---------------------------------------------------------------------------- */
[data-datepicker-actions] {
display: flex;
flex: none;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.25rem 0.375rem 0.5rem 0.75rem;
}
}
+584
View File
@@ -0,0 +1,584 @@
/*
* M3 Expressive navigation: the flexible navigation bar and the navigation rail — collapsed,
* expanded and modal.
*
* Values from androidx Compose Material 3 (Apache-2.0) at androidx-main
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: tokens/NavigationBarTokens.kt,
* NavigationBarVerticalItemTokens.kt, NavigationBarHorizontalItemTokens.kt,
* NavigationRailCollapsedTokens.kt, NavigationRailExpandedTokens.kt,
* NavigationRailBaselineItemTokens.kt, NavigationRailVerticalItemTokens.kt,
* NavigationRailHorizontalItemTokens.kt, NavigationRailColorTokens.kt, and the layout in
* ShortNavigationBar.kt, WideNavigationRail.kt and NavigationItem.kt.
*
* [data-navigation-bar] surface-container, 64px, the bottom safe area under it
* [data-navigation-bar-items] equal widths; centred from a 600px-wide bar
* [data-navigation-bar-item] data-active
* [data-navigation-pill] icon and label; the indicator itself from 600px
* [data-navigation-indicator] the 56×32 indicator around the icon below 600px
* [data-navigation-label]
*
* [data-navigation-rail="collapsed|expanded|collapsible|modal|adaptive"] data-open
* [data-navigation-rail-scrim] modal and adaptive rails
* [data-navigation-rail-panel] the <nav>
* [data-navigation-rail-header] menu button, brand, FAB — never scrolls
* [data-navigation-rail-destinations] scrolls when the window is too short
* [data-navigation-rail-section] a heading (expanded only) and its items
* [data-navigation-rail-item] data-active; the full-width pill when expanded
* [data-navigation-indicator] the 56×32 indicator when collapsed
* [data-navigation-label]
* [data-navigation-rail-footer] never scrolls
*
* `rail-collapsed:` matches a rail, and everything in it, while it is drawn collapsed — whatever
* made it so: its mode, the visitor's choice on <html data-rail> (set before the first paint by
* <x-theme-script>), or a window under `lg` for the adaptive rail. A rail item is written once
* and takes both shapes from it; so can anything an application puts in a rail
* (`<span class="rail-collapsed:hidden">`).
*/
@custom-variant rail-collapsed {
&:where([data-navigation-rail='collapsed'], [data-navigation-rail='collapsed'] *) {
@slot;
}
&:where([data-rail='collapsed'] [data-navigation-rail='collapsible'], [data-rail='collapsed'] [data-navigation-rail='collapsible'] *) {
@slot;
}
&:where([data-navigation-rail='modal']:not([data-open]), [data-navigation-rail='modal']:not([data-open]) *) {
@slot;
}
@media (width < 64rem) {
&:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) {
@slot;
}
}
@media (width >= 64rem) {
&:where([data-rail='collapsed'] [data-navigation-rail='adaptive'], [data-rail='collapsed'] [data-navigation-rail='adaptive'] *) {
@slot;
}
}
}
@layer components {
/* ---------------------------------------------------------------- the navigation bar */
[data-navigation-bar] {
container-type: inline-size;
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface-variant);
}
[data-navigation-bar-items] {
display: flex;
min-height: 4rem;
margin-inline: auto;
}
[data-navigation-bar-item] {
--navigation-layer: 0;
position: relative;
display: flex;
flex: 1 1 0;
min-width: 0;
align-items: center;
justify-content: center;
padding-block: 0.375rem;
color: var(--md-sys-color-on-surface-variant);
text-decoration: none;
cursor: pointer;
outline: none;
-webkit-tap-highlight-color: transparent;
}
[data-navigation-bar-item] [data-navigation-pill] {
display: flex;
min-width: 0;
max-width: 100%;
flex-direction: column;
align-items: center;
gap: 0.25rem;
border-radius: var(--md-sys-shape-corner-full);
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
}
[data-navigation-bar-item] [data-navigation-label] {
max-width: 100%;
padding-inline: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-navigation-bar-item][data-active] {
color: var(--md-sys-color-secondary);
}
/* From 600dp (M3's medium window), icon and label side by side in a 40px indicator, and the
items centred with the padding ShortNavigationBar's Centered arrangement computes. */
@container (width >= 37.5rem) {
[data-navigation-bar-items] {
width: calc(10% * (var(--navigation-bar-count, 7) + 3));
min-width: fit-content;
max-width: 100%;
}
[data-navigation-bar-items]:has(> :last-child:nth-child(1)) { --navigation-bar-count: 1; }
[data-navigation-bar-items]:has(> :last-child:nth-child(2)) { --navigation-bar-count: 2; }
[data-navigation-bar-items]:has(> :last-child:nth-child(3)) { --navigation-bar-count: 3; }
[data-navigation-bar-items]:has(> :last-child:nth-child(4)) { --navigation-bar-count: 4; }
[data-navigation-bar-items]:has(> :last-child:nth-child(5)) { --navigation-bar-count: 5; }
[data-navigation-bar-items]:has(> :last-child:nth-child(6)) { --navigation-bar-count: 6; }
[data-navigation-bar-item] {
min-width: max-content;
}
[data-navigation-bar-item] [data-navigation-pill] {
position: relative;
isolation: isolate;
flex-direction: row;
height: 2.5rem;
padding-inline: 1rem;
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
}
[data-navigation-bar-item] [data-navigation-label] {
padding-inline: 0;
}
[data-navigation-bar-item][data-active] {
color: var(--md-sys-color-on-secondary-container);
}
[data-navigation-bar-item][data-active] [data-navigation-pill] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
}
}
/* ---------------------------------------------------------------- the navigation rail */
[data-navigation-rail] {
--navigation-rail-expanded-width: clamp(13.75rem, var(--navigation-rail-width, 16rem), 22.5rem);
position: relative;
flex-shrink: 0;
width: var(--navigation-rail-expanded-width);
transition: width var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
@variant rail-collapsed {
width: 6rem;
}
}
/* A modal rail keeps its collapsed width in the layout while it is open over it, as Compose's
ModalWideNavigationRail does; the adaptive rail does below lg, and takes no room below sm. */
[data-navigation-rail='modal'] {
width: 6rem;
}
@media (width < 64rem) {
[data-navigation-rail='adaptive'] {
width: 6rem;
}
}
@media (width < 40rem) {
[data-navigation-rail='adaptive'] {
width: 0;
}
}
[data-navigation-rail-panel] {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
max-height: 100dvh;
/* Clip, not hide: no scroll container, so the destinations below can still scroll and
nothing sticky breaks. What only an expanded rail draws — a label, the brand — is drawn
at once when the rail expands, while the width is still growing; the clip keeps it
from spilling over the page for those frames. */
overflow-x: clip;
padding-bottom: env(safe-area-inset-bottom);
background-color: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
transition:
width var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast),
background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
}
/* Open: expanded over a scrim, in surface-container with a large corner at its inner edge. */
[data-navigation-rail][data-open] > [data-navigation-rail-panel] {
position: fixed;
inset-block: 0;
inset-inline-start: 0;
z-index: 50;
width: var(--navigation-rail-expanded-width);
max-width: calc(100vw - 3.5rem);
height: 100dvh;
max-height: none;
border-start-end-radius: var(--md-sys-shape-corner-lg);
border-end-end-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-sys-color-surface-container);
box-shadow: var(--md-sys-elevation-2);
}
/* Below sm there is no collapsed rail to grow out of: the open rail slides in from the edge,
on emphasized decelerate rather than a spring, which would overshoot and open a gap. */
@media (width < 40rem) {
[data-navigation-rail='adaptive'] > [data-navigation-rail-panel] {
position: fixed;
inset-block: 0;
inset-inline-start: 0;
z-index: 50;
display: none;
width: var(--navigation-rail-expanded-width);
max-width: calc(100vw - 3.5rem);
height: 100dvh;
max-height: none;
border-start-end-radius: var(--md-sys-shape-corner-lg);
border-end-end-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-sys-color-surface-container);
box-shadow: var(--md-sys-elevation-2);
translate: -100% 0;
transition:
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
display var(--md-sys-motion-effects-default-duration) allow-discrete;
&:dir(rtl) {
translate: 100% 0;
}
}
[data-navigation-rail='adaptive'][data-open] > [data-navigation-rail-panel] {
display: flex;
translate: 0 0;
transition:
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate),
display var(--md-sys-motion-spatial-default-duration) allow-discrete;
@starting-style {
translate: -100% 0;
}
&:dir(rtl) {
translate: 0 0;
@starting-style {
translate: 100% 0;
}
}
}
}
[data-navigation-rail-scrim] {
position: fixed;
inset: 0;
z-index: 40;
display: none;
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
opacity: 0;
transition:
opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default),
display var(--md-sys-motion-effects-default-duration) allow-discrete;
}
[data-navigation-rail][data-open] > [data-navigation-rail-scrim] {
display: block;
opacity: 1;
@starting-style {
opacity: 0;
}
}
/* 44px above the header (TopSpace), 40px under it (HeaderSpaceMinimum) — 32 here and 8 as
the destinations' own padding, which keeps the first item's focus ring inside the scroller. */
[data-navigation-rail-header] {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding-top: calc(env(safe-area-inset-top) + 2.75rem);
padding-bottom: 2rem;
}
[data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child {
padding-top: calc(env(safe-area-inset-top) + 2.75rem);
}
[data-navigation-rail-destinations] {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding-block: 0.5rem;
scrollbar-width: thin;
}
[data-navigation-rail-destinations],
[data-navigation-rail-section],
[data-navigation-rail-footer] {
display: flex;
flex-direction: column;
@variant rail-collapsed {
gap: 0.25rem;
}
}
[data-navigation-rail-footer] {
flex-shrink: 0;
padding-block: 0.5rem 1rem;
}
[data-navigation-rail-heading] {
display: flex;
align-items: center;
min-height: 3rem;
padding-inline: 2.25rem 1.25rem;
overflow: hidden;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
white-space: nowrap;
text-overflow: ellipsis;
@variant rail-collapsed {
display: none;
}
}
[data-navigation-rail-section]:not(:first-child) {
@variant rail-collapsed {
margin-top: 0.75rem;
}
}
/* Expanded: a 56px full-width pill, icon and label 8px apart, label-large. */
[data-navigation-rail-item] {
--navigation-layer: 0;
position: relative;
isolation: isolate;
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
height: 3.5rem;
margin-inline: 1.25rem;
padding-inline: 1rem;
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
text-align: start;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
outline: none;
-webkit-tap-highlight-color: transparent;
/* Collapsed: the icon in its 56×32 indicator over a label-medium label, 64px tall. */
@variant rail-collapsed {
flex-direction: column;
justify-content: center;
gap: 0.25rem;
height: auto;
min-height: 4rem;
margin-inline: 0;
padding: 0.375rem 0.25rem;
border-radius: 0;
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
white-space: normal;
}
}
[data-navigation-rail-item] [data-navigation-label] {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@variant rail-collapsed {
flex: none;
display: -webkit-box;
max-width: 100%;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
text-align: center;
overflow-wrap: anywhere;
}
}
[data-navigation-rail-item][data-active] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
@variant rail-collapsed {
background-image: none;
color: var(--md-sys-color-secondary);
}
}
[data-navigation-rail-item] [data-navigation-indicator] {
position: relative;
isolation: isolate;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: var(--md-sys-shape-corner-full);
@variant rail-collapsed {
width: 3.5rem;
height: 2rem;
}
}
/* ---------------------------------------------------------------- both: indicator, states */
[data-navigation-bar-item] [data-navigation-indicator] {
position: relative;
isolation: isolate;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 2rem;
border-radius: var(--md-sys-shape-corner-full);
}
[data-navigation-bar-item][data-active] [data-navigation-indicator] {
color: var(--md-sys-color-on-secondary-container);
}
@container (width < 37.5rem) {
[data-navigation-bar-item][data-active] [data-navigation-indicator] {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
}
}
[data-navigation-rail-item][data-active] [data-navigation-indicator] {
@variant rail-collapsed {
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
color: var(--md-sys-color-on-secondary-container);
}
}
/* The active indicator is a secondary-container fill painted as a background image, so it can
grow out of its centre without stretching the icon: when a page arrives through
wire:navigate, resources/js/navigation.js starts it at zero width for a moment, and it
springs open on the default spatial spring, as Compose's indicator does when the selection
changes (NavigationItem.kt). A full page load draws it at once; reduced motion zeroes it. */
[data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill]),
[data-navigation-rail-item],
[data-navigation-rail-item] [data-navigation-indicator] {
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
transition: background-size var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
}
/* The state layer covers only the indicator: the pill when the indicator holds the label
too, the 56×32 shape when it holds the icon alone. Hover only where a pointer can hover. */
@media (hover: hover) {
:is([data-navigation-bar-item], [data-navigation-rail-item]):hover {
--navigation-layer: 0.08;
}
}
:is([data-navigation-bar-item], [data-navigation-rail-item]):is(:focus-visible, :active) {
--navigation-layer: 0.1;
}
[data-navigation-bar-item] [data-navigation-indicator]::before,
[data-navigation-bar-item] [data-navigation-pill]::before,
[data-navigation-rail-item] [data-navigation-indicator]::before,
[data-navigation-rail-item]::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: var(--md-sys-color-on-surface);
opacity: var(--navigation-layer);
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@container (width < 37.5rem) {
[data-navigation-bar-item] [data-navigation-pill]::before {
display: none;
}
}
@container (width >= 37.5rem) {
[data-navigation-bar-item] [data-navigation-indicator]::before {
display: none;
}
}
/* The rail item's layer is on the item while expanded and on its indicator while collapsed. A
variant cannot follow a pseudo-element in a selector, so the item hands the layer on through
variables; each rule below outweighs its line in the list above. */
[data-navigation-rail-item] {
--navigation-item-layer: var(--navigation-layer);
--navigation-indicator-layer: 0;
@variant rail-collapsed {
--navigation-item-layer: 0;
--navigation-indicator-layer: var(--navigation-layer);
}
}
[data-navigation-rail-item][data-navigation-rail-item]::before {
background-color: var(--md-sys-color-on-secondary-container);
opacity: var(--navigation-item-layer);
}
[data-navigation-rail-item][data-navigation-rail-item] [data-navigation-indicator]::before {
background-color: var(--md-sys-color-on-secondary-container);
opacity: var(--navigation-indicator-layer);
}
/* M3's focus indicator, 3px of secondary 2px out, around the same shape. */
@container (width < 37.5rem) {
[data-navigation-bar-item]:focus-visible [data-navigation-indicator] {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
@container (width >= 37.5rem) {
[data-navigation-bar-item]:focus-visible [data-navigation-pill] {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
[data-navigation-rail-item]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
@variant rail-collapsed {
outline: none;
}
}
[data-navigation-rail-item]:focus-visible [data-navigation-indicator] {
@variant rail-collapsed {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
}
}
+460
View File
@@ -0,0 +1,460 @@
/*
* M3's time picker (resources/views/components/timepicker.blade.php, resources/js/timepicker.js):
* the dial, the input variant, and the dialog around them.
*
* Sizes and roles are androidx Compose Material 3's (TimePickerTokens, TimeInputTokens, TimePicker.kt
* and TimePickerDialog.kt at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, Apache-2.0):
*
* [data-timepicker-surface] surface-container-high, extra-large corner, elevation 3; 24px in
* [data-timepicker-title] label-medium, on-surface-variant, 20px above the content
* [data-timepicker-picker] the dial variant
* [data-timepicker-display] the time selector: hour and minute boxes 96×80 (display-large,
* small corner; primary-container when selected, otherwise
* surface-container-highest), a 24px separator, and the period
* selector 52×80 beside them, 4px away
* [data-timepicker-dial] 256px, surface-container-highest; numbers in body-large at radius
* 101 (the 24-hour inner ring at 69), a primary selector — a 2px line,
* an 8px centre and a 48px handle — with the number under the handle
* in on-primary; 36px below the display, 24px above the actions
* [data-timepicker-inputs] the input variant: fields 96×72 in display-medium, "Hour" and
* "Minute" (or the error) below in body-small, the period 52×72
* [data-timepicker-actions] a 48px row: the mode toggle, then Cancel and OK
*
* The period selector is Compose's current one (ComposeMaterial3Flags.isUpdatedTimepickerToggleEnabled,
* on by default): two toggle buttons 4px apart, round and surface-container-lowest when off, a 12px
* corner and primary-container with a bold label when on, not the outlined pair of the tokens.
*
* In a landscape window the dial variant lies on its side, as Compose's HorizontalTimePicker does
* whenever the screen is wider than it is tall: the display with the period selector (216×38, 16px
* under it) on the start side, the dial 36px after it, the actions under both; and the dial shrinks
* to 238 or 200px when the window is short (ClockFaceSizeModifier). The input variant always stands.
*
* The selector's angle is a registered property, so a single transition turns the line, carries the
* handle round and moves the clip that inks the number under it, on the default spatial spring. The
* numbers cross-fade between hours and minutes on the default effects spring.
*/
@property --timepicker-angle {
syntax: '<angle>';
inherits: true;
initial-value: 0deg;
}
@layer components {
[data-timepicker-field] .field-control {
cursor: pointer;
caret-color: transparent;
}
[data-timepicker-dialog] {
max-width: calc(100vw - 2rem);
max-height: calc(100dvh - 2rem);
}
[data-timepicker-surface] {
display: grid;
grid-template-columns: max-content;
justify-content: center;
padding: 1.5rem;
border-radius: var(--md-sys-shape-corner-xl);
background-color: var(--md-sys-color-surface-container-high);
box-shadow: var(--md-sys-elevation-3);
color: var(--md-sys-color-on-surface);
}
[data-timepicker-title] {
padding-bottom: 1.25rem;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
}
[data-timepicker-picker] {
display: flex;
flex-direction: column;
align-items: center;
}
/* The mode is an attribute rather than x-show, which shows only on the next frame: focus moves
into the variant that was just switched to within the same tick. */
[data-timepicker-surface]:not([data-mode="input"]) :is([data-timepicker-typing], [data-timepicker-when="input"]),
[data-timepicker-surface][data-mode="input"] :is([data-timepicker-picker], [data-timepicker-when="dial"]) {
display: none;
}
[data-timepicker-when] {
display: contents;
}
/* ---- The time selector and the period selector ------------------------------------------ */
[data-timepicker-display] {
display: flex;
margin-bottom: 2.25rem;
}
[data-timepicker-numbers],
[data-timepicker-inputs] {
display: flex;
align-items: flex-start;
direction: ltr;
}
[data-timepicker-box] {
display: grid;
place-items: center;
width: 6rem;
height: 5rem;
border-radius: var(--md-sys-shape-corner-sm);
background-color: var(--md-sys-color-surface-container-highest);
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-display-lg);
letter-spacing: var(--md-sys-typescale-display-lg-tracking);
font-variant-numeric: tabular-nums;
cursor: pointer;
transition-property: background-color, color;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
[data-timepicker-box][aria-pressed="true"] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
[data-timepicker-separator] {
display: grid;
place-items: center;
width: 1.5rem;
height: 5rem;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-display-lg);
translate: 0 -0.25rem;
user-select: none;
}
[data-timepicker-period] {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 3.25rem;
height: 5rem;
margin-inline-start: 0.25rem;
}
[data-timepicker-period] > button {
flex: 1 1 0%;
min-width: 0;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-lowest);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
cursor: pointer;
transition-property: border-radius, background-color, color;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-timepicker-period] > button:active,
[data-timepicker-period] > button[aria-pressed="true"] {
border-radius: 0.75rem;
}
[data-timepicker-period] > button[aria-pressed="true"] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
font-weight: var(--md-ref-typeface-weight-bold);
}
[data-timepicker-period] > button:disabled {
cursor: default;
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
/* ---- The dial ----------------------------------------------------------------------------- */
[data-timepicker-dial] {
--dial: 16rem;
--unit: calc(var(--dial) / 256);
--outer: calc(101 * var(--unit));
--inner: calc(69 * var(--unit));
--handle: calc(48 * var(--unit));
--reach: var(--outer);
position: relative;
flex: none;
width: var(--dial);
height: var(--dial);
margin-bottom: 1.5rem;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-highest);
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
cursor: pointer;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
outline: none;
direction: ltr;
transition: --timepicker-angle var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
}
[data-timepicker-dial]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
/* A drag keeps the handle under the pointer; the spring only settles it on release. */
[data-timepicker-dial][data-dragging] {
transition: none;
}
[data-timepicker-dial][data-inner] {
--reach: var(--inner);
}
[data-timepicker-labels],
[data-timepicker-ink],
[data-timepicker-set] {
position: absolute;
inset: 0;
}
[data-timepicker-set] {
transition-property: opacity, visibility;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-effects-default);
}
[data-timepicker-dial]:not([data-cycle="24"]) [data-timepicker-set="hour24"],
[data-timepicker-dial][data-cycle="24"] [data-timepicker-set="hour12"] {
display: none;
}
[data-timepicker-dial][data-view="minute"] :is([data-timepicker-set="hour12"], [data-timepicker-set="hour24"]),
[data-timepicker-dial]:not([data-view="minute"]) [data-timepicker-set="minute"] {
opacity: 0;
visibility: hidden;
}
[data-timepicker-set] > span {
--ring: var(--outer);
position: absolute;
left: calc(50% + var(--x) * var(--ring));
top: calc(50% + var(--y) * var(--ring));
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
translate: -50% -50%;
border-radius: var(--md-sys-shape-corner-full);
font-variant-numeric: tabular-nums;
}
[data-timepicker-set] > span[data-inner] {
--ring: var(--inner);
}
[data-timepicker-set] > span[data-disabled] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
/* The selector: the line from the centre to the handle's edge, the centre dot, the handle. */
[data-timepicker-selector] {
position: absolute;
inset: 0;
pointer-events: none;
}
[data-timepicker-track] {
position: absolute;
left: calc(50% - 1px);
bottom: 50%;
width: 2px;
height: calc(var(--reach) - var(--handle) / 2);
background-color: var(--md-sys-color-primary);
transform-origin: 50% 100%;
rotate: var(--timepicker-angle);
}
[data-timepicker-centre],
[data-timepicker-handle] {
position: absolute;
translate: -50% -50%;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-primary);
}
[data-timepicker-centre] {
left: 50%;
top: 50%;
width: 0.5rem;
height: 0.5rem;
}
[data-timepicker-handle] {
left: calc(50% + sin(var(--timepicker-angle)) * var(--reach));
top: calc(50% - cos(var(--timepicker-angle)) * var(--reach));
width: var(--handle);
height: var(--handle);
}
/* The numbers again, in on-primary, cut to the handle: what Compose draws with BlendMode.DstOver. */
[data-timepicker-ink] {
color: var(--md-sys-color-on-primary);
pointer-events: none;
clip-path: circle(calc(var(--handle) / 2) at calc(50% + sin(var(--timepicker-angle)) * var(--reach)) calc(50% - cos(var(--timepicker-angle)) * var(--reach)));
}
[data-timepicker-ink] [data-timepicker-set] > span[data-disabled] {
color: inherit;
}
/* ---- The input variant -------------------------------------------------------------------- */
[data-timepicker-inputs] [data-timepicker-separator] {
height: 4.5rem;
}
[data-timepicker-inputs] [data-timepicker-period] {
height: 4.5rem;
}
[data-timepicker-column] {
width: 6rem;
}
[data-timepicker-input] {
display: block;
width: 6rem;
height: 4.5rem;
padding: 0;
border: 0;
border-radius: var(--md-sys-shape-corner-sm);
outline: none;
background-color: var(--md-sys-color-surface-container-highest);
color: var(--md-sys-color-on-surface);
caret-color: var(--md-sys-color-primary);
font: var(--md-sys-typescale-display-md);
letter-spacing: var(--md-sys-typescale-display-md-tracking);
font-variant-numeric: tabular-nums;
text-align: center;
transition-property: background-color, color, box-shadow;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
[data-timepicker-input]:focus {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
box-shadow: inset 0 0 0 2px var(--md-sys-color-primary);
}
[data-timepicker-input][aria-invalid="true"] {
background-color: var(--md-sys-color-error-container);
color: var(--md-sys-color-error);
caret-color: var(--md-sys-color-error);
box-shadow: inset 0 0 0 1px var(--md-sys-color-error);
}
[data-timepicker-input][aria-invalid="true"]:focus {
box-shadow: inset 0 0 0 2px var(--md-sys-color-error);
}
/* SupportingText: two lines' room, 7px under the field; the error in its place. */
[data-timepicker-support] {
min-height: 2rem;
padding-top: 0.4375rem;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
[data-timepicker-support][data-error],
[data-timepicker-range-error] {
color: var(--md-sys-color-error);
}
[data-timepicker-range-error] {
max-width: 17rem;
padding-bottom: 0.5rem;
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
[data-timepicker-actions] {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 3rem;
}
[data-timepicker-actions] > [data-timepicker-spacer] {
flex: 1 1 0%;
}
/* ---- Landscape ---------------------------------------------------------------------------- */
@media (orientation: landscape) and (min-width: 37rem) {
[data-timepicker-surface][data-mode="dial"] {
grid-template-columns: 13.5rem auto;
grid-template-areas:
"display dial"
"actions actions";
column-gap: 2.25rem;
padding: 1rem 1.5rem 0.5rem;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-title] {
grid-area: display;
align-self: start;
margin-top: 0.5rem;
padding: 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-picker] {
display: contents;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-display] {
grid-area: display;
flex-direction: column;
align-self: center;
margin: 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-display] [data-timepicker-period] {
flex-direction: row;
width: 13.5rem;
height: 2.375rem;
margin: 1rem 0 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
grid-area: dial;
margin: 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-actions] {
grid-area: actions;
margin-top: 0.25rem;
}
}
@media (orientation: landscape) and (min-width: 37rem) and (max-height: 22.75rem) {
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
--dial: 14.875rem;
}
}
@media (orientation: landscape) and (min-width: 37rem) and (max-height: 21.625rem) {
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
--dial: 12.5rem;
}
}
}
+3
View File
@@ -26,8 +26,11 @@
@import './components/menu.css'; @import './components/menu.css';
@import './components/selection.css'; @import './components/selection.css';
@import './components/search.css'; @import './components/search.css';
@import './components/datepicker.css';
@import './components/timepicker.css';
@import './components/tabs.css'; @import './components/tabs.css';
@import './components/app-bar.css'; @import './components/app-bar.css';
@import './components/navigation.css';
@import './components/toolbar.css'; @import './components/toolbar.css';
@import './components/table.css'; @import './components/table.css';
+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. */ /** A programmatic scroll still counts as where the carousel is going for this long. */
const TARGET_MS = 700 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 ITEM = '[data-material-carousel-item]'
const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable], [tabindex]:not([tabindex="-1"])' const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable], [tabindex]:not([tabindex="-1"])'
@@ -805,6 +808,7 @@ document.addEventListener('alpine:init', () => {
frame: null, frame: null,
target: null, target: null,
targetAt: 0, targetAt: 0,
settle: null,
listeners: [], listeners: [],
mutations: null, mutations: null,
resizes: null, resizes: null,
@@ -817,7 +821,12 @@ document.addEventListener('alpine:init', () => {
state.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)') 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, 'keydown', (event) => this.navigate(event))
this.listen(scroller, 'focusin', (event) => this.reveal(event)) this.listen(scroller, 'focusin', (event) => this.reveal(event))
this.listen(scroller, 'click', (event) => this.open(event)) this.listen(scroller, 'click', (event) => this.open(event))
@@ -913,6 +922,33 @@ document.addEventListener('alpine:init', () => {
state.frame ??= requestAnimationFrame(() => this.render()) 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. */ /** Carousel.kt's carouselItem layer block, for every item. */
render() { render() {
cancelAnimationFrame(state.frame) cancelAnimationFrame(state.frame)
@@ -1076,6 +1112,7 @@ document.addEventListener('alpine:init', () => {
destroy() { destroy() {
cancelAnimationFrame(state.frame) cancelAnimationFrame(state.frame)
clearTimeout(state.settle)
state.listeners.forEach((remove) => remove()) state.listeners.forEach((remove) => remove())
state.resizes?.disconnect() state.resizes?.disconnect()
state.mutations?.disconnect() state.mutations?.disconnect()
+842
View File
@@ -0,0 +1,842 @@
/**
* `materialDatepicker`: the behaviour of `<x-datepicker>` — M3's docked, modal and modal-input
* date pickers on one `<dialog>`.
*
* The dialog is shown two ways. Docked, it is a `popover="manual"` placed under the field by CSS
* anchor positioning; modal (the `modal` and `input` modes, and a docked picker on a compact
* window) it is opened with `showModal()`. Everything a person sees in it is drawn by Alpine from
* the state below, and the dialog is `wire:ignore`, so a Livewire render never touches it while
* it is open.
*
* Dates are ISO strings (`2026-09-13`) throughout, computed in UTC so no time zone or daylight
* saving change can move a day; only "today" is read in the browser's own zone. Month and weekday
* names, the week's first day and the typed format come from `Intl` for the locale the server
* passes (the application's).
*
* The keyboard is WAI-ARIA's date picker dialog: arrows move a day or a week, Home and End go to
* the start and end of the week, PageUp and PageDown a month (with Shift a year), Space selects,
* Enter selects and confirms, Escape closes and hands focus back to the field. Focus never leaves
* the `min``max` span, so a disabled day cannot be chosen from the keyboard either.
*/
const COMPACT = '(max-width: 39.99rem)'
// CLDR's first day of the week by region, for an engine without `Intl.Locale#getWeekInfo`.
const WEEK_STARTS_SUNDAY = 'AG AS BD BR BS BT BW BZ CA CN CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW'.split(' ')
const WEEK_STARTS_SATURDAY = 'AE AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY'.split(' ')
const YEARS_FROM = 1900
const YEARS_TO = 2100
function pad(number, length = 2) {
return String(number).padStart(length, '0')
}
/** A UTC timestamp for a calendar day; `setUTCFullYear` so years below 100 are not read as 19xx. */
function utc(year, month, day) {
const date = new Date(0)
date.setUTCFullYear(year, month - 1, day)
return date
}
function iso(date) {
return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
}
/** The ISO string, or null when it is not a real calendar day. */
function valid(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(typeof value === 'string' ? value : '')
if (!match) {
return null
}
const date = utc(+match[1], +match[2], +match[3])
return date.getUTCMonth() + 1 === +match[2] && date.getUTCDate() === +match[3] ? match[0] : null
}
function parts(value) {
return value.split('-').map(Number)
}
function addDays(value, days) {
const [year, month, day] = parts(value)
return iso(utc(year, month, day + days))
}
/** The same day in another month, or that month's last day when it is shorter. */
function addMonths(value, months) {
const [year, month, day] = parts(value)
const first = utc(year, month + months, 1)
const length = utc(first.getUTCFullYear(), first.getUTCMonth() + 2, 0).getUTCDate()
return iso(utc(first.getUTCFullYear(), first.getUTCMonth() + 1, Math.min(day, length)))
}
function localToday() {
const now = new Date()
return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
/** 0 for Sunday … 6 for Saturday. */
function firstDayOfWeek(locale) {
try {
const tag = new Intl.Locale(locale)
const info = typeof tag.getWeekInfo === 'function' ? tag.getWeekInfo() : tag.weekInfo
if (info?.firstDay) {
return info.firstDay % 7
}
const region = tag.maximize().region
if (WEEK_STARTS_SUNDAY.includes(region)) {
return 0
}
if (WEEK_STARTS_SATURDAY.includes(region)) {
return 6
}
} catch {
// An unknown tag falls through to Monday, ISO 8601's first day.
}
return 1
}
/**
* The typed format: the locale's short numeric date, reduced to `dd`, `MM` and `yyyy` and one
* delimiter — androidx's `datePatternAsInputFormat`, fed from `formatToParts` because `Intl` has
* no pattern to give. `de` gives `dd.MM.yyyy`, `en-US` `MM/dd/yyyy`, `ja` `yyyy/MM/dd`.
*/
function inputFormat(locale) {
let pattern = ''
try {
pattern = new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'UTC' })
.formatToParts(utc(2026, 11, 22))
.map((part) => ({ year: 'y', month: 'M', day: 'd', literal: part.value })[part.type] ?? '')
.join('')
.replace(/[^dMy/\-.]/g, '')
.replace(/d{1,2}/g, 'dd')
.replace(/M{1,2}/g, 'MM')
.replace(/y{1,4}/g, 'yyyy')
.replace('My', 'M/y')
.replace(/\.$/, '')
} catch {
pattern = ''
}
if (!/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern)) {
pattern = 'yyyy-MM-dd'
}
return {
pattern,
placeholder: pattern.toUpperCase(),
order: pattern.replace(/[^dMy]/g, '').replace('dd', 'd').replace('MM', 'M').replace('yyyy', 'y'),
}
}
function normaliseRange(value) {
return { start: valid(value?.start), end: valid(value?.end) }
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialDatepicker', (config) => ({
value: config.value,
open: false,
presentation: 'docked',
typing: false,
view: 'days',
draft: null,
focused: null,
shown: null,
text: '',
fieldError: '',
entry: '',
entryEnd: '',
entryError: '',
today: localToday(),
compact: false,
refocus: true,
init() {
const locale = config.locale || document.documentElement.lang || 'en'
const format = (options) => new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options })
this.firstDay = firstDayOfWeek(locale)
this.format = inputFormat(locale)
this.numbers = new Intl.NumberFormat(locale, { useGrouping: false })
this.formats = {
monthYear: format({ year: 'numeric', month: 'long' }),
month: format({ month: 'short' }),
monthLong: format({ month: 'long' }),
year: format({ year: 'numeric' }),
headline: format({ year: 'numeric', month: 'short', day: 'numeric' }),
long: format({ weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
weekdayNarrow: format({ weekday: 'narrow' }),
weekdayLong: format({ weekday: 'long' }),
}
this.min = valid(config.min)
this.max = valid(config.max)
// androidx's 19002100, narrowed to the years `min` and `max` leave open.
this.yearsFrom = this.min ? parts(this.min)[0] : Math.min(YEARS_FROM, this.max ? parts(this.max)[0] : YEARS_FROM)
this.yearsTo = this.max ? parts(this.max)[0] : Math.max(YEARS_TO, this.yearsFrom)
this.text = this.display(this.current())
this.moveTo(this.start() ?? this.today, false)
this.$watch('value', () => {
if (document.activeElement !== this.$refs.input) {
this.text = this.display(this.current())
this.fieldError = ''
}
})
const query = window.matchMedia(COMPACT)
this.compact = query.matches
query.addEventListener('change', (event) => (this.compact = event.matches))
},
// ---- Values --------------------------------------------------------------------------
/** The bound value, cleaned: an ISO string or null, or `{ start, end }` for a range. */
current() {
return config.range ? normaliseRange(this.value) : valid(this.value)
},
/** Writes only a change, so leaving a field untouched sends Livewire nothing. */
write(value) {
const next = config.range ? { start: value?.start ?? null, end: value?.end ?? null } : (value ?? null)
if (JSON.stringify(next) !== JSON.stringify(this.current())) {
this.value = next
}
},
/** A date as the field shows and takes it: `13.09.2026`. */
typed(value) {
if (!value) {
return ''
}
const [year, month, day] = parts(value)
return this.format.pattern.replace('yyyy', pad(year, 4)).replace('MM', pad(month)).replace('dd', pad(day))
},
display(value) {
if (!config.range) {
return this.typed(value)
}
if (!value.start && !value.end) {
return ''
}
return `${this.typed(value.start)} ${this.typed(value.end)}`
},
/**
* Reads typed digits in the locale's order: `13.9.2026`, `13/09/2026` and `13092026` are the
* same day in `de`. Returns an ISO string, or null for text that is not a date.
*/
parse(text) {
const groups = String(text ?? '').match(/\d+/g) ?? []
let values = groups
if (groups.length === 1 && groups[0].length === 8) {
let offset = 0
values = [...this.format.order].map((unit) => {
const length = unit === 'y' ? 4 : 2
const piece = groups[0].slice(offset, offset + length)
offset += length
return piece
})
}
if (values.length !== 3) {
return null
}
const units = Object.fromEntries([...this.format.order].map((unit, index) => [unit, values[index]]))
if (units.y.length !== 4 || units.M.length > 2 || units.d.length > 2) {
return null
}
return valid(`${units.y}-${pad(+units.M)}-${pad(+units.d)}`)
},
parseRange(text) {
const groups = String(text ?? '').match(/\d+/g) ?? []
if (groups.length === 2 && groups.every((group) => group.length === 8)) {
return { start: this.parse(groups[0]), end: this.parse(groups[1]) }
}
if (groups.length === 6) {
return { start: this.parse(groups.slice(0, 3).join(' ')), end: this.parse(groups.slice(3).join(' ')) }
}
return null
},
/** androidx's DateInputValidator: the pattern, then min and max, then the year range. */
problem(value) {
if (!value) {
return config.strings.pattern.replace(':pattern', this.format.placeholder)
}
if (this.disabled(value)) {
return config.strings.notAllowed.replace(':date', this.formats.headline.format(utc(...parts(value))))
}
const year = parts(value)[0]
if (year < this.yearsFrom || year > this.yearsTo) {
return config.strings.yearRange.replace(':start', this.numbers.format(this.yearsFrom)).replace(':end', this.numbers.format(this.yearsTo))
}
return ''
},
disabled(value) {
return (this.min !== null && value < this.min) || (this.max !== null && value > this.max)
},
clamp(value) {
if (this.min !== null && value < this.min) {
return this.min
}
return this.max !== null && value > this.max ? this.max : value
},
// ---- The field ----------------------------------------------------------------------
/** Typing into the docked field: a complete, allowed date is written at once. */
typeInField() {
const text = this.text.trim()
const value = config.range ? this.parseRange(text) : this.parse(text)
const complete = config.range ? value?.start && value?.end : value
if (!complete) {
return
}
const problem = this.fieldProblem(value)
if (problem === '') {
this.fieldError = ''
this.write(value)
this.follow(config.range ? value.start : value)
}
},
/** Leaving the field, or Enter: the text is the value, or it says what is wrong with it. */
commitField() {
const text = this.text.trim()
if (text === '') {
this.fieldError = ''
if (config.range ? this.current().start || this.current().end : this.current()) {
this.write(null)
}
return
}
const value = config.range ? this.parseRange(text) : this.parse(text)
const problem = this.fieldProblem(value)
this.fieldError = problem
if (problem === '') {
this.write(value)
this.text = this.display(config.range ? normaliseRange(value) : value)
}
},
/** 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)
}
if (!value) {
return config.strings.pattern.replace(':pattern', `${this.format.placeholder} ${this.format.placeholder}`)
}
return this.problem(value.start) || this.problem(value.end) || (value.start > value.end ? config.strings.invalidRange : '')
},
/** An open calendar follows a date typed into the field. */
follow(value) {
if (this.open && value) {
this.draft = this.current()
this.moveTo(value, false)
}
},
// ---- Opening and closing --------------------------------------------------------------
show(focusInside = true) {
if (this.open || config.disabled || config.readonly) {
return
}
const value = this.current()
this.today = localToday()
this.presentation = config.mode === 'docked' && !this.compact ? 'docked' : 'modal'
this.typing = config.mode === 'input'
this.view = 'days'
this.draft = value
this.moveTo(this.start() ?? this.today, false)
this.entryError = ''
this.fillEntry()
this.refocus = true
this.open = true
const dialog = this.$refs.dialog
if (this.presentation === 'docked') {
dialog.showPopover()
} else {
dialog.showModal()
}
if (focusInside || this.presentation === 'modal') {
this.settled(() => this.focusInside())
}
},
/**
* Runs a step once what it needs is on screen. `x-show` reveals an element a frame after the
* state changes (a timeout in a hidden tab), and a hidden element cannot take focus.
*/
settled(callback) {
this.$nextTick(() => (document.visibilityState === 'visible' ? requestAnimationFrame : setTimeout)(callback))
},
focusInside() {
if (this.typing) {
this.$refs.entry?.focus()
} else {
this.focusDay()
}
},
/** Closes without keeping what was picked. */
cancel(refocus = true) {
if (!this.open) {
return
}
this.refocus = refocus
this.close()
},
close() {
const dialog = this.$refs.dialog
this.open = false
this.view = 'days'
if (dialog.matches(':popover-open')) {
dialog.hidePopover()
}
if (dialog.open) {
dialog.close()
}
if (this.refocus) {
this.$refs.input.focus()
}
},
/** OK: the picked date (or typed one) becomes the value. */
confirm() {
if (this.typing && !this.takeEntry()) {
return
}
this.write(this.draft)
this.text = this.display(this.current())
this.fieldError = ''
this.refocus = true
this.close()
},
/** A press outside a docked picker, or focus leaving it, puts it away. */
leave(event) {
if (this.open && this.presentation === 'docked' && event.relatedTarget && !this.$root.contains(event.relatedTarget)) {
this.cancel(false)
}
},
// ---- Picking --------------------------------------------------------------------------
pick(value) {
if (!value || this.disabled(value)) {
return
}
this.focused = value
this.shown = value.slice(0, 8) + '01'
if (!config.range) {
this.draft = value
return
}
const { start, end } = this.draft ?? { start: null, end: null }
this.draft = start && !end && value >= start ? { start, end: value } : { start: value, end: null }
},
complete() {
return config.range ? Boolean(this.draft?.start && this.draft?.end) : Boolean(this.draft)
},
choose(cell) {
if (cell.blank || cell.disabled) {
return
}
this.pick(cell.value)
this.focusDay()
},
focusDay() {
this.settled(() => this.$refs.dialog.querySelector('[data-datepicker-day][tabindex="0"]')?.focus())
},
/** Focuses a day (inside min and max) and shows its month. */
moveTo(value, focus = true) {
this.focused = this.clamp(value)
this.shown = this.focused.slice(0, 8) + '01'
if (focus) {
this.focusDay()
}
},
/** The single date, or a range's start. */
start() {
const value = this.current()
return config.range ? value.start : value
},
gridKey(event) {
const rtl = getComputedStyle(this.$refs.dialog).direction === 'rtl'
const focused = this.focused
const column = (utc(...parts(focused)).getUTCDay() - this.firstDay + 7) % 7
const target = {
ArrowRight: () => addDays(focused, rtl ? -1 : 1),
ArrowLeft: () => addDays(focused, rtl ? 1 : -1),
ArrowDown: () => addDays(focused, 7),
ArrowUp: () => addDays(focused, -7),
Home: () => addDays(focused, -column),
End: () => addDays(focused, 6 - column),
PageUp: () => addMonths(focused, event.shiftKey ? -12 : -1),
PageDown: () => addMonths(focused, event.shiftKey ? 12 : 1),
}[event.key]
if (target) {
event.preventDefault()
this.moveTo(target())
return
}
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault()
this.pick(focused)
if (event.key === 'Enter' && this.complete()) {
this.confirm()
}
}
},
/** The previous or next month (or year) buttons; focus stays on the button. */
step(months) {
if (!this.canStep(months)) {
return
}
this.moveTo(addMonths(this.focused, months), false)
},
monthAllowed(year, month) {
const first = `${pad(year, 4)}-${pad(month)}-01`
const last = iso(utc(year, month + 1, 0))
return year >= this.yearsFrom && year <= this.yearsTo && !(this.max !== null && first > this.max) && !(this.min !== null && last < this.min)
},
canStep(months) {
const [year, month] = parts(addMonths(this.shown, months))
return this.monthAllowed(year, month)
},
// ---- Years and months ---------------------------------------------------------------
/** Opens the year grid (or a docked picker's month or year list), or goes back to the days. */
toggleView(view) {
this.view = this.view === view ? 'days' : view
if (this.view === 'days') {
this.focusDay()
return
}
this.settled(() => {
const list = [...this.$refs.dialog.querySelectorAll('[data-datepicker-list]')].find((each) => each.getClientRects().length > 0)
const option = list?.querySelector('[aria-selected="true"]') ?? list?.querySelector('[role="option"]')
option?.scrollIntoView({ block: 'center' })
option?.focus()
})
},
/** The focused day moves to the chosen year and month, kept inside min and max. */
showMonthOf(year, month) {
const [focusedYear, focusedMonth] = parts(this.focused)
this.moveTo(addMonths(this.focused, (year - focusedYear) * 12 + month - focusedMonth))
this.view = 'days'
},
/** Arrows, Home and End in a year or month list; the options are buttons, so Enter and Space are the browser's. */
listKey(event, columns = 1) {
const options = [...event.currentTarget.querySelectorAll('[role="option"]')]
const index = options.indexOf(document.activeElement)
const move = {
ArrowDown: columns,
ArrowUp: -columns,
ArrowRight: columns > 1 ? 1 : 0,
ArrowLeft: columns > 1 ? -1 : 0,
}[event.key]
let next = null
if (move) {
next = options[Math.min(options.length - 1, Math.max(0, index + move))]
} else if (event.key === 'Home') {
next = options[0]
} else if (event.key === 'End') {
next = options.at(-1)
}
if (next) {
event.preventDefault()
next.focus()
next.scrollIntoView({ block: 'nearest' })
}
},
// ---- Text entry in the dialog --------------------------------------------------------
fillEntry() {
if (config.range) {
this.entry = this.typed(this.draft?.start)
this.entryEnd = this.typed(this.draft?.end)
} else {
this.entry = this.typed(this.draft)
}
},
/** As it is typed, a whole and allowed date becomes the draft, so the headline follows it. */
typeEntry() {
const read = (text) => {
const value = this.parse(text)
return value && this.problem(value) === '' ? value : null
}
this.entryError = ''
this.draft = config.range ? { start: read(this.entry), end: read(this.entryEnd) } : read(this.entry)
},
/** Reads the dialog's text fields into the draft; false, with the reason shown, when it cannot. */
takeEntry() {
if (!config.range) {
if (this.entry.trim() === '') {
this.draft = null
this.entryError = ''
return true
}
const value = this.parse(this.entry)
this.entryError = this.problem(value)
if (this.entryError !== '') {
return false
}
this.draft = value
return true
}
const start = this.entry.trim() === '' ? null : this.parse(this.entry)
const end = this.entryEnd.trim() === '' ? null : this.parse(this.entryEnd)
this.entryError = (this.entry.trim() !== '' ? this.problem(start) : '')
|| (this.entryEnd.trim() !== '' ? this.problem(end) : '')
|| (start && end && start > end ? config.strings.invalidRange : '')
if (this.entryError !== '') {
return false
}
this.draft = { start, end }
return true
},
toggleTyping() {
if (this.typing) {
if (!this.takeEntry()) {
return
}
const start = config.range ? this.draft.start : this.draft
this.moveTo(start ?? this.focused, false)
} else {
this.fillEntry()
this.entryError = ''
}
this.typing = !this.typing
this.view = 'days'
this.settled(() => this.focusInside())
},
// ---- What the template draws ---------------------------------------------------------
get weekdays() {
return Array.from({ length: 7 }, (_, index) => {
// 1 January 2023 was a Sunday.
const date = utc(2023, 1, 1 + ((this.firstDay + index) % 7))
return { narrow: this.formats.weekdayNarrow.format(date), long: this.formats.weekdayLong.format(date) }
})
},
get weeks() {
const [year, month] = parts(this.shown)
const first = utc(year, month, 1)
const offset = (first.getUTCDay() - this.firstDay + 7) % 7
const range = config.range ? (this.draft ?? { start: null, end: null }) : null
const outside = this.presentation === 'docked'
return Array.from({ length: 6 }, (_, row) => Array.from({ length: 7 }, (_, column) => {
const date = utc(year, month, 1 - offset + row * 7 + column)
const value = iso(date)
const inMonth = date.getUTCMonth() + 1 === month
const start = range ? value === range.start : value === this.draft
const end = range ? value === range.end : false
return {
value,
blank: !inMonth && !outside,
outside: !inMonth,
label: this.numbers.format(date.getUTCDate()),
name: this.formats.long.format(date),
disabled: this.disabled(value),
today: value === this.today,
selected: start || end,
start: range !== null && start && Boolean(range.end) && range.end !== range.start,
end: range !== null && end && range.end !== range.start,
between: range !== null && Boolean(range.start && range.end) && value > range.start && value < range.end,
focused: inMonth && value === this.focused,
}
}))
},
get years() {
const shown = parts(this.shown)[0]
const current = parts(this.today)[0]
return Array.from({ length: this.yearsTo - this.yearsFrom + 1 }, (_, index) => {
const year = this.yearsFrom + index
return { value: year, label: this.numbers.format(year), selected: year === shown, current: year === current }
})
},
get months() {
const [year, shown] = parts(this.shown)
return Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: this.formats.monthLong.format(utc(2023, index + 1, 1)),
selected: index + 1 === shown,
disabled: !this.monthAllowed(year, index + 1),
}))
},
get monthYear() {
return this.shown ? this.formats.monthYear.format(utc(...parts(this.shown))) : ''
},
get monthLabel() {
return this.shown ? this.formats.month.format(utc(...parts(this.shown))) : ''
},
get yearLabel() {
return this.shown ? this.formats.year.format(utc(...parts(this.shown))) : ''
},
/** M3's headline: the chosen date, or what the picker is for until there is one. */
get headline() {
const date = (value) => (value ? this.formats.headline.format(utc(...parts(value))) : null)
if (config.range) {
return `${date(this.draft?.start) ?? config.strings.start} ${date(this.draft?.end) ?? config.strings.end}`
}
return date(this.draft) ?? (this.typing ? config.strings.entered : config.strings.selected)
},
get placeholder() {
return config.range ? `${this.format.placeholder} ${this.format.placeholder}` : this.format.placeholder
},
get serialised() {
return config.range ? this.current() : this.current() ?? ''
},
}))
})
+3
View File
@@ -20,7 +20,10 @@ import './carousel.js'
import './chips.js' import './chips.js'
import './field.js' import './field.js'
import './search.js' import './search.js'
import './datepicker.js'
import './timepicker.js'
import './slider.js' import './slider.js'
import './tabs.js' import './tabs.js'
import './app-bar.js' import './app-bar.js'
import './navigation.js'
import './toolbar.js' import './toolbar.js'
+11 -1
View File
@@ -15,6 +15,7 @@ document.addEventListener('alpine:init', () => {
window.Alpine.data('materialMenu', () => ({ window.Alpine.data('materialMenu', () => ({
closedAt: -Infinity, closedAt: -Infinity,
returnFocus: true, returnFocus: true,
focusWasInside: false,
listeners: [], listeners: [],
init() { init() {
@@ -26,6 +27,13 @@ document.addEventListener('alpine:init', () => {
// and close() have already done their part, synchronously, because this event is // 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 // queued and a screen reader or a test reading aria-expanded in between would be told
// the menu is shut. // 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) => { this.listen(menu, 'toggle', (event) => {
const opened = event.newState === 'open' const opened = event.newState === 'open'
@@ -37,9 +45,11 @@ document.addEventListener('alpine:init', () => {
this.closedAt = performance.now() this.closedAt = performance.now()
if (this.returnFocus && menu.contains(document.activeElement)) { if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
this.control()?.focus() this.control()?.focus()
} }
this.focusWasInside = false
}) })
// A press outside closes the menu without pulling focus back to the trigger. // A press outside closes the menu without pulling focus back to the trigger.
+141
View File
@@ -0,0 +1,141 @@
/**
* Navigation: the rail's state, shared by every rail and menu button on the page.
*
* `$store.rail.collapsed` is the visitor's choice for a collapsible rail, remembered in
* localStorage. <x-theme-script> has already applied it before the first paint as
* <html data-rail="expanded|collapsed">, which is what the stylesheet keys on (the
* `rail-collapsed:` variant); the store starts from that attribute and writes it back.
*
* `$store.rail.open` is the modal rail: on a window too narrow for an expanded rail, a menu
* button opens it over a scrim (`show()`), and Escape, the scrim or leaving the page closes it
* (`hide()`). It is never remembered.
*
* `materialNavigationRail` is one rail's view of the store for its `mode` — see
* resources/views/components/navigation-rail.blade.php.
*/
const WIDE = '(min-width: 64rem)'
/*
* The active indicator grows out of its centre when a page arrives through wire:navigate. The
* new page's indicator is new markup, so the only way to animate it is a starting style — and
* only while a navigation swaps the page in, or every full load would animate it too. The sheet
* is adopted as the navigation starts and dropped two frames after it ends; the transition itself
* is resources/css/components/navigation.css.
*/
const arriving = new CSSStyleSheet()
arriving.replaceSync(`@starting-style {
:is([data-navigation-bar-item], [data-navigation-rail-item])[data-active],
:is([data-navigation-bar-item], [data-navigation-rail-item])[data-active] :is([data-navigation-indicator], [data-navigation-pill]) {
background-size: 0% 100%;
}
}`)
document.addEventListener('livewire:navigating', () => {
if (!document.adoptedStyleSheets.includes(arriving)) {
document.adoptedStyleSheets = [...document.adoptedStyleSheets, arriving]
}
})
document.addEventListener('livewire:navigated', () => {
requestAnimationFrame(() => requestAnimationFrame(() => {
document.adoptedStyleSheets = document.adoptedStyleSheets.filter((sheet) => sheet !== arriving)
}))
})
document.addEventListener('alpine:init', () => {
const root = document.documentElement
window.Alpine.store('rail', {
collapsed: root.dataset.rail === 'collapsed',
open: false,
toggle() {
this.set(!this.collapsed)
},
collapse() {
this.set(true)
},
expand() {
this.set(false)
},
set(collapsed) {
this.collapsed = collapsed
root.dataset.rail = collapsed ? 'collapsed' : 'expanded'
try {
localStorage.setItem(root.dataset.railKey || 'material-rail', root.dataset.rail)
} catch {
// Blocked storage: the rail still toggles, it just will not remember.
}
},
show() {
this.open = true
},
hide() {
this.open = false
},
})
// A destination chosen in the modal rail leaves the page; the next one starts with it shut.
document.addEventListener('livewire:navigating', () => window.Alpine.store('rail').hide())
window.Alpine.data('materialNavigationRail', (mode) => ({
wide: mode === 'adaptive' ? window.matchMedia(WIDE).matches : false,
query: null,
onWidth: null,
init() {
if (mode !== 'adaptive') {
return
}
// From lg the adaptive rail is a standard, collapsible rail: a modal left open while
// the window widens is shut, or its focus trap would hold a page that has no scrim.
this.query = window.matchMedia(WIDE)
this.onWidth = (event) => {
this.wide = event.matches
if (event.matches) {
this.$store.rail.hide()
}
}
this.query.addEventListener('change', this.onWidth)
},
destroy() {
this.query?.removeEventListener('change', this.onWidth)
},
/** Whether this rail expands over a scrim rather than in the layout. */
get modal() {
return mode === 'modal' || (mode === 'adaptive' && !this.wide)
},
get open() {
return this.modal && this.$store.rail.open
},
get expanded() {
if (this.open || mode === 'expanded') {
return true
}
return (mode === 'collapsible' || (mode === 'adaptive' && this.wide)) && !this.$store.rail.collapsed
},
/** The rail's own menu button: open or close the modal, or collapse and expand in place. */
menu() {
if (this.modal) {
this.$store.rail.open ? this.$store.rail.hide() : this.$store.rail.show()
} else {
this.$store.rail.toggle()
}
},
}))
})
+14
View File
@@ -7,6 +7,10 @@
* *
* `value` is an accessor, so binding a control with `x-model="$store.theme.value"` goes * `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. * 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', () => { document.addEventListener('alpine:init', () => {
const root = document.documentElement const root = document.documentElement
@@ -18,6 +22,7 @@ document.addEventListener('alpine:init', () => {
window.Alpine.store('theme', { window.Alpine.store('theme', {
choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system', choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system',
resolved: root.dataset.theme === 'dark' ? 'dark' : 'light', resolved: root.dataset.theme === 'dark' ? 'dark' : 'light',
scheme: root.dataset.scheme || null,
get value() { get value() {
return this.choice return this.choice
@@ -48,6 +53,15 @@ document.addEventListener('alpine:init', () => {
toggle() { toggle() {
this.set(this.resolved === 'dark' ? 'light' : 'dark') 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 // The head script repaints on an OS change while the choice is `system`; this keeps the
+684
View File
@@ -0,0 +1,684 @@
/**
* `materialTimepicker(value, config)`: the behaviour of `<x-timepicker>` — a field that opens M3's
* time picker in a modal `<dialog>`, as a clock dial or as two text fields.
*
* The picker edits a draft, `hour` (023) and `minute`; only OK writes it to `value` as `H:i`.
* Cancel, Escape and a press on the scrim leave `value` alone, and every close hands focus back to
* the field. Whether the hours run 112 with AM and PM or 023 comes from `config.format` or, without
* one, from the locale (`Intl.DateTimeFormat(locale, { hour: 'numeric' })`'s hour cycle).
*
* The dial:
* - A press sets the value where it lands, and the selector turns there on the default spatial
* spring; a drag makes the handle follow the pointer and settles on the nearest value when
* released. Hours then move on to minutes, as Compose does after a tap (100ms later) or a drag.
* - On a 24-hour dial the inner ring holds 1223 and the outer 0011: a press nearer the centre than
* 74dp (scaled with the dial) is on the inner ring.
* - A tap picks minutes in fives (or in `step`s, when `step` is not a divisor of five); a drag in ones
* (or `step`s).
* - The dial is a slider for the keyboard: the arrows change the hour or minute it shows, Home and End
* go to the first and last allowed, Enter confirms. The keyboard never moves on to minutes by
* itself; the hour and minute boxes above the dial switch between them.
*
* `min` and `max` (`H:i`, inclusive; `min` later than `max` is a range across midnight) and `step`
* (minutes) limit what can be chosen: a tap on a number outside them does nothing, a drag and the
* arrows skip to the nearest allowed value, a period switch that would leave them lands on the
* nearest allowed time, and typed values outside them are errors. They are a
* convenience for the person choosing, not validation — validate on the server too.
*
* The selector's angle is a registered custom property (`--timepicker-angle`, components/
* timepicker.css), so one CSS transition turns the line, moves the handle, and moves the clip that
* shows the number under the handle in on-primary, all on the same spring.
*
* ---------------------------------------------------------------------------------------
* Behaviour and geometry follow androidx Compose Material 3 (https://github.com/androidx/androidx),
* commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326:
*
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
* (AnalogTimePickerState.rotateTo, endValueForAnimation, moveSelector, onTap, ClockDialNode,
* selectorPos, TimeInputImpl, shouldSwitchFocusToMinute, the ring and distance constants)
*
* Copyright 2022-2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ---------------------------------------------------------------------------------------
*/
/** ClockDialContainerSize: the dial's own coordinate space. */
const DIAL = 256
/** MaxDistance: nearer the centre than this, a 24-hour dial is on its inner ring. */
const INNER_REACH = 74
/** onTap's delay(100) before a tapped hour moves on to minutes. */
const MOVE_ON_MS = 100
/** How far a pointer moves before a press on the dial is a drag. */
const DRAG_SLOP = 4
const pad = (number) => String(number).padStart(2, '0')
/** "112" stays on one line in a 96px column: word joiners either side of a dash between numbers. */
const keepRange = (text) => text.replace(/(\d)\s*([-])\s*(\d)/g, '$1\u2060$2\u2060$3')
/** The shortest way round from one angle to another, in degrees. */
const turn = (from, to) => ((((to - from) % 360) + 540) % 360) - 180
function parse(value) {
const match = /^(\d{1,2}):(\d{2})/.exec(typeof value === 'string' ? value : '')
if (!match) {
return null
}
const hour = Number(match[1])
const minute = Number(match[2])
return hour < 24 && minute < 60 ? hour * 60 + minute : null
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialTimepicker', (value = null, config = {}) => ({
value,
open: false,
mode: 'dial',
view: 'hour',
hour: 0,
minute: 0,
angle: 0,
dragging: false,
scrimPressed: false,
hourText: '',
minuteText: '',
attempted: false,
is24: false,
step: 1,
earliest: null,
latest: null,
moveOn: null,
formatter: null,
periods: ['AM', 'PM'],
init() {
const locale = config.locale || undefined
try {
this.is24 = config.format ? Number(config.format) === 24 : ['h23', 'h24'].includes(new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hourCycle)
this.formatter = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', hourCycle: this.is24 ? 'h23' : 'h12' })
const dayPeriod = (hour) => new Intl.DateTimeFormat(locale, { hour: 'numeric', hourCycle: 'h12' }).formatToParts(new Date(2000, 0, 1, hour)).find((part) => part.type === 'dayPeriod')?.value
this.periods = [dayPeriod(9) ?? config.strings.am, dayPeriod(21) ?? config.strings.pm]
} catch {
this.is24 = Number(config.format) === 24
this.formatter = null
this.periods = [config.strings.am, config.strings.pm]
}
this.step = Math.min(Math.max(Math.trunc(Number(config.step) || 1), 1), 60)
this.earliest = parse(config.min)
this.latest = parse(config.max)
},
// ---- What is shown ---------------------------------------------------------------------
get display() {
const time = parse(this.value)
return time === null ? '' : this.format(time)
},
format(time) {
const date = new Date(2000, 0, 1, Math.floor(time / 60), time % 60)
return this.formatter ? this.formatter.format(date) : `${pad(date.getHours())}:${pad(date.getMinutes())}`
},
get isPm() {
return this.hour >= 12
},
get hourLabel() {
return this.is24 ? pad(this.hour) : pad(this.hour % 12 || 12)
},
get minuteLabel() {
return pad(this.minute)
},
/** selectorPos: a 24-hour dial's afternoon hours are on the inner ring. */
get inner() {
return this.is24 && this.view === 'hour' && this.hour >= 12
},
get valueText() {
const strings = config.strings
if (this.view === 'minute') {
return strings.minutes.replace(':minute', this.minute)
}
return this.is24 ? strings.hours.replace(':hour', this.hour) : `${strings.oclock.replace(':hour', this.hour % 12 || 12)} ${this.periods[this.isPm ? 1 : 0]}`
},
// ---- What may be chosen ----------------------------------------------------------------
inRange(time) {
const { earliest, latest } = this
if (earliest !== null && latest !== null && earliest > latest) {
return time >= earliest || time <= latest
}
return (earliest === null || time >= earliest) && (latest === null || time <= latest)
},
allowed(hour, minute) {
return minute % this.step === 0 && this.inRange(hour * 60 + minute)
},
hourAllowed(hour) {
for (let minute = 0; minute < 60; minute += this.step) {
if (this.allowed(hour, minute)) {
return true
}
}
return false
},
minuteAllowed(minute) {
return this.allowed(this.hour, minute)
},
periodAllowed(pm) {
for (let hour = pm ? 12 : 0; hour < (pm ? 24 : 12); hour++) {
if (this.hourAllowed(hour)) {
return true
}
}
return false
},
/** The allowed time nearest to one that is not, searching outwards a minute at a time. */
nearest(time) {
for (let distance = 0; distance <= 720; distance++) {
for (const candidate of [time - distance, time + distance]) {
const wrapped = (candidate + 1440) % 1440
if (this.allowed(Math.floor(wrapped / 60), wrapped % 60)) {
return wrapped
}
}
}
return null
},
/** The minute nearest to this one that the current hour allows. */
nearestMinute(minute) {
for (let distance = 0; distance <= 30; distance++) {
for (const candidate of [minute - distance, minute + distance]) {
const wrapped = (candidate + 60) % 60
if (this.minuteAllowed(wrapped)) {
return wrapped
}
}
}
return null
},
/** The hour nearest to this one on its own ring (period), then on the other. */
nearestHour(hour) {
const base = hour >= 12 ? 12 : 0
for (const ring of [base, 12 - base]) {
for (let distance = 0; distance <= 6; distance++) {
for (const candidate of [hour - distance, hour + distance]) {
const inRing = ring + ((((candidate - base) % 12) + 12) % 12)
if (this.hourAllowed(inRing)) {
return inRing
}
}
}
if (!this.is24) {
return null
}
}
return null
},
settle(time) {
const allowed = this.allowed(Math.floor(time / 60), time % 60) ? time : this.nearest(time)
if (allowed !== null) {
this.hour = Math.floor(allowed / 60)
this.minute = allowed % 60
}
},
/** An hour chosen: keep the minute if the new hour allows it, otherwise the nearest one it does. */
setHour(hour) {
this.hour = hour
if (!this.minuteAllowed(this.minute)) {
this.minute = this.nearestMinute(this.minute) ?? this.minute
}
},
// ---- The dialog ------------------------------------------------------------------------
show() {
if (this.open || this.$refs.input.disabled) {
return
}
const now = new Date()
const time = parse(this.value) ?? now.getHours() * 60 + Math.round(now.getMinutes() / this.step) * this.step
this.settle(time % 1440)
this.view = 'hour'
this.attempted = false
this.fillText()
this.angle = this.angleFor()
this.open = true
// After Alpine has drawn the draft, so nothing animates from the last time it was open.
this.$nextTick(() => {
this.$refs.dialog.showModal()
this.focusMode()
})
},
cancel() {
this.$refs.dialog.close()
},
confirm() {
if (this.mode === 'input' && !this.readText()) {
return
}
if (!this.allowed(this.hour, this.minute)) {
return
}
this.value = `${pad(this.hour)}:${pad(this.minute)}`
this.$refs.dialog.close()
},
closed() {
clearTimeout(this.moveOn)
this.open = false
this.dragging = false
this.$refs.input.focus({ preventScroll: true })
},
focusMode() {
if (this.mode === 'input') {
const field = this.view === 'minute' ? this.$refs.minuteInput : this.$refs.hourInput
field.focus()
field.select()
} else {
this.$refs.dial.focus({ preventScroll: true })
}
},
toggleMode() {
clearTimeout(this.moveOn)
if (this.mode === 'dial') {
this.mode = 'input'
this.attempted = false
this.fillText()
} else {
this.mode = 'dial'
this.angle = this.angleFor()
}
this.$nextTick(() => this.focusMode())
},
/** The hour or minute box above the dial. */
choose(view) {
clearTimeout(this.moveOn)
this.view = view
this.aim()
},
setPeriod(pm) {
if (this.isPm === pm || !this.periodAllowed(pm)) {
return
}
const hour = this.hour + (pm ? 12 : -12)
if (this.hourAllowed(hour)) {
this.setHour(hour)
} else {
const time = this.nearestInPeriod(pm, hour * 60 + this.minute)
this.hour = Math.floor(time / 60)
this.minute = time % 60
}
this.fillText()
this.aim()
},
/** The allowed time in a period nearest to `time` (periodAllowed has said there is one). */
nearestInPeriod(pm, time) {
let best = null
for (let candidate = pm ? 720 : 0; candidate < (pm ? 1440 : 720); candidate++) {
if (this.allowed(Math.floor(candidate / 60), candidate % 60) && (best === null || Math.abs(candidate - time) < Math.abs(best - time))) {
best = candidate
}
}
return best ?? time
},
// ---- The dial --------------------------------------------------------------------------
angleFor() {
return this.view === 'hour' ? (this.hour % 12) * 30 : this.minute * 6
},
/** endValueForAnimation: turn the short way round, so 11 to 1 never spins backwards. */
aim(degrees = this.angleFor()) {
this.angle += turn(this.angle, degrees)
},
press(event) {
if (event.button !== 0 || !event.isPrimary) {
return
}
event.preventDefault()
clearTimeout(this.moveOn)
const dial = this.$refs.dial
const startX = event.clientX
const startY = event.clientY
let dragged = false
dial.focus({ preventScroll: true })
try {
dial.setPointerCapture(event.pointerId)
} catch {
// A pointer the browser does not know (a synthetic event) cannot be captured.
}
const move = (moveEvent) => {
if (!dragged && Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) < DRAG_SLOP) {
return
}
dragged = true
this.dragging = true
this.pick(moveEvent, false)
}
const release = (upEvent, cancelled = false) => {
dial.removeEventListener('pointermove', move)
dial.removeEventListener('pointerup', release)
dial.removeEventListener('pointercancel', abandon)
this.dragging = false
if (cancelled) {
this.aim()
return
}
if (dragged) {
this.aim()
} else if (!this.pick(upEvent, true)) {
return
}
if (this.view === 'hour') {
this.moveOn = setTimeout(() => this.choose('minute'), dragged ? 0 : MOVE_ON_MS)
}
}
const abandon = (cancelEvent) => release(cancelEvent, true)
dial.addEventListener('pointermove', move)
dial.addEventListener('pointerup', release)
dial.addEventListener('pointercancel', abandon)
},
/**
* The value under the pointer. A tap turns the selector to the value it chose; a drag keeps the
* handle under the pointer (rotateTo without animation) until it is released.
*/
pick(event, tap) {
const box = this.$refs.dial.getBoundingClientRect()
const x = event.clientX - (box.left + box.width / 2)
const y = event.clientY - (box.top + box.height / 2)
const degrees = ((Math.atan2(x, -y) * 180) / Math.PI + 360) % 360
if (this.view === 'hour') {
let hour = Math.round(degrees / 30) % 12
if (this.is24 ? Math.hypot(x, y) < (INNER_REACH * box.width) / DIAL : this.isPm) {
hour += 12
}
// A tap on a number outside the limits does nothing; a drag settles on the nearest allowed.
hour = this.hourAllowed(hour) ? hour : tap ? null : this.nearestHour(hour)
if (hour === null) {
return false
}
this.setHour(hour)
} else {
const unit = tap && 5 % this.step === 0 ? 5 : this.step
let minute = (Math.round(degrees / 6 / unit) * unit) % 60
minute = this.minuteAllowed(minute) ? minute : tap ? null : this.nearestMinute(minute)
if (minute === null) {
return false
}
this.minute = minute
}
this.aim(tap ? this.angleFor() : degrees)
return true
},
key(event) {
const moves = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1 }
if (event.key === 'Enter') {
event.preventDefault()
this.confirm()
} else if (event.key in moves) {
event.preventDefault()
this.nudge(moves[event.key])
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
this.extreme(event.key === 'End')
}
},
nudge(direction) {
clearTimeout(this.moveOn)
if (this.view === 'hour') {
for (let offset = 1; offset <= 24; offset++) {
const hour = (((this.hour + direction * offset) % 24) + 24) % 24
if (this.hourAllowed(hour)) {
this.setHour(hour)
break
}
}
} else {
const aligned = Math.round(this.minute / this.step) * this.step
for (let offset = aligned === this.minute ? 1 : 0; offset <= 60; offset++) {
const minute = (((aligned + direction * offset * this.step) % 60) + 60) % 60
if (this.minuteAllowed(minute)) {
this.minute = minute
break
}
}
}
this.aim()
},
extreme(last) {
const values = this.view === 'hour'
? [...Array(24).keys()].filter((hour) => this.hourAllowed(hour))
: [...Array(60).keys()].filter((minute) => this.minuteAllowed(minute))
if (values.length === 0) {
return
}
if (this.view === 'hour') {
this.setHour(last ? values.at(-1) : values[0])
} else {
this.minute = last ? values.at(-1) : values[0]
}
this.aim()
},
// ---- The text fields -------------------------------------------------------------------
fillText() {
this.hourText = this.hourLabel
this.minuteText = this.minuteLabel
},
get hourTextValid() {
const number = Number(this.hourText)
return /^\d{1,2}$/.test(this.hourText) && (this.is24 ? number <= 23 : number >= 1 && number <= 12)
},
get minuteTextValid() {
return /^\d{1,2}$/.test(this.minuteText) && Number(this.minuteText) <= 59
},
get minuteTextOnStep() {
return !this.minuteTextValid || Number(this.minuteText) % this.step === 0
},
get hourError() {
if (this.hourTextValid || (this.hourText === '' && !this.attempted)) {
return null
}
return keepRange(this.is24 ? config.strings.hourError24 : config.strings.hourError12)
},
get minuteError() {
if (this.minuteText === '' && !this.attempted) {
return null
}
if (!this.minuteTextValid) {
return keepRange(config.strings.minuteError)
}
return this.minuteTextOnStep ? null : config.strings.stepError.replace(':step', this.step)
},
get rangeError() {
if (this.earliest === null && this.latest === null) {
return null
}
if (this.mode !== 'input' || !this.hourTextValid || !this.minuteTextValid || this.inRange(this.hour * 60 + this.minute)) {
return null
}
const strings = config.strings
if (this.earliest !== null && this.latest !== null) {
return strings.between.replace(':min', this.format(this.earliest)).replace(':max', this.format(this.latest))
}
return this.earliest !== null ? strings.after.replace(':min', this.format(this.earliest)) : strings.before.replace(':max', this.format(this.latest))
},
typeHour(event) {
const text = event.target.value.replace(/\D/g, '').slice(0, 2)
event.target.value = text
this.hourText = text
if (this.hourTextValid) {
const number = Number(text)
this.hour = this.is24 ? number : (number % 12) + (this.isPm ? 12 : 0)
}
// shouldSwitchFocusToMinute: two valid digits typed at the end move on to the minute.
if (event.inputType?.startsWith('insert') && text.length === 2 && this.hourTextValid && event.target.selectionStart === 2) {
this.view = 'minute'
this.$refs.minuteInput.focus()
this.$refs.minuteInput.select()
}
},
typeMinute(event) {
const text = event.target.value.replace(/\D/g, '').slice(0, 2)
event.target.value = text
this.minuteText = text
if (this.minuteTextValid) {
this.minute = Number(text)
}
},
/** Reads both fields for OK; on an error, says so and puts focus on the field to fix. */
readText() {
this.attempted = true
const invalid = !this.hourTextValid
? this.$refs.hourInput
: !this.minuteTextValid || !this.minuteTextOnStep
? this.$refs.minuteInput
: !this.inRange(this.hour * 60 + this.minute)
? this.$refs.hourInput
: null
if (invalid) {
invalid.focus()
invalid.select()
return false
}
return true
},
}))
})
@@ -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('')); $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 @endphp
<x-menu :$label :$position {{ $attributes }}> <x-livewire-material::menu :$label :$position {{ $attributes }}>
<x-slot:trigger> <x-slot:trigger>
<button <button
type="button" type="button"
@@ -45,7 +45,7 @@
@elseif (filled($initials)) @elseif (filled($initials))
{{ $initials }} {{ $initials }}
@else @else
<x-icon name="person" /> <x-livewire-material::icon name="person" />
@endif @endif
</button> </button>
</x-slot:trigger> </x-slot:trigger>
@@ -60,20 +60,20 @@
@endif @endif
</div> </div>
<x-menu-separator /> <x-livewire-material::menu-separator />
@endif @endif
{{ $slot }} {{ $slot }}
@if ($theme) @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> <span x-text="$store.theme.resolved === 'dark' ? @js(__('Light theme')) : @js(__('Dark theme'))">{{ __('Theme') }}</span>
</x-menu-item> </x-livewire-material::menu-item>
@endif @endif
@isset($footer) @isset($footer)
<x-menu-separator /> <x-livewire-material::menu-separator />
{{ $footer }} {{ $footer }}
@endisset @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]) }} {{ $attributes->class(['flex items-start gap-3 rounded-corner-md p-4', $colours]) }}
> >
@if ($icon) @if ($icon)
<x-icon :name="$icon" filled class="size-6" /> <x-livewire-material::icon :name="$icon" filled class="size-6" />
@endif @endif
<div class="min-w-0 flex-1 space-y-1 self-center"> <div class="min-w-0 flex-1 space-y-1 self-center">
@@ -59,7 +59,7 @@
@if ($dismissible) @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"> <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> </button>
@endif @endif
</div> </div>
@@ -0,0 +1,146 @@
{{-- The adaptive app shell: navigation that changes shape with the window, around the page.
<x-app-shell :destinations="[
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares'), 'active' => request()->routeIs('shares*'), 'badge' => 3],
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
]">
<x-slot:brand><a href="/" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
<x-slot:top>…the page's app bar…</x-slot:top>
…the page…
</x-app-shell>
- Below `sm`: a navigation bar with the destinations marked `bar`, pinned to the bottom.
Everything else is in the modal rail, which slides in when something calls
`$store.rail.show()` — put a menu button in the app bar for it, hidden from `sm`:
`<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>`.
- `sm` to `lg`: the collapsed rail, whose menu button opens it expanded, as a modal.
- From `lg`: the expanded rail, collapsed and expanded again by its menu button; the choice is
remembered and applied before the first paint (`$store.rail`, <x-theme-script>).
`destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally
`active` (by default: the URL is the current one), `badge` (`true` for a dot, or a count),
`section` (a heading the destination is grouped under in the rail; only an expanded rail shows
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
`navigate` (`false` for a full page load instead of `wire:navigate`).
Slots, each rendered once: `brand` (beside the rail's menu button while it is expanded),
`rail-header` (under it: a FAB see `<x-navigation-rail>` for its two shapes), `rail-footer`
(at the foot of the rail: footer destinations, an account), `actions` (a row of icon buttons at
the very foot, stacked when the rail is collapsed: a theme toggle, sign out), `top` (the app
bar, above the page at every width) and the page itself. The rail is one element at every
width, so what is in it is also in the modal rail a phone opens. `label` names both navigation
landmarks ("Main"); `rail-width` is the expanded rail's width.
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`.
`max-lg:overflow-x-clip` on the content region is the backstop under every page, and it stays
`clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll
container and break every `position: sticky` inside it (an app bar, a list-detail pane). Below
`lg` only, so a wide window never clips what overhangs on purpose.
Nothing application-specific belongs in here: an app's destinations and chrome come in through
the props and slots. --}}
@props([
'destinations' => [],
'label' => null,
'railWidth' => '16rem',
])
@php
$label ??= __('Main');
$current = request()->url();
$items = collect($destinations)
->filter(fn ($item): bool => is_array($item) && filled($item['title'] ?? null))
->map(fn (array $item): array => [
'title' => (string) $item['title'],
'icon' => $item['icon'] ?? null,
'url' => $item['url'] ?? null,
'active' => (bool) ($item['active'] ?? (filled($item['url'] ?? null) && rtrim(url($item['url']), '/') === rtrim($current, '/'))),
'badge' => $item['badge'] ?? null,
'section' => filled($item['section'] ?? null) ? (string) $item['section'] : null,
'bar' => ($item['bar'] ?? true) !== false,
'navigate' => ($item['navigate'] ?? true) !== false,
])
->values();
$barItems = $items->where('bar', true)->values();
// Consecutive destinations under the same heading form one group, in the order given.
$groups = $items->chunkWhile(fn (array $item, int $key, $chunk): bool => $item['section'] === $chunk->last()['section']);
@endphp
<div
data-app-shell
@class([
'min-h-dvh bg-surface text-on-surface sm:flex',
'max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]' => $barItems->isNotEmpty(),
])
>
<a
href="#content"
data-skip-link
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(env(safe-area-inset-top)+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
>{{ __('Skip to content') }}</a>
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
@isset($brand)
<x-slot:brand>{{ $brand }}</x-slot:brand>
@endisset
@isset($railHeader)
<x-slot:header>{{ $railHeader }}</x-slot:header>
@endisset
@foreach ($groups as $group)
@if ($group->first()['section'] !== null)
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
@foreach ($group as $item)
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
@endforeach
</x-livewire-material::navigation-rail-section>
@else
@foreach ($group as $item)
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
@endforeach
@endif
@endforeach
@if (isset($railFooter) || isset($actions))
<x-slot:footer>
{{ $railFooter ?? '' }}
@isset($actions)
<div data-app-shell-actions class="flex items-center gap-1 px-5 pt-2 rail-collapsed:flex-col">
{{ $actions }}
</div>
@endisset
</x-slot:footer>
@endif
</x-livewire-material::navigation-rail>
<div class="flex min-w-0 flex-1 flex-col">
{{ $top ?? '' }}
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 outline-none max-lg:overflow-x-clip max-sm:pb-(--material-bottom-bar)">
{{ $slot }}
</main>
</div>
@if ($barItems->isNotEmpty())
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
<x-livewire-material::navigation-bar :label="$label">
@foreach ($barItems as $item)
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
@endforeach
</x-livewire-material::navigation-bar>
</div>
@endif
<x-livewire-material::toast />
</div>
+4 -4
View File
@@ -202,13 +202,13 @@
<{{ $tag }} {{ $attributes }}> <{{ $tag }} {{ $attributes }}>
@if ($spinnerTarget) @if ($spinnerTarget)
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center"> <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> </span>
@endif @endif
@if ($icon) @if ($icon)
<span class="contents" @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif> <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> </span>
@endif @endif
@@ -217,10 +217,10 @@
@endunless @endunless
@if ($iconRight) @if ($iconRight)
<x-icon :name="$iconRight" :class="$iconSize" /> <x-livewire-material::icon :name="$iconRight" :class="$iconSize" />
@endif @endif
@if ($tip !== null) @if ($tip !== null)
<x-tooltip :text="$tip" :side="$tipSide" :anchor="$anchor" /> <x-livewire-material::tooltip :text="$tip" :side="$tipSide" :anchor="$anchor" />
@endif @endif
</{{ $tag }}> </{{ $tag }}>
+1 -1
View File
@@ -60,7 +60,7 @@
</div> </div>
@if ($separator) @if ($separator)
<x-divider class="mt-4" /> <x-livewire-material::divider class="mt-4" />
@endif @endif
</div> </div>
@endif @endif
@@ -136,7 +136,7 @@
'hidden pointer-fine:flex' => $controls === null, 'hidden pointer-fine:flex' => $controls === null,
'flex' => $controls === true, 'flex' => $controls === true,
])> ])>
<x-button <x-livewire-material::button
icon="chevron_left" icon="chevron_left"
variant="tonal" variant="tonal"
:tooltip="__('Previous')" :tooltip="__('Previous')"
@@ -145,7 +145,7 @@
x-on:click="previous()" x-on:click="previous()"
class="rtl:-scale-x-100" class="rtl:-scale-x-100"
/> />
<x-button <x-livewire-material::button
icon="chevron_right" icon="chevron_right"
variant="tonal" variant="tonal"
:tooltip="__('Next')" :tooltip="__('Next')"
@@ -18,7 +18,9 @@
@php @php
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'check-'.substr(md5($model.'|'.$label.'|'.$attributes->get('value')), 0, 12); $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 @endphp
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}> <div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}>
@@ -36,8 +38,8 @@
@if ($indeterminate) data-indeterminate @endif @if ($indeterminate) data-indeterminate @endif
@if ($messages !== []) aria-invalid="true" aria-describedby="{{ $id }}-support" @endif @if ($messages !== []) aria-invalid="true" aria-describedby="{{ $id }}-support" @endif
/> />
<x-icon name="check" class="size-4" data-check /> <x-livewire-material::icon name="check" class="size-4" data-check />
<x-icon name="remove" class="size-4" data-mixed /> <x-livewire-material::icon name="remove" class="size-4" data-mixed />
</span> </span>
@if (filled($label) || filled($hint)) @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]) /> <img src="{{ $avatar }}" alt="" @class(['me-2 size-6 shrink-0 rounded-corner-full object-cover', 'opacity-38' => $disabled]) />
@endif @endif
@elseif ($icon) @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 @endif
<span data-chip-label class="truncate">{{ $label ?? $slot }}</span> <span data-chip-label class="truncate">{{ $label ?? $slot }}</span>
@@ -251,7 +251,7 @@
'cursor-not-allowed' => $disabled, 'cursor-not-allowed' => $disabled,
]) ])
> >
<x-icon name="close" class="size-4.5" /> <x-livewire-material::icon name="close" class="size-4.5" />
</button> </button>
@endif @endif
@@ -260,7 +260,7 @@
@endif @endif
@if ($tooltip !== null) @if ($tooltip !== null)
<x-tooltip :text="$tooltip" :anchor="$anchor" /> <x-livewire-material::tooltip :text="$tooltip" :anchor="$anchor" />
@endif @endif
</span> </span>
@else @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), '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) @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 @endif
<x-icon name="check" data-chip-check :class="$check" /> <x-livewire-material::icon name="check" data-chip-check :class="$check" />
</span> </span>
@elseif ($icon) @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 @endif
<span class="truncate">{{ $label ?? $slot }}</span> <span class="truncate">{{ $label ?? $slot }}</span>
@if ($iconRight) @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 @endif
@if ($tooltip !== null) @if ($tooltip !== null)
<x-tooltip :text="$tooltip" :anchor="$anchor" /> <x-livewire-material::tooltip :text="$tooltip" :anchor="$anchor" />
@endif @endif
</{{ $tag }}> </{{ $tag }}>
@endif @endif
+11 -9
View File
@@ -35,8 +35,10 @@
@php @php
$model = $attributes->wire('model')->value() ?: null; $model = $attributes->wire('model')->value() ?: null;
$messages = $model !== null && isset($errors) // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')]))) $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 => [ $choices = collect($options)->map(fn ($option): array => [
'value' => data_get($option, $optionValue), 'value' => data_get($option, $optionValue),
@@ -115,7 +117,7 @@
@if ($model === null) x-modelable="value" @endif @if ($model === null) x-modelable="value" @endif
> >
<div style="anchor-name: {{ $anchor }}"> <div style="anchor-name: {{ $anchor }}">
<x-field :$id :$label :$hint :$messages :$icon :$variant> <x-livewire-material::field :$id :$label :$hint :$messages :$icon :$variant>
<input <input
id="{{ $id }}" id="{{ $id }}"
type="text" type="text"
@@ -144,9 +146,9 @@
/> />
<x-slot:trailing> <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-slot:trailing>
</x-field> </x-livewire-material::field>
</div> </div>
<ul <ul
@@ -170,7 +172,7 @@
class="field-option" class="field-option"
> >
<span x-text="option.label"></span> <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> </li>
</template> </template>
<li x-show="filtered.length === 0" class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</li> <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 @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) @foreach ($choices as $index => $choice)
<x-chip <x-livewire-material::chip
type="filter" type="filter"
:label="$choice['label']" :label="$choice['label']"
:selected="$isSelected($choice['value'])" :selected="$isSelected($choice['value'])"
@@ -213,6 +215,6 @@
x-on:click="toggle({{ $index }})" x-on:click="toggle({{ $index }})"
/> />
@endforeach @endforeach
</x-chip-set> </x-livewire-material::chip-set>
</div> </div>
@endif @endif
@@ -28,12 +28,12 @@
'px-4' => $variant === 'filled', 'px-4' => $variant === 'filled',
])> ])>
@if ($icon) @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 @endif
<span class="min-w-0 flex-1">{{ $heading ?? $title }}</span> <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> </summary>
<div @class(['pb-4 type-body-md text-on-surface-variant', 'px-4' => $variant === 'filled', 'pt-1'])> <div @class(['pb-4 type-body-md text-on-surface-variant', 'px-4' => $variant === 'filled', 'pt-1'])>
@@ -0,0 +1,464 @@
{{-- A date field with M3's date pickers: docked under the field, modal, or modal text input.
`mode` picks M3's three:
- `docked` (the default): a text field that takes a typed date in the locale's numeric format
(`13.09.2026` in `de`, `09/13/2026` in `en-US`), with the calendar dropping open under it
on a press of the field, its calendar button, or ArrowDown. On a compact window (below
`sm`) the calendar opens as the modal picker instead, where a docked one would not fit.
- `modal`: the field only shows the date; pressing it (or Enter, Space, ArrowDown) opens the
calendar in a dialog, with a pencil to switch to typing.
- `input`: the same dialog, opened on its text field, with a calendar icon to switch back.
The calendar and the dialog's text field pick a draft; OK (or Enter on a day) makes it the
value, Cancel or Escape leaves the value alone and returns focus to the field. What is typed
into the docked field is the value as soon as it is a whole, allowed date.
`wire:model` stores `Y-m-d` strings (`x-model` without Livewire). `range` picks a start and an
end, bound as one array, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null)
one property rather than two, because a `wire:model` names one property, Livewire sends a
range as one update so `after_or_equal:period.start` validates against the end it came
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; `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.
Month and weekday names, the first day of the week and the typed format come from `Intl` for
`app()->getLocale()` (resources/js/datepicker.js). Replaces ReStride's flatpickr picker: its
`config` becomes `min`, `max`, `range` and `mode`.
M3's date pickers (DatePickerModalTokens and DateInputModalTokens from androidx Compose
Material 3, androidx commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, with the layout of
DatePicker.kt, DateRangePicker.kt and DateInput.kt; the docked picker's values from
material-web's md-comp-date-picker-docked tokens, v0_192, as Compose has none; all Apache-2.0):
surface-container-high at elevation 3, 360px wide, the extra-large corner (modal) or large
(docked), 40px days in 48px cells, today outlined in primary, the chosen day in primary and a
range's middle in secondary-container. The styles are resources/css/components/datepicker.css. --}}
@props([
'label' => null,
'hint' => null,
'icon' => null,
'size' => 'md',
'variant' => null,
'mode' => 'docked',
'range' => false,
'min' => null,
'max' => null,
'value' => null,
'clearable' => false,
])
@php
$model = $attributes->wire('model')->value() ?: null;
$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);
// 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());
$toIso = function (mixed $date): ?string {
if ($date instanceof \DateTimeInterface) {
return $date->format('Y-m-d');
}
if (is_string($date) && preg_match('/^(\d{4})-(\d{2})-(\d{2})(?:$|[T ])/', $date, $match) === 1 && checkdate((int) $match[2], (int) $match[3], (int) $match[1])) {
return substr($date, 0, 10);
}
return null;
};
// Rendered as the bound property already says, so the field does not change once Alpine starts.
$current = $value;
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$current = data_get($component, $model);
}
$current = $range
? ['start' => $toIso(data_get($current, 'start')), 'end' => $toIso(data_get($current, 'end'))]
: $toIso($current);
// The typed format as resources/js/datepicker.js derives it, from ICU's short date when PHP has intl.
$pattern = 'yyyy-MM-dd';
if (class_exists(\IntlDateFormatter::class)) {
$short = (string) (new \IntlDateFormatter(str_replace('-', '_', $locale), \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE))->getPattern();
$candidate = rtrim(str_replace('My', 'M/y', (string) preg_replace(['/[^dMy\/\-.]/', '/d{1,2}/', '/M{1,2}/', '/y{1,4}/'], ['', 'dd', 'MM', 'yyyy'], $short)), '.');
if (preg_match('/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[\/\-.][dMy]+[\/\-.][dMy]+$/', $candidate) === 1) {
$pattern = $candidate;
}
}
$typed = fn (?string $date): string => $date === null ? '' : strtr($pattern, ['yyyy' => substr($date, 0, 4), 'MM' => substr($date, 5, 2), 'dd' => substr($date, 8, 2)]);
$display = $range
? ($current['start'] === null && $current['end'] === null ? '' : $typed($current['start']).' '.$typed($current['end']))
: $typed($current);
$title = $range ? __('Select dates') : __('Select date');
$inputTitle = $range ? __('Enter dates') : __('Select date');
$fieldName = $attributes->get('name');
$invalid = $messages !== [];
$config = [
'locale' => $locale,
'mode' => $mode,
'range' => (bool) $range,
'min' => $toIso($min),
'max' => $toIso($max),
'disabled' => (bool) $attributes->get('disabled'),
'readonly' => (bool) $attributes->get('readonly'),
'strings' => [
'selected' => __('Selected date'),
'entered' => __('Entered date'),
'start' => __('Start date'),
'end' => __('End date'),
'pattern' => __('Date does not match expected pattern: :pattern'),
'yearRange' => __('Date out of expected year range :start - :end'),
'notAllowed' => __('Date not allowed: :date'),
'invalidRange' => __('Invalid date range input'),
],
];
@endphp
<div
{{ $attributes->only(['class', 'wire:key', 'x-model']) }}
x-data="materialDatepicker({
@if ($model !== null) value: @entangle($attributes->wire('model')), @else value: @js($current), @endif
...@js($config),
})"
@if ($model === null) x-modelable="value" @endif
x-on:focusout="leave($event)"
x-on:pointerdown.outside="open && presentation === 'docked' && cancel(false)"
data-datepicker
>
<div style="anchor-name: {{ $anchor }}">
<x-livewire-material::field
:$id
:$label
:$icon
:$size
:$variant
:data-invalid="$invalid ? '' : null"
:data-readonly="$attributes->get('readonly') ? '' : null"
x-bind:data-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? '' : null"
>
<input
{{ $attributes->whereDoesntStartWith('wire:model')->except(['class', 'id', 'wire:key', 'x-model', 'name', 'value', 'placeholder', 'type']) }}
x-ref="input"
id="{{ $id }}"
type="text"
role="combobox"
aria-haspopup="dialog"
aria-controls="{{ $id }}-picker"
aria-expanded="false"
x-bind:aria-expanded="open.toString()"
aria-describedby="{{ $id }}-support"
@if ($invalid) aria-invalid="true" @endif
x-bind:aria-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? 'true' : null"
autocomplete="off"
value="{{ $display }}"
placeholder=" "
class="field-control"
x-model="text"
@if ($mode === 'docked')
x-bind:placeholder="placeholder"
x-on:input="typeInField()"
x-on:change="commitField()"
x-on:keydown.enter="commitField()"
x-on:click="show(false)"
x-on:keydown.arrow-down.prevent="open ? focusInside() : show()"
x-on:keydown.escape="if (open) { $event.preventDefault(); $event.stopPropagation(); cancel(); }"
@else
readonly
x-on:click="show()"
x-on:keydown.enter.prevent="show()"
x-on:keydown.space.prevent="show()"
x-on:keydown.arrow-down.prevent="show()"
@endif
/>
<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"
aria-label="{{ $title }}"
aria-haspopup="dialog"
aria-controls="{{ $id }}-picker"
x-bind:aria-expanded="open.toString()"
x-on:click="open ? cancel() : show()"
@if ($mode !== 'docked') tabindex="-1" @endif
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
data-datepicker-toggle
>
<x-livewire-material::icon name="calendar_today" class="size-(--field-icon)" />
</button>
</x-slot:trailing>
</x-livewire-material::field>
</div>
<div id="{{ $id }}-support" data-datepicker-support data-size="{{ in_array($size, ['sm', 'xs'], true) ? $size : 'md' }}">
<p x-cloak x-show="fieldError !== ''" x-text="fieldError" role="alert" data-datepicker-error></p>
@if ($invalid)
<div x-show="fieldError === ''" role="alert" data-datepicker-error>
@foreach ($messages as $message)
<p>{{ $message }}</p>
@endforeach
</div>
@elseif (filled($hint))
<p x-show="fieldError === ''">{{ $hint }}</p>
@endif
</div>
@if (filled($fieldName))
@if ($range)
<input type="hidden" name="{{ $fieldName }}[start]" value="{{ $current['start'] }}" x-bind:value="serialised.start ?? ''" />
<input type="hidden" name="{{ $fieldName }}[end]" value="{{ $current['end'] }}" x-bind:value="serialised.end ?? ''" />
@else
<input type="hidden" name="{{ $fieldName }}" value="{{ $current }}" x-bind:value="serialised" />
@endif
@endif
<dialog
wire:ignore
x-ref="dialog"
id="{{ $id }}-picker"
popover="manual"
aria-label="{{ $title }}"
style="position-anchor: {{ $anchor }}"
x-bind:data-presentation="presentation"
x-on:cancel.prevent="cancel()"
x-on:click.self="presentation === 'modal' && cancel()"
x-on:keydown.escape.prevent.stop="cancel()"
data-datepicker-picker
>
<div tabindex="-1" data-datepicker-surface>
<div data-datepicker-header x-show="presentation === 'modal'" @if ($range) data-range @endif>
<p data-datepicker-title x-text="typing ? @js($inputTitle) : @js($title)">{{ $title }}</p>
<div data-datepicker-headline-row>
<p data-datepicker-headline aria-live="polite" x-text="headline"></p>
<span x-show="! typing">
<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-livewire-material::button icon="date_range" :tooltip="__('Switch to calendar input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
</span>
</div>
</div>
<div x-show="! typing" data-datepicker-calendar>
<span id="{{ $id }}-month" class="sr-only" aria-live="polite" x-text="monthYear"></span>
<div data-datepicker-nav x-show="presentation === 'modal'">
<button
type="button"
data-datepicker-menu-button
x-on:click="toggleView('years')"
x-bind:aria-expanded="(view === 'years').toString()"
x-bind:aria-label="monthYear + ', ' + @js(__('Switch to selecting a year'))"
>
<span x-text="monthYear"></span>
<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-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-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"
data-datepicker-menu-button
x-on:click="toggleView('months')"
x-bind:aria-expanded="(view === 'months').toString()"
x-bind:aria-label="monthLabel + ', ' + @js(__('Switch to selecting a month'))"
>
<span x-text="monthLabel"></span>
<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-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-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"
data-datepicker-menu-button
x-on:click="toggleView('years')"
x-bind:aria-expanded="(view === 'years').toString()"
x-bind:aria-label="yearLabel + ', ' + @js(__('Switch to selecting a year'))"
>
<span x-text="yearLabel"></span>
<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-livewire-material::button icon="chevron_right" :tooltip="__('Next year')" x-on:click="step(12)" x-bind:disabled="view !== 'days' || ! canStep(12)" />
</span>
</span>
</div>
<table role="grid" aria-labelledby="{{ $id }}-month" x-show="view === 'days'" x-on:keydown="gridKey($event)" data-datepicker-grid>
<thead>
<tr>
<template x-for="weekday in weekdays" :key="weekday.long">
<th scope="col" x-bind:abbr="weekday.long" x-text="weekday.narrow"></th>
</template>
</tr>
</thead>
<tbody>
<template x-for="(week, row) in weeks" :key="row">
<tr>
<template x-for="(cell, column) in week" :key="column">
<td
role="gridcell"
x-bind:tabindex="cell.blank ? null : (cell.focused ? 0 : -1)"
x-bind:aria-label="cell.blank ? null : cell.name"
x-bind:aria-selected="! cell.blank && (cell.selected || cell.between) ? 'true' : null"
x-bind:aria-disabled="! cell.blank && cell.disabled ? 'true' : null"
x-bind:aria-current="! cell.blank && cell.today ? 'date' : null"
x-bind:data-value="cell.blank ? null : cell.value"
x-bind:data-blank="cell.blank ? '' : null"
x-bind:data-outside="cell.outside ? '' : null"
x-bind:data-today="cell.today ? '' : null"
x-bind:data-selected="cell.selected ? '' : null"
x-bind:data-start="cell.start ? '' : null"
x-bind:data-end="cell.end ? '' : null"
x-bind:data-between="cell.between ? '' : null"
x-on:click="choose(cell)"
data-datepicker-day
><span x-text="cell.blank ? '' : cell.label"></span></td>
</template>
</tr>
</template>
</tbody>
</table>
<div role="listbox" aria-label="{{ __('Years') }}" x-show="view === 'years' && presentation === 'modal'" x-on:keydown="listKey($event, 3)" data-datepicker-list data-datepicker-years>
<template x-for="year in years" :key="year.value">
<button
type="button"
role="option"
x-bind:aria-selected="year.selected.toString()"
x-bind:tabindex="year.selected ? 0 : -1"
x-bind:data-current="year.current ? '' : null"
x-on:click="showMonthOf(year.value, +shown.slice(5, 7))"
x-text="year.label"
data-datepicker-year
></button>
</template>
</div>
<div role="listbox" aria-label="{{ __('Months') }}" x-show="view === 'months' && presentation === 'docked'" x-on:keydown="listKey($event)" data-datepicker-list data-datepicker-menu>
<template x-for="month in months" :key="month.value">
<button
type="button"
role="option"
x-bind:aria-selected="month.selected.toString()"
x-bind:aria-disabled="month.disabled ? 'true' : null"
x-bind:tabindex="month.selected ? 0 : -1"
x-on:click="month.disabled || showMonthOf(+shown.slice(0, 4), month.value)"
data-datepicker-option
>
<x-livewire-material::icon name="check" data-datepicker-check />
<span x-text="month.label"></span>
</button>
</template>
</div>
<div role="listbox" aria-label="{{ __('Years') }}" x-show="view === 'years' && presentation === 'docked'" x-on:keydown="listKey($event)" data-datepicker-list data-datepicker-menu>
<template x-for="year in years" :key="year.value">
<button
type="button"
role="option"
x-bind:aria-selected="year.selected.toString()"
x-bind:tabindex="year.selected ? 0 : -1"
x-on:click="showMonthOf(year.value, +shown.slice(5, 7))"
data-datepicker-option
>
<x-livewire-material::icon name="check" data-datepicker-check />
<span x-text="year.label"></span>
</button>
</template>
</div>
</div>
<div x-show="typing" data-datepicker-entry>
<div @if ($range) data-range @endif data-datepicker-entry-fields>
<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"
autocomplete="off"
placeholder=" "
x-bind:placeholder="format.placeholder"
x-bind:aria-invalid="entryError !== '' ? 'true' : null"
aria-describedby="{{ $id }}-entry-support"
x-ref="entry"
x-model="entry"
x-on:input="typeEntry()"
x-on:keydown.enter.prevent="confirm()"
class="field-control"
/>
</x-livewire-material::field>
@if ($range)
<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"
autocomplete="off"
placeholder=" "
x-bind:placeholder="format.placeholder"
x-bind:aria-invalid="entryError !== '' ? 'true' : null"
aria-describedby="{{ $id }}-entry-support"
x-model="entryEnd"
x-on:input="typeEntry()"
x-on:keydown.enter.prevent="confirm()"
class="field-control"
/>
</x-livewire-material::field>
@endif
</div>
<div id="{{ $id }}-entry-support" data-datepicker-support>
<p x-show="entryError !== ''" x-text="entryError" role="alert" data-datepicker-error></p>
</div>
</div>
<div x-show="view === 'days' || typing || presentation === 'modal'" data-datepicker-actions>
<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>
</div>
+2 -2
View File
@@ -113,13 +113,13 @@
@if ($withCloseButton) @if ($withCloseButton)
<span class="-me-3 -mt-2 inline-flex shrink-0"> <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> </span>
@endif @endif
</div> </div>
@if ($separator) @if ($separator)
<x-divider class="mt-4" /> <x-livewire-material::divider class="mt-4" />
@endif @endif
</div> </div>
@endif @endif
@@ -19,8 +19,8 @@
<div {{ $attributes->class('flex flex-col items-center gap-4 px-4 py-10 text-center') }}> <div {{ $attributes->class('flex flex-col items-center gap-4 px-4 py-10 text-center') }}>
<div class="relative grid size-28 place-items-center"> <div class="relative grid size-28 place-items-center">
<x-shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" /> <x-livewire-material::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::icon :name="$icon" class="relative size-12 text-on-secondary-container" />
</div> </div>
@if ($title) @if ($title)
@@ -40,7 +40,7 @@
<{{ $tag }} {{ $attributes }}> <{{ $tag }} {{ $attributes }}>
@if ($icon) @if ($icon)
<x-icon :name="$icon" class="size-6" /> <x-livewire-material::icon :name="$icon" class="size-6" />
@endif @endif
<span>{{ $label ?? $slot }}</span> <span>{{ $label ?? $slot }}</span>
@@ -49,8 +49,8 @@
$colours, $colours,
]) ])
> >
<x-icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" /> <x-livewire-material::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="close" class="hidden size-5 group-aria-expanded/fab:block" />
</button> </button>
</span> </span>
+2 -2
View File
@@ -65,7 +65,7 @@
<{{ $tag }} {{ $attributes }}> <{{ $tag }} {{ $attributes }}>
@if ($icon) @if ($icon)
<x-icon :name="$icon" :class="$iconSize" /> <x-livewire-material::icon :name="$icon" :class="$iconSize" />
@endif @endif
@if ($extended) @if ($extended)
@@ -73,6 +73,6 @@
@endif @endif
@if ($tooltip !== null) @if ($tooltip !== null)
<x-tooltip :text="$tooltip" :anchor="$anchor" /> <x-livewire-material::tooltip :text="$tooltip" :anchor="$anchor" />
@endif @endif
</{{ $tag }}> </{{ $tag }}>
+1 -1
View File
@@ -46,7 +46,7 @@
> >
<div class="field-box"> <div class="field-box">
@if ($icon) @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 @endif
@if (filled($prefix)) @if (filled($prefix))
+6 -4
View File
@@ -21,12 +21,14 @@
@php @php
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'field-'.substr(md5('file|'.$model.'|'.$label), 0, 12); $id = $attributes->get('id') ?? 'field-'.substr(md5('file|'.$model.'|'.$label), 0, 12);
$messages = $model !== null && isset($errors) // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')]))) $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 @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 <input
{{ $attributes->except(['class', 'id', 'type']) }} {{ $attributes->except(['class', 'id', 'type']) }}
type="file" type="file"
@@ -35,4 +37,4 @@
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif @if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control" class="field-control"
/> />
</x-field> </x-livewire-material::field>
+1 -1
View File
@@ -16,7 +16,7 @@
@isset($actions) @isset($actions)
@if ($separator) @if ($separator)
<x-divider /> <x-livewire-material::divider />
@endif @endif
<div {{ $actions->attributes->class(['flex flex-wrap items-center justify-end gap-2']) }}> <div {{ $actions->attributes->class(['flex flex-wrap items-center justify-end gap-2']) }}>
+4 -2
View File
@@ -33,7 +33,9 @@
@php @php
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$name ??= $model; $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'; $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
$segment = [ $segment = [
@@ -79,7 +81,7 @@
/> />
@if (filled(data_get($option, $optionIcon))) @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 @endif
<span class="truncate">{{ data_get($option, $optionLabel) }}</span> <span class="truncate">{{ data_get($option, $optionLabel) }}</span>
+8 -6
View File
@@ -30,10 +30,12 @@
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' '; $placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|'.$placeholder), 0, 12); $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 @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 <input
{{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }} {{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }}
id="{{ $id }}" id="{{ $id }}"
@@ -54,7 +56,7 @@
aria-label="{{ __('Clear') }}" aria-label="{{ __('Clear') }}"
data-field-clear data-field-clear
> >
<x-icon name="close" class="size-(--field-icon)" /> <x-livewire-material::icon name="close" class="size-(--field-icon)" />
</button> </button>
@endif @endif
@@ -67,13 +69,13 @@
aria-label="{{ __('Copy') }}" aria-label="{{ __('Copy') }}"
data-field-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> </button>
@endif @endif
@if ($iconRight) @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 @endif
</x-slot:trailing> </x-slot:trailing>
@endif @endif
</x-field> </x-livewire-material::field>
@@ -61,7 +61,7 @@
@elseif ($image) @elseif ($image)
<img src="{{ $image }}" alt="" class="size-14 shrink-0 rounded-corner-sm object-cover" /> <img src="{{ $image }}" alt="" class="size-14 shrink-0 rounded-corner-sm object-cover" />
@elseif ($icon) @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 @endif
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
@@ -94,6 +94,6 @@
@endif @endif
@if ($iconRight) @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 @endif
</div> </div>
@@ -60,7 +60,7 @@
<{{ $tag }} {{ $attributes }}> <{{ $tag }} {{ $attributes }}>
@if ($icon) @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 @endif
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
@@ -76,6 +76,6 @@
@endif @endif
@if ($iconRight) @if ($iconRight)
<x-icon :name="$iconRight" :class="'size-5 '.$iconInk" /> <x-livewire-material::icon :name="$iconRight" :class="'size-5 '.$iconInk" />
@endif @endif
</{{ $tag }}> </{{ $tag }}>
+8 -7
View File
@@ -69,7 +69,7 @@
])> ])>
@if ($fullscreen) @if ($fullscreen)
<div class="flex h-16 shrink-0 items-center gap-1 px-1 sm:hidden"> <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)) @if (filled($title))
<span class="truncate type-title-lg">{{ $title }}</span> <span class="truncate type-title-lg">{{ $title }}</span>
@@ -78,22 +78,23 @@
@endif @endif
<div @class(['min-h-0 flex-1', 'max-sm:overflow-y-auto max-sm:px-6 max-sm:pb-6' => $fullscreen])> <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) @if (filled($title) || filled($subtitle) || $icon)
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon, 'text-center' => $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) @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 @endif
@if (filled($title)) @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 @endif
@if (filled($subtitle)) @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 @endif
@if ($separator) @if ($separator)
<x-divider class="mt-4" /> <x-livewire-material::divider class="mt-4" />
@endif @endif
</div> </div>
@endif @endif
@@ -0,0 +1,69 @@
{{-- One destination in an `<x-navigation-bar>`.
<x-navigation-bar-item label="Inbox" icon="inbox" link="{{ route('inbox') }}" :active="request()->routeIs('inbox')" badge="12" />
`link` renders an anchor with `wire:navigate` (unless `external` or `no-wire-navigate`);
without one it is a button, for a destination a Livewire action switches to. `active` marks
the current destination: `aria-current="page"`, the filled icon in on-secondary-container on
the secondary-container indicator, and the label in secondary (on-secondary-container inside
the medium bar's pill).
`badge` puts M3's badge on the icon: `true` for the small dot, a number or a few characters
for the large one (999+ at most). Screen readers hear a count after the label (", 12");
`badge-label` replaces it with words ("12 unread") and gives a dot something to say.
Values from NavigationBarTokens.kt and NavigationBarVerticalItemTokens.kt (androidx Compose
Material 3, Apache-2.0); see `<x-navigation-bar>`. --}}
@props([
'label' => null,
'icon' => null,
'link' => null,
'external' => false,
'noWireNavigate' => false,
'active' => false,
'badge' => null,
'badgeLabel' => null,
])
@php
$isLink = filled($link);
$tag = $isLink ? 'a' : 'button';
$dot = $badge === true;
$count = ! $dot && $badge !== null && $badge !== false && $badge !== '';
$spoken = $badgeLabel ?? ($count ? $badge : null);
$attributes = $attributes->merge(array_filter([
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
'type' => $isLink ? null : 'button',
'aria-current' => $active ? 'page' : null,
'data-active' => $active ? true : null,
], fn ($value): bool => $value !== null));
@endphp
<{{ $tag }} data-navigation-bar-item {{ $attributes }}>
<span data-navigation-pill>
<span data-navigation-indicator>
<span class="relative inline-flex">
@if ($icon)
<x-livewire-material::icon :name="$icon" :filled="$active" class="size-6" />
@endif
@if ($dot)
<x-livewire-material::badge floating />
@elseif ($count)
<x-livewire-material::badge :value="$badge" max="999" floating />
@endif
</span>
</span>
<span data-navigation-label>{{ $label ?? $slot }}</span>
</span>
@if ($spoken !== null)
<span class="sr-only">, {{ $spoken }}</span>
@endif
</{{ $tag }}>
@@ -0,0 +1,40 @@
{{-- M3 Expressive's flexible navigation bar: three to five destinations at the bottom of a
compact window.
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
<x-navigation-bar>
<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
<x-navigation-bar-item label="Starred" icon="star" link="/starred" />
<x-navigation-bar-item label="Sent" icon="send" link="/sent" />
</x-navigation-bar>
</div>
The short bar, 64px tall in surface-container, with the bottom safe area added under it. On a
bar narrower than 600px (M3's compact window) each item is the icon in a 56×32 indicator over a
label-medium label, and the items share the width equally. From 600px (medium) icon and label
sit side by side in a 40px pill and the items gather in the middle, with the padding Compose's
Centered arrangement gives three to six items. Both follow the bar's own width (a container
query), so a bar in a narrow column keeps the compact items.
It does not position itself: wrap it in the element that pins it (`fixed inset-x-0 bottom-0`)
and hides it where a rail takes over. `<x-app-shell>` does both, and lifts the snackbar and a
`fab` button above it through `--material-bottom-bar`.
`label` names the landmark ("Main" by default).
Values from androidx Compose Material 3 (Apache-2.0), androidx-main
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationBarTokens.kt,
NavigationBarVerticalItemTokens.kt, NavigationBarHorizontalItemTokens.kt and
ShortNavigationBar.kt, all under
https://github.com/androidx/androidx/tree/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3
the styles are resources/css/components/navigation.css. --}}
@props([
'label' => null,
])
<nav aria-label="{{ $label ?? __('Main') }}" data-navigation-bar {{ $attributes }}>
<div data-navigation-bar-items>
{{ $slot }}
</div>
</nav>
@@ -0,0 +1,74 @@
{{-- One destination in an `<x-navigation-rail>`, drawn in whichever shape the rail has.
<x-navigation-rail-item label="Inbox" icon="inbox" link="{{ route('inbox') }}" :active="request()->routeIs('inbox')" badge="12" />
Expanded, it is a 56px full-width pill: the icon, the label-large label beside it and a count
at its end. Collapsed, the icon sits in a 56×32 indicator over a label-medium label (two lines
at most), with the count on the icon. The current destination (`active`) is
`aria-current="page"` with the filled icon on secondary-container, its label in secondary when
collapsed.
`link` renders an anchor with `wire:navigate` (unless `external` or `no-wire-navigate`);
without one it is a button. `badge`: `true` for M3's small dot on the icon, a number or a few
characters for the large badge (999+ at most). Screen readers hear a count after the label
(", 12"); `badge-label` replaces it with words ("12 unread") and gives a dot something to say.
Values from NavigationRailVerticalItemTokens.kt, NavigationRailHorizontalItemTokens.kt,
NavigationRailBaselineItemTokens.kt and NavigationRailColorTokens.kt (androidx Compose
Material 3, Apache-2.0); see `<x-navigation-rail>`. Compose's expanded item hugs its label; the
package draws the full-width pill, which leaves room for the count at the end. --}}
@props([
'label' => null,
'icon' => null,
'link' => null,
'external' => false,
'noWireNavigate' => false,
'active' => false,
'badge' => null,
'badgeLabel' => null,
])
@php
$isLink = filled($link);
$tag = $isLink ? 'a' : 'button';
$dot = $badge === true;
$count = ! $dot && $badge !== null && $badge !== false && $badge !== '';
$spoken = $badgeLabel ?? ($count ? $badge : null);
$attributes = $attributes->merge(array_filter([
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
'type' => $isLink ? null : 'button',
'aria-current' => $active ? 'page' : null,
'data-active' => $active ? true : null,
], fn ($value): bool => $value !== null));
@endphp
<{{ $tag }} data-navigation-rail-item {{ $attributes }}>
<span data-navigation-indicator>
<span class="relative inline-flex">
@if ($icon)
<x-livewire-material::icon :name="$icon" :filled="$active" class="size-6" />
@endif
@if ($dot)
<x-livewire-material::badge floating />
@elseif ($count)
<span class="hidden rail-collapsed:contents"><x-livewire-material::badge :value="$badge" max="999" floating /></span>
@endif
</span>
</span>
<span data-navigation-label>{{ $label ?? $slot }}</span>
@if ($count)
<span class="flex shrink-0 rail-collapsed:hidden"><x-livewire-material::badge :value="$badge" max="999" /></span>
@endif
@if ($spoken !== null)
<span class="sr-only">, {{ $spoken }}</span>
@endif
</{{ $tag }}>
@@ -0,0 +1,24 @@
{{-- A group of destinations in an `<x-navigation-rail>` under a heading.
<x-navigation-rail-section label="Labels">
<x-navigation-rail-item label="Travel" icon="label" link="/labels/travel" />
<x-navigation-rail-item label="Receipts" icon="label" link="/labels/receipts" />
</x-navigation-rail-section>
The heading (title-small, on-surface-variant, in line with the icons) shows only while the
rail is expanded, as M3 draws section headers; collapsed, a little space sets the group apart.
It names the group for screen readers either way. --}}
@props([
'label',
])
@php
$headingId = 'navigation-section-'.substr(md5((string) $label), 0, 10);
@endphp
<div role="group" aria-labelledby="{{ $headingId }}" data-navigation-rail-section {{ $attributes }}>
<p id="{{ $headingId }}" data-navigation-rail-heading>{{ $label }}</p>
{{ $slot }}
</div>
@@ -0,0 +1,135 @@
{{-- M3 Expressive's navigation rail: destinations down the start edge of a medium or wider
window, collapsed (96px, icon over label) or expanded (icon beside label in a full-width pill).
<div class="flex min-h-dvh">
<x-navigation-rail mode="collapsible">
<x-slot:brand><span class="type-title-lg">Mail</span></x-slot:brand>
<x-slot:header>
<x-fab icon="edit" tooltip-right="Compose" />
</x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
<x-navigation-rail-section label="Labels">
<x-navigation-rail-item label="Travel" icon="label" link="/labels/travel" />
</x-navigation-rail-section>
<x-slot:footer>
<x-navigation-rail-item label="Settings" icon="settings" link="/settings" />
</x-slot:footer>
</x-navigation-rail>
<main class="min-w-0 flex-1"></main>
</div>
`mode` says what decides its width:
- `collapsed` always collapsed; `expanded` always expanded.
- `collapsible` (the default) the visitor's choice: expanded until the menu button collapses
it. The choice is `$store.rail`, remembered in localStorage and applied by <x-theme-script>
before the first paint (<html data-rail>), so the rail never paints wide and snaps shut.
- `modal` collapsed in the layout; the menu button (or `$store.rail.show()` from anywhere)
opens it expanded over a scrim, holding focus until Escape, the scrim, the menu button or
leaving the page closes it (Compose's ModalWideNavigationRail).
- `adaptive` what `<x-app-shell>` uses: below `sm` nothing until `$store.rail.show()` slides
it in as a modal; from `sm` collapsed, opening as a modal; from `lg` collapsible.
Slots: `brand` beside the menu button, only while expanded; `header` under it a FAB, drawn
as an extended FAB when expanded (`rail-collapsed:` below); the destinations in the default
slot, which alone scroll when the window is too short; `footer`, pinned to the foot. Header and
footer never scroll, so nothing in them is cut off by the scroller's edge.
Anything inside can take both shapes with the `rail-collapsed:` variant, which applies while
the rail is drawn collapsed for whatever reason:
`<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>`
`<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>`.
Nothing that shows while collapsed may be wider than 96px.
Props: `label` names the landmark ("Main"); `width` is the expanded width (`16rem`, held
between M3's 220 and 360dp); `menu` shows the menu button (by default for `collapsible`,
`modal` and `adaptive`). The rail does not scroll with the page: in a flex row it sticks to
the top of the viewport, as tall as the viewport at most.
Values from androidx Compose Material 3 (Apache-2.0), androidx-main
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationRailCollapsedTokens.kt,
NavigationRailExpandedTokens.kt, NavigationRailBaselineItemTokens.kt and
WideNavigationRail.kt under
https://github.com/androidx/androidx/tree/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3
surface (surface-container and elevation 2 with a large inner corner when modal), 44px above
the header and 40px under it, 4px between collapsed items. The styles are
resources/css/components/navigation.css; the behaviour resources/js/navigation.js. --}}
@props([
'mode' => 'collapsible',
'label' => null,
'width' => '16rem',
'menu' => null,
])
@php
$mode = in_array($mode, ['collapsed', 'expanded', 'collapsible', 'modal', 'adaptive'], true) ? $mode : 'collapsible';
$interactive = in_array($mode, ['collapsible', 'modal', 'adaptive'], true);
$canOpen = in_array($mode, ['modal', 'adaptive'], true);
$menu ??= $interactive;
$collapsedAtFirst = in_array($mode, ['collapsed', 'modal'], true);
@endphp
<div
data-navigation-rail="{{ $mode }}"
@if ($interactive)
x-data="materialNavigationRail('{{ $mode }}')"
x-bind:data-open="open"
@endif
{{ $attributes->merge(['style' => "--navigation-rail-width: {$width}"]) }}
>
@if ($canOpen)
<div data-navigation-rail-scrim aria-hidden="true" x-on:click="$store.rail.hide()"></div>
@endif
<nav
data-navigation-rail-panel
aria-label="{{ $label ?? __('Main') }}"
@if ($canOpen)
x-trap.inert.noscroll="open"
x-on:keydown.escape.window="open && $store.rail.hide()"
@endif
>
@if ($menu || isset($brand) || isset($header))
<div data-navigation-rail-header>
@if ($menu || isset($brand))
<div @class(['flex w-full min-w-0 items-center gap-3 pe-5', 'ps-7' => $menu, 'ps-5' => ! $menu])>
@if ($menu)
<button
type="button"
data-navigation-rail-menu
aria-label="{{ $collapsedAtFirst ? __('Expand navigation') : __('Collapse navigation') }}"
aria-expanded="{{ $collapsedAtFirst ? 'false' : 'true' }}"
x-on:click="menu()"
x-bind:aria-label="expanded ? @js(__('Collapse navigation')) : @js(__('Expand navigation'))"
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-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
@isset($brand)
<div class="min-w-0 flex-1 rail-collapsed:hidden">{{ $brand }}</div>
@endisset
</div>
@endif
@isset($header)
<div {{ $header->attributes->class(['flex w-full flex-col items-start gap-2 px-5']) }}>{{ $header }}</div>
@endisset
</div>
@endif
<div data-navigation-rail-destinations>
{{ $slot }}
</div>
@isset($footer)
<div data-navigation-rail-footer {{ $footer->attributes }}>{{ $footer }}</div>
@endisset
</nav>
</div>
@@ -17,10 +17,12 @@
@php @php
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|password'), 0, 12); $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 @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 <input
{{ $attributes->except(['class', 'id', 'type', 'placeholder']) }} {{ $attributes->except(['class', 'id', 'type', 'placeholder']) }}
id="{{ $id }}" id="{{ $id }}"
@@ -43,8 +45,8 @@
class="field-trailing field-button" class="field-trailing field-button"
data-field-reveal data-field-reveal
> >
<x-icon name="visibility" class="size-(--field-icon)" x-show="! shown" /> <x-livewire-material::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_off" class="size-(--field-icon)" x-show="shown" x-cloak />
</button> </button>
</x-slot:trailing> </x-slot:trailing>
</x-field> </x-livewire-material::field>
+3 -1
View File
@@ -23,7 +23,9 @@
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$name = $attributes->get('name') ?? $model ?? 'radio-'.substr(md5($label.'|'.json_encode($options)), 0, 12); $name = $attributes->get('name') ?? $model ?? 'radio-'.substr(md5($label.'|'.json_encode($options)), 0, 12);
$id = 'radio-'.substr(md5($name.'|'.$label), 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 @endphp
<fieldset <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()"> <div data-search-bar role="search" x-on:click="if ($event.target === $el) $refs.input.focus()">
<span data-search-leading x-show="! fullScreen"> <span data-search-leading x-show="! fullScreen">
<x-icon :name="$icon" /> <x-livewire-material::icon :name="$icon" />
</span> </span>
<button type="button" data-search-leading data-search-back x-show="fullScreen" x-cloak x-on:click="close()" aria-label="{{ __('Back') }}"> <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> </button>
<input <input
@@ -64,7 +64,7 @@
/> />
<button type="button" data-search-clear x-on:click="clear()" aria-label="{{ __('Clear') }}"> <button type="button" data-search-clear x-on:click="clear()" aria-label="{{ __('Clear') }}">
<x-icon name="close" /> <x-livewire-material::icon name="close" />
</button> </button>
@isset($trailing) @isset($trailing)
@@ -33,21 +33,21 @@
<div {{ $attributes->class(['min-w-0']) }} data-section-nav> <div {{ $attributes->class(['min-w-0']) }} data-section-nav>
@if ($current) @if ($current)
<div class="sm:hidden" data-section-picker> <div class="sm:hidden" data-section-picker>
<x-menu :$label position="bottom-start"> <x-livewire-material::menu :$label position="bottom-start">
<x-slot:trigger> <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"> <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']) @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 @endisset
<span class="min-w-0 flex-1 truncate">{{ $current['title'] }}</span> <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> </button>
</x-slot:trigger> </x-slot:trigger>
@foreach ($items as $item) @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 @endforeach
</x-menu> </x-livewire-material::menu>
</div> </div>
@endif @endif
@@ -65,11 +65,11 @@
> >
<span data-tab-content> <span data-tab-content>
@isset($item['icon']) @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 @endisset
<span class="truncate">{{ $item['title'] }}</span> <span class="truncate">{{ $item['title'] }}</span>
@if (filled($item['badge'] ?? null)) @if (filled($item['badge'] ?? null))
<x-badge :value="$item['badge']" /> <x-livewire-material::badge :value="$item['badge']" />
@endif @endif
<span data-tab-indicator aria-hidden="true"></span> <span data-tab-indicator aria-hidden="true"></span>
</span> </span>
+6 -4
View File
@@ -29,10 +29,12 @@
@php @php
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|select'), 0, 12); $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 @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 <select
{{ $attributes->except(['class', 'id']) }} {{ $attributes->except(['class', 'id']) }}
id="{{ $id }}" id="{{ $id }}"
@@ -52,6 +54,6 @@
</select> </select>
<x-slot:trailing> <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-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']) @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) }}" 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> </span>
@endif @endif
</div> </div>
@@ -29,7 +29,7 @@
]) ])
> >
{{ $slot }} {{ $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)', '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, 'opacity-0 group-hover/sort:opacity-60 group-focus-visible/sort:opacity-60' => ! $active,
])" /> ])" />
@@ -37,7 +37,7 @@
@endphp @endphp
<div data-button-group="split" data-size="{{ $size }}" {{ $attributes->only('class')->class('inline-flex items-center gap-0.5') }}> <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') }} {{ $attributes->except('class') }}
:label="$label" :label="$label"
:icon="$icon" :icon="$icon"
@@ -51,9 +51,9 @@
:class="$leadingPadding" :class="$leadingPadding"
/> />
<x-menu :position="$position" :label="$menuLabel"> <x-livewire-material::menu :position="$position" :label="$menuLabel">
<x-slot:trigger> <x-slot:trigger>
<x-button <x-livewire-material::button
icon="keyboard_arrow_down" icon="keyboard_arrow_down"
:aria-label="$menuLabel" :aria-label="$menuLabel"
:variant="$variant" :variant="$variant"
@@ -67,5 +67,5 @@
</x-slot:trigger> </x-slot:trigger>
{{ $slot }} {{ $slot }}
</x-menu> </x-livewire-material::menu>
</div> </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 {{ $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"> <div class="flex items-center gap-2 type-label-lg text-on-surface-variant">
@if ($icon) @if ($icon)
<x-icon :name="$icon" class="size-5" /> <x-livewire-material::icon :name="$icon" class="size-5" />
@endif @endif
<span>{{ $title }}</span> <span>{{ $title }}</span>
</div> </div>
+2 -2
View File
@@ -72,12 +72,12 @@
> >
<span data-tab-content> <span data-tab-content>
@isset($tab['icon']) @isset($tab['icon'])
<x-icon :name="$tab['icon']" class="size-6" /> <x-livewire-material::icon :name="$tab['icon']" class="size-6" />
@endisset @endisset
<span class="inline-flex items-center gap-1.5"> <span class="inline-flex items-center gap-1.5">
{{ $tab['label'] }} {{ $tab['label'] }}
@if (filled($tab['badge'] ?? null)) @if (filled($tab['badge'] ?? null))
<x-badge :value="$tab['badge']" /> <x-livewire-material::badge :value="$tab['badge']" />
@endif @endif
</span> </span>
<span data-tab-indicator aria-hidden="true"></span> <span data-tab-indicator aria-hidden="true"></span>
@@ -19,10 +19,12 @@
$model = $attributes->whereStartsWith('wire:model')->first(); $model = $attributes->whereStartsWith('wire:model')->first();
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' '; $placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|textarea'), 0, 12); $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 @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 <textarea
{{ $attributes->except(['class', 'id', 'placeholder']) }} {{ $attributes->except(['class', 'id', 'placeholder']) }}
id="{{ $id }}" id="{{ $id }}"
@@ -36,4 +38,4 @@
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif @if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control" class="field-control"
></textarea> ></textarea>
</x-field> </x-livewire-material::field>
@@ -1,4 +1,5 @@
{{-- The theme, decided before the first paint. Include it in <head>, ahead of @vite. {{-- The theme and the navigation rail's width, decided before the first paint. Include it in
<head>, ahead of @vite.
It reads the visitor's choice from localStorage (`livewire-material.theme.storage_key`): It reads the visitor's choice from localStorage (`livewire-material.theme.storage_key`):
`light`, `dark` or `system`, falling back to `theme.default`. `system` follows the `light`, `dark` or `system`, falling back to `theme.default`. `system` follows the
@@ -10,15 +11,34 @@
JSON-encoded (maryUI's `"dark"`) is adopted once and removed. Nothing is written for a 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. visitor who never chose, so changing `theme.default` later reaches them too.
<html> survives wire:navigate, so this only has to run on a full load. --}} With colour profiles (`livewire-material.profiles`, generated by `material:scheme`), the
active one `Scheme::profile()`, which asks the application's resolver is written to
<html data-scheme>, which the generated stylesheet keys each profile on.
The rail rides along for the theme's reason: <html data-rail> is `expanded` or `collapsed`
(`livewire-material.rail.storage_key`, falling back to `rail.default`), and a collapsible
rail's width is CSS keyed on it (the `rail-collapsed:` variant). Set any later, a collapsed
rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
changes it.
wire:navigate swaps the body, merges the head without running this again, and gives <html>
the next page's attributes which the server rendered without any of these, so Livewire
removes them. They are put back as the new page is swapped in (`onSwap`, in the same task,
before anything paints), so this only has to run on a full load. --}}
@php @php
$theme = config('livewire-material.theme'); $theme = config('livewire-material.theme');
$rail = config('livewire-material.rail');
$settings = [ $settings = [
'scheme' => \NoNameWeb\LivewireMaterial\Support\Scheme::profile(),
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system', 'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
'key' => $theme['storage_key'] ?? 'material-theme', 'key' => $theme['storage_key'] ?? 'material-theme',
'legacy' => array_values($theme['legacy_keys'] ?? []), 'legacy' => array_values($theme['legacy_keys'] ?? []),
'rail' => [
'default' => ($rail['default'] ?? null) === 'collapsed' ? 'collapsed' : 'expanded',
'key' => $rail['storage_key'] ?? 'material-rail',
],
]; ];
@endphp @endphp
@@ -28,8 +48,15 @@
var media = window.matchMedia('(prefers-color-scheme: dark)'); var media = window.matchMedia('(prefers-color-scheme: dark)');
var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; }; var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; };
var choice = settings.default; var choice = settings.default;
var rail = settings.rail.default;
try { try {
var storedRail = localStorage.getItem(settings.rail.key);
if (storedRail === 'collapsed' || storedRail === 'expanded') {
rail = storedRail;
}
var stored = localStorage.getItem(settings.key); var stored = localStorage.getItem(settings.key);
if (valid(stored)) { if (valid(stored)) {
@@ -56,10 +83,30 @@
root.setAttribute('data-theme', current === 'system' ? (media.matches ? 'dark' : 'light') : current); 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-key', settings.key);
root.setAttribute('data-theme-choice', choice); root.setAttribute('data-theme-choice', choice);
root.setAttribute('data-rail-key', settings.rail.key);
root.setAttribute('data-rail', rail);
apply(); apply();
media.addEventListener('change', apply); media.addEventListener('change', apply);
document.addEventListener('livewire:navigating', function (event) {
var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) {
return [name, root.getAttribute(name)];
});
event.detail.onSwap(function () {
kept.forEach(function (attribute) {
if (attribute[1] !== null) {
root.setAttribute(attribute[0], attribute[1]);
}
});
});
});
})(@json($settings)); })(@json($settings));
</script> </script>
@@ -40,8 +40,8 @@
data-theme-option="{{ $choice }}" 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" 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-livewire-material::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="$icon" class="size-4.5 in-aria-checked:hidden" />
{{ __($text) }} {{ __($text) }}
</button> </button>
@endforeach @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']) }} {{ $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') @if ($mode === 'cycle')
<x-icon name="light_mode" x-cloak x-show="$store.theme.choice === 'light'" /> <x-livewire-material::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-livewire-material::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="brightness_auto" x-cloak x-show="$store.theme.choice === 'system'" />
@else @else
<x-icon name="light_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-icon name="dark_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 @endif
</button> </button>
@endif @endif
@@ -0,0 +1,364 @@
{{-- A time: M3's time picker, opened from a text field.
<x-timepicker label="Starts at" wire:model="startsAt" />
<x-timepicker label="Alarm" wire:model.live="alarm" format="24" step="5" min="06:00" max="22:00" clearable />
The field is read-only and opens the picker in a modal `<dialog>` on a press, Enter, Space or
ArrowDown (the clock icon at its end opens it too). The picker is M3's dial: the hour and minute
boxes on top, AM and PM beside them on a 12-hour clock, the clock face below. A press or a drag
on the dial picks the hour, then it moves on to the minutes; the arrow keys change the value on
the focused dial, Home and End go to the ends, Enter confirms, and Escape, Cancel or a press on
the scrim close it without a change, handing focus back to the field. The keyboard icon swaps
the dial for M3's input variant: two text fields, with the error under a field that holds
something impossible. In a landscape window the dial lies on its side, as Compose lays it out.
`wire:model` (or `x-model`) holds the time as `H:i`, and null until one is chosen; a value with
seconds (`09:30:00`, from a `time` column) is read, and written back as `H:i`. Nothing is written
until OK. The draft starts at the bound time, or now. Errors are read from the bag under the
`wire:model` name and replace the hint.
`format` is `12` or `24`; without it the hour cycle is the locale's (`locale`, the app locale by
default) as `Intl.DateTimeFormat` reports it, and the field shows the time as the locale writes
it. `step` is the minute step (a tap on the minute dial picks fives, or steps when five is not a
multiple of the step; a drag and the arrows move by the step). `min` and `max` (`H:i`, inclusive;
a `min` later than `max` spans midnight) grey out and skip what lies outside them and turn typed
values outside them into errors validate on the server as well. `clearable` adds a button that
empties the field; `name` posts the value from a hidden input. `label`, `hint`, `icon`, `variant`
and `size` are the field's; other attributes (`required`, `disabled`, `placeholder`) reach the
field's input.
From androidx Compose Material 3 at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326 (Apache-2.0):
TimePickerTokens and TimeInputTokens a surface-container-high dialog with an extra-large
corner and elevation 3, a 256dp surface-container-highest dial with body-large numbers, a primary
selector with a 48dp handle, a 2dp line and an 8dp centre, 96×80 time selector boxes in
display-large (primary-container when selected), 96×72 time fields in display-medium and
TimePicker.kt and TimePickerDialog.kt for the layout, the 24-hour inner ring (1223, at 69dp;
0011 outside at 101dp, as Material Components for Android labels them too), the gestures, the
move on to minutes and the error texts. The period selector is Compose's current default
(`isUpdatedTimepickerToggleEnabled`): two separate shape-morphing toggle buttons in
primary-container, not the outlined pair its tokens still describe. The dial's numbers are
aria-hidden; the dial is a `slider` whose value text names the hour or minute. The dialog is
`wire:ignore`, so a Livewire render never closes an open picker or resets its draft. --}}
@props([
'label' => null,
'hint' => null,
'icon' => null,
'variant' => null,
'size' => 'md',
'format' => null,
'locale' => null,
'min' => null,
'max' => null,
'step' => 1,
'value' => null,
'name' => null,
'clearable' => false,
])
@php
$model = $attributes->wire('model')->value() ?: null;
// 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;
$locale = str_replace('_', '-', filled($locale) ? $locale : app()->getLocale());
$interval = is_numeric($step) && (int) $step >= 1 && (int) $step <= 60 ? (int) $step : 1;
$clock = fn (mixed $time): ?string => is_string($time) && preg_match('/^([01]?\d|2[0-3]):([0-5]\d)/', $time, $parts) === 1
? sprintf('%02d:%02d', $parts[1], $parts[2])
: null;
$earliest = $clock($min);
$latest = $clock($max);
// Rendered as the bound property already says, so the field is not empty until Alpine starts.
$current = $value;
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$current = data_get($component, $model);
}
$current = $clock($current);
// The first frame writes the time as the browser will; ICU decides the hour cycle on both sides.
$display = '';
if ($current !== null) {
$time = \Carbon\CarbonImmutable::createFromFormat('!H:i', $current, 'UTC');
$shown = $cycle;
if (class_exists(\IntlDatePatternGenerator::class)) {
$generator = new \IntlDatePatternGenerator(str_replace('-', '_', $locale));
$shown ??= preg_match('/[hK]/', preg_replace("/'[^']*'/", '', (string) $generator->getBestPattern('j'))) === 1 ? 12 : 24;
$pattern = $generator->getBestPattern($shown === 12 ? 'hmm' : 'Hmm');
$display = (string) (new \IntlDateFormatter(str_replace('-', '_', $locale), \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'UTC', null, $pattern))->format($time);
} else {
$display = $shown === 12 ? $time->format('g:i A') : $time->format('H:i');
}
}
$config = [
'locale' => $locale,
'format' => $cycle,
'min' => $earliest,
'max' => $latest,
'step' => $interval,
'strings' => [
'am' => __('AM'),
'pm' => __('PM'),
'oclock' => __(':hour o\'clock'),
'hours' => __(':hour hours'),
'minutes' => __(':minute minutes'),
'hourError12' => __('Hour must be 112'),
'hourError24' => __('Hour must be 023'),
'minuteError' => __('Minute must be 059'),
'stepError' => __('Minute must be a multiple of :step'),
'between' => __('Choose a time from :min to :max'),
'after' => __('Choose :min or later'),
'before' => __('Choose :max or earlier'),
],
];
$constrained = $earliest !== null || $latest !== null || $interval > 1;
// The dial's numbers, placed round the ring from twelve o'clock (TimePicker.kt's CircularLayout).
$spot = fn (int $index): string => sprintf('--x: %.4F; --y: %.4F', sin(deg2rad($index * 30)), -cos(deg2rad($index * 30)));
$sets = [
'hour12' => array_map(fn (int $index): array => ['value' => $index ?: 12, 'text' => $index ?: 12, 'index' => $index, 'inner' => false, 'allowed' => "hourAllowed({$index} + (isPm ? 12 : 0))"], range(0, 11)),
'hour24' => [
...array_map(fn (int $index): array => ['value' => $index, 'text' => $index === 0 ? '00' : $index, 'index' => $index, 'inner' => false, 'allowed' => "hourAllowed({$index})"], range(0, 11)),
...array_map(fn (int $index): array => ['value' => $index + 12, 'text' => $index + 12, 'index' => $index, 'inner' => true, 'allowed' => 'hourAllowed('.($index + 12).')'], range(0, 11)),
],
'minute' => array_map(fn (int $index): array => ['value' => $index * 5, 'text' => $index === 0 ? '00' : $index * 5, 'index' => $index, 'inner' => false, 'allowed' => 'minuteAllowed('.($index * 5).')'], range(0, 11)),
];
$inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'id', 'wire:key', 'placeholder']);
$disabled = (bool) $attributes->get('disabled');
$described = $messages !== [] || filled($hint);
@endphp
<div
{{ $attributes->only(['class', 'wire:key', 'x-model']) }}
x-data="materialTimepicker(@if ($model !== null) @entangle($attributes->wire('model')) @else @js($current) @endif, @js($config))"
@if ($model === null) x-modelable="value" @endif
>
<x-livewire-material::field :$id :$label :$hint :$messages :$icon :$size :$variant data-timepicker-field>
<input
{{ $inputAttributes }}
id="{{ $id }}"
type="text"
readonly
value="{{ $display }}"
x-bind:value="display"
x-ref="input"
placeholder="{{ filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ' }}"
autocomplete="off"
aria-haspopup="dialog"
aria-controls="{{ $id }}-dialog"
aria-expanded="false"
x-bind:aria-expanded="open.toString()"
@if ($messages !== []) aria-invalid="true" @endif
@if ($described) aria-describedby="{{ $id }}-support" @endif
class="field-control"
x-on:click="show()"
x-on:keydown.enter.prevent="show()"
x-on:keydown.space.prevent="show()"
x-on:keydown.arrow-down.prevent="show()"
/>
<x-slot:trailing>
@if ($clearable)
<button
type="button"
x-on:click="value = null; $refs.input.focus()"
class="field-trailing field-clear field-button"
aria-label="{{ __('Clear') }}"
data-field-clear
@disabled($disabled)
>
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
</button>
@endif
<button
type="button"
tabindex="-1"
x-on:click="show()"
class="field-trailing field-button"
aria-label="{{ __('Choose time') }}"
data-timepicker-open
@disabled($disabled)
>
<x-livewire-material::icon name="schedule" class="size-(--field-icon)" />
</button>
</x-slot:trailing>
</x-livewire-material::field>
@if (filled($name))
<input type="hidden" name="{{ $name }}" value="{{ $current }}" x-bind:value="value ?? ''" />
@endif
<dialog
id="{{ $id }}-dialog"
wire:ignore
x-ref="dialog"
aria-labelledby="{{ $id }}-title"
data-timepicker-dialog
x-on:close="closed()"
x-on:pointerdown="scrimPressed = $event.target === $el"
x-on:click.self="if (scrimPressed) cancel()"
class="m-auto overflow-visible bg-transparent p-0 text-on-surface backdrop:bg-scrim/32 opacity-100 scale-100 starting:opacity-0 starting:scale-95 transition-[opacity,scale] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast"
>
<div data-timepicker-surface x-bind:data-mode="mode" data-mode="dial">
<h2 id="{{ $id }}-title" data-timepicker-title x-text="mode === 'dial' ? @js(__('Select time')) : @js(__('Enter time'))">{{ __('Select time') }}</h2>
<div data-timepicker-picker>
<div data-timepicker-display>
<div data-timepicker-numbers>
<button
type="button"
data-timepicker-box="hour"
class="state-layer focus-ring"
aria-pressed="true"
x-bind:aria-pressed="(view === 'hour').toString()"
x-bind:aria-label="@js(__('Select hour')) + ', ' + hourLabel"
x-on:click="choose('hour')"
x-text="hourLabel"
></button>
<span data-timepicker-separator aria-hidden="true">:</span>
<button
type="button"
data-timepicker-box="minute"
class="state-layer focus-ring"
aria-pressed="false"
x-bind:aria-pressed="(view === 'minute').toString()"
x-bind:aria-label="@js(__('Select minutes')) + ', ' + minuteLabel"
x-on:click="choose('minute')"
x-text="minuteLabel"
></button>
</div>
<div data-timepicker-period role="group" aria-label="{{ __('Select AM or PM') }}" x-show="! is24">
@foreach ([false => 'am', true => 'pm'] as $pm => $period)
<button
type="button"
data-timepicker-period-option="{{ $period }}"
class="state-layer focus-ring"
x-bind:aria-pressed="({{ $pm ? '' : '! ' }}isPm).toString()"
x-bind:disabled="! periodAllowed({{ $pm ? 'true' : 'false' }})"
x-on:click="setPeriod({{ $pm ? 'true' : 'false' }})"
x-text="periods[{{ (int) $pm }}]"
>{{ $pm ? __('PM') : __('AM') }}</button>
@endforeach
</div>
</div>
<div
x-ref="dial"
data-timepicker-dial
role="slider"
tabindex="0"
aria-valuemin="0"
x-bind:aria-label="view === 'hour' ? @js(__('Select hour')) : @js(__('Select minutes'))"
x-bind:aria-valuemax="view === 'hour' ? 23 : 59"
x-bind:aria-valuenow="view === 'hour' ? hour : minute"
x-bind:aria-valuetext="valueText"
x-bind:data-view="view"
x-bind:data-cycle="is24 ? '24' : '12'"
x-bind:data-inner="inner ? '' : null"
x-bind:data-dragging="dragging ? '' : null"
x-bind:style="{ '--timepicker-angle': angle + 'deg' }"
x-on:pointerdown="press($event)"
x-on:keydown="key($event)"
>
@foreach (['labels', 'ink'] as $layer)
@if ($layer === 'ink')
<div data-timepicker-selector aria-hidden="true">
<span data-timepicker-track></span>
<span data-timepicker-centre></span>
<span data-timepicker-handle></span>
</div>
@endif
<div data-timepicker-{{ $layer }} aria-hidden="true">
@foreach ($sets as $set => $labels)
<div data-timepicker-set="{{ $set }}">
@foreach ($labels as $spotLabel)
<span
@if ($layer === 'labels') data-timepicker-label="{{ $set }}" data-value="{{ $spotLabel['value'] }}" @endif
@if ($spotLabel['inner']) data-inner @endif
@if ($constrained && $layer === 'labels') x-bind:data-disabled="{{ $spotLabel['allowed'] }} ? null : ''" @endif
style="{{ $spot($spotLabel['index']) }}"
>{{ $spotLabel['text'] }}</span>
@endforeach
</div>
@endforeach
</div>
@endforeach
</div>
</div>
<div data-timepicker-typing>
<div data-timepicker-inputs>
@foreach (['hour' => __('Hour'), 'minute' => __('Minute')] as $part => $partLabel)
@if ($part === 'minute')
<span data-timepicker-separator aria-hidden="true">:</span>
@endif
<div data-timepicker-column>
<input
id="{{ $id }}-{{ $part }}"
type="text"
inputmode="numeric"
maxlength="2"
autocomplete="off"
data-timepicker-input="{{ $part }}"
x-ref="{{ $part }}Input"
aria-label="{{ $partLabel }}"
aria-describedby="{{ $id }}-{{ $part }}-support"
x-bind:aria-invalid="{{ $part }}Error ? 'true' : null"
x-bind:value="{{ $part }}Text"
x-on:input="{{ $part === 'hour' ? 'typeHour' : 'typeMinute' }}($event)"
x-on:focus="view = '{{ $part }}'; $el.select()"
x-on:keydown.enter.prevent="confirm()"
/>
<p
id="{{ $id }}-{{ $part }}-support"
data-timepicker-support
aria-live="polite"
x-bind:data-error="{{ $part }}Error ? '' : null"
x-text="{{ $part }}Error ?? @js($partLabel)"
>{{ $partLabel }}</p>
</div>
@endforeach
<div data-timepicker-period role="group" aria-label="{{ __('Select AM or PM') }}" x-show="! is24">
@foreach ([false => 'am', true => 'pm'] as $pm => $period)
<button
type="button"
data-timepicker-period-option="{{ $period }}"
class="state-layer focus-ring"
x-bind:aria-pressed="({{ $pm ? '' : '! ' }}isPm).toString()"
x-bind:disabled="! periodAllowed({{ $pm ? 'true' : 'false' }})"
x-on:click="setPeriod({{ $pm ? 'true' : 'false' }})"
x-text="periods[{{ (int) $pm }}]"
>{{ $pm ? __('PM') : __('AM') }}</button>
@endforeach
</div>
</div>
<p data-timepicker-range-error role="alert" x-show="rangeError" x-text="rangeError"></p>
</div>
<div data-timepicker-actions>
<span data-timepicker-when="dial">
<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-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-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>
</div>
+5 -5
View File
@@ -47,10 +47,10 @@
'text-inverse-warning': current.type === 'warning', 'text-inverse-warning': current.type === 'warning',
'text-inverse-info': current.type === 'info', '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 === 'success'"><x-livewire-material::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 === 'error'"><x-livewire-material::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 === 'warning'"><x-livewire-material::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 === 'info'"><x-livewire-material::icon name="info" filled class="size-6" /></span>
</span> </span>
</template> </template>
@@ -65,7 +65,7 @@
<template x-if="current.action || ! current.timeout"> <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()"> <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> </button>
</template> </template>
</div> </div>
+2 -2
View File
@@ -36,8 +36,8 @@
/> />
<span data-handle> <span data-handle>
@if ($icons) @if ($icons)
<x-icon name="check" class="size-4" data-on /> <x-livewire-material::icon name="check" class="size-4" data-on />
<x-icon name="close" class="size-4" data-off /> <x-livewire-material::icon name="close" class="size-4" data-off />
@endif @endif
</span> </span>
</span> </span>
@@ -0,0 +1,10 @@
{{-- Forbidden. A message the app passed along (`abort(403, '…')`, a policy's `Response::deny('')`)
is the sentence, as in the framework's own page; otherwise a general one. --}}
@extends('errors::minimal')
@section('title', __('Forbidden'))
@section('code', '403')
@section('shape', 'gem')
@section('headline', __('You dont have access'))
@section('message', isset($exception) && $exception->getMessage() !== '' ? __($exception->getMessage()) : __('Your account isnt allowed to open this page.'))
@@ -0,0 +1,9 @@
{{-- Not found. --}}
@extends('errors::minimal')
@section('title', __('Not Found'))
@section('code', '404')
@section('shape', 'cookie-9')
@section('headline', __('Page not found'))
@section('message', __('The page youre looking for doesnt exist or has moved.'))
@@ -0,0 +1,16 @@
{{-- Page expired: the form's CSRF token outlived the session. Reloading the page the form was on
issues a new one, so that is the action a link to it, never a reload of this response,
which would post the stale form again. --}}
@extends('errors::minimal')
@section('title', __('Page Expired'))
@section('code', '419')
@section('shape', 'clover-4')
@section('headline', __('This page has expired'))
@section('message', __('It was open for a while. Refresh it, then try again.'))
@section('actions')
<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
@@ -0,0 +1,9 @@
{{-- Too many requests. --}}
@extends('errors::minimal')
@section('title', __('Too Many Requests'))
@section('code', '429')
@section('shape', 'cookie-12')
@section('headline', __('Slow down a little'))
@section('message', __('There were too many requests in a short time. Wait a moment, then try again.'))
@@ -0,0 +1,9 @@
{{-- Server error. Nothing from the exception is shown: its message is for the log, not the visitor. --}}
@extends('errors::minimal')
@section('title', __('Server Error'))
@section('code', '500')
@section('shape', 'soft-burst')
@section('headline', __('Something went wrong'))
@section('message', __('An error on our side stopped this page from loading. Please try again in a moment.'))
@@ -0,0 +1,22 @@
{{-- Service unavailable, usually maintenance mode. A message the app passed (`abort(503, '…')`) is
the sentence; maintenance mode's own "Service Unavailable" is not a message, so it is not.
`php artisan down --render="errors::503"` renders this once, in the console, with no
`$exception` and no request so the one action reloads whatever address the visitor is
on, in the browser, rather than a URL decided here. --}}
@extends('errors::minimal')
@php
$reason = isset($exception) ? $exception->getMessage() : '';
@endphp
@section('title', __('Service Unavailable'))
@section('code', '503')
@section('shape', 'puffy')
@section('headline', __('Well be right back'))
@section('message', $reason !== '' && $reason !== 'Service Unavailable' ? __($reason) : __('Were making some improvements. Please check back soon.'))
@section('actions')
<x-livewire-material::button :label="__('Try again')" variant="filled" size="md" onclick="location.reload()" />
@endsection
@@ -0,0 +1,83 @@
{{-- The layout every error page extends: this package's 403, 404, 419, 429, 500 and 503, and the
framework's own 401 and 402, which extend `errors::minimal` and find this file before the
framework's (the provider appends this folder's parent to `view.paths`).
Sections the framework layout's `title`, `code` and `message`, plus three of its own, so
either layout renders the other's pages:
`title` the browser tab, followed by the app name;
`code` the status, drawn in display type over the shape;
`headline` what happened, in a few words (without it, `message` is the headline);
`message` one sentence on what to do about it;
`shape` an M3 Expressive shape name (`cookie-7` by default), in primary-container;
`actions` the buttons; by default a filled Home and, when the visitor came from a page on
the way here, a text Back.
The app's own Vite entries (`livewire-material.showcase.vite`) bring its scheme, font and
utilities. But an error page is also what shows while a deploy has no build yet, so when
those tags cannot be made the page brings a small stylesheet of its own: the app's scheme
from its scheme data, drawn onto the `data-error-*` hooks. Keep the hooks when changing
the markup. The shape turns once a minute, unless the visitor asks for reduced motion. --}}
@php
$assets = \NoNameWeb\LivewireMaterial\Support\ErrorPage::assets();
$back = \NoNameWeb\LivewireMaterial\Support\ErrorPage::backUrl();
@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="robots" content="noindex" />
<title>@yield('title') · {{ config('app.name') }}</title>
<x-livewire-material::theme-script />
@if ($assets !== null)
{{ $assets }}
@else
<style data-error-fallback>{{ \NoNameWeb\LivewireMaterial\Support\ErrorPage::fallbackStyles() }}</style>
@endif
<style>
@keyframes material-error-turn { to { transform: rotate(1turn); } }
@media (prefers-reduced-motion: no-preference) { [data-error-shape] { animation: material-error-turn 60s linear infinite; } }
</style>
</head>
<body class="min-h-dvh bg-surface font-sans text-on-surface antialiased">
<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-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>
</div>
<h1 data-error-headline class="mt-10 type-headline-md text-balance sm:type-headline-lg">
@hasSection('headline')
@yield('headline')
@else
@yield('message')
@endif
</h1>
@hasSection('headline')
<p data-error-message class="mt-3 max-w-md type-body-lg text-balance text-on-surface-variant">@yield('message')</p>
@endif
<div data-error-actions class="mt-10 flex flex-wrap items-center justify-center gap-3">
@hasSection('actions')
@yield('actions')
@else
<x-livewire-material::button :link="url('/')" :label="__('Go home')" variant="filled" size="md" no-wire-navigate />
@if ($back !== null)
<x-livewire-material::button :link="$back" :label="__('Go back')" variant="text" size="md" no-wire-navigate />
@endif
@endif
</div>
</main>
</body>
</html>
@@ -0,0 +1,24 @@
{{-- The mail's masthead: the app name in title-lg, or the logo from
`livewire-material.mail.logo` (`src`, `width`, `height`).
Replaces the framework's header, which swaps the name for Laravel's own logo whenever the
app is still called "Laravel". A logo keeps the name as its alt text, for readers with
images off; its width and height are repeated as attributes because Outlook sizes an image
from those and ignores the stylesheet. Serve the file at twice those dimensions to stay
crisp, from an absolute URL a queued mail has no request to resolve a relative one. --}}
@props(['url', 'logo' => config('livewire-material.mail.logo')])
@php
$name = trim(strip_tags((string) $slot));
$src = is_array($logo) ? ($logo['src'] ?? null) : null;
@endphp
<tr>
<td class="header">
<a href="{{ $url }}" style="display: inline-block;">
@if (filled($src))
<img src="{{ $src }}" class="logo" alt="{{ $name }}" @if (filled($logo['width'] ?? null)) width="{{ $logo['width'] }}" @endif @if (filled($logo['height'] ?? null)) height="{{ $logo['height'] }}" @endif style="border: 0;">
@else
{{ $name }}
@endif
</a>
</td>
</tr>
@@ -0,0 +1,35 @@
{{-- Every Markdown mail passes through here. The framework's message, with the footer open to
the mail: a `<x-slot:footer>` inside `<x-mail::message>` replaces the default line for the
sender's address, a privacy link, or an unsubscribe link on mail that is not transactional.
Keep the Markdown below unindented: four spaces make a code block. --}}
<x-mail::layout>
{{-- Header --}}
<x-slot:header>
<x-mail::header :url="config('app.url')">
{{ config('app.name') }}
</x-mail::header>
</x-slot:header>
{{-- Body --}}
{!! $slot !!}
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{!! $subcopy !!}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}}
<x-slot:footer>
<x-mail::footer>
@isset($footer)
{!! $footer !!}
@else
© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}
@endisset
</x-mail::footer>
</x-slot:footer>
</x-mail::layout>
@@ -0,0 +1,32 @@
{{-- The plain-text twin of html/message.blade.php: the same footer slot, no markup. --}}
<x-mail::layout>
{{-- Header --}}
<x-slot:header>
<x-mail::header :url="config('app.url')">
{{ config('app.name') }}
</x-mail::header>
</x-slot:header>
{{-- Body --}}
{{ $slot }}
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{{ $subcopy }}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}}
<x-slot:footer>
<x-mail::footer>
@isset($footer)
{{ $footer }}
@else
© {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.')
@endisset
</x-mail::footer>
</x-slot:footer>
</x-mail::layout>
+426
View File
@@ -0,0 +1,426 @@
{{-- The Markdown mail theme, as CSS rendered from the application's colour scheme.
Select it with `mail.markdown.theme` = `livewire-material::mail.theme` (MAIL_MARKDOWN_THEME),
or `$theme` / `->theme()` on one mailable or MailMessage. Laravel renders a namespaced theme
as a view (Illuminate\Mail\Markdown::render) and hands the result to CssToInlineStyles, which
writes it onto every element's `style` — so the colours are the app's light scheme, read
from `livewire-material.scheme` (Support\Scheme, falling back to the package's default), and
a regenerated scheme reaches the next mail without a copy to keep in step.
What a mail client can take, and so what this is:
- Hexes only. No custom properties, no `color-mix()`, no alpha: a client resolves none of
them, and Outlook drops an alpha channel.
- Light only. `Css\Processor::doCleanup()` strips every `@media` block from the theme before
inlining, so a `prefers-color-scheme` rule here would be deleted silently; it could only
live in a `<style>` in a published layout, and the framework layout declares
`color-scheme: light` in two meta tags besides. Clients that force dark invert the light
tones, which are mid-tone enough to survive it.
- M3's typescale on the bare tags CommonMark writes (`h1` headline-sm, `p` body-lg, a table
head title-sm), in px, with a system font stack: web fonts and
`font-variation-settings` reach too few clients to be worth an asset.
- No elevation. The card separates from the page by tone, as M3 surfaces do.
- The button is M3's filled button at the `md` size (56px, title-md label, full corners),
written as borders because padding on an `<a>` is not honoured everywhere. Outlook
Classic squares the corners; it is still a button. `x-mail::button`'s `color` takes any
role with a filled pair primary, secondary, tertiary, error, success, warning, info
and the framework's blue, green and red. --}}
@php
$role = \NoNameWeb\LivewireMaterial\Support\Scheme::light();
$page = $role['surface-container'];
$card = $role['surface-container-lowest'];
$tint = $role['surface-container'];
$buttons = [
'primary' => ['primary', 'on-primary'],
'secondary' => ['secondary', 'on-secondary'],
'tertiary' => ['tertiary', 'on-tertiary'],
'error' => ['error', 'on-error'],
'success' => ['success', 'on-success'],
'warning' => ['warning', 'on-warning'],
'info' => ['info', 'on-info'],
'blue' => ['primary', 'on-primary'],
'green' => ['success', 'on-success'],
'red' => ['error', 'on-error'],
];
@endphp
/* Base */
body,
body *:not(html):not(style):not(br):not(tr):not(code) {
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, Roboto, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
position: relative;
}
body {
-webkit-text-size-adjust: none;
background-color: {{ $page }};
color: {{ $role['on-surface-variant'] }};
height: 100%;
line-height: 1.5;
margin: 0;
padding: 0;
width: 100% !important;
}
p,
ul,
ol,
blockquote {
line-height: 1.5;
text-align: start;
}
a {
color: {{ $role['primary'] }};
}
a img {
border: none;
}
/* Typography: M3's typescale */
h1 {
color: {{ $role['on-surface'] }};
font-size: 24px;
font-weight: 400;
letter-spacing: 0;
line-height: 32px;
margin-top: 0;
text-align: start;
}
h2 {
color: {{ $role['on-surface'] }};
font-size: 22px;
font-weight: 400;
letter-spacing: 0;
line-height: 28px;
margin-top: 0;
text-align: start;
}
h3 {
color: {{ $role['on-surface'] }};
font-size: 16px;
font-weight: 500;
letter-spacing: 0.15px;
line-height: 24px;
margin-top: 0;
text-align: start;
}
p {
color: {{ $role['on-surface-variant'] }};
font-size: 16px;
font-weight: 400;
letter-spacing: 0.5px;
line-height: 24px;
margin-top: 0;
text-align: start;
}
ul,
ol {
color: {{ $role['on-surface-variant'] }};
font-size: 16px;
font-weight: 400;
letter-spacing: 0.5px;
line-height: 24px;
margin: 0 0 16px;
padding-left: 24px;
}
li {
margin: 0 0 4px;
}
/* A `->line()` per item makes a loose list, each item wrapped in a paragraph with a
paragraph's margin; without this it reads as numbered paragraphs, not one list. */
li p {
margin: 0;
}
/* Bold stays bold: a 500 would fall to 400 in the many system fonts without a medium weight. */
strong {
color: {{ $role['on-surface'] }};
}
blockquote {
border-left: 4px solid {{ $role['outline-variant'] }};
margin: 0 0 16px;
padding-left: 16px;
}
code {
background-color: {{ $tint }};
border-radius: 4px;
color: {{ $role['on-surface'] }};
font-size: 14px;
padding: 2px 4px;
}
hr {
border: 0;
border-top: 1px solid {{ $role['outline-variant'] }};
margin: 24px 0;
}
p.sub {
font-size: 14px;
letter-spacing: 0.25px;
line-height: 20px;
}
img {
max-width: 100%;
}
/* Layout */
.wrapper {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: {{ $page }};
margin: 0;
padding: 0;
width: 100%;
}
.content {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 0;
padding: 0;
width: 100%;
}
/* Header: the app name in title-lg, or the configured logo */
.header {
padding: 32px 0 24px;
text-align: center;
}
.header a {
color: {{ $role['on-surface'] }};
font-size: 22px;
font-weight: 500;
letter-spacing: 0;
line-height: 28px;
text-decoration: none;
}
.logo {
border: 0;
display: block;
height: auto;
margin: 0 auto;
}
/* Body: the card, separated from the page by tone alone */
.body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: {{ $page }};
border: 0;
margin: 0;
padding: 0;
width: 100%;
}
.inner-body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
background-color: {{ $card }};
border: 0;
border-radius: 24px;
margin: 0 auto;
padding: 0;
width: 570px;
}
/* A long URL breaks where it has to; `break-all`, the framework's value, breaks ordinary words too. */
.inner-body a {
overflow-wrap: anywhere;
}
.content-cell {
max-width: 100vw;
padding: 32px;
}
/* Subcopy */
.subcopy {
border-top: 1px solid {{ $role['outline-variant'] }};
margin-top: 24px;
padding-top: 24px;
}
.subcopy p {
font-size: 14px;
letter-spacing: 0.25px;
line-height: 20px;
}
/* Footer: body-sm */
.footer {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
margin: 0 auto;
padding: 0;
text-align: center;
width: 570px;
}
.footer p {
color: {{ $role['on-surface-variant'] }};
font-size: 12px;
font-weight: 400;
letter-spacing: 0.4px;
line-height: 16px;
text-align: center;
}
.footer a {
color: {{ $role['on-surface-variant'] }};
text-decoration: underline;
}
/* Tables: a title-sm head over an outline-variant rule, body-md cells, a rule between rows */
.table table {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
border-collapse: collapse;
margin: 24px auto;
width: 100%;
}
.table th {
border-bottom: 1px solid {{ $role['outline-variant'] }};
color: {{ $role['on-surface-variant'] }};
font-size: 14px;
font-weight: 500;
letter-spacing: 0.1px;
line-height: 20px;
margin: 0;
padding: 0 12px 12px;
text-align: start;
}
/* A Markdown table's column alignment arrives as an `align` attribute, which an inline
`text-align` would override; the head follows it here as the cells do by themselves. */
.table th[align="center"] {
text-align: center;
}
.table th[align="right"] {
text-align: right;
}
.table td {
border-bottom: 1px solid {{ $role['outline-variant'] }};
color: {{ $role['on-surface'] }};
font-size: 14px;
font-weight: 400;
font-variant-numeric: tabular-nums;
letter-spacing: 0.25px;
line-height: 20px;
margin: 0;
padding: 12px;
}
.table tr:last-child td {
border-bottom: 0;
}
/* Buttons: M3's filled button, md */
.action {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
float: unset;
margin: 32px auto;
padding: 0;
text-align: center;
width: 100%;
}
.button {
-webkit-text-size-adjust: none;
border-radius: 9999px;
display: inline-block;
font-size: 16px;
font-weight: 500;
letter-spacing: 0.15px;
line-height: 24px;
overflow: hidden;
text-decoration: none;
}
@foreach ($buttons as $name => [$container, $content])
.button-{{ $name }} {
background-color: {{ $role[$container] }};
border-bottom: 16px solid {{ $role[$container] }};
border-left: 24px solid {{ $role[$container] }};
border-right: 24px solid {{ $role[$container] }};
border-top: 16px solid {{ $role[$container] }};
color: {{ $role[$content] }};
}
@endforeach
/* Panel: a tinted container, no rule down its edge */
.panel {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
border-collapse: separate;
margin: 24px 0;
width: 100%;
}
.panel-content {
background-color: {{ $tint }};
border-radius: 16px;
color: {{ $role['on-surface'] }};
padding: 16px 20px;
}
.panel-content p {
color: {{ $role['on-surface'] }};
}
.panel-item {
padding: 0;
}
.panel-item p:last-of-type {
margin-bottom: 0;
padding-bottom: 0;
}
/* Utilities */
.break-all {
overflow-wrap: anywhere;
}
@@ -4,15 +4,15 @@
@if ($paginator->hasPages()) @if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-pagination class="flex items-center justify-between gap-4"> <nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-pagination class="flex items-center justify-between gap-4">
@if ($paginator->onFirstPage()) @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 @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 @endif
@if ($paginator->hasMorePages()) @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 @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 @endif
</nav> </nav>
@endif @endif
@@ -19,11 +19,11 @@
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@if ($paginator->onFirstPage()) @if ($paginator->onFirstPage())
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Previous') }}"> <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> </span>
@else @else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" class="{{ $live }}" aria-label="{{ __('Previous') }}"> <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> </a>
@endif @endif
@@ -45,11 +45,11 @@
@if ($paginator->hasMorePages()) @if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next" class="{{ $live }}" aria-label="{{ __('Next') }}"> <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> </a>
@else @else
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Next') }}"> <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> </span>
@endif @endif
</div> </div>
@@ -19,21 +19,21 @@
@if ($paginator->hasPages()) @if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-pagination class="flex items-center justify-between gap-4"> <nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-pagination class="flex items-center justify-between gap-4">
@if ($paginator->onFirstPage()) @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) @elseif ($cursor)
@php($previousCursor = $paginator->previousCursor() ?? $paginator->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 @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 @endif
@if (! $paginator->hasMorePages()) @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) @elseif ($cursor)
@php($nextCursor = $paginator->nextCursor() ?? $paginator->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 @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 @endif
</nav> </nav>
@endif @endif
@@ -35,11 +35,11 @@
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@if ($paginator->onFirstPage()) @if ($paginator->onFirstPage())
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Previous') }}"> <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> </span>
@else @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') }}"> <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> </button>
@endif @endif
@@ -63,11 +63,11 @@
@if ($paginator->hasMorePages()) @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') }}"> <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> </button>
@else @else
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Next') }}"> <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> </span>
@endif @endif
</div> </div>
@@ -3,7 +3,22 @@
@props(['title' => null, 'code', 'stack' => false]) @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) @if ($title)
<h3 class="type-title-md">{{ $title }}</h3> <h3 class="type-title-md">{{ $title }}</h3>
@endif @endif
+31 -22
View File
@@ -1,27 +1,36 @@
@extends('livewire-material::showcase.layout') @extends('livewire-material::showcase.layout')
@section('content') @section('content')
<main class="mx-auto max-w-6xl space-y-16 px-4 py-10"> <div class="mx-auto w-full max-w-6xl space-y-12 px-4 pt-4 pb-16 sm:px-6">
<p class="max-w-3xl type-body-lg text-on-surface-variant"> <div class="max-w-3xl space-y-3">
Every token and component, in this application's own scheme. Components appear here as they land. <h1 class="type-headline-lg">Livewire Material</h1>
</p> <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') @foreach (collect($sections)->groupBy('group', preserveKeys: true) as $group => $entries)
@include('livewire-material::showcase.sections.type') <section class="space-y-4" aria-labelledby="group-{{ \Illuminate\Support\Str::slug($group) }}">
@include('livewire-material::showcase.sections.shape') <h2 id="group-{{ \Illuminate\Support\Str::slug($group) }}" class="type-title-lg">{{ $group }}</h2>
@include('livewire-material::showcase.sections.elevation')
@include('livewire-material::showcase.sections.motion') <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@include('livewire-material::showcase.sections.icons') @foreach ($entries as $key => $entry)
@include('livewire-material::showcase.sections.buttons') <x-livewire-material::card variant="outlined" data-list-row wire:key="section-{{ $key }}">
@include('livewire-material::showcase.sections.menus') <div class="flex items-start gap-4">
@include('livewire-material::showcase.sections.communication') <span class="grid size-12 shrink-0 place-items-center rounded-corner-lg bg-secondary-container text-on-secondary-container">
@include('livewire-material::showcase.sections.progress') <x-livewire-material::icon :name="$entry['icon']" />
@include('livewire-material::showcase.sections.containment') </span>
@include('livewire-material::showcase.sections.carousel') <div class="min-w-0">
@include('livewire-material::showcase.sections.fields') <a href="{{ route('livewire-material.section', $key) }}" wire:navigate data-list-open class="type-title-md">{{ $entry['title'] }}</a>
@include('livewire-material::showcase.sections.chips') <p class="mt-1 type-body-md text-on-surface-variant">{{ $entry['description'] }}</p>
@include('livewire-material::showcase.sections.sliders') </div>
@include('livewire-material::showcase.sections.bars') </div>
@include('livewire-material::showcase.sections.data') </x-livewire-material::card>
</main> @endforeach
</div>
</section>
@endforeach
</div>
@endsection @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> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head> <head>
<meta charset="utf-8" /> <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" /> <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')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
</head> </head>
<body class="min-h-screen bg-surface font-sans text-on-surface antialiased"> <body class="bg-surface font-sans text-on-surface antialiased">
<header class="sticky top-0 z-10 border-b border-divider bg-surface-container"> <x-livewire-material::app-shell :destinations="$destinations" label="Showcase" rail-width="17rem">
<div class="mx-auto flex max-w-6xl items-center gap-x-6 px-4 py-3"> <x-slot:brand>
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a> <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"> <x-slot:top>
@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', 'bars' => 'Bars', 'data' => 'Data'] as $anchor => $section) <x-livewire-material::app-bar variant="search">
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a> <x-slot:navigation>
@endforeach <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>
</nav> </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> {{-- The search looks through every section, example and component: the index is
@foreach (['light' => 'Light', 'dark' => 'Dark', 'system' => 'System'] as $choice => $label) fetched on first focus, and / or Ctrl+K (⌘K) reaches it from anywhere. Its names
<button keep clear of <x-search>'s own (`results`, `open`), which the slot also sees. --}}
type="button" <div
data-test="theme-{{ $choice }}" class="mx-auto w-full max-w-2xl"
class="state-layer focus-ring px-4 py-1.5 type-label-lg first:rounded-s-corner-full last:rounded-e-corner-full" x-data="{
x-on:click="$store.theme.set('{{ $choice }}')" query: '',
x-bind:class="$store.theme.choice === '{{ $choice }}' && 'bg-secondary-container text-on-secondary-container'" entries: null,
x-bind:aria-pressed="($store.theme.choice === '{{ $choice }}').toString()" async load() {
>{{ $label }}</button> this.entries ??= await (await fetch(@js(route('livewire-material.search', [], false)))).json();
@endforeach },
</div> get matches() {
</div> const words = this.query.toLowerCase().split(/\s+/).filter(Boolean);
</header> 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 @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> </body>
</html> </html>
+40
View File
@@ -0,0 +1,40 @@
{{-- The showcase's sample Markdown mail: every part the theme styles heading, prose, bold,
a list, the button, a panel, a table, subcopy and the footer slot. Markdown, so it stays
unindented. --}}
<x-mail::message>
# Your export is ready
Hello Anna, the report you asked for on **Monday** has finished. It holds three files and stays available for seven days.
<x-mail::button :url="url('/')">
Download the export
</x-mail::button>
<x-mail::panel>
Files are deleted automatically once they expire. Download them before **20 September** to keep a copy.
</x-mail::panel>
<x-mail::table>
| File | Rows | Size |
|:-----|-----:|-----:|
| customers.csv | 1,204 | 184 KB |
| orders.csv | 8,930 | 1.2 MB |
| summary.pdf | | 96 KB |
</x-mail::table>
What happens next:
1. Open the export from the button above.
2. Check the summary before you share it.
Thanks,<br>
{{ config('app.name') }}
<x-slot:subcopy>
If the button does not work, paste this address into your browser: [{{ url('/') }}]({{ url('/') }})
</x-slot:subcopy>
<x-slot:footer>
© {{ date('Y') }} {{ config('app.name') }} · [Privacy]({{ url('/') }}) · [Unsubscribe]({{ url('/') }})
</x-slot:footer>
</x-mail::message>
@@ -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 icon="format_underlined" aria-label="Underline" variant="tonal" :selected="false" />
</x-button-group> </x-button-group>
BLADE, 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"> <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="[ <x-group label="Theme" name="showcase-theme" x-model="theme" :options="[
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'], ['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'], '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'], '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 @endphp
<section id="colour" class="scroll-mt-24 space-y-6"> <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. <code>php artisan material:scheme</code>. Both themes side by side, whatever the page is showing.
</p> </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"> <div class="grid gap-4 lg:grid-cols-2">
@foreach (['light', 'dark'] as $theme) @foreach (['light', 'dark'] as $theme)
<div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface"> <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="With an action" variant="tonal" x-on:click="materialToast('Share deleted', { action: { label: 'Undo', handler: () => materialToast('Share restored', { type: 'info' }) } })" />
<x-button label="Until dismissed" variant="tonal" x-on:click="materialToast('Your storage is almost full', { type: 'warning', timeout: 0 })" /> <x-button label="Until dismissed" variant="tonal" x-on:click="materialToast('Your storage is almost full', { type: 'warning', timeout: 0 })" />
BLADE, 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' 'Rich tooltips' => <<<'BLADE'
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time. Admins can change the longest time allowed."> <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" /> <x-button icon="help" aria-label="About expiry" />
@@ -59,7 +66,7 @@
<p class="max-w-3xl type-body-md text-on-surface-variant"> <p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&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-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> </p>
@foreach ($examples as $title => $code) @foreach ($examples as $title => $code)
@@ -24,7 +24,7 @@
<div class="flex flex-wrap items-center gap-4"> <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"> <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" /> <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> </label>
@@ -0,0 +1,114 @@
@php
$examples = [
'Navigation bar' => <<<'BLADE'
<div class="w-full space-y-6">
<div class="max-w-[25rem] space-y-2">
<p class="type-label-lg text-on-surface-variant">Compact: icon over label</p>
<div class="flex h-56 flex-col overflow-hidden rounded-corner-lg border border-outline-variant bg-surface">
<div class="flex-1 p-4 type-body-md text-on-surface-variant">The page</div>
<x-navigation-bar>
<x-navigation-bar-item label="Shares" icon="folder_shared" link="#navigation" no-wire-navigate active />
<x-navigation-bar-item label="Upload" icon="upload" link="#navigation" no-wire-navigate />
<x-navigation-bar-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate badge="3" />
<x-navigation-bar-item label="Account" icon="account_circle" link="#navigation" no-wire-navigate :badge="true" badge-label="Needs attention" />
</x-navigation-bar>
</div>
</div>
<div class="max-w-[50rem] space-y-2">
<p class="type-label-lg text-on-surface-variant">From 600px wide: icon beside label, centred</p>
<div class="overflow-x-auto rounded-corner-lg border border-outline-variant">
<div class="flex h-56 min-w-[37.5rem] flex-col bg-surface">
<div class="flex-1 p-4 type-body-md text-on-surface-variant">The page</div>
<x-navigation-bar>
<x-navigation-bar-item label="Shares" icon="folder_shared" link="#navigation" no-wire-navigate active />
<x-navigation-bar-item label="Upload" icon="upload" link="#navigation" no-wire-navigate />
<x-navigation-bar-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate badge="3" />
<x-navigation-bar-item label="Account" icon="account_circle" link="#navigation" no-wire-navigate />
</x-navigation-bar>
</div>
</div>
</div>
</div>
BLADE,
'Collapsed and expanded rails' => <<<'BLADE'
<div class="flex w-full flex-wrap items-start gap-6">
<div class="flex h-[36rem] overflow-hidden rounded-corner-lg border border-outline-variant">
<x-navigation-rail mode="collapsed" label="Collapsed example">
<x-slot:header>
<x-fab icon="edit" tooltip-right="Compose" />
</x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
<x-navigation-rail-section label="Labels">
<x-navigation-rail-item label="Travel" icon="flight" link="#navigation" no-wire-navigate :badge="true" badge-label="New" />
<x-navigation-rail-item label="Receipts" icon="receipt_long" link="#navigation" no-wire-navigate />
</x-navigation-rail-section>
<x-slot:footer>
<x-navigation-rail-item label="Settings" icon="settings" link="#navigation" no-wire-navigate />
</x-slot:footer>
</x-navigation-rail>
</div>
<div class="flex h-[36rem] overflow-hidden rounded-corner-lg border border-outline-variant">
<x-navigation-rail mode="expanded" label="Expanded example">
<x-slot:header>
<x-fab label="Compose" icon="edit" />
</x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
<x-navigation-rail-section label="Labels">
<x-navigation-rail-item label="Travel" icon="flight" link="#navigation" no-wire-navigate :badge="true" badge-label="New" />
<x-navigation-rail-item label="Receipts" icon="receipt_long" link="#navigation" no-wire-navigate />
</x-navigation-rail-section>
<x-slot:footer>
<x-navigation-rail-item label="Settings" icon="settings" link="#navigation" no-wire-navigate />
</x-slot:footer>
</x-navigation-rail>
</div>
</div>
BLADE,
'Collapsible rail' => <<<'BLADE'
<div class="flex h-[30rem] w-full overflow-hidden rounded-corner-lg border border-outline-variant">
<x-navigation-rail label="Collapsible example">
<x-slot:brand>
<span class="block truncate type-title-lg">Mail</span>
</x-slot:brand>
<x-slot:header>
<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>
</x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
</x-navigation-rail>
<div class="min-w-0 flex-1 bg-surface-container-low p-6 type-body-md text-on-surface-variant">
The menu button collapses and expands the rail. The choice is remembered and applied before the next page paints every collapsible rail follows it, <code>&lt;x-app-shell&gt;</code>'s from <code>lg</code> too.
</div>
</div>
BLADE,
];
@endphp
<section id="navigation" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Navigation</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-navigation-bar&gt;</code> and <code>&lt;x-navigation-bar-item&gt;</code>, <code>&lt;x-navigation-rail&gt;</code>, <code>&lt;x-navigation-rail-item&gt;</code> and <code>&lt;x-navigation-rail-section&gt;</code>,
and <code>&lt;x-app-shell&gt;</code>, which puts them together: a bar below <code>sm</code>, a collapsed rail that opens as a modal to <code>lg</code>, a collapsible rail from there.
<a href="{{ route('livewire-material.shell') }}" class="link text-primary">Open the app shell</a> and change the window's width.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code />
@endforeach
</section>
@@ -0,0 +1,16 @@
<section id="pages" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Pages</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
The error pages, raised for real so an app's own <code>resources/views/errors</code> still wins, and the Markdown mail theme
(<code>livewire-material::mail.theme</code>) on a sample mail, both in this application's scheme.
</p>
<div class="flex flex-wrap items-center gap-2">
@foreach (['401', '402', '403', '404', '419', '429', '500', '503'] as $code)
<x-livewire-material::button :link="route('livewire-material.error', $code)" :label="$code" variant="tonal" no-wire-navigate />
@endforeach
<x-livewire-material::button :link="route('livewire-material.mail')" label="Sample mail" icon="mail" variant="outlined" no-wire-navigate />
</div>
</section>
@@ -0,0 +1,71 @@
@php
$examples = [
'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" 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>
<div class="grid content-start gap-4">
<x-datepicker label="Starts on" variant="filled" icon="event" x-model="starts" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(starts)"></code></p>
</div>
</div>
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()" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(birthday)"></code></p>
</div>
<div class="grid content-start gap-4">
<x-datepicker label="Delivery" mode="input" x-model="delivery" hint="Opens on a text field" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(delivery)"></code></p>
</div>
</div>
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" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(trip)"></code></p>
</div>
<div class="grid content-start gap-4">
<x-datepicker label="Leave" range mode="modal" x-model="leave" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(leave)"></code></p>
</div>
</div>
BLADE,
'Limits, errors and states' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2">
<div class="grid content-start gap-4">
<x-datepicker label="Within the next 30 days" :min="now()" :max="now()->addDays(30)" hint="Days outside are disabled" />
<x-datepicker label="Required" required />
</div>
<div class="grid content-start gap-4">
<x-datepicker label="Disabled" value="2026-09-13" disabled />
<x-datepicker label="Read-only" value="2026-09-13" readonly />
<div class="flex flex-wrap items-center gap-3">
<x-datepicker size="sm" aria-label="From" class="w-48" />
</div>
</div>
</div>
BLADE,
];
@endphp
<section id="pickers" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Date pickers</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-datepicker&gt;</code> docked, modal and modal input; single dates and ranges. Month and weekday names and the first day of the week follow the application's locale.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code />
@endforeach
</section>
@@ -17,7 +17,7 @@
<div class="grid grid-cols-3 gap-4 sm:grid-cols-5 lg:grid-cols-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) @foreach (\NoNameWeb\LivewireMaterial\Support\SvgFile::shapeNames() as $shape)
<div class="space-y-2 text-center"> <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> <code class="type-label-md text-on-surface-variant">{{ $shape }}</code>
</div> </div>
@endforeach @endforeach
@@ -0,0 +1,44 @@
@php
$examples = [
'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" />
<x-timepicker label="Pick-up" value="17:15" format="12" icon="local_shipping" clearable />
<x-timepicker label="Disabled" value="12:00" disabled />
</div>
<div class="grid content-start gap-4">
<x-timepicker variant="filled" label="Alarm" value="06:45" format="24" icon="alarm" />
<x-timepicker variant="filled" label="Reminder" format="24" hint="Opens at the current time" />
<x-timepicker variant="filled" label="Required" format="24" required />
</div>
</div>
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" />
<x-timepicker label="Night shift starts" value="23:00" format="24" min="22:00" max="06:00" hint="From 22:00, across midnight, to 06:00" />
</div>
<div class="grid content-start gap-4" x-data="{ time: '14:05' }">
<x-timepicker label="Bound with x-model" x-model="time" format="12" step="5" />
<p class="type-body-md text-on-surface-variant">Stored as <code x-text="time ?? 'null'"></code></p>
</div>
</div>
BLADE,
];
@endphp
<section id="timepickers" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Time pickers</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-timepicker&gt;</code> M3's dial and input time picker in a modal dialog, opened from a text field.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code />
@endforeach
</section>
+87
View File
@@ -0,0 +1,87 @@
{{-- The app shell, as a whole page: /material/shell and its sibling pages, so the rail, the bar, the
modal rail and wire:navigate between pages can be tried at any window width. --}}
@php
$page ??= 'inbox';
$pages = [
'inbox' => ['title' => 'Inbox', 'icon' => 'inbox', 'badge' => 12],
'starred' => ['title' => 'Starred', 'icon' => 'star'],
'sent' => ['title' => 'Sent', 'icon' => 'send'],
'drafts' => ['title' => 'Drafts', 'icon' => 'draft', 'badge' => true],
'travel' => ['title' => 'Travel', 'icon' => 'flight', 'section' => 'Labels', 'bar' => false],
'receipts' => ['title' => 'Receipts', 'icon' => 'receipt_long', 'section' => 'Labels', 'bar' => false],
];
$destinations = collect($pages)
->map(fn (array $destination, string $key): array => $destination + [
'url' => route('livewire-material.shell', $key === 'inbox' ? [] : ['page' => $key]),
'active' => $key === $page,
])
->values()
->all();
$current = $pages[$page];
@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, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<title>{{ $current['title'] }} · App shell · Livewire Material</title>
<x-livewire-material::theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface font-sans text-on-surface antialiased">
<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-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-livewire-material::navigation-rail-item label="Settings" icon="settings" link="#settings" no-wire-navigate />
</x-slot:rail-footer>
<x-slot:actions>
<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-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="shell-menu" /></span>
<h1 class="min-w-0 flex-1 truncate px-3 type-title-lg sm:px-0">{{ $current['title'] }}</h1>
<x-livewire-material::button icon="search" tooltip="Search" />
</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-livewire-material::list segmented>
@foreach (range(1, 14) as $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-livewire-material::list>
<div class="flex flex-wrap gap-2">
<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-livewire-material::app-shell>
@livewireScripts
</body>
</html>
+19 -1
View File
@@ -1,9 +1,27 @@
<?php <?php
use Illuminate\Support\Facades\Route; 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\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.json', [ShowcaseSymbolController::class, 'index'])->name('symbols');
Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol'); Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol');
Route::view('shell/{page?}', 'livewire-material::showcase.shell')
->whereIn('page', ['inbox', 'starred', 'sent', 'drafts', 'travel', 'receipts'])
->name('shell');
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\Console\Command;
use Illuminate\Filesystem\Filesystem; use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use JsonException; use JsonException;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
@@ -15,7 +16,7 @@ class SchemeCommand extends Command
* @var string * @var string
*/ */
protected $signature = 'material:scheme 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} {--variant=tonal-spot : tonal-spot, vibrant, expressive, neutral, fidelity, content, monochrome, rainbow or fruit-salad}
{--contrast=0 : The contrast level, from -1 to 1} {--contrast=0 : The contrast level, from -1 to 1}
{--success=#22a06b : The source of the success colour} {--success=#22a06b : The source of the success colour}
@@ -26,20 +27,73 @@ class SchemeCommand extends Command
/** /**
* @var string * @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 public function handle(Filesystem $files): int
{ {
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css'); $stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
$data = preg_replace('/\.css$/', '', $stylesheet).'.json'; $data = preg_replace('/\.css$/', '', $stylesheet).'.json';
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([ $result = Process::run([
config('livewire-material.node', 'node'), config('livewire-material.node', 'node'),
__DIR__.'/../../resources/node/scheme.mjs', __DIR__.'/../../resources/node/scheme.mjs',
json_encode([ json_encode([
'seed' => $this->argument('seed'), 'seed' => $seed,
'variant' => $this->option('variant'), 'variant' => $variant,
'contrast' => (float) $this->option('contrast'), 'contrast' => $contrast,
'success' => $this->option('success'), 'success' => $this->option('success'),
'warning' => $this->option('warning'), 'warning' => $this->option('warning'),
'info' => $this->option('info'), 'info' => $this->option('info'),
@@ -47,22 +101,27 @@ class SchemeCommand extends Command
]); ]);
if ($result->failed()) { 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 { try {
/** @var array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme */ return json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
$scheme = json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) { } 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->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"); $files->put($data, json_encode($scheme, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
$this->components->info("Wrote {$stylesheet} and {$data}."); $this->components->info("Wrote {$stylesheet} and {$data}.");
@@ -82,9 +141,7 @@ class SchemeCommand extends Command
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '', $scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
); );
$block = fn (array $roles): string => collect($roles) $blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
->map(fn (string $hex, string $role): string => " --md-sys-color-{$role}: {$hex};")
->implode("\n");
return <<<CSS return <<<CSS
/* /*
@@ -97,19 +154,76 @@ class SchemeCommand extends Command
* first paint; the light block also stands without it. * first paint; the light block also stands without it.
*/ */
:root, {$blocks}
[data-theme='light'] {
color-scheme: light;
{$block($scheme['light'])}
}
[data-theme='dark'] {
color-scheme: dark;
{$block($scheme['dark'])}
}
CSS; 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());
}
}
@@ -0,0 +1,37 @@
<?php
namespace NoNameWeb\LivewireMaterial\Http\Controllers;
use Illuminate\Http\Response;
use Illuminate\Mail\Markdown;
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
/**
* Previews of the pages the showcase cannot draw inline: an error page, raised for real so the
* exception handler picks the view exactly as it would in the application (an app's own
* `resources/views/errors` included), and a sample Markdown mail in the package theme.
*/
class ShowcasePageController
{
public function error(string $code): never
{
abort((int) $code);
}
/**
* The sample mail, with the package's header and message after the application's own mail
* components what an application gets with `livewire-material.mail.components` on.
*/
public function mail(): Response
{
$markdown = new Markdown(view(), [
'theme' => 'livewire-material::mail.theme',
'paths' => array_values(array_unique([
...(array) config('mail.markdown.paths', []),
LivewireMaterialServiceProvider::mailComponentPath(),
])),
]);
return response($markdown->render('livewire-material::showcase.mail')->toHtml());
}
}
+98 -5
View File
@@ -5,6 +5,7 @@ namespace NoNameWeb\LivewireMaterial;
use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\Factory;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class LivewireMaterialServiceProvider extends ServiceProvider class LivewireMaterialServiceProvider extends ServiceProvider
@@ -14,6 +15,8 @@ class LivewireMaterialServiceProvider extends ServiceProvider
$this->mergeConfigFrom(__DIR__.'/../config/livewire-material.php', 'livewire-material'); $this->mergeConfigFrom(__DIR__.'/../config/livewire-material.php', 'livewire-material');
$this->disableBladeIconsComponent(); $this->disableBladeIconsComponent();
$this->registerErrorViews();
$this->registerMailComponents();
} }
public function boot(): void public function boot(): void
@@ -33,18 +36,106 @@ class LivewireMaterialServiceProvider extends ServiceProvider
$this->publishes([ $this->publishes([
__DIR__.'/../config/livewire-material.php' => config_path('livewire-material.php'), __DIR__.'/../config/livewire-material.php' => config_path('livewire-material.php'),
], 'livewire-material-config'); ], 'livewire-material-config');
$this->publishes([
static::errorViewPath().'/errors' => resource_path('views/errors'),
], 'livewire-material-errors');
$this->publishes([
static::mailComponentPath().'/html' => resource_path('views/vendor/mail/html'),
static::mailComponentPath().'/text' => resource_path('views/vendor/mail/text'),
], 'livewire-material-mail');
} }
} }
/** /**
* Register the components as anonymous Blade components under the configured prefix. * 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/`.
*/
public static function errorViewPath(): string
{
return dirname(__DIR__).'/resources/views/error-pages';
}
/**
* The mail component root, laid out like the framework's: `html/` and `text/`.
*/
public static function mailComponentPath(): string
{
return dirname(__DIR__).'/resources/views/mail';
}
/**
* Append the error-view root to `view.paths`, after the application's own.
*
* The exception handler replaces the `errors` namespace each time it renders an HTTP
* exception, with every entry of `view.paths` plus `/errors` and the framework's views last
* (RegisterErrorViewPaths), so a namespace added with addNamespace() is gone by then; only a
* view path survives. That config is read at render time, but also once when the view
* finder is built: appended here, in register(), before any boot() resolves the view
* factory, the finder and `view:cache` see the same paths the handler does. Appending keeps
* `view.paths[0]` Laravel's viewPath() — the application's, and puts the application's
* own `resources/views/errors` first. Idempotent, since a cached config already holds it.
*/
protected function registerErrorViews(): void
{
$paths = (array) config('view.paths', []);
if (! in_array(static::errorViewPath(), $paths, true)) {
config(['view.paths' => [...$paths, static::errorViewPath()]]);
}
}
/**
* Put the package's mail header and message after the application's own mail components,
* when `livewire-material.mail.components` asks for it. Off by default: it changes every
* Markdown mail the application sends, theme or not, and the framework reads
* `mail.markdown.paths` once, when the Markdown renderer is first made after register().
*/
protected function registerMailComponents(): void
{
if (! config('livewire-material.mail.components')) {
return;
}
$paths = (array) config('mail.markdown.paths', []);
if (! in_array(static::mailComponentPath(), $paths, true)) {
config(['mail.markdown.paths' => [...$paths, static::mailComponentPath()]]);
}
}
/**
* 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 protected function registerComponents(): void
{ {
Blade::anonymousComponentPath( Blade::anonymousComponentPath(
__DIR__.'/../resources/views/components', static::componentPath(),
filled(config('livewire-material.prefix')) ? config('livewire-material.prefix') : null, filled(config('livewire-material.prefix')) ? config('livewire-material.prefix') : 'livewire-material',
); );
View::addNamespace(hash('xxh128', static::componentPath()), static::componentPath());
} }
/** /**
@@ -84,12 +175,14 @@ class LivewireMaterialServiceProvider extends ServiceProvider
*/ */
protected function registerShowcase(): void protected function registerShowcase(): void
{ {
// Registered even with the showcase off: `php artisan view:cache` compiles every view in the
// package's namespace, the showcase's pages included, and <x-showcase::…> must resolve there.
Blade::anonymousComponentPath(__DIR__.'/../resources/views/showcase/components', 'showcase');
if (! config('livewire-material.showcase.enabled')) { if (! config('livewire-material.showcase.enabled')) {
return; return;
} }
Blade::anonymousComponentPath(__DIR__.'/../resources/views/showcase/components', 'showcase');
if ($this->app->routesAreCached()) { if ($this->app->routesAreCached()) {
return; return;
} }

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