Compare commits
38
Commits
83ed671c4e
...
1.2.0
@@ -115,3 +115,5 @@ jobs:
|
||||
|
||||
- name: Run browser tests
|
||||
run: vendor/bin/pest --testsuite=Browser --browser ${{ matrix.browser }}
|
||||
env:
|
||||
BROWSER_TIMEOUT: 15000
|
||||
|
||||
@@ -7,3 +7,6 @@
|
||||
/workbench/public/hot
|
||||
/tests/Browser/Screenshots
|
||||
.DS_Store
|
||||
|
||||
# Planning notes stay local
|
||||
/docs/plans
|
||||
|
||||
@@ -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.
|
||||
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)
|
||||
Copyright Google LLC. SIL Open Font License 1.1 (resources/fonts/google-sans-flex/OFL.txt).
|
||||
Subset: Latin and Latin Extended, weight 400–700, roundness 0–100.
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# 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`. `--spec` is the colour spec: `2025` (M3 Expressive, the default) or `2021` (M3 as it first shipped, for a palette generated before Expressive). `--contrast` runs from -1 to 1; `--success`, `--warning` and `--info` seed the state colours. The stylesheet's header records the command that regenerates it; regenerate instead of editing the file.
|
||||
|
||||
#### Colour profiles
|
||||
|
||||
To let an installation switch between several schemes, list them as `profiles` in the config (name ⇒ `label`, `seed`, `variant`, and optionally `contrast`, `spec`, `success`, `warning`, `info`, which otherwise come from the command's options) and run `php artisan material:scheme` without a seed: every profile lands in the same stylesheet under `<html data-scheme>`. Tell the package which one is active — `Scheme::resolveProfileUsing(fn () => Setting::get('color_profile'))` in a service provider — and the head script, mails and error pages follow it. `<x-scheme-picker wire:model="colorProfile" />` lets someone choose, previewing each profile on the page.
|
||||
|
||||
### Configuration
|
||||
|
||||
```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).
|
||||
- `theme.meta` — keep `<meta name="theme-color">` (an installed web app's or a mobile browser's bar) on the resolved theme's `surface` and the active colour profile, before the first paint and after every change, `wire:navigate` included; one is added when the page has none (default `false`).
|
||||
- `profiles`, `profile` — colour profiles and the default one (see Colour profiles).
|
||||
- `fields.variant` — text fields `outlined` (default) or `filled`.
|
||||
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
|
||||
- `showcase.enabled`, `showcase.path`, `showcase.middleware`, `showcase.vite`.
|
||||
- `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`.
|
||||
+8
-3
@@ -6,7 +6,8 @@
|
||||
* nothing but `node`. (The published library imports without file extensions, which
|
||||
* plain Node refuses, so it cannot be run unbundled anyway.)
|
||||
*
|
||||
* Input: one JSON argument — {seed, variant, contrast, success, warning, info}.
|
||||
* Input: one JSON argument — {seed, variant, spec, contrast, success, warning, info}. `spec` is
|
||||
* the colour spec, '2025' (M3 Expressive, the default) or '2021' (M3 as it first shipped).
|
||||
* Output: JSON on stdout — {seed, variant, spec, contrast, light: {role: hex}, dark: {role: hex}}.
|
||||
*/
|
||||
import {
|
||||
@@ -56,9 +57,12 @@ try {
|
||||
|
||||
const hex = /^#[0-9a-f]{6}$/i
|
||||
const Scheme = VARIANTS[input.variant]
|
||||
const SPECS = ['2021', '2025']
|
||||
const spec = input.spec ?? '2025'
|
||||
|
||||
if (!hex.test(input.seed ?? '')) fail(`The seed must be a #rrggbb colour, "${input.seed}" given.`)
|
||||
if (!Scheme) fail(`Unknown variant "${input.variant}". Use one of: ${Object.keys(VARIANTS).join(', ')}.`)
|
||||
if (!SPECS.includes(spec)) fail(`Unknown spec "${spec}". Use one of: ${SPECS.join(', ')}.`)
|
||||
|
||||
for (const state of ['success', 'warning', 'info']) {
|
||||
if (!hex.test(input[state] ?? '')) fail(`The ${state} colour must be a #rrggbb colour, "${input[state]}" given.`)
|
||||
@@ -73,8 +77,9 @@ const colors = new MaterialDynamicColors()
|
||||
|
||||
function roles(isDark) {
|
||||
// The 2025 spec is M3 Expressive's colour; the library falls back to 2021 for the
|
||||
// variants the new spec does not define (fidelity, content, monochrome, …).
|
||||
const scheme = new Scheme(source, isDark, contrast, '2025')
|
||||
// variants the new spec does not define (fidelity, content, monochrome, …). 2021 is
|
||||
// M3's original colour, for an application whose palette was generated with it.
|
||||
const scheme = new Scheme(source, isDark, contrast, spec)
|
||||
const out = {}
|
||||
|
||||
for (const color of colors.allColors) {
|
||||
|
||||
@@ -8,8 +8,10 @@ return [
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Every component is an anonymous Blade component. Without a prefix they are
|
||||
| <x-button>, <x-card> and so on; set a prefix such as 'm-' when a name
|
||||
| clashes with one of the application's own components (<x-m-button>).
|
||||
| <x-button>, <x-card> and so on; set a prefix such as 'm' when a name
|
||||
| clashes with one of the application's own components, and they become
|
||||
| <x-m::button>, <x-m::card>. They are always <x-livewire-material::button>
|
||||
| as well.
|
||||
|
|
||||
*/
|
||||
|
||||
@@ -26,12 +28,36 @@ return [
|
||||
| localStorage under 'storage_key'; values found under 'legacy_keys' (an
|
||||
| earlier theme toggle's key) are adopted once and then removed.
|
||||
|
|
||||
| 'meta' keeps <meta name="theme-color"> (the colour an installed web app
|
||||
| or a mobile browser gives its bar) on the resolved theme's surface, and
|
||||
| the active colour profile's: the head script sets it before the first
|
||||
| paint, adds one when the page has none, and follows every later change,
|
||||
| wire:navigate included. A theme-color meta with a `media` attribute is
|
||||
| left alone.
|
||||
|
|
||||
*/
|
||||
|
||||
'theme' => [
|
||||
'default' => 'system',
|
||||
'storage_key' => 'material-theme',
|
||||
'legacy_keys' => [],
|
||||
'meta' => false,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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',
|
||||
],
|
||||
|
||||
/*
|
||||
@@ -75,6 +101,63 @@ return [
|
||||
|
||||
'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'; 'spec'
|
||||
| ('2025' or '2021') and the 'success', 'warning' and 'info' sources are
|
||||
| optional too, taken from the command's options when left out. Without a
|
||||
| seed, `php artisan material:scheme` generates every profile into one
|
||||
| stylesheet keyed by <html data-scheme>. 'profile' names the default one
|
||||
| (else the first); the application says which is active with
|
||||
| Scheme::resolveProfileUsing(). Regenerate after changing either.
|
||||
|
|
||||
*/
|
||||
|
||||
'profiles' => [],
|
||||
|
||||
'profile' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mail
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 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
|
||||
@@ -82,7 +165,8 @@ return [
|
||||
|
|
||||
| Every component in every variant, rendered in the application's own
|
||||
| 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.
|
||||
|
|
||||
*/
|
||||
|
||||
|
||||
@@ -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 400–700, ROND 0–100).
|
||||
- Spring motion as CSS `linear()` curves; press shape-morph; Expressive shapes generated by
|
||||
formula into `resources/svg/shapes`.
|
||||
- `App\Livewire\Concerns\Toasts` (protected `success|error|warning|info`, dispatching a window
|
||||
event) and a snackbar `<x-toast>`.
|
||||
- ~30 component tests (`$this->blade()`), browser tests (Pest browser plugin), and guard tests
|
||||
(`DesignLanguageTest`, `MaterialTokensTest`, `MaterialSymbolsTest`).
|
||||
- The Livewire traps, recorded in `../ReStride/.ai/rules/ui.md`: `wire:ignore.self` on a
|
||||
`showModal()` dialog; never pass `hidden` or a position to a component (wrap it); Blade
|
||||
directives do not compile inside a component tag's attributes; `wire('model')->value()` is
|
||||
`false`, not `null`, without `wire:model`; a field's states are CSS selectors (`:has()`),
|
||||
marked with `data-invalid` / `data-floated` / `data-readonly` where the control cannot carry
|
||||
them; the customizable select's traps (`../ReStride/.ai/rules/components-views-components.md`).
|
||||
- Mail on M3 (`../ReStride/docs/plans/material-mail.md`): one theme CSS inlined onto bare
|
||||
tags, light only, because `CssToInlineStyles` strips `@media`.
|
||||
|
||||
**What in ReStride is app-specific** and does not move: the doctrine (two button weights, no
|
||||
tertiary / primary-container — enforced by its build), `restride-theme`, `$store.install` /
|
||||
`$store.connection` in the shell, `--bottom-bar` set by its layout, sport / zone / route
|
||||
tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`,
|
||||
`share-button`, the unDraw repaint, the Ace code editor.
|
||||
|
||||
**Constraints found.**
|
||||
|
||||
- `gitea.nonameweb.ch` requires sign-in to view anything (`/explore/repos` → login, API 403).
|
||||
A "public" repo there cannot be installed anonymously until that changes.
|
||||
*(Resolved in step 1.)*
|
||||
- Laravel's Markdown mail accepts a namespaced view as theme
|
||||
(`Illuminate/Mail/Markdown.php:114`), so a package can render the theme CSS from data.
|
||||
- Laravel **replaces** the `errors` view namespace at render time with
|
||||
`config('view.paths')` + `/errors` and the framework's own
|
||||
(`Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php`), so error views a package
|
||||
adds with `addNamespace('errors', …)` are wiped; only a path in `view.paths` survives.
|
||||
- M3 Expressive deprecates bottom app bar, navigation drawer, the original navigation bar,
|
||||
segmented button, small FAB and medium/large top app bar (material-components-android docs).
|
||||
- CSS anchor positioning is in Chrome 125+, Firefox 147+, Safari 18.4+ (flip via
|
||||
`@position-try` from Safari 18.4).
|
||||
- Boost loads a package's `resources/boost/guidelines/core.blade.php` and its skills on
|
||||
`boost:install` / `boost:update --discover`.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **A shared Composer package, SealShare its first consumer; ReStride adopts later in its own
|
||||
plan** — SealShare's small surface proves the API; ReStride's three-day-old migration is not
|
||||
put at risk.
|
||||
- **Public repo on `gitea.nonameweb.ch`, MIT** — SealShare is public; its CI, Docker build,
|
||||
manual install and fork PRs must install the package without credentials. SealShare
|
||||
requires it through a `vcs` repository entry. Material Symbols (Apache-2.0), Google Sans
|
||||
Flex (OFL) and material-web token values (Apache-2.0) are compatible; attributions ship in
|
||||
`NOTICE`.
|
||||
- **Prerequisite: Gitea allows anonymous reads** (`[service] REQUIRE_SIGNIN_VIEW = false`, or
|
||||
`expensive` on 1.23+) — otherwise "public" is not installable.
|
||||
- **Name `nonameweb/livewire-material`, namespace `NoNameWeb\LivewireMaterial`,
|
||||
`config/livewire-material.php`, views `livewire-material::`, commands `material:*`** — says
|
||||
what it is and what it is for.
|
||||
- **Components unprefixed by default, prefix configurable** (maryUI's model, via
|
||||
`Blade::anonymousComponentPath($path, $prefix)`) — ReStride's call sites keep their names;
|
||||
SealShare's maryUI tags keep theirs. The provider sets `blade-icons.components.default` to
|
||||
`null`, or blade-icons' class `<x-icon>` beats ours.
|
||||
- **CSS and JS imported from `vendor/`** (`@import`, `@source`, `import`) — one dependency, one
|
||||
version; consuming Dockerfiles install Composer packages before the Vite build.
|
||||
`@source` covers the package's views and PHP, never its SVG folders.
|
||||
- **The full Material Symbols Rounded set** (400 / 0 / 24, outlined and filled, 4,135 symbols,
|
||||
5.3 MB), fetched by a maintenance script in the package repo, never at runtime — any name works
|
||||
in any app. **`<x-icon>` reads the SVG files itself; blade-icons is not used for them** (decided
|
||||
after Phase 1): blade-icons registers one Blade component per icon whenever the view factory
|
||||
resolves, ~8,000 registrations per request here. blade-icons stays a dev dependency, only to
|
||||
test that the provider still keeps its `<x-icon>` from shadowing ours in apps that have it.
|
||||
- **Google Sans Flex bundled** (ReStride's subset) via `@font-face` in the package CSS; an app
|
||||
overrides `--font-sans`.
|
||||
- **Full M3, not ReStride's doctrine** — every variant, colour role (tertiary and
|
||||
primary-container included) and container is available; each app enforces its own rules
|
||||
through a configurable guard helper the package ships.
|
||||
- **`variant` + `color` props** — `variant="filled|tonal|outlined|text|elevated"`,
|
||||
`color="primary|secondary|tertiary|error|success|warning|info"`, default `text` in primary;
|
||||
both validated against fixed lists, unknown values fall back to the default. Shorthands
|
||||
kept: `primary` = filled primary, `danger` = filled error, `caution` = filled warning. The
|
||||
same pattern for badge, alert, chip, icon button and FAB (`tone` stays an alias of `color`
|
||||
where ReStride used it).
|
||||
- **`php artisan material:scheme {seed} --variant=`** — runs Google's
|
||||
`material-color-utilities` from a single prebundled Node script inside the package; writes
|
||||
the app's `resources/css/material-scheme.css` (dark and light `--md-sys-color-*`, plus
|
||||
`success`/`warning`/`info` custom colours, harmonisation off) and
|
||||
`resources/css/material-scheme.json` (the light hexes, for the mail theme). Output is
|
||||
committed.
|
||||
- **Theme: a head script component, `light | dark | system`** — sets `data-theme` before paint;
|
||||
CSS keys only on the attribute and never asks `prefers-color-scheme`; only the script reads
|
||||
the OS (and follows its changes while `system`). `$store.theme` is the one state every
|
||||
toggle shares. Config: `theme.default`, `theme.storage_key`, `theme.legacy_keys` (adopted
|
||||
once, then removed).
|
||||
- **The whole M3 Expressive catalogue, current components only** — the six deprecated ones are
|
||||
skipped; their names alias where free (`<x-group>` renders a connected button group, FAB
|
||||
`size="sm"` renders medium).
|
||||
- **Plus the non-M3 pieces apps need** — data table, sort header, pagination views, file input,
|
||||
password, stat, alert, empty state, collapse, section nav, account menu, theme toggle, an
|
||||
adaptive app-shell composition, error pages, mail theme.
|
||||
- **Modern browser floor, no third-party JS** — Chrome 125+, Firefox 147+, Safari 18.4+:
|
||||
native `<dialog>`, Popover API, CSS anchor positioning, customizable `<select>` as a
|
||||
progressive enhancement; Alpine (bundled with Livewire) for behaviour. Date and time
|
||||
pickers, carousel and search are our own.
|
||||
- **PHP ^8.4, Laravel ^13, Livewire ^4.** No request state in singletons (Octane).
|
||||
- **Strings through `__()` with an `en` file**, publishable and overridable.
|
||||
- **Accessibility: WAI-ARIA Authoring Practices patterns, WCAG 2.2 AA contrast, full keyboard.**
|
||||
- **Showcase in the package, mounted twice** — by the Workbench for development and browser
|
||||
tests, and opt-in inside an app (`showcase.enabled`, default only when `APP_ENV=local`) so
|
||||
an app sees every component in its own scheme. Every variant, colour, size and state, a
|
||||
light/dark/system switch, and the Blade snippet beside each example.
|
||||
- **Tests: render tests for every component, browser tests against the Workbench showcase in
|
||||
Chromium, Firefox and WebKit; no screenshot diffs.**
|
||||
- **Error pages and a mail theme in the package** — the mail theme is the namespaced view
|
||||
`livewire-material::mail.theme`, rendering CSS from the app's `material-scheme.json`, so
|
||||
mail colours cannot drift from the app.
|
||||
- **Boost guideline + `livewire-material-development` skill** in the package, and a test that
|
||||
fails when a component is missing from the skill.
|
||||
- **No version tags until the whole catalogue is done; then `1.0.0`** — the waves land on `main` untagged (decided 2026-09-13; replaces "`0.x` per wave").
|
||||
- **Semantic ink and line utilities carried over from ReStride** (`text-body`, `text-meta`,
|
||||
`text-quiet`, `border-structure`, `border-chrome`, `border-divider`) — cheap `@theme` names
|
||||
ReStride's templates already use, derived from M3 roles.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- ReStride adopting the package — its own plan, after `1.0.0`.
|
||||
- A brand colour chosen at runtime (e.g. in SealShare's admin), dynamic colour, a PHP port of
|
||||
the colour maths.
|
||||
- M3 Expressive's deprecated components (bottom app bar, navigation drawer, original
|
||||
navigation bar, segmented button, small FAB, medium/large top app bar).
|
||||
- Screenshot / visual regression tests.
|
||||
- Blaze optimisation of the components — a follow-up if rendering gets slow.
|
||||
- ReStride-specific components and tokens (training row, maps, product shot, star rating, share
|
||||
button, sport/zone/route tokens, unDraw repaint, Ace code editor).
|
||||
- Publishing on Packagist — the `vcs` repository is enough; revisit if others adopt it.
|
||||
- Translations beyond `en`.
|
||||
- Browsers below the floor.
|
||||
- Non-Livewire stacks (Inertia, React, Vue).
|
||||
|
||||
## Implementation steps
|
||||
|
||||
### Phase 0 — Prerequisites
|
||||
|
||||
1. **Gitea.** *Done 2026-09-13.* `GITEA__service__REQUIRE_SIGNIN_VIEW=false` in the Gitea
|
||||
compose service (Gitea 1.26.4); `/explore/repos` and the API answer anonymously, and no
|
||||
other repo or org is public. The package lives in the public org **`noNameWEB`**:
|
||||
`https://gitea.nonameweb.ch/noNameWEB/livewire-material.git` (public, empty, `main`,
|
||||
Actions on). SealShare's `vcs` repository entry uses that URL.
|
||||
2. **Split the plan.** *Done 2026-09-13.* This file; SealShare keeps its adoption steps.
|
||||
|
||||
### Phase 1 — Package skeleton
|
||||
|
||||
3. **Repository.** `composer.json` (`nonameweb/livewire-material`, PSR-4
|
||||
`NoNameWeb\LivewireMaterial\`, requires `php ^8.4`, `laravel/framework ^13`,
|
||||
`livewire/livewire ^4`, `blade-ui-kit/blade-icons ^1.10`; dev: `orchestra/testbench`,
|
||||
`pestphp/pest`, `pestphp/pest-plugin-laravel`, `pestphp/pest-plugin-browser`,
|
||||
`laravel/pint`), `LICENSE` (MIT), `NOTICE`, `pint.json`, `phpunit.xml`, `.gitattributes`
|
||||
excluding `workbench/`, `tests/`, `bin/`, `docs/` and the Node/Vite dev files from dist
|
||||
archives.
|
||||
4. **Service provider** `LivewireMaterialServiceProvider`: merge config; register the
|
||||
anonymous component path with the configured prefix; `loadViewsFrom` (`livewire-material`),
|
||||
`loadTranslationsFrom`; set `blade-icons.components.default` to `null` in `register()`
|
||||
(before blade-icons boots and registers its component); register the icon and shape sets;
|
||||
append the package's error-view root (a directory holding only `errors/`) to
|
||||
`view.paths` after the app's own, so Laravel's namespace replacement keeps it and an app's
|
||||
`resources/views/errors` still wins; publishable config, lang, error views; commands; the
|
||||
showcase routes when enabled. No request state anywhere in the container.
|
||||
5. **Config** `config/livewire-material.php`: `prefix`, `theme.default`, `theme.storage_key`,
|
||||
`theme.legacy_keys`, `showcase.enabled` (`env('MATERIAL_SHOWCASE', app()->isLocal())`),
|
||||
`showcase.path` (`material`), `showcase.middleware` (`['web']`).
|
||||
6. **Workbench** (`orchestra/workbench`): a Laravel app with Livewire, Vite and the package
|
||||
CSS/JS built, serving the showcase at `/`; `composer serve` starts it.
|
||||
7. **CI on Gitea act_runner:** Pint, Pest render tests, browser tests in Chromium, Firefox and
|
||||
WebKit (Playwright installed with deps), on pushes and pull requests.
|
||||
8. **Boost resources skeleton:** `resources/boost/guidelines/core.blade.php` and
|
||||
`resources/boost/skills/livewire-material-development/SKILL.md`; the drift test (every
|
||||
component file under `resources/views/components` is named in the skill).
|
||||
|
||||
**Phase 1 is done (2026-09-13).** What changed from the steps above:
|
||||
|
||||
- **Testbench 11.2** runs the Workbench (`testbench.yaml`: providers listed, `start: /material`,
|
||||
`MATERIAL_SHOWCASE=true`). Vite builds into `workbench/public/build`; `composer serve` links
|
||||
that into the skeleton's `public/build` through the Workbench `sync` option, and
|
||||
`tests/TestCase.php` points `public_path()` at `workbench/public` so browser tests find the
|
||||
manifest without the command. Verified: `composer serve` answers `/material` and its CSS.
|
||||
- **The showcase is a plain view with `@extends`**, not a component: `<x-livewire-material::…>`
|
||||
resolves under `components/`, which would have made the layout a public component.
|
||||
- **blade-icons' `components.default` is set in a `booting` callback**, not in `register()`:
|
||||
`mergeConfigFrom` is shallow, so a nested key written before blade-icons merges its defaults
|
||||
wipes the rest of its `components` array. The test fails with the call removed.
|
||||
- **The showcase switch is tested by rebooting** with `MATERIAL_SHOWCASE` in the environment:
|
||||
Testbench's per-test attributes do not reach Pest closures.
|
||||
- **The Boost guideline is wrapped in `@verbatim`**: it is rendered as Blade, and a
|
||||
`<x-mary-*>` in its prose would compile as a component tag.
|
||||
- **`phpunit.xml` trap:** a comment containing a double hyphen (`--browser`) is invalid XML and
|
||||
PHPUnit refuses the file; Pest then reports an unrelated `Pest\Plugins\Tia` error on shutdown.
|
||||
- **CI** (`.github/workflows/tests.yml`): Pint, feature tests on PHP 8.4 and 8.5, browser tests
|
||||
per engine — Pest's `--browser chrome|firefox|safari`, Playwright's `chromium|firefox|webkit`.
|
||||
Locally only Chromium was run; Firefox and WebKit first run on CI.
|
||||
- **`NOTICE` moves to Phase 2**, when the first third-party assets (Symbols, font, tokens,
|
||||
colour utilities) arrive — a notice with nothing to attribute would be wrong.
|
||||
- **The scheme script is not in `bin/`:** `bin/` is export-ignored (maintenance scripts only),
|
||||
but applications run the scheme script, so it lives at `resources/node/scheme.mjs`.
|
||||
- **Found for Phase 2:** blade-icons calls `Factory::registerComponents()` whenever the view
|
||||
factory resolves, registering one Blade component per icon unless `components.disabled` —
|
||||
and without `icons:cache` it scans the folders first. With ~7,800 symbols that is per
|
||||
request under PHP-FPM. Decided before step 13.
|
||||
|
||||
### Phase 2 — Foundation
|
||||
|
||||
9. **Scheme command.** `resources/node/scheme.mjs` built once in the package repo (esbuild bundle of
|
||||
`@material/material-color-utilities`, committed, Apache-2.0 header); `material:scheme {seed}
|
||||
{--variant=tonal-spot|vibrant|expressive|fidelity|content|neutral|monochrome}
|
||||
{--success=} {--warning=} {--info=} {--output=}` runs it through `node` and writes
|
||||
`resources/css/material-scheme.css` and `resources/css/material-scheme.json`. Fails with a
|
||||
clear message when `node` is missing. A default scheme (M3 baseline `#6750A4`) ships inside
|
||||
the package so a fresh install renders before the command is run.
|
||||
10. **Tokens.** `resources/css/material.css` as the single entry, importing `tokens/shape.css`,
|
||||
`type.css` (typescale utilities incl. emphasized), `motion.css` (six spring `linear()`
|
||||
curves — spatial/effects × fast/default/slow — and reduced-motion overrides),
|
||||
`elevation.css`, `state.css` (state layers, focus ring), and the `@theme` block mapping
|
||||
**every** M3 role (primary/secondary/tertiary and their containers, surfaces, outlines,
|
||||
inverse, error, custom success/warning/info with `inverse-*`) plus the semantic ink and
|
||||
line utilities; `--color-*: initial` with white and black re-added. Each theme block
|
||||
declares `color-scheme`. Values from material-web's token files, attributed.
|
||||
11. **Font.** `resources/fonts/google-sans-flex/` (woff2 + OFL), `@font-face` and `--font-sans`
|
||||
in the entry CSS.
|
||||
12. **Theme.** `<x-theme-script />` (inline, before `@vite`): reads `storage_key`, adopts
|
||||
`legacy_keys` (their values may be JSON-encoded — maryUI's `$persist` stores `"dark"`
|
||||
with quotes), resolves `system` through `matchMedia`, writes `data-theme`; `$store.theme`
|
||||
in `resources/js/material.js` with `set()` and a `matchMedia` listener while `system`.
|
||||
13. **Icons.** `bin/fetch-symbols` (maintenance only) downloads Material Symbols Rounded
|
||||
400/0/24 outlined and filled from `google/material-design-icons` into
|
||||
`resources/svg/symbols/{outlined,filled}` with `fill="currentColor"` and no width/height;
|
||||
`<x-icon name="…" filled>` resolves `ms`/`msf`; throws on an unknown name.
|
||||
14. **Shapes.** The Expressive shape set generated by formula (ReStride's
|
||||
`components/shape.blade.php` method) into `resources/svg/shapes`; `<x-shape>`.
|
||||
15. **JS entry** `resources/js/material.js`: theme store, snackbar listener, `data-list-row`
|
||||
rows, `x-figure` count-up directive, shared keyboard helpers. Imported by an app's `app.js`.
|
||||
16. **`Toasts` concern** `NoNameWeb\LivewireMaterial\Concerns\Toasts`: protected
|
||||
`success|error|warning|info(string $title, ?string $description = null, ?int $timeout = null, ?string $redirectTo = null)`,
|
||||
dispatching a browser event and flashing across `redirectTo`.
|
||||
17. **Guard helpers** (`NoNameWeb\LivewireMaterial\Testing\DesignGuard`, Pest-friendly):
|
||||
scan given paths for maryUI tags, daisyUI component classes, colour utilities not declared
|
||||
by the compiled tokens, unknown icon names, and app-configured banned roles/variants.
|
||||
18. **Showcase shell**: layout, section navigation, theme switch, snippet renderer; a section
|
||||
per foundation piece (colour roles in both themes, type scale, shapes, motion, icons
|
||||
search).
|
||||
|
||||
**Phase 2 is done (2026-09-13).** What changed from the steps above:
|
||||
|
||||
- **Icons without blade-icons** (see Decisions): `NoNameWeb\LivewireMaterial\Support\SvgFile`
|
||||
reads a symbol or shape file on first use and keeps it for the worker. An unknown name throws,
|
||||
and a Heroicon-style name (`o-home`) throws with a pointer to the catalogue. `bin/fetch-symbols`
|
||||
sparse-checks-out google/material-design-icons (5 GB, cloned without blobs) for the
|
||||
`_24px` and `_fill1_24px` files: 4,135 symbols, byte-identical to ReStride's hand-picked ones.
|
||||
The `@material-symbols/svg-400` npm package was rejected: its "rounded" SVGs are optical size 48.
|
||||
- **All 35 M3 Expressive shapes**, ported from androidx `MaterialShapes.kt` and graphics-shapes
|
||||
into `bin/shapes.mjs` (commit in its header), each fitted to 2–98 of a 100-unit box; eight had
|
||||
control points outside the box and are split into more segments along the same outline.
|
||||
- **The scheme script** lives at `resources/node/scheme.mjs` (93 KB, esbuild bundle of
|
||||
material-color-utilities 0.4.0, spec 2025 — M3 Expressive's colour; variants the 2025 spec
|
||||
does not define fall back to 2021). The library's extensionless imports mean it cannot run
|
||||
unbundled. 65 roles per theme plus `inverse-{error,success,warning,info}`. Default state
|
||||
sources: success `#22a06b`, warning `#e2a400`, info `#1d7afc`. `config('livewire-material.node')`
|
||||
names the binary. The package's own default is `#6750A4`, tonal-spot, in `tokens/scheme.css`.
|
||||
- **Colour utilities are `@theme inline`** — contrary to ReStride's rule that `inline` breaks
|
||||
theme switching. Verified in Chromium: without `inline`, `--color-primary` resolves once on
|
||||
`:root`, so a `data-theme="dark"` section inside a light page keeps light colours; with it the
|
||||
utility reads `var(--md-sys-color-primary)` on the element and both the page toggle and nested
|
||||
themes work. Only blocks whose values are variables are inline. A browser test fails without it.
|
||||
- **Tailwind emits only the theme variables that are used**, and cannot see a class assembled in
|
||||
Blade (`type-{{ $style }}`): the showcase lists every class literally.
|
||||
- **`dark:`** is keyed on `data-theme`, so it follows the page's theme rather than the OS.
|
||||
- **Theme script** writes nothing for a visitor who never chose (a later change of
|
||||
`theme.default` still reaches them); only an adopted legacy value is stored. `data-theme-choice`
|
||||
and `data-theme-key` on `<html>` hand the state to `$store.theme` (`choice`, `resolved`, `set`,
|
||||
`toggle`, `value`).
|
||||
- **Moved out of Phase 2:** the snackbar listener goes to Phase 4 with `<x-toast>`; `data-list-row`
|
||||
rows (JS and CSS) and the list keyboard go to Phase 5 with `list` and `card`, which they style.
|
||||
`Toasts` dispatches `toast` with a 4 s default (M3's snackbar range is 4–10 s; ReStride used 3 s).
|
||||
- **`DesignGuard`**: `secondary` is a valid role now that the package exposes full M3, so it is no
|
||||
longer flagged as a daisyUI colour; icon attributes are checked per component tag (`icon` and
|
||||
`icon-right` on the same tag).
|
||||
- **Pest browser trap:** a bare `html` selector is taken as text to search for and times out;
|
||||
assert on `document.documentElement` through `assertScript`. `@name` targets `data-test`.
|
||||
- **Showcase**: colour roles in both themes side by side, type, corners and shapes, elevation,
|
||||
springs and `x-figure`, and an icon search drawing matches as CSS masks from a showcase-only
|
||||
symbol route (relative URLs — an absolute one carries `APP_URL` and misses the dev server's port).
|
||||
|
||||
### Phase 3 — Actions
|
||||
|
||||
19. Primitives the actions need: `loading` (M3 Expressive loading indicator, contained and
|
||||
not), plain `tooltip` (from a fine pointer only, not laid out while hidden), `menu` /
|
||||
`menu-item` / `menu-separator` (Popover API + anchor positioning, Expressive vertical menu,
|
||||
APG menu keyboard).
|
||||
20. `button` — five variants × colours × sizes `xs|sm|md|lg|xl`, `icon`, `icon-right`,
|
||||
`label`, `link` (+ `wire:navigate` unless `external` / `no-wire-navigate`), `spinner`,
|
||||
`responsive`, `tooltip*`, `disabled` on links (`aria-disabled`), press shape-morph.
|
||||
21. `icon-button` behaviour inside `button` (icon, no label): standard/filled/tonal/outlined,
|
||||
`selected` toggle (`aria-pressed`), widths.
|
||||
22. `button-group` (standard and connected; `<x-group>` alias with `wire:model` options),
|
||||
`split-button`, `fab` (56px default — M3's deprecated small FAB is gone, so `sm` is the baseline FAB — 80px `md`, 96px `lg`), extended FAB, and the
|
||||
responsive `fab` prop (extended FAB below `sm`, filled header button above — one element),
|
||||
`fab-menu`.
|
||||
|
||||
**Phase 3 is done (2026-09-13).** What changed from the steps above:
|
||||
|
||||
- **Values from androidx Compose Material 3's generated tokens** (`Button*Tokens`,
|
||||
`*IconButtonTokens`, `Fab*Tokens`, `ExtendedFab*Tokens`, `FabMenuBaselineTokens`,
|
||||
`ButtonGroupSmallTokens`, `ConnectedButtonGroupSmallTokens`, `SplitButton*Tokens`,
|
||||
`StandardMenuTokens`, `VibrantMenuTokens`, `SegmentedMenuTokens`, `PlainTooltipTokens`,
|
||||
`LoadingIndicatorTokens`), cited in each component's header. Two tokens are wrong and Compose
|
||||
overrides them in code, so the package does too: the text button's label is `primary`, not
|
||||
`on-surface-variant`, and the extra-small button's padding is 12px, not 16px.
|
||||
- **`<x-button>` is label button, icon button and toggle in one**, with `data-icon-button` on the
|
||||
icon-only form. A selected round button squares off; a selected square icon button rounds. A
|
||||
`corners` prop lets composite components (the split button) draw the corners themselves.
|
||||
- **Group and split corners are unlayered CSS** (`resources/css/components/groups.css`): a
|
||||
child button's corners are utilities, and anything in a `@layer` loses to a utility. Inner
|
||||
corners ride a `--group-corner` variable so pressing and selecting change one value while the
|
||||
rounded outer corners stay put. The standard group's press expansion is padding moved from the
|
||||
neighbours to the pressed button (a fixed step per size); icon buttons keep their width.
|
||||
- **`<x-group>` stays ReStride's native-radio design** (checkboxes with `multiple`), restyled as a
|
||||
connected button group — `wire:model`, `x-model` and the arrow keys need no script.
|
||||
- **Tooltips and menus are popovers placed by CSS anchor positioning**, with per-render anchor
|
||||
names generated in Blade (a morph updates the trigger and the popover together). `popover`
|
||||
elements must never get a `display` utility (`flex`), which beats the UA's `display: none` for
|
||||
a closed popover; use `open:flex`.
|
||||
- **Menu keyboard is WAI-ARIA's menu button**; `aria-expanded` and the first item's focus are set
|
||||
synchronously in `open()`, because the popover `toggle` event is queued and a test (or a screen
|
||||
reader) reading in between saw a shut menu. **Bug found by the browser tests:** the guard
|
||||
against a light-dismiss press reopening the menu compared against `closedAt = 0`, so every click
|
||||
in the first 250ms after page load was swallowed; it starts at `-Infinity` now, with a test.
|
||||
- **Anonymous component trap:** every prop is a local variable, so a helper variable in `@php`
|
||||
must not reuse a prop's name — a local `$corners` array silently replaced the `corners` prop.
|
||||
- **The loading indicator is androidx's own geometry, in SVG + SMIL** (`bin/loading-indicator.mjs`,
|
||||
`resources/svg/loading-indicator/`): `Morph` is ported, so each of the seven morphs is a path
|
||||
whose control points SMIL can interpolate in every engine (CSS `d:` has no WebKit support).
|
||||
The spring is two keySplines (<1% error), each morph's path shrinks under its successor at the
|
||||
hand-over, and the per-morph quarter turn runs on its own 2.6s cycle so the 630° per shape
|
||||
cycle never needs a reset. 21.7 KB. Two deliberate differences from Compose: the spring settles
|
||||
inside the 650ms instead of snapping back from 9% past, and frames centre on exact curve bounds,
|
||||
so there is no 0.1–0.18 unit jump at three hand-overs. Reduced motion shows `static.svg`.
|
||||
- **Showcase examples are Blade strings rendered with `Blade::render()` beside their source**
|
||||
(`<x-showcase::example>`, an anonymous component path registered only when the showcase is
|
||||
enabled), so the snippet can never disagree with what is drawn.
|
||||
- **Browser tests in three engines, locally too** (Playwright's Firefox and WebKit are installed):
|
||||
- WebKit, like Safari on macOS, leaves buttons out of the Tab order; focus them directly.
|
||||
- Firefox counts a scripted focus as `:focus-visible` only after a key press.
|
||||
- Firefox flaked ~3 runs in 4 with **HTTP 431 from Pest's in-process Amp server** on
|
||||
`livewire.js`, so Alpine never started. It appeared once the showcase inlined a 60 KB list of
|
||||
symbol names; the icon search now fetches `symbols.json` on `x-intersect.once`, and the suite
|
||||
passed 4 of 4. Keep showcase pages lean.
|
||||
- A click that lands before Alpine starts does nothing; tests wait for `networkidle`, and the
|
||||
showcase's theme switch is `x-cloak` so Playwright's click waits for it.
|
||||
|
||||
### Phase 4 — Communication
|
||||
|
||||
23. `badge` (dot, count, label; variant/colour), `progress` (linear, circular, **wavy**,
|
||||
determinate and indeterminate), `toast` (M3 snackbar, action, timeout, stacked), rich
|
||||
`tooltip`, `alert` (tinted container, icon, actions slot), `stat` (figure with
|
||||
`x-figure`), `empty-state`.
|
||||
|
||||
**Phase 4 is done (2026-09-13).** What changed from the steps above:
|
||||
|
||||
- **`<x-progress>` was built by a separate agent** from Compose's `ProgressIndicator.kt` and
|
||||
`WavyProgressIndicator.kt`: server-rendered SVG first frame, then an Alpine component that ports
|
||||
the drawing and keyframes and animates only while something moves and it is on screen; the SVG
|
||||
is `wire:ignore` and a MutationObserver on the root's `data-value` turns a morph or a `bind`
|
||||
expression into motion. Flat circular indeterminate has no track, as in Compose.
|
||||
- **`<x-toast>` listens from the moment `snackbar.js` loads**, not on its Alpine component, and holds
|
||||
toasts until the host registers — a toast dispatched before Alpine starts is shown, not lost.
|
||||
`@persist` keeps the host across `wire:navigate`.
|
||||
- **`<x-badge>` is M3's dot and count** (`floating` pins it to an icon) **plus a status label**
|
||||
(`tonal`, `outline`), which M3 lacks and every app needs. `<x-alert>`, `<x-stat>` and
|
||||
`<x-empty-state>` are built from M3's parts; `<x-rich-tooltip>` is transient or `persistent`.
|
||||
- **A commit went out broken and was fixed forward:** `648ad8e` staged `material.js` and the
|
||||
showcase index while they held the progress agent's temporary lines, without its files.
|
||||
**With agents in the same tree, stage by explicit path and read the diff of shared files first.**
|
||||
- **Browser test lessons (all three engines, locally):**
|
||||
- `waitForEvent('networkidle')` can return before a repeated visit has loaded in Firefox (an
|
||||
empty document, then a page still streaming in). Every helper now also asserts
|
||||
`document.readyState === 'complete'` and that Alpine and Livewire exist; assertions retry.
|
||||
- Firefox runs Playwright's evaluate in a sandbox: an event built there has a `detail` the page
|
||||
cannot read, and assignments to Alpine's reactive proxies do not trigger. Go through the page's
|
||||
realm with `window.eval("…")` (or `Livewire.dispatch`).
|
||||
- Pest retries a failing `assertScript`; a script that changes state must not be written so
|
||||
that a retry starts from the changed state.
|
||||
|
||||
### Phase 5 — Containment
|
||||
|
||||
24. `card` (elevated, filled, outlined; `title`, `subtitle`, `actions` slot; clickable row
|
||||
contract), `divider`, `list` / `list-item` (one-, two-, three-line; leading/trailing;
|
||||
selectable), `modal` (native `<dialog>`, `showModal()`, `wire:ignore.self`, writes back
|
||||
`false`/`null`, `fullscreen` below `sm`, basic dialog with icon/headline/actions),
|
||||
`bottom-sheet` (modal and standard, drag handle), `drawer` (side sheet: modal and
|
||||
standard; `pane` for list-detail from `xl`; `width` prop), `carousel` (multi-browse, uncontained, hero,
|
||||
full-screen on CSS scroll-snap), `collapse`.
|
||||
|
||||
**Phase 5 is done (2026-09-13).** What changed from the steps above:
|
||||
|
||||
- **`<x-carousel>` was built by a separate agent in its own worktree**, porting Compose's keyline
|
||||
maths (`Arrangement`, `Keylines`, `Strategy`, `KeylineSnapPosition`, androidx
|
||||
`7ac433e44e797de53af85226797862687f37735f`; checked against 43 values from androidx's unit tests).
|
||||
Items are laid out at the large size on a native scroll-snap row and masked each frame with a
|
||||
`clip-path` inset; snap positions are `scroll-margin-inline-start`. Full-screen follows
|
||||
material-components-android, which Compose lacks; one item per swipe is `scroll-snap-stop`, not
|
||||
fling physics. Hooks are `data-material-carousel*`: the design guard rejects `carousel*` classes
|
||||
as daisyUI's.
|
||||
- **The drawer is the side sheet**, with `pane` for list-detail from `xl`, and the bottom sheet is its
|
||||
own component (modal or `standard`) with drag-to-dismiss.
|
||||
- **`<x-list-item>`'s trailing slot is `end`**: a slot named like the `trailing` prop replaced it.
|
||||
- **The design guard also rejects Blade directives inside component tags** (`@class` on
|
||||
`<x-icon>`), which reach the browser as text.
|
||||
- **`x-trap.inert` hides the page with `aria-hidden`**, not `inert`; tests assert what it sets.
|
||||
- **WebKit returns focus from a dialog only to an element that had focus**, and a click does not
|
||||
focus a button there: tests open dialogs from the keyboard.
|
||||
|
||||
### Phase 6 — Text inputs and selection
|
||||
|
||||
25. `form`, `field` (the shared shell: **outlined and filled**, floating label via `:has()`,
|
||||
notch, `hint` replaced by error, `aria-invalid` / `aria-describedby`, `data-*` state
|
||||
marks), `input` (prefix/suffix, icons, `copyable` trailing button with a snackbar),
|
||||
`password` (reveal toggle), `textarea` (auto-grow), `select` (native, customizable-select
|
||||
enhancement with ReStride's traps), `checkbox` (incl. indeterminate), `radio`, `toggle`
|
||||
(M3 switch, icons), `file` (native input inside the field, batch and per-file errors).
|
||||
26. `chip` (assist, filter, input, suggestion), `choices` (filter chips, or a searchable
|
||||
combobox with a menu when `searchable`), `slider` (standard, centered, range; native range
|
||||
inputs, value label), `search` (search bar and search view, results through a Livewire
|
||||
property).
|
||||
|
||||
**Phase 6 is done (2026-09-13).** What changed from the steps above:
|
||||
|
||||
- **The field family is ReStride's, extended with M3's filled text field** (`variant`, default from
|
||||
`config('livewire-material.fields.variant')`): a surface-container-highest box, extra-small top
|
||||
corners, the outline turned into an indicator line and the label floating inside. A textarea's
|
||||
top offset is half margin, so text scrolled up in a full textarea disappears under the edge
|
||||
instead of running through the label; it grows with `field-sizing: content` from `rows` to
|
||||
`max-rows`, with a script fallback (`field.js`) that no current engine needs.
|
||||
- **Checkbox, radio and switch are CSS on native inputs** (`components/selection.css`), hooked by
|
||||
`data-checkbox`, `data-radio`, `data-switch`: `checkbox`, `radio` and `toggle` are daisyUI class
|
||||
names the design guard rejects. The indeterminate checkbox is `data-indeterminate`, kept in step
|
||||
with the property by a MutationObserver, since HTML has no attribute for it.
|
||||
- **`<x-chip>` and `<x-slider>` were built by separate agents in their own worktrees.** Chips: one
|
||||
component for four types; a filter chip is a native checkbox when bound and a toggle button
|
||||
otherwise; removable input chips hand focus on by `wire:key` after a morph. Slider: native range
|
||||
inputs under a drawing ported from Compose (`wire:ignore`); `.number` is added to the binding,
|
||||
because a string sent and an integer returned made Livewire move the handle back mid-drag.
|
||||
- **`<x-choices>` keeps typed values in Alpine** (filter chips as toggle buttons, or a searchable
|
||||
combobox whose list is a `popover="manual"` placed by CSS anchor positioning, so a card's
|
||||
`overflow-hidden` never clips it). Selecting the text on focus has to happen on the `click` that
|
||||
follows the press: Chromium collapses a selection made in the focus handler.
|
||||
- **`<x-search>` is the search bar and view**: docked from `sm`, full screen and trapped below it.
|
||||
It closes on `pointerdown` outside, not `click` (the bar moves under the pointer as it goes full
|
||||
screen, and the click then lands outside), and focus returned to the input within 250ms of a close
|
||||
does not reopen it (Escape, and the trap letting go).
|
||||
- **A slot rendered by a `@foreach` with no items is not empty** to `isNotEmpty()`; use
|
||||
`hasActualContent()`.
|
||||
|
||||
### Phase 7 — Pickers
|
||||
|
||||
27. `datepicker` (docked, modal, modal input; `Intl` month/day names and week start from the
|
||||
app locale; `min`/`max`; single and range; `wire:model` stores `Y-m-d`), `timepicker`
|
||||
(dial and input; 12/24h from locale; stores `H:i`). APG grid keyboard for the calendar.
|
||||
|
||||
### Phase 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.
|
||||
@@ -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.
|
||||
- Never write maryUI tags (`<x-mary-*>`) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`…). They compile to nothing and fail silently.
|
||||
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand.
|
||||
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand. With colour profiles (`livewire-material.profiles`), run it without a seed after changing them; the active profile comes from `Scheme::resolveProfileUsing()`.
|
||||
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
|
||||
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
|
||||
@endverbatim
|
||||
|
||||
@@ -44,7 +44,36 @@ The scheme is generated, never hand-edited. Regenerate it with the seed and vari
|
||||
php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
||||
```
|
||||
|
||||
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The command also writes `material-scheme.json` beside the stylesheet.
|
||||
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (default, M3 Expressive) or `2021` (M3's original colour — keep it for a palette generated before Expressive; the library itself uses 2021 for variants 2025 does not define, and the header records the spec actually used). `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The header of the stylesheet records the whole command, every option that differs from its default included. The command also writes `material-scheme.json` beside the stylesheet.
|
||||
|
||||
### Colour profiles
|
||||
|
||||
An installation that switches between several schemes lists them in `config/livewire-material.php` and runs the command without a seed, which generates every profile into the same stylesheet, keyed by `<html data-scheme>`:
|
||||
|
||||
```php
|
||||
'profiles' => [
|
||||
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
|
||||
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
|
||||
],
|
||||
'profile' => 'indigo', // the default; else the first
|
||||
```
|
||||
|
||||
```bash
|
||||
php artisan material:scheme
|
||||
```
|
||||
|
||||
- Each profile: `seed`, and optionally `label` (default: the name as a headline), `variant` (default `tonal-spot`), `contrast` (default 0), `spec`, `success`, `warning`, `info` (for these four, without the key the command's `--spec`, `--success`, `--warning`, `--info` or their defaults apply).
|
||||
- Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet.
|
||||
- The application says which profile is active, once, in a service provider. The closure runs every time a colour is drawn (head script, mail, error page), so it may read the database; a name that is not a generated profile, or a closure that throws, falls back to the default:
|
||||
|
||||
```php
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
|
||||
```
|
||||
|
||||
- `<x-theme-script>` writes the active profile to `<html data-scheme>` before the first paint; mails and error pages draw it too. `Scheme::profiles()` lists the generated profiles (name ⇒ label, light and dark roles) and `Scheme::profile()` names the active one — validate a stored choice with `Rule::in(array_keys(Scheme::profiles()))`.
|
||||
- Choose with `<x-scheme-picker wire:model="colorProfile" />` (see Components). Never set `data-scheme` on an element inside the page expecting a different profile there: profiles key on `<html>`.
|
||||
|
||||
## Tokens
|
||||
|
||||
@@ -62,7 +91,15 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
|
||||
|
||||
## Theme
|
||||
|
||||
`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control.
|
||||
`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`, `theme.meta`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything.
|
||||
|
||||
`theme.meta` (default `false`) keeps the browser's bar in the page's colour, for an installed web app: the head script sets the `content` of every `<meta name="theme-color">` without a `media` attribute to the resolved theme's `surface` — of the profile in `<html data-scheme>` — before the first paint, adding one to `<head>` when there is none. It follows every later change of `data-theme` or `data-scheme` (`$store.theme.set()`/`toggle()`, an OS change while `system`, `previewScheme()`), and paints the next page's meta after `wire:navigate`. A theme-color meta the layout renders itself goes before `<x-theme-script />` (after it, the script has already added one, and the page ends up with two), or is left out. A `media="(prefers-color-scheme: …)"` pair follows the OS instead of the visitor's choice: drop it when turning this on.
|
||||
|
||||
## Safe areas
|
||||
|
||||
Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through `var(--material-safe-top|bottom|left|right, env(safe-area-inset-…))`. The layout needs `viewport-fit=cover` in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on `<html>` or any ancestor: a browser test fakes a notch with `document.documentElement.style.setProperty('--material-safe-top', '47px')`, and an app that draws its own status strip adds its height.
|
||||
|
||||
`--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `<x-app-shell>` (an offline banner): the shell adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at `bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`, below `sm` only.
|
||||
|
||||
## Toasts
|
||||
|
||||
@@ -85,6 +122,59 @@ class Settings extends Component
|
||||
|
||||
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
|
||||
|
||||
### `<x-icon>`
|
||||
@@ -105,7 +195,7 @@ One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, size
|
||||
|
||||
### `<x-theme-script>`
|
||||
|
||||
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`.
|
||||
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`. With `theme.meta` on it also paints `<meta name="theme-color">` (see Theme); a layout's own theme-color meta goes before it.
|
||||
|
||||
### `<x-button>`
|
||||
|
||||
@@ -154,7 +244,7 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
|
||||
</x-menu>
|
||||
```
|
||||
|
||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
|
||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
|
||||
|
||||
### `<x-button-group>`
|
||||
|
||||
@@ -172,7 +262,7 @@ A choice between a few options as a connected button group of native radios (che
|
||||
]" hint="Recipients lose access after that" />
|
||||
```
|
||||
|
||||
Props: `label`, `hint`, `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
|
||||
Props: `label`, `hint`, `hint-class` (classes for the hint, as on the fields; a colour there paints it), `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
|
||||
|
||||
### `<x-split-button>`
|
||||
|
||||
@@ -199,7 +289,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
|
||||
</div>
|
||||
```
|
||||
|
||||
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard as `<x-menu>`.
|
||||
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as `<x-menu>`.
|
||||
|
||||
### `<x-loading>`
|
||||
|
||||
@@ -223,6 +313,16 @@ materialToast('Share deleted', { type: 'success', description: null, timeout: 40
|
||||
|
||||
`type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer.
|
||||
|
||||
- `action`: `label`, plus `handler` (a function) and/or `event` (a name). Pressing it closes the snackbar, calls `handler`, then dispatches `new CustomEvent(event)` on `window`; give both and both run. Use `event` where a function cannot travel, such as a toast built from JSON.
|
||||
- `sticky: true` keeps a toast until it is dismissed or its action is pressed (any `timeout` is ignored), without holding the queue up: a toast dispatched meanwhile shows in its place, and the sticky one comes back once the queue is empty. One sticky toast is kept at a time; a newer one replaces it. Use it for a question that must be answered, not for news:
|
||||
|
||||
```js
|
||||
window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: 'A new version is ready', sticky: true, action: { label: 'Reload', event: 'app:update' } } }))
|
||||
window.addEventListener('app:update', () => location.reload())
|
||||
```
|
||||
|
||||
- Hooks: `data-toast` on the snackbar on screen, `data-toast-action` on its action button (`[data-toast]` is absent while nothing shows). Target these in tests, not classes.
|
||||
|
||||
### `<x-progress>`
|
||||
|
||||
M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless a `size-*` class is passed), flat or `wavy`, determinate with a `value` or indeterminate without one.
|
||||
@@ -253,7 +353,10 @@ A value the server changes animates after a morph (the SVG is `wire:ignore`; onl
|
||||
### `<x-badge>`
|
||||
|
||||
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
|
||||
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
|
||||
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Built in" color="primary" solid />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container, in the colour itself (`solid`, for a label that has to stand out), or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`, `neutral`, `plain`; an unknown colour is `error`.
|
||||
- `color="neutral"` — neutral ink on every variant: a dot or count in on-surface-variant with surface text, `tonal` in surface-container-high with on-surface-variant text, `outline` in the outline-variant edge with on-surface-variant text.
|
||||
- `color="plain"` — no background, text or border colour in any variant (shape, size and type stay), so the classes you pass paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`. Pass both a background and a text class; an `outline` badge's edge takes the text colour unless you pass a `border-*` colour.
|
||||
- The value is `value` or the slot; the slot renders as HTML: `<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. `value` is escaped. A slot that holds only whitespace or comments is still a dot.
|
||||
|
||||
### `<x-alert>`
|
||||
|
||||
@@ -279,7 +382,7 @@ A few lines of context around a trigger, with an optional `title` and `actions`
|
||||
</x-rich-tooltip>
|
||||
```
|
||||
|
||||
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). `side`: `bottom` (default), `top`, `left`, `right`.
|
||||
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`.
|
||||
|
||||
### `<x-stat>`
|
||||
|
||||
@@ -289,6 +392,15 @@ Shows on hover and keyboard focus; `persistent` opens it on press and keeps it u
|
||||
|
||||
"Nothing here yet": `icon` on an Expressive `shape` (`cookie-9` by default), `title`, `description` or slot, and an `actions` slot. Use it for an empty collection, not for a filter that matched nothing.
|
||||
|
||||
The `illustration` slot draws the application's own artwork in place of the shape and icon (`icon` and `shape` are then unused). Size the artwork yourself; the slot's attributes go on the element around it, so its `class` sets the colour `currentColor` takes. Mark decorative SVG `aria-hidden="true"`. A slot holding only whitespace or comments leaves the shape and icon.
|
||||
|
||||
```blade
|
||||
<x-empty-state title="No routes yet" description="Draw one on the map.">
|
||||
<x-slot:illustration class="text-primary"><svg class="size-32" viewBox="0 0 120 120" aria-hidden="true">…</svg></x-slot:illustration>
|
||||
<x-slot:actions><x-button label="Draw a route" variant="filled" /></x-slot:actions>
|
||||
</x-empty-state>
|
||||
```
|
||||
|
||||
### `<x-card>`
|
||||
|
||||
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`; slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). Do not pass `bg-*`; use `variant`.
|
||||
@@ -324,6 +436,15 @@ A card or list item that opens something is a **row**: `data-list-row` on it and
|
||||
|
||||
A disclosure on native `<details>`: `<x-collapse title="Advanced" icon="tune" open variant="filled">…</x-collapse>` (`variant` `plain` or `filled`; `heading` slot for rich titles). Keeps its state through a morph.
|
||||
|
||||
Bind the open state to a boolean, both ways, with `wire:model` (any modifiers; `.live` sends each toggle at once) or `x-model`:
|
||||
|
||||
```blade
|
||||
<x-collapse title="Fine-tuning" wire:model="fineTuning">…</x-collapse>
|
||||
<div x-data="{ advanced: false }"><x-collapse title="Advanced" x-model="advanced">…</x-collapse></div>
|
||||
```
|
||||
|
||||
Toggling writes the property; changing the property (in an action or in Alpine) opens or closes it. With `wire:model` the server renders it open or closed as the property is, so there is no flash, and `open` is ignored; with `x-model`, `open` is only the first paint until Alpine starts. The bound state is `collapseOpen` in the `<details>` scope. Without a binding there is no Alpine on it.
|
||||
|
||||
### `<x-modal>`
|
||||
|
||||
An M3 dialog on native `<dialog>`. Bind with `wire:model` to a flag or an id; closing (Escape, scrim, `close()`) writes back `false` or `null`. Without `wire:model` it uses `open` from the surrounding Alpine scope.
|
||||
@@ -341,7 +462,7 @@ Props: `title`, `subtitle`, `icon` (centred hero icon), `separator`, `persistent
|
||||
|
||||
### `<x-drawer>`
|
||||
|
||||
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`.
|
||||
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Escape leaves a pane open unless `pane-close-on-escape`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`.
|
||||
|
||||
### `<x-bottom-sheet>`
|
||||
|
||||
@@ -414,7 +535,7 @@ A one-column grid of fields with an `actions` slot at the foot (the slot takes i
|
||||
|
||||
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
|
||||
|
||||
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name (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 all but `<x-file>` a `hint-class`, classes added to the hint: `hint-class="text-warning"`), and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint and sets `aria-invalid`. `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||
|
||||
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
|
||||
- `<x-password>`: a reveal button; `icon`, `size`.
|
||||
@@ -463,6 +584,53 @@ 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.
|
||||
|
||||
### `<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 />
|
||||
<x-datepicker label="Race day" wire:model="raceDay" :week-start="$user->week_start" :format="$user->date_format" />
|
||||
```
|
||||
|
||||
| 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 |
|
||||
| `week-start` | `null` | the first day of the week, `0` (Sunday) to `6` (Saturday), instead of the locale's: the calendar's columns, weekday header and Home/End follow it. Anything else is ignored |
|
||||
| `format` | `null` | the typed and displayed format instead of the locale's: `dd`, `MM` and `yyyy`, each once, around one delimiter (`.`, `/`, `-`) — `dd.MM.yyyy`, `dd/MM/yyyy`, `MM/dd/yyyy`, `yyyy-MM-dd`. The field, the dialog's text fields, a range and the error message follow it; `wire:model` still stores `Y-m-d`. Anything else is ignored |
|
||||
|
||||
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()` (the last two unless `week-start` and `format` say otherwise — for a per-person setting). Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
|
||||
|
||||
### `<x-timepicker>`
|
||||
|
||||
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 12–23 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>`
|
||||
|
||||
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 +660,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.
|
||||
|
||||
### `<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 page's, also during a Livewire update request), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for the badge: "3 unread"), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||
- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
|
||||
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`).
|
||||
- Below `sm` the shell sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, a `fab` button and the page's bottom padding clear the bar; pad anything else you pin to the bottom with it. See Safe areas.
|
||||
- The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
|
||||
|
||||
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
|
||||
|
||||
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>`
|
||||
|
||||
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).
|
||||
@@ -530,7 +776,7 @@ M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skip
|
||||
|
||||
### `<x-section-nav>`
|
||||
|
||||
Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the request's. `label`, `no-wire-navigate`.
|
||||
Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below, whose items mark the current section as the page (`current`) and carry each section's badge. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). `label`, `no-wire-navigate`.
|
||||
|
||||
### `<x-account-menu>`
|
||||
|
||||
@@ -549,6 +795,14 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
|
||||
|
||||
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (segmented buttons for settings pages). Every toggle on a page shares the store.
|
||||
|
||||
### `<x-scheme-picker>`
|
||||
|
||||
A choice of colour profile (see Colour profiles): a swatch per generated profile — its name and its primary, secondary and tertiary colour — over native radios. `wire:model` or `x-model` (with `name`) binds the chosen name; choosing previews it on the page at once; storing it is the application's. `label`, `hint`, `name`, `profiles` (default `Scheme::profiles()`). A validation error for the bound property replaces the hint. Without profiles it renders nothing.
|
||||
|
||||
```blade
|
||||
<x-scheme-picker :label="__('Colour profile')" wire:model="colorProfile" :hint="__('Applies to every page after saving')" />
|
||||
```
|
||||
|
||||
### `<x-table>`, `<x-sort-header>`
|
||||
|
||||
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>` (`size="xs"` for a dense one); cell utilities (`text-end`, `whitespace-nowrap`) always win. Scrolling is yours: wrap it in `<div class="overflow-x-auto">`. A row that opens something is `data-list-row` with one `data-list-open` control; a selected row is `aria-selected="true"`.
|
||||
@@ -590,7 +844,7 @@ It fails on maryUI tags, daisyUI classes, colours the theme does not declare, un
|
||||
|
||||
## Conventions
|
||||
|
||||
- Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}name>` when `config('livewire-material.prefix')` is set.
|
||||
- Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}::name>` when `config('livewire-material.prefix')` is set; `<x-livewire-material::name>` always works.
|
||||
- Write class names out whole. Tailwind cannot compile `'text-'.$tone` or `type-{{ $size }}`, and the design guard cannot read them.
|
||||
- The showcase at `/material` (local only, `MATERIAL_SHOWCASE=true` to force it) renders every token and component.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
z-index: 20;
|
||||
display: block;
|
||||
min-height: var(--app-bar-height);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-top: var(--material-safe-top, env(safe-area-inset-top));
|
||||
background-color: var(--md-sys-color-surface);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
transition: background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -15,18 +15,25 @@
|
||||
*
|
||||
* Split button (`<x-split-button>`): the same idea for two halves; the trailing half turns
|
||||
* round and its chevron turns over while its menu is open (SplitButton*Tokens).
|
||||
*
|
||||
* "Full" here is half the size's height (`--group-full`), never `--md-sys-shape-corner-full`'s
|
||||
* 9999px. One element mixes full outer corners with small inner ones, and when a box's radii
|
||||
* add up to more than its side, CSS scales every radius by the same factor: a 9999px corner
|
||||
* beside an 8px one shrank the 8px one to a hundredth of a pixel, so the inner corners drew
|
||||
* square.
|
||||
*/
|
||||
|
||||
[data-button-group] {
|
||||
--group-inner: var(--md-sys-shape-corner-sm);
|
||||
--group-inner-pressed: var(--md-sys-shape-corner-xs);
|
||||
--group-full: 1.25rem;
|
||||
}
|
||||
|
||||
[data-button-group][data-size='xs'] { --group-pad: 0.75rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; }
|
||||
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; }
|
||||
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; }
|
||||
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); }
|
||||
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); }
|
||||
[data-button-group][data-size='xs'] { --group-pad: 0.75rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; --group-full: 1rem; }
|
||||
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; --group-full: 1.25rem; }
|
||||
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; --group-full: 1.75rem; }
|
||||
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); --group-full: 3rem; }
|
||||
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); --group-full: 4.25rem; }
|
||||
|
||||
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) {
|
||||
padding-inline: calc(var(--group-pad) + var(--group-grow));
|
||||
@@ -60,19 +67,19 @@
|
||||
|
||||
[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)),
|
||||
[data-split='trailing'][aria-expanded='true'] {
|
||||
--group-corner: var(--md-sys-shape-corner-full);
|
||||
--group-corner: var(--group-full);
|
||||
}
|
||||
|
||||
[data-button-group='connected'] > :first-child,
|
||||
[data-split='leading'] {
|
||||
border-start-start-radius: var(--md-sys-shape-corner-full);
|
||||
border-end-start-radius: var(--md-sys-shape-corner-full);
|
||||
border-start-start-radius: var(--group-full);
|
||||
border-end-start-radius: var(--group-full);
|
||||
}
|
||||
|
||||
[data-button-group='connected'] > :last-child,
|
||||
[data-split='trailing'] {
|
||||
border-start-end-radius: var(--md-sys-shape-corner-full);
|
||||
border-end-end-radius: var(--md-sys-shape-corner-full);
|
||||
border-start-end-radius: var(--group-full);
|
||||
border-end-end-radius: var(--group-full);
|
||||
}
|
||||
|
||||
[data-split='trailing'] svg {
|
||||
|
||||
@@ -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: var(--material-safe-left, env(safe-area-inset-left)) var(--material-safe-right, env(safe-area-inset-right));
|
||||
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
[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: var(--material-safe-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(var(--material-safe-top, env(safe-area-inset-top)) + 2.75rem);
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
[data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child {
|
||||
padding-top: calc(var(--material-safe-top, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,8 +165,8 @@
|
||||
[data-search][data-full-screen] [data-search-bar] {
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
height: calc(4.5rem + env(safe-area-inset-top));
|
||||
padding-top: env(safe-area-inset-top);
|
||||
height: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top)));
|
||||
padding-top: var(--material-safe-top, env(safe-area-inset-top));
|
||||
padding-inline: 0.25rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
@@ -175,7 +175,7 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
max-height: none;
|
||||
padding-top: calc(4.5rem + env(safe-area-inset-top));
|
||||
padding-top: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top)));
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,11 @@
|
||||
|
||||
[data-toolbar][data-variant="docked"] {
|
||||
width: 100%;
|
||||
min-height: calc(4rem + env(safe-area-inset-bottom));
|
||||
min-height: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
|
||||
justify-content: center;
|
||||
column-gap: clamp(0.25rem, 4vw, 2rem);
|
||||
padding-inline: 1rem;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
@@ -74,7 +74,7 @@
|
||||
/* Placed over the page: centred above the bottom edge, or centred against the end edge. */
|
||||
[data-toolbar-place="bottom"] {
|
||||
position: fixed;
|
||||
bottom: calc(1rem + env(safe-area-inset-bottom));
|
||||
bottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
|
||||
left: 50%;
|
||||
z-index: 30;
|
||||
translate: -50% 0;
|
||||
@@ -83,7 +83,7 @@
|
||||
[data-toolbar-place="end"] {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
inset-inline-end: calc(1rem + env(safe-area-inset-right));
|
||||
inset-inline-end: calc(1rem + var(--material-safe-right, env(safe-area-inset-right)));
|
||||
z-index: 30;
|
||||
translate: 0 -50%;
|
||||
}
|
||||
|
||||
@@ -26,8 +26,11 @@
|
||||
@import './components/menu.css';
|
||||
@import './components/selection.css';
|
||||
@import './components/search.css';
|
||||
@import './components/datepicker.css';
|
||||
@import './components/timepicker.css';
|
||||
@import './components/tabs.css';
|
||||
@import './components/app-bar.css';
|
||||
@import './components/navigation.css';
|
||||
@import './components/toolbar.css';
|
||||
@import './components/table.css';
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ document.addEventListener('alpine:init', () => {
|
||||
collapsed: false,
|
||||
height: null,
|
||||
frame: null,
|
||||
// Set in init(). Declared here, or Alpine writes them to the outermost x-data scope,
|
||||
// where a second app bar in the same page scope would take the first one's observer.
|
||||
schedule: null,
|
||||
resizes: null,
|
||||
|
||||
init() {
|
||||
this.measure = this.measure.bind(this)
|
||||
|
||||
@@ -69,6 +69,9 @@ const MEDIUM_ITEM_FLEX_PERCENTAGE = 0.1
|
||||
/** A programmatic scroll still counts as where the carousel is going for this long. */
|
||||
const TARGET_MS = 700
|
||||
|
||||
// How long the row must go without a scroll event to count as having come to rest.
|
||||
const SETTLE_MS = 150
|
||||
|
||||
const ITEM = '[data-material-carousel-item]'
|
||||
const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable], [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
@@ -805,6 +808,7 @@ document.addEventListener('alpine:init', () => {
|
||||
frame: null,
|
||||
target: null,
|
||||
targetAt: 0,
|
||||
settle: null,
|
||||
listeners: [],
|
||||
mutations: null,
|
||||
resizes: null,
|
||||
@@ -817,7 +821,12 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
state.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
|
||||
this.listen(scroller, 'scroll', () => this.schedule(), { passive: true })
|
||||
this.listen(scroller, 'scroll', () => this.scrolled(), { passive: true })
|
||||
|
||||
// A scroll the person makes themselves is theirs to end wherever it ends.
|
||||
for (const type of ['pointerdown', 'wheel', 'touchstart']) {
|
||||
this.listen(scroller, type, () => (state.target = null), { passive: true })
|
||||
}
|
||||
this.listen(scroller, 'keydown', (event) => this.navigate(event))
|
||||
this.listen(scroller, 'focusin', (event) => this.reveal(event))
|
||||
this.listen(scroller, 'click', (event) => this.open(event))
|
||||
@@ -913,6 +922,33 @@ document.addEventListener('alpine:init', () => {
|
||||
state.frame ??= requestAnimationFrame(() => this.render())
|
||||
},
|
||||
|
||||
/**
|
||||
* Each scroll frame, and once the row comes to rest: a scroll the buttons or keys started
|
||||
* must end on its item. WebKit on Linux can re-snap a smooth scroll to the item it left
|
||||
* when the masks change the layout under it, so an arrival somewhere else is sent on
|
||||
* again, once, at once.
|
||||
*/
|
||||
scrolled() {
|
||||
this.schedule()
|
||||
|
||||
clearTimeout(state.settle)
|
||||
state.settle = setTimeout(() => {
|
||||
const target = state.target
|
||||
|
||||
if (target === null || performance.now() - state.targetAt > TARGET_MS * 3) {
|
||||
return
|
||||
}
|
||||
|
||||
state.target = null
|
||||
|
||||
const left = state.snaps[target]
|
||||
|
||||
if (left !== undefined && Math.abs(this.scrollOffset() - left) > 1) {
|
||||
this.$refs.scroller.scrollTo({ left: state.rtl ? -left : left, behavior: 'instant' })
|
||||
}
|
||||
}, SETTLE_MS)
|
||||
},
|
||||
|
||||
/** Carousel.kt's carouselItem layer block, for every item. */
|
||||
render() {
|
||||
cancelAnimationFrame(state.frame)
|
||||
@@ -1076,6 +1112,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
destroy() {
|
||||
cancelAnimationFrame(state.frame)
|
||||
clearTimeout(state.settle)
|
||||
state.listeners.forEach((remove) => remove())
|
||||
state.resizes?.disconnect()
|
||||
state.mutations?.disconnect()
|
||||
|
||||
@@ -0,0 +1,866 @@
|
||||
/**
|
||||
* `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), unless the component names the first day (`weekStart`, 0 for Sunday
|
||||
* to 6) or the format (`format`, such as `dd.MM.yyyy`); everything that reads `firstDay` and
|
||||
* `format` below then follows those.
|
||||
*
|
||||
* The keyboard is WAI-ARIA's date picker dialog: arrows move a day or a week, Home and End go to
|
||||
* the start and end of the week, PageUp and PageDown a month (with Shift a year), Space selects,
|
||||
* 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: `weekStart` when it is one of those, otherwise the locale's. */
|
||||
function firstDayOfWeek(locale, weekStart = null) {
|
||||
if (Number.isInteger(weekStart) && weekStart >= 0 && weekStart <= 6) {
|
||||
return weekStart
|
||||
}
|
||||
|
||||
try {
|
||||
const tag = new Intl.Locale(locale)
|
||||
const info = typeof tag.getWeekInfo === 'function' ? tag.getWeekInfo() : tag.weekInfo
|
||||
|
||||
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`. A `chosen`
|
||||
* pattern of the same shape (each unit once, one delimiter) replaces the locale's.
|
||||
*/
|
||||
function inputFormat(locale, chosen = null) {
|
||||
const units = /^(dd|MM|yyyy)([/\-.])(dd|MM|yyyy)\2(dd|MM|yyyy)$/.exec(typeof chosen === 'string' ? chosen : '')
|
||||
|
||||
if (units && new Set([units[1], units[3], units[4]]).size === 3) {
|
||||
return formatOf(chosen)
|
||||
}
|
||||
|
||||
let pattern = ''
|
||||
|
||||
try {
|
||||
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 = ''
|
||||
}
|
||||
|
||||
return formatOf(/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern) ? pattern : 'yyyy-MM-dd')
|
||||
}
|
||||
|
||||
/** A pattern, the placeholder it shows (`DD.MM.YYYY`) and the order its units are typed in (`dMy`). */
|
||||
function formatOf(pattern) {
|
||||
return {
|
||||
pattern,
|
||||
placeholder: pattern.toUpperCase(),
|
||||
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,
|
||||
// Set in init() from the config. Declared here, or Alpine writes them to the outermost
|
||||
// x-data scope, where every picker inside the same page scope would share the last one's.
|
||||
firstDay: 0,
|
||||
format: null,
|
||||
numbers: null,
|
||||
formats: {},
|
||||
min: null,
|
||||
max: null,
|
||||
yearsFrom: null,
|
||||
yearsTo: null,
|
||||
|
||||
init() {
|
||||
const locale = config.locale || document.documentElement.lang || 'en'
|
||||
const format = (options) => new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options })
|
||||
|
||||
this.firstDay = firstDayOfWeek(locale, config.weekStart ?? null)
|
||||
this.format = inputFormat(locale, config.format ?? null)
|
||||
this.numbers = new Intl.NumberFormat(locale, { useGrouping: false })
|
||||
this.formats = {
|
||||
monthYear: format({ year: 'numeric', month: 'long' }),
|
||||
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 1900–2100, 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() ?? ''
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -20,7 +20,10 @@ import './carousel.js'
|
||||
import './chips.js'
|
||||
import './field.js'
|
||||
import './search.js'
|
||||
import './datepicker.js'
|
||||
import './timepicker.js'
|
||||
import './slider.js'
|
||||
import './tabs.js'
|
||||
import './app-bar.js'
|
||||
import './navigation.js'
|
||||
import './toolbar.js'
|
||||
|
||||
+89
-8
@@ -3,29 +3,71 @@
|
||||
*
|
||||
* The menu button is the trigger's first button or link. Its ARIA attributes are written by
|
||||
* script, which a Livewire morph removes along with anything else the server did not render,
|
||||
* so they are written again whenever the trigger is used.
|
||||
* so they are written again whenever the trigger is used and after every morph — an open menu
|
||||
* lives through one (the popover is keyed), and its button must still say so.
|
||||
*
|
||||
* The popover hangs on the menu button by CSS anchor positioning. The server can only name the
|
||||
* wrapper around the trigger slot, and a trigger taken out of the flow — a `position: fixed` FAB
|
||||
* in a corner of the window — leaves that wrapper behind as an empty box where the page put it,
|
||||
* so the menu opened there. Script moves the name onto the menu button, beside any name the button
|
||||
* carries itself (a button's tooltip anchors on it too), and moves it again after every morph,
|
||||
* which puts the server's attributes, and a fresh name, back.
|
||||
*/
|
||||
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
|
||||
|
||||
// A popover="auto" closes on the press that lands on its trigger, and the click that follows
|
||||
// would open it again. A close this recent is taken as that press.
|
||||
// would open it again. A close this recent is taken as that press. It is timed from
|
||||
// `beforetoggle`, which fires as the popover closes: `toggle` is queued, and arrives after that
|
||||
// click.
|
||||
const REOPEN_GUARD_MS = 250
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialMenu', () => ({
|
||||
closedAt: -Infinity,
|
||||
anchored: null,
|
||||
returnFocus: true,
|
||||
focusWasInside: false,
|
||||
listeners: [],
|
||||
|
||||
init() {
|
||||
const menu = this.$refs.menu
|
||||
|
||||
this.label()
|
||||
this.anchor()
|
||||
|
||||
// A morph rewrites the wrapper's style with this render's name and the button's without
|
||||
// it, takes the button's ARIA attributes away and gives the popover a new id; the
|
||||
// observer runs before the next frame is drawn, so an open menu never moves and its
|
||||
// button never shows it shut.
|
||||
const observer = new MutationObserver(() => {
|
||||
this.anchor()
|
||||
this.label()
|
||||
})
|
||||
|
||||
observer.observe(this.$refs.trigger, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'aria-haspopup', 'aria-controls', 'aria-expanded'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
observer.observe(menu, { attributes: true, attributeFilter: ['id'] })
|
||||
this.listeners.push(() => observer.disconnect())
|
||||
|
||||
// Only closes the browser starts — Escape, a press outside — arrive here alone; open()
|
||||
// and close() have already done their part, synchronously, because this event is
|
||||
// queued and a screen reader or a test reading aria-expanded in between would be told
|
||||
// the menu is shut.
|
||||
// Whether focus was in the menu is read before it closes: once closed, a browser may
|
||||
// already have handed focus to what had it before the menu opened (WebKit does, when
|
||||
// that was a focusable region around the trigger).
|
||||
this.listen(menu, 'beforetoggle', (event) => {
|
||||
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
|
||||
|
||||
if (event.newState === 'closed') {
|
||||
this.closedAt = performance.now()
|
||||
}
|
||||
})
|
||||
|
||||
this.listen(menu, 'toggle', (event) => {
|
||||
const opened = event.newState === 'open'
|
||||
|
||||
@@ -35,11 +77,11 @@ document.addEventListener('alpine:init', () => {
|
||||
return
|
||||
}
|
||||
|
||||
this.closedAt = performance.now()
|
||||
|
||||
if (this.returnFocus && menu.contains(document.activeElement)) {
|
||||
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
|
||||
this.control()?.focus()
|
||||
}
|
||||
|
||||
this.focusWasInside = false
|
||||
})
|
||||
|
||||
// A press outside closes the menu without pulling focus back to the trigger.
|
||||
@@ -54,6 +96,40 @@ document.addEventListener('alpine:init', () => {
|
||||
return this.$refs.trigger.querySelector('button, a[href], [tabindex]')
|
||||
},
|
||||
|
||||
/**
|
||||
* Moves the anchor name the server gave the wrapper onto the menu button. The wrapper holds a
|
||||
* name only as rendered — this render's, which the popover's `position-anchor` matches — so
|
||||
* it is read there.
|
||||
*/
|
||||
anchor() {
|
||||
const trigger = this.$refs.trigger
|
||||
const control = this.control()
|
||||
const rendered = trigger.style.getPropertyValue('anchor-name').trim()
|
||||
const name = rendered.startsWith('--') ? rendered : this.anchored
|
||||
|
||||
// No menu button, or an engine without anchor positioning: the wrapper keeps the name.
|
||||
if (!control || !name) {
|
||||
return
|
||||
}
|
||||
|
||||
const names = control.style
|
||||
.getPropertyValue('anchor-name')
|
||||
.split(',')
|
||||
.map((each) => each.trim())
|
||||
.filter((each) => each.startsWith('--'))
|
||||
|
||||
if (!names.includes(name)) {
|
||||
control.style.setProperty('anchor-name', [...names.filter((each) => each !== this.anchored), name].join(', '))
|
||||
}
|
||||
|
||||
this.anchored = name
|
||||
|
||||
if (rendered !== '') {
|
||||
trigger.style.removeProperty('anchor-name')
|
||||
}
|
||||
},
|
||||
|
||||
/** Writes only what differs: the observer that calls this watches these same attributes. */
|
||||
label() {
|
||||
const control = this.control()
|
||||
|
||||
@@ -61,9 +137,13 @@ document.addEventListener('alpine:init', () => {
|
||||
return
|
||||
}
|
||||
|
||||
control.setAttribute('aria-haspopup', 'menu')
|
||||
control.setAttribute('aria-controls', this.$refs.menu.id)
|
||||
control.setAttribute('aria-expanded', String(this.isOpen()))
|
||||
const attributes = { 'aria-haspopup': 'menu', 'aria-controls': this.$refs.menu.id, 'aria-expanded': String(this.isOpen()) }
|
||||
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
if (control.getAttribute(name) !== value) {
|
||||
control.setAttribute(name, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
isOpen() {
|
||||
@@ -72,6 +152,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
open(focus = 'first') {
|
||||
this.label()
|
||||
this.anchor()
|
||||
|
||||
if (!this.isOpen()) {
|
||||
this.$refs.menu.showPopover()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -4,6 +4,11 @@
|
||||
* One snackbar at a time, as M3 shows them. Each waits its turn, stays for its timeout (paused
|
||||
* while hovered or focused, so it is never pulled away from someone reading or reaching for its
|
||||
* action) and is replaced by the next.
|
||||
*
|
||||
* A `sticky` toast ("A new version is ready" with a Reload action) stays until it is answered, but
|
||||
* never holds the queue up: it is kept aside rather than queued, a toast that arrives while it shows
|
||||
* takes its place, and it comes back once the queue is empty. Only one is kept — a newer sticky
|
||||
* toast replaces it. Dismissing it, or pressing its action, lets it go.
|
||||
*/
|
||||
const DEFAULT_TIMEOUT_MS = 4000
|
||||
|
||||
@@ -25,6 +30,7 @@ document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialSnackbar', () => ({
|
||||
queue: [],
|
||||
current: null,
|
||||
sticky: null,
|
||||
timer: null,
|
||||
remaining: 0,
|
||||
startedAt: 0,
|
||||
@@ -44,24 +50,43 @@ document.addEventListener('alpine:init', () => {
|
||||
// Livewire dispatches named arguments as the detail object; a positional dispatch
|
||||
// arrives as an array whose first entry is that object.
|
||||
const toast = Array.isArray(detail) ? detail[0] : detail
|
||||
const sticky = toast.sticky === true
|
||||
|
||||
this.queue.push({
|
||||
const entry = {
|
||||
id: ++sequence,
|
||||
type: toast.type ?? null,
|
||||
title: toast.title ?? '',
|
||||
description: toast.description ?? null,
|
||||
timeout: toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS),
|
||||
timeout: sticky || toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS),
|
||||
action: toast.action ?? null,
|
||||
})
|
||||
sticky,
|
||||
}
|
||||
|
||||
if (!this.current) {
|
||||
if (sticky) {
|
||||
const showing = !this.current || this.current === this.sticky
|
||||
this.sticky = entry
|
||||
|
||||
if (showing) {
|
||||
this.next()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.queue.push(entry)
|
||||
|
||||
// A sticky toast steps aside for it, and comes back from next() once the queue is empty.
|
||||
if (!this.current || this.current === this.sticky) {
|
||||
this.next()
|
||||
}
|
||||
},
|
||||
|
||||
next() {
|
||||
// Cleared and forgotten here, so a toast dismissed early never leaves its timer running
|
||||
// to cut the next one short.
|
||||
clearTimeout(this.timer)
|
||||
this.current = this.queue.shift() ?? null
|
||||
this.timer = null
|
||||
this.current = this.queue.shift() ?? this.sticky
|
||||
|
||||
if (this.current?.timeout) {
|
||||
this.remaining = this.current.timeout
|
||||
@@ -92,13 +117,24 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
dismiss() {
|
||||
this.timer = null
|
||||
if (this.current && this.current === this.sticky) {
|
||||
this.sticky = null
|
||||
}
|
||||
|
||||
this.next()
|
||||
},
|
||||
|
||||
// Closed before the handler and the event run, so a toast either of them shows is not the
|
||||
// one dismissed.
|
||||
act() {
|
||||
this.current?.action?.handler?.()
|
||||
const action = this.current?.action
|
||||
|
||||
this.dismiss()
|
||||
action?.handler?.()
|
||||
|
||||
if (typeof action?.event === 'string' && action.event !== '') {
|
||||
window.dispatchEvent(new CustomEvent(action.event))
|
||||
}
|
||||
},
|
||||
|
||||
icon(type) {
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
*
|
||||
* `value` is an accessor, so binding a control with `x-model="$store.theme.value"` goes
|
||||
* through the same write as `set()` and `toggle()`: the attributes, then localStorage.
|
||||
*
|
||||
* `scheme` is the colour profile on screen (<html data-scheme>, which the server chose), and
|
||||
* `previewScheme(name)` shows another one on this page without storing anything — the
|
||||
* application saves a choice itself, and the next full load draws what it saved.
|
||||
*/
|
||||
document.addEventListener('alpine:init', () => {
|
||||
const root = document.documentElement
|
||||
@@ -18,6 +22,7 @@ document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.store('theme', {
|
||||
choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system',
|
||||
resolved: root.dataset.theme === 'dark' ? 'dark' : 'light',
|
||||
scheme: root.dataset.scheme || null,
|
||||
|
||||
get value() {
|
||||
return this.choice
|
||||
@@ -48,6 +53,15 @@ document.addEventListener('alpine:init', () => {
|
||||
toggle() {
|
||||
this.set(this.resolved === 'dark' ? 'light' : 'dark')
|
||||
},
|
||||
|
||||
previewScheme(name) {
|
||||
if (typeof name !== 'string' || !/^[a-z0-9-]+$/.test(name)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.scheme = name
|
||||
root.dataset.scheme = name
|
||||
},
|
||||
})
|
||||
|
||||
// The head script repaints on an OS change while the choice is `system`; this keeps the
|
||||
|
||||
@@ -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` (0–23) 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 1–12 with AM and PM or 0–23 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 12–23 and the outer 00–11: 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')
|
||||
|
||||
/** "1–12" 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
|
||||
},
|
||||
}))
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
@@ -32,7 +32,7 @@
|
||||
$initials = $image ? null : ($avatar ?? \Illuminate\Support\Str::of((string) $name)->explode(' ')->filter()->take(2)->map(fn (string $word): string => mb_strtoupper(mb_substr($word, 0, 1)))->join(''));
|
||||
@endphp
|
||||
|
||||
<x-menu :$label :$position {{ $attributes }}>
|
||||
<x-livewire-material::menu :$label :$position {{ $attributes }}>
|
||||
<x-slot:trigger>
|
||||
<button
|
||||
type="button"
|
||||
@@ -45,7 +45,7 @@
|
||||
@elseif (filled($initials))
|
||||
{{ $initials }}
|
||||
@else
|
||||
<x-icon name="person" />
|
||||
<x-livewire-material::icon name="person" />
|
||||
@endif
|
||||
</button>
|
||||
</x-slot:trigger>
|
||||
@@ -60,20 +60,20 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<x-menu-separator />
|
||||
<x-livewire-material::menu-separator />
|
||||
@endif
|
||||
|
||||
{{ $slot }}
|
||||
|
||||
@if ($theme)
|
||||
<x-menu-item icon="contrast" x-on:click="$store.theme.toggle()" data-account-theme>
|
||||
<x-livewire-material::menu-item icon="contrast" x-on:click="$store.theme.toggle()" data-account-theme>
|
||||
<span x-text="$store.theme.resolved === 'dark' ? @js(__('Light theme')) : @js(__('Dark theme'))">{{ __('Theme') }}</span>
|
||||
</x-menu-item>
|
||||
</x-livewire-material::menu-item>
|
||||
@endif
|
||||
|
||||
@isset($footer)
|
||||
<x-menu-separator />
|
||||
<x-livewire-material::menu-separator />
|
||||
|
||||
{{ $footer }}
|
||||
@endisset
|
||||
</x-menu>
|
||||
</x-livewire-material::menu>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
{{ $attributes->class(['flex items-start gap-3 rounded-corner-md p-4', $colours]) }}
|
||||
>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" filled class="size-6" />
|
||||
<x-livewire-material::icon :name="$icon" filled class="size-6" />
|
||||
@endif
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-1 self-center">
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
@if ($dismissible)
|
||||
<button type="button" class="state-layer focus-ring -m-2 inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="shown = false">
|
||||
<x-icon name="close" class="size-5" />
|
||||
<x-livewire-material::icon name="close" class="size-5" />
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
{{-- 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 page's; during a Livewire update request, the page the
|
||||
component was rendered on rather than the update endpoint), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for
|
||||
the badge instead: "3 unread"),
|
||||
`section` (a heading the destination is grouped under in the rail; only an expanded rail shows
|
||||
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
|
||||
`navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||
|
||||
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`:
|
||||
the bar's 64px, the bottom safe area (`--material-safe-bottom`, else the device's inset) and
|
||||
`--material-bottom-extra` (0px unless the application docks something, an offline banner, on
|
||||
top of the bar).
|
||||
|
||||
`max-lg:overflow-x-clip` on the content region is the backstop under every page, and it stays
|
||||
`clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll
|
||||
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 = \Livewire\Livewire::isLivewireRequest() ? \Livewire\Livewire::originalUrl() : 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,
|
||||
'badgeLabel' => filled($item['badgeLabel'] ?? null) ? (string) $item['badgeLabel'] : null,
|
||||
'section' => filled($item['section'] ?? null) ? (string) $item['section'] : null,
|
||||
'bar' => ($item['bar'] ?? true) !== false,
|
||||
'navigate' => ($item['navigate'] ?? true) !== false,
|
||||
])
|
||||
->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+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty(),
|
||||
])
|
||||
>
|
||||
<a
|
||||
href="#content"
|
||||
data-skip-link
|
||||
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
|
||||
>{{ __('Skip to content') }}</a>
|
||||
|
||||
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
|
||||
@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']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||||
@endforeach
|
||||
</x-livewire-material::navigation-rail-section>
|
||||
@else
|
||||
@foreach ($group as $item)
|
||||
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :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']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||||
@endforeach
|
||||
</x-livewire-material::navigation-bar>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<x-livewire-material::toast />
|
||||
</div>
|
||||
@@ -10,8 +10,18 @@
|
||||
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
|
||||
|
||||
The status label is not an M3 badge but every app needs one: `tonal` draws the value in the
|
||||
colour's container ("Expired" in error-container), `outline` in a neutral edge. `color` (alias
|
||||
`tone`): `error` (the default), `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
|
||||
colour's container ("Expired" in error-container), `solid` in the colour itself (a label that
|
||||
has to stand out, "Built in" in primary), `outline` in a neutral edge. `color` (alias
|
||||
`tone`): `error` (the default), `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`,
|
||||
and two without a hue of their own:
|
||||
- `neutral` — neutral ink on every variant: on-surface-variant with surface text as a dot or
|
||||
count (the ink an outline badge already writes in), surface-container-high with
|
||||
on-surface-variant text when `tonal`, the outline-variant edge when `outline`.
|
||||
- `plain` — no background, text or border colour at all, only shape, size and type, so the
|
||||
caller's classes paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`.
|
||||
|
||||
The value is `value`, or the slot, which renders as HTML — an icon beside the word:
|
||||
`<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. With neither it is a dot.
|
||||
|
||||
A count or dot says nothing to a screen reader on its own: give the icon's control a label
|
||||
that includes it ("Notifications, 4 new"), or pass `label` here. --}}
|
||||
@@ -22,29 +32,41 @@
|
||||
'color' => null,
|
||||
'tone' => null,
|
||||
'tonal' => false,
|
||||
'solid' => false,
|
||||
'outline' => false,
|
||||
'floating' => false,
|
||||
'label' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$color = in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) ? ($color ?? $tone) : 'error';
|
||||
$text = $value ?? ($slot->isNotEmpty() ? trim((string) $slot) : null);
|
||||
$color = in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info', 'neutral', 'plain'], true) ? ($color ?? $tone) : 'error';
|
||||
// hasActualContent(): a slot holding only a comment, or an empty @foreach, is still a dot.
|
||||
$markup = $value === null && $slot->hasActualContent();
|
||||
$text = $value ?? ($markup ? trim((string) $slot) : null);
|
||||
$dot = blank($text);
|
||||
$status = ($tonal || $outline) && ! $dot;
|
||||
$status = ($tonal || $solid || $outline) && ! $dot;
|
||||
|
||||
if (! $dot && $max !== null && is_numeric($text) && (int) $text > (int) $max) {
|
||||
$text = $max.'+';
|
||||
$markup = false;
|
||||
}
|
||||
|
||||
$filled = [
|
||||
'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary',
|
||||
'error' => 'bg-error text-on-error', 'success' => 'bg-success text-on-success', 'warning' => 'bg-warning text-on-warning', 'info' => 'bg-info text-on-info',
|
||||
'neutral' => 'bg-on-surface-variant text-surface',
|
||||
];
|
||||
$container = [
|
||||
'primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container',
|
||||
'error' => 'bg-error-container text-on-error-container', 'success' => 'bg-success-container text-on-success-container', 'warning' => 'bg-warning-container text-on-warning-container', 'info' => 'bg-info-container text-on-info-container',
|
||||
'neutral' => 'bg-surface-container-high text-on-surface-variant',
|
||||
];
|
||||
$paint = match (true) {
|
||||
$color === 'plain' => '',
|
||||
! $status, $solid => $filled[$color],
|
||||
$tonal => $container[$color],
|
||||
default => 'border-outline-variant text-on-surface-variant',
|
||||
};
|
||||
|
||||
$attributes = $attributes
|
||||
->class([
|
||||
@@ -52,9 +74,8 @@
|
||||
'size-1.5 rounded-corner-full' => $dot,
|
||||
'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums' => ! $dot && ! $status,
|
||||
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
|
||||
$filled[$color] => ! $status,
|
||||
$container[$color] => $tonal && ! $dot,
|
||||
'border border-outline-variant text-on-surface-variant' => $outline && ! $tonal && ! $dot,
|
||||
'border' => $outline && ! $tonal && ! $solid && ! $dot,
|
||||
$paint => $paint !== '',
|
||||
'absolute top-0.5 end-0.5' => $floating && $dot,
|
||||
'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
|
||||
])
|
||||
@@ -64,4 +85,4 @@
|
||||
]));
|
||||
@endphp
|
||||
|
||||
<span {{ $attributes }}>@unless ($dot){{ $text }}@endunless</span>
|
||||
<span {{ $attributes }}>@unless ($dot)@if ($markup){{ $slot }}@else{{ $text }}@endif@endunless</span>
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
||||
style="--sheet-max-height: {{ $height }}"
|
||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
|
||||
'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[env(safe-area-inset-bottom)] text-on-surface shadow-elevation-1',
|
||||
'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[var(--material-safe-bottom,env(safe-area-inset-bottom))] text-on-surface shadow-elevation-1',
|
||||
$attributes->get('class'),
|
||||
]) }}
|
||||
>
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
|
||||
With an `icon` and no label it is an icon button: `width` is `narrow`, `default` or `wide`,
|
||||
`variant="text"` is M3's standard icon button, and the tooltip or label names it for screen
|
||||
readers. `selected` makes it a toggle: `true` or `false` sets `aria-pressed` and M3's
|
||||
selected colours, and a selected round button turns square (a selected square icon button
|
||||
readers. `selected` makes it a toggle: `true` or `false` sets `aria-pressed` (not on a `link`,
|
||||
which is no toggle — give it `aria-current` instead) and M3's selected colours, and a selected round button turns square (a selected square icon button
|
||||
turns round). Text buttons are not toggles in M3; a selected one takes the tonal container.
|
||||
|
||||
Values from androidx Compose Material 3's tokens (Button*Tokens, *IconButtonTokens,
|
||||
@@ -191,7 +191,9 @@
|
||||
'type' => $isLink ? null : $type,
|
||||
'disabled' => ! $isLink && $disabled ? true : null,
|
||||
'aria-label' => $iconOnly && ! $attributes->has('aria-label') ? ($label ?? $tip) : null,
|
||||
'aria-pressed' => $selected === null ? null : ($selected ? 'true' : 'false'),
|
||||
// A link is not a toggle: ARIA defines aria-pressed for buttons only. A selected link keeps
|
||||
// the selected look; `aria-current` is the caller's to set (`:aria-current="'page'"`).
|
||||
'aria-pressed' => $selected === null || $isLink ? null : ($selected ? 'true' : 'false'),
|
||||
'data-icon-button' => $iconOnly ? true : null,
|
||||
'wire:loading.attr' => $spinnerTarget ? 'disabled' : null,
|
||||
'wire:target' => $spinnerTarget,
|
||||
@@ -202,13 +204,13 @@
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($spinnerTarget)
|
||||
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center">
|
||||
<x-loading :class="$iconSize" :label="false" />
|
||||
<x-livewire-material::loading :class="$iconSize" :label="false" />
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if ($icon)
|
||||
<span class="contents" @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif>
|
||||
<x-icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-sm:size-6' => $fab])" />
|
||||
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-sm:size-6' => $fab])" />
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@@ -217,10 +219,10 @@
|
||||
@endunless
|
||||
|
||||
@if ($iconRight)
|
||||
<x-icon :name="$iconRight" :class="$iconSize" />
|
||||
<x-livewire-material::icon :name="$iconRight" :class="$iconSize" />
|
||||
@endif
|
||||
|
||||
@if ($tip !== null)
|
||||
<x-tooltip :text="$tip" :side="$tipSide" :anchor="$anchor" />
|
||||
<x-livewire-material::tooltip :text="$tip" :side="$tipSide" :anchor="$anchor" />
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</div>
|
||||
|
||||
@if ($separator)
|
||||
<x-divider class="mt-4" />
|
||||
<x-livewire-material::divider class="mt-4" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -44,7 +44,9 @@
|
||||
longer slides inside its mask. RTL mirrors the keylines, keys and buttons.
|
||||
|
||||
Re-measures itself when resized, when a Livewire morph resets its styles and when items
|
||||
come and go. --}}
|
||||
come and go. The row's id, which the buttons control, is new with every render; the row
|
||||
carries a `wire:key` (see `<x-menu>`), so a morph patches it in place — its scroll position
|
||||
and listeners kept — rather than swapping in a copy. --}}
|
||||
|
||||
@props([
|
||||
'layout' => 'multi-browse',
|
||||
@@ -116,6 +118,7 @@
|
||||
|
||||
<div
|
||||
x-ref="scroller"
|
||||
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-carousel']) }}
|
||||
id="{{ $scrollerId }}"
|
||||
role="region"
|
||||
aria-roledescription="{{ __('carousel') }}"
|
||||
@@ -136,7 +139,7 @@
|
||||
'hidden pointer-fine:flex' => $controls === null,
|
||||
'flex' => $controls === true,
|
||||
])>
|
||||
<x-button
|
||||
<x-livewire-material::button
|
||||
icon="chevron_left"
|
||||
variant="tonal"
|
||||
:tooltip="__('Previous')"
|
||||
@@ -145,7 +148,7 @@
|
||||
x-on:click="previous()"
|
||||
class="rtl:-scale-x-100"
|
||||
/>
|
||||
<x-button
|
||||
<x-livewire-material::button
|
||||
icon="chevron_right"
|
||||
variant="tonal"
|
||||
:tooltip="__('Next')"
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$id = $attributes->get('id') ?? 'check-'.substr(md5($model.'|'.$label.'|'.$attributes->get('value')), 0, 12);
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}>
|
||||
@@ -36,8 +38,8 @@
|
||||
@if ($indeterminate) data-indeterminate @endif
|
||||
@if ($messages !== []) aria-invalid="true" aria-describedby="{{ $id }}-support" @endif
|
||||
/>
|
||||
<x-icon name="check" class="size-4" data-check />
|
||||
<x-icon name="remove" class="size-4" data-mixed />
|
||||
<x-livewire-material::icon name="check" class="size-4" data-check />
|
||||
<x-livewire-material::icon name="remove" class="size-4" data-mixed />
|
||||
</span>
|
||||
|
||||
@if (filled($label) || filled($hint))
|
||||
|
||||
@@ -230,7 +230,7 @@
|
||||
<img src="{{ $avatar }}" alt="" @class(['me-2 size-6 shrink-0 rounded-corner-full object-cover', 'opacity-38' => $disabled]) />
|
||||
@endif
|
||||
@elseif ($icon)
|
||||
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
|
||||
<x-livewire-material::icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
|
||||
@endif
|
||||
|
||||
<span data-chip-label class="truncate">{{ $label ?? $slot }}</span>
|
||||
@@ -251,7 +251,7 @@
|
||||
'cursor-not-allowed' => $disabled,
|
||||
])
|
||||
>
|
||||
<x-icon name="close" class="size-4.5" />
|
||||
<x-livewire-material::icon name="close" class="size-4.5" />
|
||||
</button>
|
||||
@endif
|
||||
|
||||
@@ -260,7 +260,7 @@
|
||||
@endif
|
||||
|
||||
@if ($tooltip !== null)
|
||||
<x-tooltip :text="$tooltip" :anchor="$anchor" />
|
||||
<x-livewire-material::tooltip :text="$tooltip" :anchor="$anchor" />
|
||||
@endif
|
||||
</span>
|
||||
@else
|
||||
@@ -277,22 +277,22 @@
|
||||
'h-4.5 w-0 transition-[width] duration-(--md-sys-motion-effects-default-duration) ease-effects-default group-has-checked/chip:w-4.5 group-has-checked/chip:duration-(--md-sys-motion-spatial-fast-duration) group-has-checked/chip:ease-spatial-fast group-aria-pressed/chip:w-4.5 group-aria-pressed/chip:duration-(--md-sys-motion-spatial-fast-duration) group-aria-pressed/chip:ease-spatial-fast' => blank($icon),
|
||||
])>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-4.5 transition-opacity duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-has-checked/chip:opacity-0 group-aria-pressed/chip:opacity-0', $leadingInk])" />
|
||||
<x-livewire-material::icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-4.5 transition-opacity duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-has-checked/chip:opacity-0 group-aria-pressed/chip:opacity-0', $leadingInk])" />
|
||||
@endif
|
||||
<x-icon name="check" data-chip-check :class="$check" />
|
||||
<x-livewire-material::icon name="check" data-chip-check :class="$check" />
|
||||
</span>
|
||||
@elseif ($icon)
|
||||
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
|
||||
<x-livewire-material::icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
|
||||
@endif
|
||||
|
||||
<span class="truncate">{{ $label ?? $slot }}</span>
|
||||
|
||||
@if ($iconRight)
|
||||
<x-icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['ms-2 size-4.5', $trailingInk])" />
|
||||
<x-livewire-material::icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['ms-2 size-4.5', $trailingInk])" />
|
||||
@endif
|
||||
|
||||
@if ($tooltip !== null)
|
||||
<x-tooltip :text="$tooltip" :anchor="$anchor" />
|
||||
<x-livewire-material::tooltip :text="$tooltip" :anchor="$anchor" />
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
@endif
|
||||
|
||||
@@ -35,8 +35,10 @@
|
||||
|
||||
@php
|
||||
$model = $attributes->wire('model')->value() ?: null;
|
||||
$messages = $model !== null && isset($errors)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
||||
: [];
|
||||
$choices = collect($options)->map(fn ($option): array => [
|
||||
'value' => data_get($option, $optionValue),
|
||||
@@ -115,7 +117,7 @@
|
||||
@if ($model === null) x-modelable="value" @endif
|
||||
>
|
||||
<div style="anchor-name: {{ $anchor }}">
|
||||
<x-field :$id :$label :$hint :$messages :$icon :$variant>
|
||||
<x-livewire-material::field :$id :$label :$hint :$messages :$icon :$variant>
|
||||
<input
|
||||
id="{{ $id }}"
|
||||
type="text"
|
||||
@@ -144,9 +146,9 @@
|
||||
/>
|
||||
|
||||
<x-slot:trailing>
|
||||
<x-icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
|
||||
<x-livewire-material::icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
|
||||
</x-slot:trailing>
|
||||
</x-field>
|
||||
</x-livewire-material::field>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
@@ -170,7 +172,7 @@
|
||||
class="field-option"
|
||||
>
|
||||
<span x-text="option.label"></span>
|
||||
<x-icon name="check" class="field-check size-6" />
|
||||
<x-livewire-material::icon name="check" class="field-check size-6" />
|
||||
</li>
|
||||
</template>
|
||||
<li x-show="filtered.length === 0" class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</li>
|
||||
@@ -202,9 +204,9 @@
|
||||
}"
|
||||
@if ($model === null) x-modelable="selection" @endif
|
||||
>
|
||||
<x-chip-set :$label :$hint :error-field="$model">
|
||||
<x-livewire-material::chip-set :$label :$hint :error-field="$model">
|
||||
@foreach ($choices as $index => $choice)
|
||||
<x-chip
|
||||
<x-livewire-material::chip
|
||||
type="filter"
|
||||
:label="$choice['label']"
|
||||
:selected="$isSelected($choice['value'])"
|
||||
@@ -213,6 +215,6 @@
|
||||
x-on:click="toggle({{ $index }})"
|
||||
/>
|
||||
@endforeach
|
||||
</x-chip-set>
|
||||
</x-livewire-material::chip-set>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -6,7 +6,18 @@
|
||||
`heading` slot), an optional leading `icon`, a chevron that turns over on the spatial spring,
|
||||
and — where the browser supports animating `details` content (`interpolate-size`) — a height
|
||||
that eases open. `open` starts it open. `variant`: `plain` (the default, on the surface around
|
||||
it) or `filled` (a surface-container tile with a large corner). --}}
|
||||
it) or `filled` (a surface-container tile with a large corner).
|
||||
|
||||
Its open state can be bound, both ways:
|
||||
- `wire:model` (any modifiers; `.live` tells the server at once) entangles it with a Livewire
|
||||
boolean property. The server renders `open` from the property, so the first paint matches it
|
||||
and `open` is ignored; toggling writes the property, and the property changing opens or
|
||||
closes it.
|
||||
- `x-model` binds an Alpine property through `x-modelable`; `open` is then only the first
|
||||
paint, until Alpine starts and applies the property.
|
||||
The state lives in `collapseOpen` on the `<details>`, a name kept clear of `open`, which a
|
||||
dialog inside the slot may be reading from a scope around it. Without a binding there is no
|
||||
Alpine on it at all. --}}
|
||||
|
||||
@props([
|
||||
'title' => null,
|
||||
@@ -15,10 +26,26 @@
|
||||
'variant' => 'plain',
|
||||
])
|
||||
|
||||
@php
|
||||
$model = $attributes->wire('model')->value() ?: null;
|
||||
$bound = $model !== null || count($attributes->whereStartsWith('x-model')->getAttributes()) > 0;
|
||||
$expanded = (bool) $open;
|
||||
|
||||
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
|
||||
$expanded = (bool) data_get($component, $model);
|
||||
}
|
||||
@endphp
|
||||
|
||||
<details
|
||||
wire:ignore.self
|
||||
@if ($open) open @endif
|
||||
{{ $attributes->class([
|
||||
@if ($bound)
|
||||
x-data="{ collapseOpen: @if ($model !== null) @entangle($attributes->wire('model')) @else @js($expanded) @endif }"
|
||||
@if ($model === null) x-modelable="collapseOpen" @endif
|
||||
x-effect="$el.open = collapseOpen"
|
||||
x-on:toggle="collapseOpen = $el.open"
|
||||
@endif
|
||||
@if ($expanded) open @endif
|
||||
{{ $attributes->whereDoesntStartWith('wire:model')->class([
|
||||
'group/collapse [interpolate-size:allow-keywords]',
|
||||
'rounded-corner-lg bg-surface-container' => $variant === 'filled',
|
||||
]) }}
|
||||
@@ -28,12 +55,12 @@
|
||||
'px-4' => $variant === 'filled',
|
||||
])>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" class="size-6 text-on-surface-variant" />
|
||||
<x-livewire-material::icon :name="$icon" class="size-6 text-on-surface-variant" />
|
||||
@endif
|
||||
|
||||
<span class="min-w-0 flex-1">{{ $heading ?? $title }}</span>
|
||||
|
||||
<x-icon name="expand_more" class="size-6 text-on-surface-variant transition-[rotate] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast group-open/collapse:rotate-180" />
|
||||
<x-livewire-material::icon name="expand_more" class="size-6 text-on-surface-variant transition-[rotate] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast group-open/collapse:rotate-180" />
|
||||
</summary>
|
||||
|
||||
<div @class(['pb-4 type-body-md text-on-surface-variant', 'px-4' => $variant === 'filled', 'pt-1'])>
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
{{-- 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). An application that lets each person choose
|
||||
overrides the last two: `week-start` is the first day of the week, 0 (Sunday) to 6 (Saturday),
|
||||
and `format` the typed and displayed format, `dd`, `MM` and `yyyy` in any order around one
|
||||
delimiter (`.`, `/` or `-`: `dd.MM.yyyy`, `MM/dd/yyyy`, `yyyy-MM-dd`). The field, the typed-date
|
||||
reader, the calendar's columns and weekday header, Home and End, and the dialog's text fields
|
||||
all follow them; the names stay the locale's, and `wire:model` still stores `Y-m-d`. A value
|
||||
that is neither (`week-start="7"`, `format="d.M.yy"`) is ignored, as `null` is. Replaces
|
||||
ReStride's flatpickr picker: its `config` becomes `min`, `max`, `range` and `mode`.
|
||||
|
||||
M3's date pickers (DatePickerModalTokens and DateInputModalTokens from androidx Compose
|
||||
Material 3, androidx commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, with the layout of
|
||||
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,
|
||||
'weekStart' => null,
|
||||
'format' => null,
|
||||
])
|
||||
|
||||
@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());
|
||||
$weekStart = (is_int($weekStart) || is_string($weekStart)) && preg_match('/^[0-6]\z/', (string) $weekStart) === 1 ? (int) $weekStart : null;
|
||||
$format = is_string($format) && preg_match('/^(dd|MM|yyyy)([.\/-])(dd|MM|yyyy)\2(dd|MM|yyyy)\z/', $format, $units) === 1 && count(array_unique([$units[1], $units[3], $units[4]])) === 3
|
||||
? $format
|
||||
: null;
|
||||
|
||||
$toIso = function (mixed $date): ?string {
|
||||
if ($date instanceof \DateTimeInterface) {
|
||||
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: `format`, or as resources/js/datepicker.js derives it, from ICU's short date when PHP has intl.
|
||||
$pattern = $format ?? 'yyyy-MM-dd';
|
||||
if ($format === null && class_exists(\IntlDateFormatter::class)) {
|
||||
$short = (string) (new \IntlDateFormatter(str_replace('-', '_', $locale), \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE))->getPattern();
|
||||
$candidate = rtrim(str_replace('My', 'M/y', (string) preg_replace(['/[^dMy\/\-.]/', '/d{1,2}/', '/M{1,2}/', '/y{1,4}/'], ['', 'dd', 'MM', 'yyyy'], $short)), '.');
|
||||
if (preg_match('/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[\/\-.][dMy]+[\/\-.][dMy]+$/', $candidate) === 1) {
|
||||
$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),
|
||||
'weekStart' => $weekStart,
|
||||
'format' => $format,
|
||||
'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>
|
||||
@@ -15,7 +15,9 @@
|
||||
As a pane (`pane`, from `xl`) nothing is covered: the page renders the drawer after its list in
|
||||
an `xl:flex xl:items-start xl:gap-6` row, the drawer sticks under the top of the viewport, the
|
||||
list stays usable and another row swaps what it shows — no scrim, no trap, no inert page. While
|
||||
closed it takes no room. `pane-width` sizes the pane (the sheet's width by default). The body is
|
||||
closed it takes no room. `pane-width` sizes the pane (the sheet's width by default). Escape closes
|
||||
the sheet but leaves a pane open, since the page beside it is still in use; `pane-close-on-escape`
|
||||
closes the pane on Escape too, for a pane that is a transient detail. The body is
|
||||
a size container, so its contents lay out by the room the sheet or pane actually has (`@md:`),
|
||||
never by the viewport.
|
||||
|
||||
@@ -34,6 +36,7 @@
|
||||
'width' => '25rem',
|
||||
'pane' => false,
|
||||
'paneWidth' => null,
|
||||
'paneCloseOnEscape' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -55,11 +58,11 @@
|
||||
},
|
||||
@endif
|
||||
}"
|
||||
@if ($closeOnEscape) x-on:keydown.window.escape="if (open && ! wide) close()" @endif
|
||||
@if ($closeOnEscape) x-on:keydown.window.escape="{{ $paneCloseOnEscape ? 'if (open) close()' : 'if (open && ! wide) close()' }}" @endif
|
||||
data-sheet="{{ $id }}"
|
||||
@if ($pane)
|
||||
x-bind:class="! open && 'xl:hidden'"
|
||||
class="xl:sticky xl:top-[calc(env(safe-area-inset-top)+1.25rem)] xl:shrink-0 xl:self-start"
|
||||
class="xl:sticky xl:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1.25rem)] xl:shrink-0 xl:self-start"
|
||||
data-pane
|
||||
@endif
|
||||
>
|
||||
@@ -90,11 +93,11 @@
|
||||
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
||||
style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth ?? $width }}"
|
||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
|
||||
'fixed top-[env(safe-area-inset-top)] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1',
|
||||
'fixed top-[var(--material-safe-top,env(safe-area-inset-top))] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1',
|
||||
'end-0 sm:rounded-s-corner-lg' => ! $start,
|
||||
'start-0 sm:rounded-e-corner-lg' => $start,
|
||||
'sm:w-(--sheet-width) sm:max-w-[calc(100vw-4rem)]',
|
||||
'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-env(safe-area-inset-top))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane,
|
||||
'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-var(--material-safe-top,env(safe-area-inset-top)))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane,
|
||||
$attributes->get('class'),
|
||||
]) }}
|
||||
>
|
||||
@@ -113,13 +116,13 @@
|
||||
|
||||
@if ($withCloseButton)
|
||||
<span class="-me-3 -mt-2 inline-flex shrink-0">
|
||||
<x-button icon="close" :tooltip-left="__('Close')" x-on:click="close()" />
|
||||
<x-livewire-material::button icon="close" :tooltip-left="__('Close')" x-on:click="close()" />
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($separator)
|
||||
<x-divider class="mt-4" />
|
||||
<x-livewire-material::divider class="mt-4" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -8,7 +8,17 @@
|
||||
Not an M3 component; built from M3 Expressive's parts — `shape` (any `<x-shape>` name,
|
||||
`cookie-9` by default) in secondary-container behind the `icon` in on-secondary-container,
|
||||
a title-large `title`, body-medium `description` or slot. For "your filter matched nothing"
|
||||
use a plain line of text instead: a picture there is consolation for a typo. --}}
|
||||
use a plain line of text instead: a picture there is consolation for a typo.
|
||||
|
||||
An application with its own artwork puts it in the `illustration` slot, which is drawn in place
|
||||
of the shape and icon (both props are then unused). The slot sizes itself; its attributes go on
|
||||
the element around it, so `class` can set the colour an SVG's `currentColor` takes:
|
||||
|
||||
<x-empty-state title="No routes yet">
|
||||
<x-slot:illustration class="text-primary"><svg class="size-32" aria-hidden="true">…</svg></x-slot:illustration>
|
||||
</x-empty-state>
|
||||
|
||||
A slot holding only whitespace or comments counts as empty, and the shape and icon stay. --}}
|
||||
|
||||
@props([
|
||||
'icon' => 'inbox',
|
||||
@@ -18,10 +28,14 @@
|
||||
])
|
||||
|
||||
<div {{ $attributes->class('flex flex-col items-center gap-4 px-4 py-10 text-center') }}>
|
||||
<div class="relative grid size-28 place-items-center">
|
||||
<x-shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" />
|
||||
<x-icon :name="$icon" class="relative size-12 text-on-secondary-container" />
|
||||
</div>
|
||||
@if (isset($illustration) && $illustration->hasActualContent())
|
||||
<div {{ $illustration->attributes }}>{{ $illustration }}</div>
|
||||
@else
|
||||
<div class="relative grid size-28 place-items-center">
|
||||
<x-livewire-material::shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" />
|
||||
<x-livewire-material::icon :name="$icon" class="relative size-12 text-on-secondary-container" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($title)
|
||||
<h3 class="type-title-lg">{{ $title }}</h3>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" class="size-6" />
|
||||
<x-livewire-material::icon :name="$icon" class="size-6" />
|
||||
@endif
|
||||
|
||||
<span>{{ $label ?? $slot }}</span>
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a
|
||||
round close button in the colour itself while the list is open above it, end-aligned; the
|
||||
list is a `popover="auto"` menu with the menu keyboard of `<x-menu>`. `label` names the FAB
|
||||
for screen readers. Give the items the same `color`.
|
||||
for screen readers. Give the items the same `color`. Like `<x-menu>`'s, the list is keyed for
|
||||
Livewire, so it stays open through a render of the component around it.
|
||||
|
||||
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px
|
||||
above the close button. --}}
|
||||
@@ -49,13 +50,14 @@
|
||||
$colours,
|
||||
])
|
||||
>
|
||||
<x-icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" />
|
||||
<x-icon name="close" class="hidden size-5 group-aria-expanded/fab:block" />
|
||||
<x-livewire-material::icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" />
|
||||
<x-livewire-material::icon name="close" class="hidden size-5 group-aria-expanded/fab:block" />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<div
|
||||
x-ref="menu"
|
||||
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-fab-menu']) }}
|
||||
id="material-fab-menu-{{ $key }}"
|
||||
popover="auto"
|
||||
role="menu"
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" :class="$iconSize" />
|
||||
<x-livewire-material::icon :name="$icon" :class="$iconSize" />
|
||||
@endif
|
||||
|
||||
@if ($extended)
|
||||
@@ -73,6 +73,6 @@
|
||||
@endif
|
||||
|
||||
@if ($tooltip !== null)
|
||||
<x-tooltip :text="$tooltip" :anchor="$anchor" />
|
||||
<x-livewire-material::tooltip :text="$tooltip" :anchor="$anchor" />
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
>
|
||||
<div class="field-box">
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" class="field-icon size-(--field-icon)" />
|
||||
<x-livewire-material::icon :name="$icon" class="field-icon size-(--field-icon)" />
|
||||
@endif
|
||||
|
||||
@if (filled($prefix))
|
||||
|
||||
@@ -21,12 +21,14 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5('file|'.$model.'|'.$label), 0, 12);
|
||||
$messages = $model !== null && isset($errors)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
||||
: [];
|
||||
@endphp
|
||||
|
||||
<x-field :$id :$label :$hint :$messages :$variant floated :class="$attributes->get('class')">
|
||||
<x-livewire-material::field :$id :$label :$hint :$messages :$variant floated :class="$attributes->get('class')">
|
||||
<input
|
||||
{{ $attributes->except(['class', 'id', 'type']) }}
|
||||
type="file"
|
||||
@@ -35,4 +37,4 @@
|
||||
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
||||
class="field-control"
|
||||
/>
|
||||
</x-field>
|
||||
</x-livewire-material::field>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
@isset($actions)
|
||||
@if ($separator)
|
||||
<x-divider />
|
||||
<x-livewire-material::divider />
|
||||
@endif
|
||||
|
||||
<div {{ $actions->attributes->class(['flex flex-wrap items-center justify-end gap-2']) }}>
|
||||
|
||||
@@ -12,13 +12,19 @@
|
||||
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
|
||||
`inline`. An option with `'disabled' => true` greys its own segment.
|
||||
|
||||
ReStride's props, kept: `label`, `hint`, `name` (needed with `x-model`, which names no
|
||||
property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`, `variant`,
|
||||
`multiple`, `inline`. A validation message for the bound property replaces the hint. --}}
|
||||
ReStride's props, kept: `label`, `hint`, `hint-class`, `name` (needed with `x-model`, which
|
||||
names no property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`,
|
||||
`variant`, `multiple`, `inline`. A validation message for the bound property replaces the hint.
|
||||
|
||||
`hint-class` adds classes to the hint, as on `<x-field>`: a colour there paints it
|
||||
(`hint-class="text-warning"` for a hint that warns). The hint's own colour then carries no
|
||||
specificity, as the field's does in the components layer, because which of two colour
|
||||
utilities wins depends on the order Tailwind emits them. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'hint' => null,
|
||||
'hintClass' => null,
|
||||
'name' => null,
|
||||
'options' => [],
|
||||
'optionValue' => 'id',
|
||||
@@ -33,7 +39,9 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$name ??= $model;
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
|
||||
|
||||
$segment = [
|
||||
@@ -44,6 +52,10 @@
|
||||
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
|
||||
][$size];
|
||||
|
||||
$hintClasses = filled($hintClass)
|
||||
? \Illuminate\Support\Arr::toCssClasses(['mt-1 type-body-sm [:where(&)]:text-on-surface-variant', $hintClass])
|
||||
: 'mt-1 type-body-sm text-on-surface-variant';
|
||||
|
||||
$iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
|
||||
|
||||
$colours = match ($variant) {
|
||||
@@ -79,7 +91,7 @@
|
||||
/>
|
||||
|
||||
@if (filled(data_get($option, $optionIcon)))
|
||||
<x-icon :name="data_get($option, $optionIcon)" :class="$iconSize" />
|
||||
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :class="$iconSize" />
|
||||
@endif
|
||||
|
||||
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
|
||||
@@ -92,6 +104,6 @@
|
||||
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
||||
@endforeach
|
||||
@elseif (filled($hint))
|
||||
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
|
||||
<p class="{{ $hintClasses }}">{{ $hint }}</p>
|
||||
@endif
|
||||
</fieldset>
|
||||
|
||||
@@ -30,10 +30,12 @@
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|'.$placeholder), 0, 12);
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
<x-field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
|
||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
|
||||
<input
|
||||
{{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }}
|
||||
id="{{ $id }}"
|
||||
@@ -54,7 +56,7 @@
|
||||
aria-label="{{ __('Clear') }}"
|
||||
data-field-clear
|
||||
>
|
||||
<x-icon name="close" class="size-(--field-icon)" />
|
||||
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
|
||||
</button>
|
||||
@endif
|
||||
|
||||
@@ -67,13 +69,13 @@
|
||||
aria-label="{{ __('Copy') }}"
|
||||
data-field-copy
|
||||
>
|
||||
<x-icon name="content_copy" class="size-(--field-icon)" />
|
||||
<x-livewire-material::icon name="content_copy" class="size-(--field-icon)" />
|
||||
</button>
|
||||
@endif
|
||||
|
||||
@if ($iconRight)
|
||||
<x-icon :name="$iconRight" class="field-trailing size-(--field-icon)" />
|
||||
<x-livewire-material::icon :name="$iconRight" class="field-trailing size-(--field-icon)" />
|
||||
@endif
|
||||
</x-slot:trailing>
|
||||
@endif
|
||||
</x-field>
|
||||
</x-livewire-material::field>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
@elseif ($image)
|
||||
<img src="{{ $image }}" alt="" class="size-14 shrink-0 rounded-corner-sm object-cover" />
|
||||
@elseif ($icon)
|
||||
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
|
||||
<x-livewire-material::icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
|
||||
@endif
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -94,6 +94,6 @@
|
||||
@endif
|
||||
|
||||
@if ($iconRight)
|
||||
<x-icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
|
||||
<x-livewire-material::icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
{{-- One item in an `<x-menu>`: an action, a link, or a choice.
|
||||
|
||||
`label`, a leading `icon`, an `icon-right`, a `description` under the label and a
|
||||
`shortcut` at the end (M3's trailing supporting text: "⌘C"). `link` makes it an anchor, with
|
||||
`wire:navigate` unless `external` or `no-wire-navigate`. `selected` (true or false) makes it a
|
||||
`menuitemcheckbox` with `aria-checked`; a selected item takes Expressive's selected shape and
|
||||
tertiary-container. `disabled` keeps it in the list, out of reach. `keep-open` leaves the menu
|
||||
open when it is activated — for a choice the person may want to change twice.
|
||||
`label`, a leading `icon` (`icon-class` adds classes to it), an `icon-right`, a `description`
|
||||
under the label and a `shortcut` at the end (M3's trailing supporting text: "⌘C"). `link`
|
||||
makes it an anchor, with `wire:navigate` unless `external` or `no-wire-navigate`. `selected`
|
||||
(true or false) makes it a `menuitemcheckbox` with `aria-checked`; a selected item takes
|
||||
Expressive's selected shape and tertiary-container. `current` is for a menu of places rather
|
||||
than choices — a section picker — and marks the page you are on: `aria-current="page"`, the
|
||||
selected shape in secondary-container, the colour M3 gives the navigation indicator. `badge`
|
||||
draws `<x-badge>` at the end of the row: `true` for a dot, or a count. `disabled` keeps it in the list, out of
|
||||
reach. `keep-open` leaves the menu open when it is activated — for a choice the person may
|
||||
want to change twice.
|
||||
|
||||
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
|
||||
sport's colour (`icon-class="text-sport-run"`). A colour there paints the icon, a selected
|
||||
item's too: the icon's own colour then carries no specificity, because which of two colour
|
||||
utilities wins depends on the order Tailwind emits them. A disabled item's icon stays
|
||||
disabled.
|
||||
|
||||
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
|
||||
12px at the ends of the list. --}}
|
||||
@@ -13,6 +23,7 @@
|
||||
@props([
|
||||
'label' => null,
|
||||
'icon' => null,
|
||||
'iconClass' => null,
|
||||
'iconRight' => null,
|
||||
'description' => null,
|
||||
'shortcut' => null,
|
||||
@@ -20,6 +31,8 @@
|
||||
'external' => false,
|
||||
'noWireNavigate' => false,
|
||||
'selected' => null,
|
||||
'current' => false,
|
||||
'badge' => null,
|
||||
'disabled' => false,
|
||||
'keepOpen' => false,
|
||||
])
|
||||
@@ -36,11 +49,13 @@
|
||||
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary',
|
||||
'py-2' => filled($description),
|
||||
'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true,
|
||||
'rounded-corner-md bg-secondary-container text-on-secondary-container' => $current && $selected !== true,
|
||||
'pointer-events-none text-on-surface/38' => $disabled,
|
||||
])
|
||||
->merge(array_filter([
|
||||
'role' => $selected === null ? 'menuitem' : 'menuitemcheckbox',
|
||||
'aria-checked' => $selected === null ? null : ($selected ? 'true' : 'false'),
|
||||
'aria-current' => $current ? 'page' : null,
|
||||
'aria-disabled' => $disabled ? 'true' : null,
|
||||
'tabindex' => '-1',
|
||||
'type' => $isLink ? null : 'button',
|
||||
@@ -54,13 +69,22 @@
|
||||
$iconInk = match (true) {
|
||||
$disabled => 'text-on-surface/38',
|
||||
$selected === true => 'text-on-tertiary-container',
|
||||
$current => 'text-on-secondary-container',
|
||||
default => 'text-on-surface-variant',
|
||||
};
|
||||
|
||||
$leadingIcon = match (true) {
|
||||
blank($iconClass) => 'size-5 '.$iconInk,
|
||||
$disabled => \Illuminate\Support\Arr::toCssClasses(['size-5', $iconClass, 'text-on-surface/38!']),
|
||||
$selected === true => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-tertiary-container', $iconClass]),
|
||||
$current => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-secondary-container', $iconClass]),
|
||||
default => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-surface-variant', $iconClass]),
|
||||
};
|
||||
@endphp
|
||||
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" :filled="$selected === true" :class="'size-5 '.$iconInk" />
|
||||
<x-livewire-material::icon :name="$icon" :filled="$selected === true || $current" :class="$leadingIcon" />
|
||||
@endif
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
@@ -71,11 +95,14 @@
|
||||
@endif
|
||||
</span>
|
||||
|
||||
@if ($badge !== null && $badge !== false && $badge !== '')
|
||||
<x-livewire-material::badge :value="$badge === true ? null : $badge" class="shrink-0" />
|
||||
@endif
|
||||
@if ($shortcut)
|
||||
<span @class(['shrink-0 type-label-sm', $iconInk])>{{ $shortcut }}</span>
|
||||
@endif
|
||||
|
||||
@if ($iconRight)
|
||||
<x-icon :name="$iconRight" :class="'size-5 '.$iconInk" />
|
||||
<x-livewire-material::icon :name="$iconRight" :class="'size-5 '.$iconInk" />
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
|
||||
@@ -13,10 +13,24 @@
|
||||
The trigger's first button or link becomes the menu button (aria-haspopup, aria-expanded,
|
||||
aria-controls). The list is a `popover="auto"` in the top layer, placed by CSS anchor
|
||||
positioning at `position` (`bottom-start`, `bottom-end`, `top-start`, `top-end`) and flipping
|
||||
when there is no room; a click outside or Escape closes it. The keyboard is WAI-ARIA's menu
|
||||
button: Enter, Space or ArrowDown open on the first item, ArrowUp on the last; arrows, Home,
|
||||
End and typing a letter move between items; Tab closes; activating an item closes the menu
|
||||
unless the item says `keep-open`, and Escape returns focus to the trigger.
|
||||
when there is no room — to the other side, the other end, or both, so a menu on a FAB in a
|
||||
corner of the window opens back across it; a click outside or Escape closes it. The keyboard
|
||||
is WAI-ARIA's menu button: Enter, Space or ArrowDown open on the first item, ArrowUp on the
|
||||
last; arrows, Home, End and typing a letter move between items; Tab closes; activating an item
|
||||
closes the menu unless the item says `keep-open`, and Escape returns focus to the trigger.
|
||||
|
||||
The anchor name is rendered on the wrapper around the trigger slot, the only element the
|
||||
server can name, and resources/js/menu.js moves it onto the menu button itself: a trigger
|
||||
that is `position: fixed` (`<x-button fab>` on a phone) leaves the wrapper behind as an empty
|
||||
box where the page put it, and the menu opened there.
|
||||
|
||||
The id and the anchor name are new with every render. The popover carries a `wire:key`, which
|
||||
a Livewire morph matches it by before the id, so a render of the component around an open
|
||||
menu patches it in place — still open, focus and listeners kept — instead of swapping in a
|
||||
closed copy; menu.js then writes the menu button's ARIA attributes again. The key goes
|
||||
through an attribute bag: Livewire compiles a `wire:key` written in a template into the key
|
||||
of the loop iteration around it, which would give every child component after the menu the
|
||||
same key.
|
||||
|
||||
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
|
||||
2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from
|
||||
@@ -43,6 +57,7 @@
|
||||
|
||||
<div
|
||||
x-ref="menu"
|
||||
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-menu']) }}
|
||||
id="material-menu-{{ $key }}"
|
||||
popover="auto"
|
||||
role="menu"
|
||||
@@ -53,7 +68,7 @@
|
||||
x-on:click="activate($event)"
|
||||
@class([
|
||||
'm-0 min-w-28 max-w-70 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
|
||||
'my-1 [position-try-fallbacks:flip-block,flip-inline]',
|
||||
'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
|
||||
'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
|
||||
'bg-surface-container-low text-on-surface' => ! $vibrant,
|
||||
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
|
||||
|
||||
@@ -64,12 +64,12 @@
|
||||
>
|
||||
<div @class([
|
||||
'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3',
|
||||
'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[env(safe-area-inset-top)]' => $fullscreen,
|
||||
'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen,
|
||||
$boxClass,
|
||||
])>
|
||||
@if ($fullscreen)
|
||||
<div class="flex h-16 shrink-0 items-center gap-1 px-1 sm:hidden">
|
||||
<x-button icon="close" :tooltip="__('Close')" x-on:click="close()" />
|
||||
<x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="close()" />
|
||||
|
||||
@if (filled($title))
|
||||
<span class="truncate type-title-lg">{{ $title }}</span>
|
||||
@@ -78,22 +78,23 @@
|
||||
@endif
|
||||
|
||||
<div @class(['min-h-0 flex-1', 'max-sm:overflow-y-auto max-sm:px-6 max-sm:pb-6' => $fullscreen])>
|
||||
@if (filled($title) || $icon)
|
||||
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon, 'text-center' => $icon])>
|
||||
@if (filled($title) || filled($subtitle) || $icon)
|
||||
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
|
||||
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
|
||||
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
|
||||
@endif
|
||||
|
||||
@if (filled($title))
|
||||
<h2 id="{{ $id }}-title" class="type-headline-sm">{{ $title }}</h2>
|
||||
<h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-sm:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
|
||||
@endif
|
||||
|
||||
@if (filled($subtitle))
|
||||
<p class="mt-4 type-body-md text-on-surface-variant">{{ $subtitle }}</p>
|
||||
<p @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-sm:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p>
|
||||
@endif
|
||||
|
||||
@if ($separator)
|
||||
<x-divider class="mt-4" />
|
||||
<x-livewire-material::divider class="mt-4" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -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
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|password'), 0, 12);
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
<x-field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" x-data="{ shown: false }">
|
||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" x-data="{ shown: false }">
|
||||
<input
|
||||
{{ $attributes->except(['class', 'id', 'type', 'placeholder']) }}
|
||||
id="{{ $id }}"
|
||||
@@ -43,8 +45,8 @@
|
||||
class="field-trailing field-button"
|
||||
data-field-reveal
|
||||
>
|
||||
<x-icon name="visibility" class="size-(--field-icon)" x-show="! shown" />
|
||||
<x-icon name="visibility_off" class="size-(--field-icon)" x-show="shown" x-cloak />
|
||||
<x-livewire-material::icon name="visibility" class="size-(--field-icon)" x-show="! shown" />
|
||||
<x-livewire-material::icon name="visibility_off" class="size-(--field-icon)" x-show="shown" x-cloak />
|
||||
</button>
|
||||
</x-slot:trailing>
|
||||
</x-field>
|
||||
</x-livewire-material::field>
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$name = $attributes->get('name') ?? $model ?? 'radio-'.substr(md5($label.'|'.json_encode($options)), 0, 12);
|
||||
$id = 'radio-'.substr(md5($name.'|'.$label), 0, 12);
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
<fieldset
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
form M3 asks for when it has actions. The bubble is a popover in surface-container with a
|
||||
medium corner and elevation 2, 312px at most, placed by anchor positioning on `side`.
|
||||
|
||||
The bubble's id and anchor name are new with every render; its `wire:key` (see `<x-menu>`)
|
||||
lets a Livewire morph patch it in place, so an open bubble stays open — through its own
|
||||
action's `wire:click` too — and resources/js/rich-tooltip.js keeps showing and hiding the
|
||||
element on the page rather than one the morph took away.
|
||||
|
||||
RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
|
||||
text in on-surface-variant, label-large actions in primary. --}}
|
||||
|
||||
@@ -35,6 +40,7 @@
|
||||
|
||||
<span
|
||||
x-ref="bubble"
|
||||
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-rich-tooltip']) }}
|
||||
id="material-rich-tooltip-{{ $key }}"
|
||||
popover="{{ $persistent ? 'auto' : 'manual' }}"
|
||||
role="{{ $persistent ? 'dialog' : 'tooltip' }}"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
{{-- A choice of colour profile: one swatch per profile `material:scheme` generated from
|
||||
`livewire-material.profiles`, each its name and its primary, secondary and tertiary colour.
|
||||
|
||||
<x-scheme-picker label="Colour profile" wire:model="colorProfile" hint="Applies to every page after saving" />
|
||||
|
||||
Native radios under the swatches, so `wire:model` and `x-model` bind as on any input and the
|
||||
arrow keys move the choice. Choosing one shows it on the page at once
|
||||
(`$store.theme.previewScheme`); storing it — and telling `Scheme::resolveProfileUsing()` — is
|
||||
the application's. The dots are the only colours not drawn from tokens: they show other
|
||||
profiles than the page's, so they are custom properties set inline from the scheme file's
|
||||
checked hexes, light or dark with the page. Without profiles it renders nothing.
|
||||
|
||||
Props: `label`, `hint`, `name` (needed with `x-model`), `profiles` (default: every generated
|
||||
profile). A validation message for the bound property replaces the hint. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'hint' => null,
|
||||
'name' => null,
|
||||
'profiles' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$profiles ??= \NoNameWeb\LivewireMaterial\Support\Scheme::profiles();
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$name ??= $model ?: 'scheme';
|
||||
$errorKey = $model ?: (filled($attributes->get('name')) ? (string) $attributes->get('name') : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
@if ($profiles !== [])
|
||||
<fieldset x-data data-scheme-picker {{ $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except('name')->class('min-w-0') }}>
|
||||
@if (filled($label))
|
||||
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
@foreach ($profiles as $profile => $scheme)
|
||||
<label
|
||||
data-scheme-option="{{ $profile }}"
|
||||
class="state-layer flex min-w-0 cursor-pointer select-none flex-col items-start gap-2 rounded-corner-lg border border-outline-variant bg-surface-container-low p-3 text-on-surface transition-[background-color,border-color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary"
|
||||
>
|
||||
<input
|
||||
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
|
||||
type="radio"
|
||||
name="{{ $name }}"
|
||||
value="{{ $profile }}"
|
||||
x-on:change="$store.theme.previewScheme($event.target.value)"
|
||||
class="peer sr-only"
|
||||
/>
|
||||
|
||||
<span class="flex shrink-0 -space-x-1.5" aria-hidden="true">
|
||||
@foreach (['primary', 'secondary', 'tertiary'] as $role)
|
||||
<span
|
||||
style="--swatch-light: {{ $scheme['light'][$role] }}; --swatch-dark: {{ $scheme['dark'][$role] }}"
|
||||
class="size-5 rounded-full bg-(--swatch-light) ring-2 ring-surface-container-low in-has-checked:ring-secondary-container dark:bg-(--swatch-dark)"
|
||||
></span>
|
||||
@endforeach
|
||||
</span>
|
||||
|
||||
<span class="w-full truncate pe-6 type-label-lg">{{ __($scheme['label']) }}</span>
|
||||
|
||||
{{-- In the corner, so a long name keeps its room. The wrapper carries the position. --}}
|
||||
<span class="pointer-events-none absolute end-2 top-2 hidden peer-checked:block">
|
||||
<x-livewire-material::icon name="check" class="size-5" />
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($messages !== [])
|
||||
@foreach ($messages as $message)
|
||||
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
||||
@endforeach
|
||||
@elseif (filled($hint))
|
||||
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
|
||||
@endif
|
||||
</fieldset>
|
||||
@endif
|
||||
@@ -37,11 +37,11 @@
|
||||
>
|
||||
<div data-search-bar role="search" x-on:click="if ($event.target === $el) $refs.input.focus()">
|
||||
<span data-search-leading x-show="! fullScreen">
|
||||
<x-icon :name="$icon" />
|
||||
<x-livewire-material::icon :name="$icon" />
|
||||
</span>
|
||||
|
||||
<button type="button" data-search-leading data-search-back x-show="fullScreen" x-cloak x-on:click="close()" aria-label="{{ __('Back') }}">
|
||||
<x-icon name="arrow_back" />
|
||||
<x-livewire-material::icon name="arrow_back" />
|
||||
</button>
|
||||
|
||||
<input
|
||||
@@ -64,7 +64,7 @@
|
||||
/>
|
||||
|
||||
<button type="button" data-search-clear x-on:click="clear()" aria-label="{{ __('Clear') }}">
|
||||
<x-icon name="close" />
|
||||
<x-livewire-material::icon name="close" />
|
||||
</button>
|
||||
|
||||
@isset($trailing)
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
single destination in the app's own navigation.
|
||||
|
||||
`items`, a list of `['title' => …, 'url' => …]` with an optional `icon`, `active` and `badge`
|
||||
(an item is current when `active` is true, or when its `url` is the request's). From `sm` they
|
||||
(an item is current when `active` is true, or when its `url` is the page's). From `sm` they
|
||||
are M3's secondary tabs as links, the current one underlined; below `sm`, where a row of them
|
||||
never fits, a button naming the current section opens a menu of all of them. The same list is
|
||||
never fits, a button naming the current section opens a menu of all of them, the current one
|
||||
marked `aria-current="page"` and each with its badge. The same list is
|
||||
rendered for both, and CSS shows one.
|
||||
|
||||
The page's URL is `Livewire::originalUrl()`: while a Livewire component on the page updates, the
|
||||
request is Livewire's update endpoint, and comparing with it left no section lit.
|
||||
|
||||
A row too long for its column wraps onto a grid rather than scrolling: below `xl` five or six
|
||||
sections go 3 + 3 and seven or more go four to a row — tabs that scroll hid the last sections on
|
||||
a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless
|
||||
@@ -20,7 +24,8 @@
|
||||
|
||||
@php
|
||||
$label ??= __('Sections');
|
||||
$isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && url()->current() === url($item['url']));
|
||||
$page = \Livewire\Livewire::originalUrl();
|
||||
$isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && $page === url($item['url']));
|
||||
$current = collect($items)->first($isCurrent) ?? ($items[0] ?? null);
|
||||
|
||||
$layout = match (true) {
|
||||
@@ -33,21 +38,21 @@
|
||||
<div {{ $attributes->class(['min-w-0']) }} data-section-nav>
|
||||
@if ($current)
|
||||
<div class="sm:hidden" data-section-picker>
|
||||
<x-menu :$label position="bottom-start">
|
||||
<x-livewire-material::menu :$label position="bottom-start">
|
||||
<x-slot:trigger>
|
||||
<button type="button" class="focus-ring flex h-12 w-[min(20rem,calc(100vw-2rem))] cursor-pointer items-center gap-3 rounded-corner-xs border border-outline px-4 text-start type-body-lg text-on-surface">
|
||||
@isset($current['icon'])
|
||||
<x-icon :name="$current['icon']" class="size-5 text-on-surface-variant" />
|
||||
<x-livewire-material::icon :name="$current['icon']" class="size-5 text-on-surface-variant" />
|
||||
@endisset
|
||||
<span class="min-w-0 flex-1 truncate">{{ $current['title'] }}</span>
|
||||
<x-icon name="arrow_drop_down" class="size-6 text-on-surface-variant" />
|
||||
<x-livewire-material::icon name="arrow_drop_down" class="size-6 text-on-surface-variant" />
|
||||
</button>
|
||||
</x-slot:trigger>
|
||||
|
||||
@foreach ($items as $item)
|
||||
<x-menu-item :label="$item['title']" :icon="$item['icon'] ?? null" :link="$item['url']" :selected="$isCurrent($item)" :no-wire-navigate="$noWireNavigate" />
|
||||
<x-livewire-material::menu-item :label="$item['title']" :icon="$item['icon'] ?? null" :link="$item['url']" :current="$isCurrent($item)" :badge="$item['badge'] ?? null" :no-wire-navigate="$noWireNavigate" />
|
||||
@endforeach
|
||||
</x-menu>
|
||||
</x-livewire-material::menu>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -65,11 +70,11 @@
|
||||
>
|
||||
<span data-tab-content>
|
||||
@isset($item['icon'])
|
||||
<x-icon :name="$item['icon']" :filled="$on" class="size-5" />
|
||||
<x-livewire-material::icon :name="$item['icon']" :filled="$on" class="size-5" />
|
||||
@endisset
|
||||
<span class="truncate">{{ $item['title'] }}</span>
|
||||
@if (filled($item['badge'] ?? null))
|
||||
<x-badge :value="$item['badge']" />
|
||||
<x-livewire-material::badge :value="$item['badge']" />
|
||||
@endif
|
||||
<span data-tab-indicator aria-hidden="true"></span>
|
||||
</span>
|
||||
|
||||
@@ -29,10 +29,12 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|select'), 0, 12);
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
<x-field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant floated :class="$attributes->get('class')">
|
||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant floated :class="$attributes->get('class')">
|
||||
<select
|
||||
{{ $attributes->except(['class', 'id']) }}
|
||||
id="{{ $id }}"
|
||||
@@ -52,6 +54,6 @@
|
||||
</select>
|
||||
|
||||
<x-slot:trailing>
|
||||
<x-icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
|
||||
<x-livewire-material::icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
|
||||
</x-slot:trailing>
|
||||
</x-field>
|
||||
</x-livewire-material::field>
|
||||
|
||||
@@ -321,7 +321,7 @@
|
||||
@class(['absolute top-1/2 flex -translate-y-1/2 rtl:-scale-x-100', $iconInk[$color], 'group-has-disabled/slider:text-on-surface/38'])
|
||||
style="left: {{ $calc($iconAt) }}"
|
||||
>
|
||||
<x-icon :name="$icon" :class="$iconSize === 32 ? 'size-8' : 'size-6'" />
|
||||
<x-livewire-material::icon :name="$icon" :class="$iconSize === 32 ? 'size-8' : 'size-6'" />
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
])
|
||||
>
|
||||
{{ $slot }}
|
||||
<x-icon :name="$direction === 'desc' ? 'arrow_downward' : 'arrow_upward'" :class="\Illuminate\Support\Arr::toCssClasses([
|
||||
<x-livewire-material::icon :name="$direction === 'desc' ? 'arrow_downward' : 'arrow_upward'" :class="\Illuminate\Support\Arr::toCssClasses([
|
||||
'size-4 transition-opacity duration-(--md-sys-motion-effects-fast-duration)',
|
||||
'opacity-0 group-hover/sort:opacity-60 group-focus-visible/sort:opacity-60' => ! $active,
|
||||
])" />
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
@endphp
|
||||
|
||||
<div data-button-group="split" data-size="{{ $size }}" {{ $attributes->only('class')->class('inline-flex items-center gap-0.5') }}>
|
||||
<x-button
|
||||
<x-livewire-material::button
|
||||
{{ $attributes->except('class') }}
|
||||
:label="$label"
|
||||
:icon="$icon"
|
||||
@@ -51,9 +51,9 @@
|
||||
:class="$leadingPadding"
|
||||
/>
|
||||
|
||||
<x-menu :position="$position" :label="$menuLabel">
|
||||
<x-livewire-material::menu :position="$position" :label="$menuLabel">
|
||||
<x-slot:trigger>
|
||||
<x-button
|
||||
<x-livewire-material::button
|
||||
icon="keyboard_arrow_down"
|
||||
:aria-label="$menuLabel"
|
||||
:variant="$variant"
|
||||
@@ -67,5 +67,5 @@
|
||||
</x-slot:trigger>
|
||||
|
||||
{{ $slot }}
|
||||
</x-menu>
|
||||
</x-livewire-material::menu>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div {{ $attributes->class('flex flex-col gap-1 rounded-corner-lg bg-surface-container p-4') }}>
|
||||
<div class="flex items-center gap-2 type-label-lg text-on-surface-variant">
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" class="size-5" />
|
||||
<x-livewire-material::icon :name="$icon" class="size-5" />
|
||||
@endif
|
||||
<span>{{ $title }}</span>
|
||||
</div>
|
||||
|
||||
@@ -72,12 +72,12 @@
|
||||
>
|
||||
<span data-tab-content>
|
||||
@isset($tab['icon'])
|
||||
<x-icon :name="$tab['icon']" class="size-6" />
|
||||
<x-livewire-material::icon :name="$tab['icon']" class="size-6" />
|
||||
@endisset
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
{{ $tab['label'] }}
|
||||
@if (filled($tab['badge'] ?? null))
|
||||
<x-badge :value="$tab['badge']" />
|
||||
<x-livewire-material::badge :value="$tab['badge']" />
|
||||
@endif
|
||||
</span>
|
||||
<span data-tab-indicator aria-hidden="true"></span>
|
||||
|
||||
@@ -19,10 +19,12 @@
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|textarea'), 0, 12);
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
<x-field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
|
||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
|
||||
<textarea
|
||||
{{ $attributes->except(['class', 'id', 'placeholder']) }}
|
||||
id="{{ $id }}"
|
||||
@@ -36,4 +38,4 @@
|
||||
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
||||
class="field-control"
|
||||
></textarea>
|
||||
</x-field>
|
||||
</x-livewire-material::field>
|
||||
|
||||
@@ -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`):
|
||||
`light`, `dark` or `system`, falling back to `theme.default`. `system` follows the
|
||||
@@ -10,16 +11,58 @@
|
||||
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.
|
||||
|
||||
<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.
|
||||
|
||||
With `theme.meta` on, the browser's own chrome follows too: the `content` of every
|
||||
<meta name="theme-color"> without a `media` attribute — one is added to <head> when there is
|
||||
none — is the resolved theme's `surface`, from the scheme file (`Scheme`), for the profile in
|
||||
<html data-scheme> (else the active one). A MutationObserver on <html> keeps it in step with
|
||||
whatever changes `data-theme` or `data-scheme` afterwards: `$store.theme.set()` and `toggle()`,
|
||||
an OS change while `system`, a profile preview, the application's own script. A layout's own
|
||||
theme-color meta belongs before this script: one written after it is only painted on
|
||||
DOMContentLoaded, beside the one added here. Off by default, and then none of it is emitted.
|
||||
|
||||
wire:navigate swaps the body, merges the head without running this again, and gives <html>
|
||||
the next page's attributes — which the server rendered without any of these, so Livewire
|
||||
removes them. They are put back as the new page is swapped in (`onSwap`, in the same task,
|
||||
before anything paints), so this only has to run on a full load. The head merge also puts the
|
||||
next page's server-rendered theme-color meta in place of the painted one, so it is painted
|
||||
again there, and once more on `livewire:navigated`. --}}
|
||||
|
||||
@php
|
||||
$theme = config('livewire-material.theme');
|
||||
$rail = config('livewire-material.rail');
|
||||
|
||||
$settings = [
|
||||
'scheme' => \NoNameWeb\LivewireMaterial\Support\Scheme::profile(),
|
||||
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
|
||||
'key' => $theme['storage_key'] ?? 'material-theme',
|
||||
'legacy' => array_values($theme['legacy_keys'] ?? []),
|
||||
'rail' => [
|
||||
'default' => ($rail['default'] ?? null) === 'collapsed' ? 'collapsed' : 'expanded',
|
||||
'key' => $rail['storage_key'] ?? 'material-rail',
|
||||
],
|
||||
];
|
||||
|
||||
// Only the surfaces the meta can show: the active scheme's, and every profile's for a preview.
|
||||
if ((bool) ($theme['meta'] ?? false)) {
|
||||
$surfaces = fn (array $scheme): array => ['light' => $scheme['light']['surface'], 'dark' => $scheme['dark']['surface']];
|
||||
$schemeProfiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles();
|
||||
|
||||
$settings['meta'] = [
|
||||
// PHP 8.5 deprecates a null array offset: without profiles the scheme is null.
|
||||
...$surfaces(($settings['scheme'] !== null ? ($schemeProfiles[$settings['scheme']] ?? null) : null) ?? \NoNameWeb\LivewireMaterial\Support\Scheme::load()),
|
||||
'profiles' => (object) collect($schemeProfiles)->map($surfaces)->all(),
|
||||
];
|
||||
}
|
||||
@endphp
|
||||
|
||||
<script>
|
||||
@@ -28,8 +71,15 @@
|
||||
var media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; };
|
||||
var choice = settings.default;
|
||||
var rail = settings.rail.default;
|
||||
|
||||
try {
|
||||
var storedRail = localStorage.getItem(settings.rail.key);
|
||||
|
||||
if (storedRail === 'collapsed' || storedRail === 'expanded') {
|
||||
rail = storedRail;
|
||||
}
|
||||
|
||||
var stored = localStorage.getItem(settings.key);
|
||||
|
||||
if (valid(stored)) {
|
||||
@@ -56,10 +106,67 @@
|
||||
root.setAttribute('data-theme', current === 'system' ? (media.matches ? 'dark' : 'light') : current);
|
||||
};
|
||||
|
||||
if (settings.scheme) {
|
||||
root.setAttribute('data-scheme', settings.scheme);
|
||||
}
|
||||
|
||||
root.setAttribute('data-theme-key', settings.key);
|
||||
root.setAttribute('data-theme-choice', choice);
|
||||
root.setAttribute('data-rail-key', settings.rail.key);
|
||||
root.setAttribute('data-rail', rail);
|
||||
apply();
|
||||
|
||||
media.addEventListener('change', apply);
|
||||
@if (isset($settings['meta']))
|
||||
|
||||
var paintThemeColor = function () {
|
||||
var theme = root.getAttribute('data-theme');
|
||||
var scheme = root.getAttribute('data-scheme');
|
||||
|
||||
if ((theme !== 'light' && theme !== 'dark') || !document.head) {
|
||||
return;
|
||||
}
|
||||
|
||||
var colour = (Object.prototype.hasOwnProperty.call(settings.meta.profiles, scheme) ? settings.meta.profiles[scheme] : settings.meta)[theme];
|
||||
var metas = document.head.querySelectorAll('meta[name="theme-color"]:not([media])');
|
||||
|
||||
if (metas.length === 0) {
|
||||
var meta = document.createElement('meta');
|
||||
|
||||
meta.setAttribute('name', 'theme-color');
|
||||
document.head.appendChild(meta);
|
||||
metas = [meta];
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(metas, function (meta) {
|
||||
if (meta.getAttribute('content') !== colour) {
|
||||
meta.setAttribute('content', colour);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
paintThemeColor();
|
||||
new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme'] });
|
||||
document.addEventListener('DOMContentLoaded', paintThemeColor);
|
||||
document.addEventListener('livewire:navigated', paintThemeColor);
|
||||
@endif
|
||||
|
||||
document.addEventListener('livewire:navigating', function (event) {
|
||||
var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) {
|
||||
return [name, root.getAttribute(name)];
|
||||
});
|
||||
|
||||
event.detail.onSwap(function () {
|
||||
kept.forEach(function (attribute) {
|
||||
if (attribute[1] !== null) {
|
||||
root.setAttribute(attribute[0], attribute[1]);
|
||||
}
|
||||
});
|
||||
@if (isset($settings['meta']))
|
||||
|
||||
paintThemeColor();
|
||||
@endif
|
||||
});
|
||||
});
|
||||
})(@json($settings));
|
||||
</script>
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
data-theme-option="{{ $choice }}"
|
||||
class="state-layer focus-ring inline-flex min-w-0 flex-1 cursor-pointer items-center justify-center gap-2 border-outline px-3 type-label-lg text-on-surface not-first:border-s first:rounded-s-corner-full last:rounded-e-corner-full aria-checked:bg-secondary-container aria-checked:text-on-secondary-container"
|
||||
>
|
||||
<x-icon name="check" class="hidden size-4.5 in-aria-checked:block" />
|
||||
<x-icon :name="$icon" class="size-4.5 in-aria-checked:hidden" />
|
||||
<x-livewire-material::icon name="check" class="hidden size-4.5 in-aria-checked:block" />
|
||||
<x-livewire-material::icon :name="$icon" class="size-4.5 in-aria-checked:hidden" />
|
||||
{{ __($text) }}
|
||||
</button>
|
||||
@endforeach
|
||||
@@ -65,12 +65,12 @@
|
||||
{{ $attributes->class(['state-layer focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant transition-[border-radius] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast active:rounded-corner-sm']) }}
|
||||
>
|
||||
@if ($mode === 'cycle')
|
||||
<x-icon name="light_mode" x-cloak x-show="$store.theme.choice === 'light'" />
|
||||
<x-icon name="dark_mode" x-cloak x-show="$store.theme.choice === 'dark'" />
|
||||
<x-icon name="brightness_auto" x-cloak x-show="$store.theme.choice === 'system'" />
|
||||
<x-livewire-material::icon name="light_mode" x-cloak x-show="$store.theme.choice === 'light'" />
|
||||
<x-livewire-material::icon name="dark_mode" x-cloak x-show="$store.theme.choice === 'dark'" />
|
||||
<x-livewire-material::icon name="brightness_auto" x-cloak x-show="$store.theme.choice === 'system'" />
|
||||
@else
|
||||
<x-icon name="light_mode" x-cloak x-show="$store.theme.resolved === 'dark'" />
|
||||
<x-icon name="dark_mode" x-cloak x-show="$store.theme.resolved !== 'dark'" />
|
||||
<x-livewire-material::icon name="light_mode" x-cloak x-show="$store.theme.resolved === 'dark'" />
|
||||
<x-livewire-material::icon name="dark_mode" x-cloak x-show="$store.theme.resolved !== 'dark'" />
|
||||
@endif
|
||||
</button>
|
||||
@endif
|
||||
|
||||
@@ -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 (12–23, at 69dp;
|
||||
00–11 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 1–12'),
|
||||
'hourError24' => __('Hour must be 0–23'),
|
||||
'minuteError' => __('Minute must be 0–59'),
|
||||
'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,9 +5,18 @@
|
||||
|
||||
It shows every `toast` browser event — what `NoNameWeb\LivewireMaterial\Concerns\Toasts`
|
||||
dispatches from a Livewire component — and for `window.materialToast(title, options)` from
|
||||
JavaScript (`{ type, description, timeout, action: { label, handler } }`). Toasts queue and
|
||||
show in turn, each for its `timeout` (4s by default; M3 asks for 4–10s), paused while the
|
||||
pointer or focus is on it. A toast with an action or no timeout gets a close button.
|
||||
JavaScript (`{ type, description, timeout, sticky, action: { label, handler, event } }`).
|
||||
Toasts queue and show in turn, each for its `timeout` (4s by default; M3 asks for 4–10s),
|
||||
paused while the pointer or focus is on it. A toast with an action or no timeout gets a close
|
||||
button. Pressing the action closes the snackbar, calls `handler` and dispatches `event` (a
|
||||
name) on `window`; both may be given.
|
||||
|
||||
`sticky: true` keeps a toast until it is dismissed or its action pressed, without holding up
|
||||
the queue: a toast that arrives meanwhile shows in its place, and the sticky one comes back
|
||||
once the queue is empty. One is kept at a time; a newer sticky toast replaces it.
|
||||
|
||||
Hooks for tests and styling: `data-toast` on the snackbar on screen, `data-toast-action` on its
|
||||
action button.
|
||||
|
||||
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
|
||||
still on screen when the next page arrives.
|
||||
@@ -32,6 +41,7 @@
|
||||
<template x-if="current">
|
||||
<div
|
||||
x-bind:key="current.id"
|
||||
data-toast
|
||||
x-bind:role="current.type === 'error' || current.type === 'warning' ? 'alert' : 'status'"
|
||||
aria-live="polite"
|
||||
x-on:mouseenter="pause()"
|
||||
@@ -47,10 +57,10 @@
|
||||
'text-inverse-warning': current.type === 'warning',
|
||||
'text-inverse-info': current.type === 'info',
|
||||
}">
|
||||
<span x-show="current.type === 'success'"><x-icon name="check_circle" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'error'"><x-icon name="error" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'warning'"><x-icon name="warning" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'info'"><x-icon name="info" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'success'"><x-livewire-material::icon name="check_circle" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'error'"><x-livewire-material::icon name="error" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'warning'"><x-livewire-material::icon name="warning" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'info'"><x-livewire-material::icon name="info" filled class="size-6" /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -60,12 +70,12 @@
|
||||
</div>
|
||||
|
||||
<template x-if="current.action">
|
||||
<button type="button" class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
|
||||
<button type="button" data-toast-action class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
|
||||
</template>
|
||||
|
||||
<template x-if="current.action || ! current.timeout">
|
||||
<button type="button" class="state-layer focus-ring inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="dismiss()">
|
||||
<x-icon name="close" class="size-5" />
|
||||
<x-livewire-material::icon name="close" class="size-5" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
/>
|
||||
<span data-handle>
|
||||
@if ($icons)
|
||||
<x-icon name="check" class="size-4" data-on />
|
||||
<x-icon name="close" class="size-4" data-off />
|
||||
<x-livewire-material::icon name="check" class="size-4" data-on />
|
||||
<x-livewire-material::icon name="close" class="size-4" data-off />
|
||||
@endif
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -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 don’t have access'))
|
||||
@section('message', isset($exception) && $exception->getMessage() !== '' ? __($exception->getMessage()) : __('Your account isn’t 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 you’re looking for doesn’t 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', __('We’ll be right back'))
|
||||
@section('message', $reason !== '' && $reason !== 'Service Unavailable' ? __($reason) : __('We’re 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>
|
||||
@@ -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())
|
||||
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-pagination class="flex items-center justify-between gap-4">
|
||||
@if ($paginator->onFirstPage())
|
||||
<x-button variant="outlined" icon="chevron_left" :label="__('Previous')" disabled />
|
||||
<x-livewire-material::button variant="outlined" icon="chevron_left" :label="__('Previous')" disabled />
|
||||
@else
|
||||
<x-button variant="outlined" icon="chevron_left" :label="__('Previous')" :link="$paginator->previousPageUrl()" no-wire-navigate rel="prev" />
|
||||
<x-livewire-material::button variant="outlined" icon="chevron_left" :label="__('Previous')" :link="$paginator->previousPageUrl()" no-wire-navigate rel="prev" />
|
||||
@endif
|
||||
|
||||
@if ($paginator->hasMorePages())
|
||||
<x-button variant="outlined" icon-right="chevron_right" :label="__('Next')" :link="$paginator->nextPageUrl()" no-wire-navigate rel="next" />
|
||||
<x-livewire-material::button variant="outlined" icon-right="chevron_right" :label="__('Next')" :link="$paginator->nextPageUrl()" no-wire-navigate rel="next" />
|
||||
@else
|
||||
<x-button variant="outlined" icon-right="chevron_right" :label="__('Next')" disabled />
|
||||
<x-livewire-material::button variant="outlined" icon-right="chevron_right" :label="__('Next')" disabled />
|
||||
@endif
|
||||
</nav>
|
||||
@endif
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
<div class="flex items-center gap-1">
|
||||
@if ($paginator->onFirstPage())
|
||||
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Previous') }}">
|
||||
<x-icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
|
||||
<x-livewire-material::icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
|
||||
</span>
|
||||
@else
|
||||
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" class="{{ $live }}" aria-label="{{ __('Previous') }}">
|
||||
<x-icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
|
||||
<x-livewire-material::icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@@ -45,11 +45,11 @@
|
||||
|
||||
@if ($paginator->hasMorePages())
|
||||
<a href="{{ $paginator->nextPageUrl() }}" rel="next" class="{{ $live }}" aria-label="{{ __('Next') }}">
|
||||
<x-icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
|
||||
<x-livewire-material::icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
|
||||
</a>
|
||||
@else
|
||||
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Next') }}">
|
||||
<x-icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
|
||||
<x-livewire-material::icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -19,21 +19,21 @@
|
||||
@if ($paginator->hasPages())
|
||||
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" data-pagination class="flex items-center justify-between gap-4">
|
||||
@if ($paginator->onFirstPage())
|
||||
<x-button variant="outlined" icon="chevron_left" :label="__('Previous')" disabled />
|
||||
<x-livewire-material::button variant="outlined" icon="chevron_left" :label="__('Previous')" disabled />
|
||||
@elseif ($cursor)
|
||||
@php($previousCursor = $paginator->previousCursor() ?? $paginator->cursor())
|
||||
<x-button variant="outlined" icon="chevron_left" :label="__('Previous')" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $previousCursor?->encode() }}" wire:click="setPage('{{ $previousCursor?->encode() }}', '{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage" />
|
||||
<x-livewire-material::button variant="outlined" icon="chevron_left" :label="__('Previous')" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $previousCursor?->encode() }}" wire:click="setPage('{{ $previousCursor?->encode() }}', '{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage" />
|
||||
@else
|
||||
<x-button variant="outlined" icon="chevron_left" :label="__('Previous')" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage" />
|
||||
<x-livewire-material::button variant="outlined" icon="chevron_left" :label="__('Previous')" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage" />
|
||||
@endif
|
||||
|
||||
@if (! $paginator->hasMorePages())
|
||||
<x-button variant="outlined" icon-right="chevron_right" :label="__('Next')" disabled />
|
||||
<x-livewire-material::button variant="outlined" icon-right="chevron_right" :label="__('Next')" disabled />
|
||||
@elseif ($cursor)
|
||||
@php($nextCursor = $paginator->nextCursor() ?? $paginator->cursor())
|
||||
<x-button variant="outlined" icon-right="chevron_right" :label="__('Next')" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $nextCursor?->encode() }}" wire:click="setPage('{{ $nextCursor?->encode() }}', '{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage" />
|
||||
<x-livewire-material::button variant="outlined" icon-right="chevron_right" :label="__('Next')" wire:key="cursor-{{ $paginator->getCursorName() }}-{{ $nextCursor?->encode() }}" wire:click="setPage('{{ $nextCursor?->encode() }}', '{{ $paginator->getCursorName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage" />
|
||||
@else
|
||||
<x-button variant="outlined" icon-right="chevron_right" :label="__('Next')" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage" />
|
||||
<x-livewire-material::button variant="outlined" icon-right="chevron_right" :label="__('Next')" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage" />
|
||||
@endif
|
||||
</nav>
|
||||
@endif
|
||||
|
||||
@@ -35,11 +35,11 @@
|
||||
<div class="flex items-center gap-1">
|
||||
@if ($paginator->onFirstPage())
|
||||
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Previous') }}">
|
||||
<x-icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
|
||||
<x-livewire-material::icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
|
||||
</span>
|
||||
@else
|
||||
<button type="button" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.'.$paginator->getPageName() }}" class="{{ $live }}" aria-label="{{ __('Previous') }}">
|
||||
<x-icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
|
||||
<x-livewire-material::icon name="chevron_left" class="size-6 rtl:-scale-x-100" />
|
||||
</button>
|
||||
@endif
|
||||
|
||||
@@ -63,11 +63,11 @@
|
||||
|
||||
@if ($paginator->hasMorePages())
|
||||
<button type="button" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.'.$paginator->getPageName() }}" class="{{ $live }}" aria-label="{{ __('Next') }}">
|
||||
<x-icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
|
||||
<x-livewire-material::icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
|
||||
</button>
|
||||
@else
|
||||
<span class="{{ $dead }}" aria-disabled="true" aria-label="{{ __('Next') }}">
|
||||
<x-icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
|
||||
<x-livewire-material::icon name="chevron_right" class="size-6 rtl:-scale-x-100" />
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,22 @@
|
||||
|
||||
@props(['title' => null, 'code', 'stack' => false])
|
||||
|
||||
<div class="space-y-4 rounded-corner-lg bg-surface-container p-4">
|
||||
@php
|
||||
// Written as an application without a prefix writes it; under a configured prefix the tags are
|
||||
// rewritten to it, so what renders and what is shown is what this application would write.
|
||||
$prefix = config('livewire-material.prefix');
|
||||
|
||||
if (filled($prefix)) {
|
||||
$names = collect(glob(\NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider::componentPath().'/*.blade.php'))
|
||||
->map(fn (string $path): string => preg_quote(basename($path, '.blade.php'), '/'))
|
||||
->sortByDesc(fn (string $name): int => strlen($name))
|
||||
->implode('|');
|
||||
|
||||
$code = preg_replace('/<(\/?)x-('.$names.')(?=[\s\/>])/', '<$1x-'.$prefix.'::$2', $code);
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div @if ($title) id="{{ \NoNameWeb\LivewireMaterial\Showcase\Sections::anchor($title) }}" @endif class="scroll-mt-24 space-y-4 rounded-corner-lg bg-surface-container p-4">
|
||||
@if ($title)
|
||||
<h3 class="type-title-md">{{ $title }}</h3>
|
||||
@endif
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
@extends('livewire-material::showcase.layout')
|
||||
|
||||
@section('content')
|
||||
<main class="mx-auto max-w-6xl space-y-16 px-4 py-10">
|
||||
<p class="max-w-3xl type-body-lg text-on-surface-variant">
|
||||
Every token and component, in this application's own scheme. Components appear here as they land.
|
||||
</p>
|
||||
<div class="mx-auto w-full max-w-6xl space-y-12 px-4 pt-4 pb-16 sm:px-6">
|
||||
<div class="max-w-3xl space-y-3">
|
||||
<h1 class="type-headline-lg">Livewire Material</h1>
|
||||
<p class="type-title-lg">Material 3 Expressive for Laravel and Livewire.</p>
|
||||
<p class="type-body-lg text-on-surface-variant">
|
||||
Every token and component, rendered in this application's own scheme and theme. Pick a section in the
|
||||
navigation, search for one above (or press <kbd class="rounded-corner-xs bg-surface-container-highest px-1.5 type-label-md">/</kbd>), or start below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@include('livewire-material::showcase.sections.colour')
|
||||
@include('livewire-material::showcase.sections.type')
|
||||
@include('livewire-material::showcase.sections.shape')
|
||||
@include('livewire-material::showcase.sections.elevation')
|
||||
@include('livewire-material::showcase.sections.motion')
|
||||
@include('livewire-material::showcase.sections.icons')
|
||||
@include('livewire-material::showcase.sections.buttons')
|
||||
@include('livewire-material::showcase.sections.menus')
|
||||
@include('livewire-material::showcase.sections.communication')
|
||||
@include('livewire-material::showcase.sections.progress')
|
||||
@include('livewire-material::showcase.sections.containment')
|
||||
@include('livewire-material::showcase.sections.carousel')
|
||||
@include('livewire-material::showcase.sections.fields')
|
||||
@include('livewire-material::showcase.sections.chips')
|
||||
@include('livewire-material::showcase.sections.sliders')
|
||||
@include('livewire-material::showcase.sections.bars')
|
||||
@include('livewire-material::showcase.sections.data')
|
||||
</main>
|
||||
@foreach (collect($sections)->groupBy('group', preserveKeys: true) as $group => $entries)
|
||||
<section class="space-y-4" aria-labelledby="group-{{ \Illuminate\Support\Str::slug($group) }}">
|
||||
<h2 id="group-{{ \Illuminate\Support\Str::slug($group) }}" class="type-title-lg">{{ $group }}</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($entries as $key => $entry)
|
||||
<x-livewire-material::card variant="outlined" data-list-row wire:key="section-{{ $key }}">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="grid size-12 shrink-0 place-items-center rounded-corner-lg bg-secondary-container text-on-secondary-container">
|
||||
<x-livewire-material::icon :name="$entry['icon']" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<a href="{{ route('livewire-material.section', $key) }}" wire:navigate data-list-open class="type-title-md">{{ $entry['title'] }}</a>
|
||||
<p class="mt-1 type-body-md text-on-surface-variant">{{ $entry['description'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-livewire-material::card>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endforeach
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -1,47 +1,179 @@
|
||||
{{-- The showcase's frame, and the package's own app shell at work: the rail groups every section,
|
||||
collapses and expands from `lg`, and opens as a modal from the app bar's menu button on a phone.
|
||||
Pages move with wire:navigate, so the rail keeps its place and the theme stays. --}}
|
||||
|
||||
@php
|
||||
$sections ??= \NoNameWeb\LivewireMaterial\Showcase\Sections::all();
|
||||
$section ??= null;
|
||||
$title = $section !== null ? $sections[$section]['title'] : 'Livewire Material';
|
||||
|
||||
$destinations = [[
|
||||
'title' => 'Overview',
|
||||
'icon' => 'home',
|
||||
'url' => route('livewire-material.showcase'),
|
||||
'active' => $section === null,
|
||||
'bar' => false,
|
||||
]];
|
||||
|
||||
foreach ($sections as $key => $entry) {
|
||||
$destinations[] = [
|
||||
'title' => $entry['title'],
|
||||
'icon' => $entry['icon'],
|
||||
'url' => route('livewire-material.section', $key),
|
||||
'active' => $key === $section,
|
||||
'section' => $entry['group'],
|
||||
'bar' => false,
|
||||
];
|
||||
}
|
||||
@endphp
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex" />
|
||||
|
||||
<title>@yield('title', 'Livewire Material')</title>
|
||||
<title>{{ $section !== null ? $title.' · Livewire Material' : 'Livewire Material' }}</title>
|
||||
|
||||
<x-theme-script />
|
||||
<x-livewire-material::theme-script />
|
||||
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="min-h-screen bg-surface font-sans text-on-surface antialiased">
|
||||
<header class="sticky top-0 z-10 border-b border-divider bg-surface-container">
|
||||
<div class="mx-auto flex max-w-6xl items-center gap-x-6 px-4 py-3">
|
||||
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
|
||||
<body class="bg-surface font-sans text-on-surface antialiased">
|
||||
<x-livewire-material::app-shell :destinations="$destinations" label="Showcase" rail-width="17rem">
|
||||
<x-slot:brand>
|
||||
<a href="{{ route('livewire-material.showcase') }}" wire:navigate class="block truncate rounded-corner-xs type-title-lg focus-ring">Livewire Material</a>
|
||||
</x-slot:brand>
|
||||
|
||||
<nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections">
|
||||
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'bars' => 'Bars', 'data' => 'Data'] as $anchor => $section)
|
||||
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
<x-slot:top>
|
||||
<x-livewire-material::app-bar variant="search">
|
||||
<x-slot:navigation>
|
||||
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="showcase-menu" /></span>
|
||||
</x-slot:navigation>
|
||||
|
||||
<div class="ms-auto flex shrink-0 rounded-corner-full border border-outline" role="group" aria-label="Theme" x-data x-cloak>
|
||||
@foreach (['light' => 'Light', 'dark' => 'Dark', 'system' => 'System'] as $choice => $label)
|
||||
<button
|
||||
type="button"
|
||||
data-test="theme-{{ $choice }}"
|
||||
class="state-layer focus-ring px-4 py-1.5 type-label-lg first:rounded-s-corner-full last:rounded-e-corner-full"
|
||||
x-on:click="$store.theme.set('{{ $choice }}')"
|
||||
x-bind:class="$store.theme.choice === '{{ $choice }}' && 'bg-secondary-container text-on-secondary-container'"
|
||||
x-bind:aria-pressed="($store.theme.choice === '{{ $choice }}').toString()"
|
||||
>{{ $label }}</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{{-- The search looks through every section, example and component: the index is
|
||||
fetched on first focus, and / or Ctrl+K (⌘K) reaches it from anywhere. Its names
|
||||
keep clear of <x-search>'s own (`results`, `open`), which the slot also sees. --}}
|
||||
<div
|
||||
class="mx-auto w-full max-w-2xl"
|
||||
x-data="{
|
||||
query: '',
|
||||
entries: null,
|
||||
async load() {
|
||||
this.entries ??= await (await fetch(@js(route('livewire-material.search', [], false)))).json();
|
||||
},
|
||||
get matches() {
|
||||
const words = this.query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (! this.entries || words.length === 0) return [];
|
||||
const phrase = words.join(' ');
|
||||
return this.entries
|
||||
.map((entry) => {
|
||||
const title = entry.title.toLowerCase().replace(/[<>]/g, '');
|
||||
const text = `${title} ${entry.context} ${entry.kind} ${entry.keywords}`.toLowerCase();
|
||||
if (! words.every((word) => text.includes(word))) return null;
|
||||
const rank = title === phrase ? 0 : title.startsWith(phrase) ? 1 : title.includes(phrase) ? 2 : 3;
|
||||
return { ...entry, rank };
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
.slice(0, 30);
|
||||
},
|
||||
go(event, result) {
|
||||
if (! result || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || (event.button ?? 0) !== 0) return;
|
||||
event.preventDefault();
|
||||
const url = new URL(result.url, window.location.href);
|
||||
const search = this.$root.querySelector('[data-search]');
|
||||
this.query = '';
|
||||
if (search) window.Alpine.$data(search).close();
|
||||
if (url.pathname === window.location.pathname) {
|
||||
window.location.hash = url.hash;
|
||||
document.getElementById(url.hash.slice(1))?.scrollIntoView({ block: 'start' });
|
||||
} else {
|
||||
window.Livewire.navigate(url.pathname + url.hash);
|
||||
}
|
||||
},
|
||||
shortcut(event) {
|
||||
const typing = event.target.closest('input, textarea, select, [contenteditable]');
|
||||
if ((event.key === '/' && ! typing) || (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey))) {
|
||||
event.preventDefault();
|
||||
document.getElementById('showcase-search').focus();
|
||||
}
|
||||
},
|
||||
}"
|
||||
x-on:keydown.window="shortcut($event)"
|
||||
>
|
||||
<x-livewire-material::search
|
||||
id="showcase-search"
|
||||
placeholder="Search components, examples and sections"
|
||||
x-model="query"
|
||||
x-on:focus.once="load()"
|
||||
x-on:keydown.enter.prevent="go($event, matches[0])"
|
||||
>
|
||||
<p x-show="query.trim() === ''" class="px-4 py-3 type-body-md text-on-surface-variant">
|
||||
Type a component (<code>datepicker</code>), an example or a section. Press <kbd class="rounded-corner-xs bg-surface-container-highest px-1.5 type-label-md">/</kbd> to search from anywhere.
|
||||
</p>
|
||||
<p x-cloak x-show="query.trim() !== '' && entries !== null && matches.length === 0" class="px-4 py-3 type-body-md text-on-surface-variant">
|
||||
Nothing matches “<span x-text="query.trim()"></span>”.
|
||||
</p>
|
||||
<template x-for="result in matches" x-bind:key="result.url + result.title">
|
||||
<a
|
||||
x-bind:href="result.url"
|
||||
x-on:click="go($event, result)"
|
||||
class="state-layer focus-ring flex min-h-14 flex-col justify-center px-4 py-2 outline-none"
|
||||
data-showcase-result
|
||||
>
|
||||
<span class="truncate type-body-lg text-on-surface" x-text="result.title"></span>
|
||||
<span class="truncate type-body-sm text-on-surface-variant" x-text="`${result.kind} · ${result.context}`"></span>
|
||||
</a>
|
||||
</template>
|
||||
</x-livewire-material::search>
|
||||
</div>
|
||||
|
||||
@yield('content')
|
||||
<x-slot:actions>
|
||||
{{-- With colour profiles, a preview of each on this page; nothing is stored. --}}
|
||||
@if (($profiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles()) !== [])
|
||||
<x-livewire-material::menu label="Colour profile" position="bottom-end" data-test="showcase-profiles">
|
||||
<x-slot:trigger>
|
||||
<x-livewire-material::button icon="palette" tooltip="Colour profile" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-toast />
|
||||
@foreach ($profiles as $name => $profile)
|
||||
<x-livewire-material::menu-item :label="$profile['label']" x-on:click="$store.theme.previewScheme('{{ $name }}')" data-scheme-preview="{{ $name }}" />
|
||||
@endforeach
|
||||
</x-livewire-material::menu>
|
||||
@endif
|
||||
|
||||
<span class="ms-2 me-3 max-md:hidden"><x-livewire-material::theme-toggle mode="picker" /></span>
|
||||
<span class="md:hidden"><x-livewire-material::theme-toggle mode="cycle" /></span>
|
||||
</x-slot:actions>
|
||||
</x-livewire-material::app-bar>
|
||||
</x-slot:top>
|
||||
|
||||
@yield('content')
|
||||
</x-livewire-material::app-shell>
|
||||
|
||||
@livewireScripts
|
||||
|
||||
{{-- wire:navigate keeps the URL's hash but not the scroll to it: a search result that opens an
|
||||
example on another page lands on the example. --}}
|
||||
<script data-navigate-once>
|
||||
document.addEventListener('livewire:navigated', () => {
|
||||
const target = () => (window.location.hash.length > 1 ? document.getElementById(decodeURIComponent(window.location.hash.slice(1))) : null);
|
||||
const land = () => target()?.scrollIntoView({ block: 'start' });
|
||||
|
||||
// After the swap's own scroll to the top and the page transition, and once more when
|
||||
// the page's components have settled their size.
|
||||
requestAnimationFrame(() => requestAnimationFrame(land));
|
||||
setTimeout(() => {
|
||||
const box = target()?.getBoundingClientRect();
|
||||
|
||||
if (box && (box.top < 0 || box.top > window.innerHeight / 2)) {
|
||||
land();
|
||||
}
|
||||
}, 400);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,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-group>
|
||||
BLADE,
|
||||
'A choice as a connected group (<x-group>)' => <<<'BLADE'
|
||||
'A choice as a connected group' => <<<'BLADE'
|
||||
<div x-data="{ theme: 'system', days: ['mon'] }" class="grid w-full gap-6 md:grid-cols-2">
|
||||
<x-group label="Theme" name="showcase-theme" x-model="theme" :options="[
|
||||
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
|
||||
@@ -84,7 +84,7 @@
|
||||
['id' => 'system', 'name' => 'System', 'icon' => 'computer'],
|
||||
]" />
|
||||
|
||||
<x-group label="Days" name="showcase-days" x-model="days" multiple variant="outlined" hint="Choose any" :options="[
|
||||
<x-group label="Days" name="showcase-days" x-model="days" multiple variant="outlined" hint="Thursday is fully booked" hint-class="text-warning" :options="[
|
||||
['id' => 'mon', 'name' => 'Mon'],
|
||||
['id' => 'tue', 'name' => 'Tue'],
|
||||
['id' => 'wed', 'name' => 'Wed'],
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
'Surface' => ['bg-surface', 'bg-surface-dim', 'bg-surface-bright', 'bg-surface-container-lowest', 'bg-surface-container-low', 'bg-surface-container', 'bg-surface-container-high', 'bg-surface-container-highest', 'bg-on-surface', 'bg-on-surface-variant', 'bg-inverse-surface', 'bg-inverse-on-surface', 'bg-outline', 'bg-outline-variant'],
|
||||
'Ink and lines' => ['bg-body', 'bg-meta', 'bg-quiet', 'bg-structure', 'bg-chrome', 'bg-divider'],
|
||||
];
|
||||
|
||||
$examples = [
|
||||
'Colour profiles' => <<<'BLADE'
|
||||
<div x-data="{ profile: $store.theme.scheme }" class="w-full max-w-3xl">
|
||||
<x-scheme-picker label="Colour profile" name="profile" x-model="profile" hint="Previews on this page; an application stores the choice and names it with Scheme::resolveProfileUsing()." />
|
||||
</div>
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section id="colour" class="scroll-mt-24 space-y-6">
|
||||
@@ -20,6 +28,16 @@
|
||||
<code>php artisan material:scheme</code>. Both themes side by side, whatever the page is showing.
|
||||
</p>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
With colour profiles in <code>livewire-material.profiles</code>, the command generates each one under
|
||||
<code><html data-scheme></code>, and <code><x-scheme-picker></code> chooses between them. Without
|
||||
profiles the picker draws nothing.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
<x-showcase::example :$title :$code />
|
||||
@endforeach
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
@foreach (['light', 'dark'] as $theme)
|
||||
<div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface">
|
||||
|
||||
@@ -7,13 +7,29 @@
|
||||
<x-badge value="Expired" tonal />
|
||||
<x-badge value="Active" color="success" tonal />
|
||||
<x-badge value="Password" color="info" tonal />
|
||||
<x-badge value="Built in" color="primary" solid />
|
||||
<x-badge value="Pro" outline />
|
||||
<x-badge value="Draft" color="neutral" tonal />
|
||||
<x-badge value="Archived" color="neutral" outline />
|
||||
<span class="relative inline-flex"><x-icon name="chat" /><x-badge value="2" color="neutral" floating /></span>
|
||||
<x-badge tonal color="tertiary"><x-icon name="bolt" filled class="size-3" /> Pro</x-badge>
|
||||
<x-badge value="Beta" tonal color="plain" class="bg-primary-fixed text-on-primary-fixed" />
|
||||
BLADE,
|
||||
'Snackbars' => <<<'BLADE'
|
||||
<x-button label="Saved" variant="tonal" x-on:click="materialToast('Settings saved', { type: 'success' })" />
|
||||
<x-button label="With a description" variant="tonal" x-on:click="materialToast('Upload failed', { type: 'error', description: 'The file is larger than 4 GB.' })" />
|
||||
<x-button label="With an action" variant="tonal" x-on:click="materialToast('Share deleted', { action: { label: 'Undo', handler: () => materialToast('Share restored', { type: 'info' }) } })" />
|
||||
<x-button label="Until dismissed" variant="tonal" x-on:click="materialToast('Your storage is almost full', { type: 'warning', timeout: 0 })" />
|
||||
<div x-data class="flex flex-wrap items-center gap-4">
|
||||
<x-button label="Saved" variant="tonal" x-on:click="materialToast('Settings saved', { type: 'success' })" />
|
||||
<x-button label="With a description" variant="tonal" x-on:click="materialToast('Upload failed', { type: 'error', description: 'The file is larger than 4 GB.' })" />
|
||||
<x-button label="With an action" variant="tonal" x-on:click="materialToast('Share deleted', { action: { label: 'Undo', handler: () => materialToast('Share restored', { type: 'info' }) } })" />
|
||||
<x-button label="Until dismissed" variant="tonal" x-on:click="materialToast('Your storage is almost full', { type: 'warning', timeout: 0 })" />
|
||||
<x-button label="Sticky, with an event" variant="tonal" x-on:click="materialToast('A new version is ready', { type: 'info', sticky: true, action: { label: 'Reload', event: 'showcase:reload' } })" x-on:showcase:reload.window="materialToast('Reloading…')" />
|
||||
</div>
|
||||
BLADE,
|
||||
'Plain tooltips' => <<<'BLADE'
|
||||
<x-button icon="content_copy" tooltip="Copy link" />
|
||||
<x-button icon="qr_code_2" tooltip-bottom="Show QR code" />
|
||||
<x-tooltip text="Expires in 3 days" side="right">
|
||||
<span tabindex="0" class="focus-ring rounded-corner-xs type-body-md text-on-surface-variant">holiday-photos.zip</span>
|
||||
</x-tooltip>
|
||||
BLADE,
|
||||
'Rich tooltips' => <<<'BLADE'
|
||||
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time. Admins can change the longest time allowed.">
|
||||
@@ -51,6 +67,22 @@
|
||||
</x-slot:actions>
|
||||
</x-empty-state>
|
||||
BLADE,
|
||||
'Empty state with an illustration' => <<<'BLADE'
|
||||
<x-empty-state title="No routes yet" description="Draw a route on the map, or import one from a GPX file." class="w-full">
|
||||
<x-slot:illustration class="text-primary">
|
||||
<svg class="size-32" viewBox="0 0 120 120" fill="none" aria-hidden="true">
|
||||
<circle cx="60" cy="60" r="56" class="fill-primary-container" />
|
||||
<path d="M28 86C40 62 56 92 68 64S86 42 90 46" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-dasharray="1 10" />
|
||||
<circle cx="28" cy="86" r="7" fill="currentColor" />
|
||||
<path d="M90 18a12 12 0 0 1 12 12c0 10-12 22-12 22S78 40 78 30a12 12 0 0 1 12-12Z" class="fill-tertiary" />
|
||||
<circle cx="90" cy="30" r="4" class="fill-on-tertiary" />
|
||||
</svg>
|
||||
</x-slot:illustration>
|
||||
<x-slot:actions>
|
||||
<x-button label="Draw a route" icon="route" variant="filled" />
|
||||
</x-slot:actions>
|
||||
</x-empty-state>
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
@@ -59,7 +91,7 @@
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><x-badge></code>, <code><x-toast></code> (the snackbar host, fed by the <code>Toasts</code> concern or <code>materialToast()</code>),
|
||||
<code><x-rich-tooltip></code>, <code><x-alert></code>, <code><x-stat></code> and <code><x-empty-state></code>.
|
||||
<code><x-tooltip></code>, <code><x-rich-tooltip></code>, <code><x-alert></code>, <code><x-stat></code> and <code><x-empty-state></code>.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user