Compare commits
16
Commits
83ed671c4e
...
1.1.0
@@ -115,3 +115,5 @@ jobs:
|
||||
|
||||
- name: Run browser tests
|
||||
run: vendor/bin/pest --testsuite=Browser --browser ${{ matrix.browser }}
|
||||
env:
|
||||
BROWSER_TIMEOUT: 15000
|
||||
|
||||
@@ -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,169 @@
|
||||
# Livewire Material
|
||||
|
||||
Material 3 Expressive components for Laravel and Livewire, built on Tailwind CSS 4.
|
||||
|
||||
- Anonymous Blade components for the current M3 Expressive catalogue: buttons and FABs, menus, chips, text fields, selection controls, sliders, pickers, dialogs and sheets, lists, cards, carousels, progress and loading indicators, snackbars, tabs, app bars, toolbars, navigation bars and rails, an adaptive app shell, data tables and pagination.
|
||||
- A colour scheme generated from one seed colour with Google's colour science (`php artisan material:scheme`), light and dark, and a theme that is chosen before the first paint.
|
||||
- The full Material Symbols Rounded set and the M3 Expressive shapes, drawn inline without an icon package.
|
||||
- Error pages and a Markdown mail theme in the same scheme.
|
||||
- A showcase of every component in the application's own scheme, a design guard for tests, and Laravel Boost guidelines and a skill for AI agents.
|
||||
|
||||
No JavaScript libraries beyond the Alpine that ships with Livewire. Browsers: Chrome 125+, Firefox 147+, Safari 18.4+.
|
||||
|
||||
## Requirements
|
||||
|
||||
PHP 8.4+, Laravel 13, Livewire 4, Tailwind CSS 4 with Vite, and Node (for `material:scheme`).
|
||||
|
||||
## Installation
|
||||
|
||||
The package is served from Gitea. Add the repository and require it:
|
||||
|
||||
```bash
|
||||
composer config repositories.livewire-material vcs https://gitea.nonameweb.ch/noNameWEB/livewire-material.git
|
||||
composer require nonameweb/livewire-material
|
||||
```
|
||||
|
||||
### Stylesheet and script
|
||||
|
||||
The application's build imports from `vendor/`, so Composer packages must be installed before `npm run build` — in a Dockerfile, copy `composer.json`, run `composer install`, then build the assets.
|
||||
|
||||
```css
|
||||
/* resources/css/app.css */
|
||||
@import 'tailwindcss';
|
||||
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
|
||||
@import './material-scheme.css';
|
||||
|
||||
@source '../../vendor/nonameweb/livewire-material/resources/views';
|
||||
@source '../../vendor/nonameweb/livewire-material/src';
|
||||
```
|
||||
|
||||
```js
|
||||
// resources/js/app.js
|
||||
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
|
||||
```
|
||||
|
||||
Do not install Alpine separately; Livewire provides it.
|
||||
|
||||
### Layout
|
||||
|
||||
The theme script goes in `<head>`, before `@vite`, so the page paints in the visitor's theme:
|
||||
|
||||
```blade
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<x-theme-script />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="bg-surface font-sans text-on-surface antialiased">
|
||||
{{ $slot }}
|
||||
<x-toast />
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Colour scheme
|
||||
|
||||
Generate the scheme from a seed colour. It writes `resources/css/material-scheme.css` (imported above) and `material-scheme.json` (read by the mail theme):
|
||||
|
||||
```bash
|
||||
php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
||||
```
|
||||
|
||||
Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--contrast` runs from -1 to 1; `--success`, `--warning` and `--info` seed the state colours. Regenerate instead of editing the file.
|
||||
|
||||
#### Colour profiles
|
||||
|
||||
To let an installation switch between several schemes, list them as `profiles` in the config (name ⇒ `label`, `seed`, `variant`) and run `php artisan material:scheme` without a seed: every profile lands in the same stylesheet under `<html data-scheme>`. Tell the package which one is active — `Scheme::resolveProfileUsing(fn () => Setting::get('color_profile'))` in a service provider — and the head script, mails and error pages follow it. `<x-scheme-picker wire:model="colorProfile" />` lets someone choose, previewing each profile on the page.
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag=livewire-material-config
|
||||
```
|
||||
|
||||
- `prefix` — components are `<x-button>`, `<x-card>`… Set `'m'` when a name clashes with the application's own components, and they become `<x-m::button>`. `<x-livewire-material::button>` always works.
|
||||
- `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys` (an earlier toggle's localStorage keys, adopted once).
|
||||
- `profiles`, `profile` — colour profiles and the default one (see Colour profiles).
|
||||
- `fields.variant` — text fields `outlined` (default) or `filled`.
|
||||
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
|
||||
- `showcase.enabled`, `showcase.path`, `showcase.middleware`, `showcase.vite`.
|
||||
- `node` — the Node binary for `material:scheme`.
|
||||
|
||||
## Usage
|
||||
|
||||
```blade
|
||||
<x-card title="holiday-photos.zip" subtitle="248 MB · expires in 3 days" variant="outlined">
|
||||
<x-slot:actions>
|
||||
<x-button label="Copy link" icon="content_copy" wire:click="copy" />
|
||||
<x-button label="Delete" danger wire:click="$set('confirming', true)" />
|
||||
</x-slot:actions>
|
||||
</x-card>
|
||||
|
||||
<x-modal wire:model="confirming" title="Delete this share?" icon="delete">
|
||||
Recipients lose access at once.
|
||||
<x-slot:actions>
|
||||
<x-button label="Cancel" x-on:click="close()" />
|
||||
<x-button label="Delete" danger wire:click="delete" />
|
||||
</x-slot:actions>
|
||||
</x-modal>
|
||||
```
|
||||
|
||||
```php
|
||||
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
|
||||
|
||||
class Shares extends Component
|
||||
{
|
||||
use Toasts;
|
||||
|
||||
public function copy(): void
|
||||
{
|
||||
$this->success('Link copied');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every component, prop and slot is documented in the Boost skill (`resources/boost/skills/livewire-material-development/SKILL.md`) and shown in the showcase.
|
||||
|
||||
## Showcase
|
||||
|
||||
While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/material` shows every token and component, in every variant, in the application's own scheme and theme: an overview, and a page per section behind a navigation rail (the package's own app shell), with a search over every section, example and component (press `/`).
|
||||
|
||||
## Testing the design
|
||||
|
||||
```php
|
||||
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
||||
|
||||
it('uses only what compiles', function () {
|
||||
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
|
||||
->forbidColours(['tertiary'])
|
||||
->violations())->toBe([]);
|
||||
});
|
||||
```
|
||||
|
||||
The guard fails on maryUI tags, daisyUI classes, colours the theme does not declare, unknown Material Symbol names and Blade directives written inside component tags.
|
||||
|
||||
## AI agents
|
||||
|
||||
With [Laravel Boost](https://github.com/laravel/boost), `php artisan boost:install` (or `boost:update --discover`) picks up the package's guideline and the `livewire-material-development` skill.
|
||||
|
||||
## Developing the package
|
||||
|
||||
```bash
|
||||
composer install && npm install
|
||||
npm run build # the Workbench's assets (or `npm run dev` while working)
|
||||
composer serve # the showcase at http://127.0.0.1:8000/material
|
||||
vendor/bin/pest --testsuite=Feature
|
||||
npx playwright install
|
||||
vendor/bin/pest --testsuite=Browser --browser chrome # also firefox, safari
|
||||
```
|
||||
|
||||
## Credits
|
||||
|
||||
Material Symbols, the M3 Expressive shapes, Google Sans Flex, material-color-utilities and Jetpack Compose Material 3's tokens and algorithms are Google's and the Android Open Source Project's; see `NOTICE`.
|
||||
|
||||
## License
|
||||
|
||||
MIT. See `LICENSE`.
|
||||
@@ -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.
|
||||
|
|
||||
*/
|
||||
|
||||
@@ -34,6 +36,22 @@ return [
|
||||
'legacy_keys' => [],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Navigation rail
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Whether a collapsible navigation rail starts 'expanded' or 'collapsed'
|
||||
| until the visitor toggles it. The head script applies the choice before
|
||||
| the first paint, from localStorage under 'storage_key'.
|
||||
|
|
||||
*/
|
||||
|
||||
'rail' => [
|
||||
'default' => 'expanded',
|
||||
'storage_key' => 'material-rail',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fields
|
||||
@@ -75,6 +93,61 @@ 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'. 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 +155,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.
|
||||
|
|
||||
*/
|
||||
|
||||
|
||||
@@ -493,6 +493,24 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`,
|
||||
app locale; `min`/`max`; single and range; `wire:model` stores `Y-m-d`), `timepicker`
|
||||
(dial and input; 12/24h from locale; stores `H:i`). APG grid keyboard for the calendar.
|
||||
|
||||
**Phase 7 is done (2026-09-13).** Both pickers were built by separate agents in their own
|
||||
worktrees. What changed from the step above:
|
||||
|
||||
- **`<x-datepicker>` is one `<dialog wire:ignore>` for every mode**: docked opens it as a
|
||||
`popover="manual"` under the field (CSS anchor positioning, flipping above), modal and input
|
||||
with `showModal()`, and docked becomes modal below `sm`. Dates are `Y-m-d` strings computed in
|
||||
UTC; only "today" reads the browser's zone. The typed format is androidx's
|
||||
`datePatternAsInputFormat` from `Intl` (`de` → `dd.MM.yyyy`). **A range binds one array**,
|
||||
`['start' => …, 'end' => …]`, not two models: one update, so `after_or_equal:trip.start`
|
||||
validates against the matching end, and all its errors land on one field. Docked values come from
|
||||
material-web's docked tokens, which Compose lacks.
|
||||
- **`<x-timepicker>` follows current Compose, not older Android**: a 24-hour dial puts 00–11 outside
|
||||
and 12–23 inside, and the period selector is two separate toggles. The selector angle is a
|
||||
registered custom property, so one transition turns the line and moves the handle.
|
||||
- **Alpine's `x-show` reveals an element on the next animation frame**: focusing into it from
|
||||
`$nextTick` failed in Firefox and Safari. Wait a frame, or switch views with an attribute and CSS.
|
||||
- **A Livewire public property named `$slot` renders empty** in the component's view.
|
||||
|
||||
### Phase 8 — Navigation
|
||||
|
||||
28. `app-bar` (small, center-aligned, medium flexible, large flexible, search app bar; sticky,
|
||||
@@ -506,6 +524,28 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`,
|
||||
the theme script), content region with `wire:transition.navigate`, snackbar host. Nothing
|
||||
app-specific inside; apps pass destinations and extra chrome as slots.
|
||||
|
||||
**Phase 8 is done (2026-09-13).** What changed from the steps above:
|
||||
|
||||
- **App bars, toolbars, tabs, section nav, account menu and theme toggle were built in main; the
|
||||
navigation bar, rail and app shell by an agent in its own worktree.**
|
||||
- **A medium or large app bar collapses without script moving anything**: the bar is sticky at a
|
||||
negative top (its measured height less the 64px row, so a wrapped title still fits) and its row
|
||||
is sticky at 0 inside it; script only reports `scrolled` and `collapsed`.
|
||||
- **The tab indicator moves in a view transition** (`view-transition-name` from a per-tablist
|
||||
custom property, `view-transition-class` for the timing); every tab draws its own indicator, so it
|
||||
is right before Alpine starts.
|
||||
- **Livewire 4.4's `wire:navigate` gives `<html>` the next page's attributes and removes the rest**,
|
||||
which dropped `data-theme` on every in-app navigation. The head script saves the theme and rail
|
||||
attributes on `livewire:navigating` and puts them back in `onSwap`, before anything paints.
|
||||
- **The rail's collapsed state is applied before first paint** by the head script
|
||||
(`<html data-rail>`, `rail.default`, `rail.storage_key`) and read by a `rail-collapsed:` variant;
|
||||
`$store.rail` changes it. The shell draws no phone menu button: the app bar in `top` calls
|
||||
`$store.rail.show()`.
|
||||
- **Playwright's locators are strict**: `assertAttribute` on a selector matching two elements fails.
|
||||
- **Tailwind only compiles classes it can see**: a class written only in a test probe (`h-[200vh]`)
|
||||
does not exist in the Workbench build; use inline styles there.
|
||||
- **A Tailwind `@variant` nested under a pseudo-element or a `* +` selector compiles to broken CSS.**
|
||||
|
||||
### Phase 9 — Data, pages, mail
|
||||
|
||||
30. `table` (`.data-table`, descendant selectors, fine-pointer density, `position: relative`),
|
||||
@@ -517,6 +557,20 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`,
|
||||
`material-scheme.json` (falling back to the default scheme); `html/header` and
|
||||
`html/message` overrides; typescale on bare tags; filled primary button.
|
||||
|
||||
**Steps 30–32 are done (2026-09-13).** Tables, sort headers and pagination were built in main; error
|
||||
pages and the mail theme by an agent in its own worktree. What changed from the steps above:
|
||||
|
||||
- **The paginators are prepended to the `pagination` and `livewire` view namespaces** rather than
|
||||
set as the default view, because Livewire sets its own default on every render; published
|
||||
`vendor/pagination` and `vendor/livewire` views still win (`livewire-material.pagination`).
|
||||
- **The error-view root is `resources/views/error-pages`** (inside `views`, so an application's
|
||||
`@source` line already covers its classes), appended to `view.paths` in `register()`, before the
|
||||
view finder is built. The layout is `errors::minimal`, so the framework's 401 and 402 use it too.
|
||||
Without a Vite build the pages fall back to an inline stylesheet from the scheme JSON. Testbench's
|
||||
skeleton ships its own `errors/503`, which wins in the Workbench.
|
||||
- **Mail is light only**: the CSS inliner strips `@media`. The header and message components are
|
||||
opt-in (`livewire-material.mail.components`) or published, because they change every Markdown mail.
|
||||
|
||||
### Phase 10 — `1.0.0`
|
||||
|
||||
33. Showcase complete (every component, variant, colour, size and state, both themes); the
|
||||
@@ -524,6 +578,34 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`,
|
||||
(drift test green); README (install, CSS/JS wiring, scheme, theme, prefix, showcase,
|
||||
guard, Docker ordering note); tag `1.0.0`.
|
||||
|
||||
**Phase 10 is done (2026-09-13).** What changed from the step above:
|
||||
|
||||
- **The package's own views write `<x-livewire-material::name>`** for the components they use. Blade
|
||||
spells a configured prefix `<x-m::button>` (not `<x-m-button>`, as the config first claimed), and
|
||||
an unqualified `<x-button>` inside a package view would have broken under a prefix and been
|
||||
shadowed by an application's own `components/button.blade.php`. A test scans every package view
|
||||
(outside comments and the showcase's example heredocs) for unqualified tags, and the showcase
|
||||
rewrites its examples to the configured prefix. The component path string stays
|
||||
`__DIR__.'/../resources/views/components'`: Blade names the view namespace after its hash, and
|
||||
compiled views keep that name.
|
||||
- **A test checks that every component appears in the showcase.**
|
||||
- **The showcase is the package's own app shell**: an overview at `/material` and a page per section
|
||||
(`/material/{section}`, `Showcase\Sections`), grouped in the navigation rail, moved between with
|
||||
wire:navigate. It turned up a WebKit bug in `<x-menu>`: inside a focusable region (the shell's
|
||||
`<main tabindex="-1">`), WebKit hands focus back to that region as the popover closes, so the
|
||||
menu now reads whether focus was inside on `beforetoggle` before returning it to the trigger.
|
||||
- **The showcase has a search** in a search app bar (`<x-search>`, `/` or Ctrl+K): an index of every
|
||||
section, every example (by its anchor) and every component, read from the section views and fetched
|
||||
as `/material/search.json` on first focus. Names in a slot's Alpine scope must keep clear of the
|
||||
component's own: `results` and `open` in `<x-search>`'s scope hid the page's. wire:navigate keeps a
|
||||
URL's hash but scrolls to the top, so the layout lands on the hash after the swap settles.
|
||||
- **Verified in a fresh Laravel 13.31 application** (`composer create-project`, the package from a
|
||||
path repository, the README's CSS, JS and layout, `material:scheme "#4f46e5"`, `npm run build`):
|
||||
a Livewire page with an app bar, tabs, a card, a form with validation, a date picker, a dialog and
|
||||
a toast works without console errors; `/material` and the 404 page render in its scheme.
|
||||
- **`<x-datepicker clearable>`** empties a date, or both ends of a range, as `<x-timepicker clearable>`
|
||||
does.
|
||||
|
||||
### Phase 11 — SealShare 2.0.0
|
||||
|
||||
Tracked in SealShare's `docs/plans/livewire-material.md`, after `1.0.0`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,6 +46,34 @@ php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
||||
|
||||
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The command also writes `material-scheme.json` beside the stylesheet.
|
||||
|
||||
### Colour profiles
|
||||
|
||||
An installation that switches between several schemes lists them in `config/livewire-material.php` and runs the command without a seed, which generates every profile into the same stylesheet, keyed by `<html data-scheme>`:
|
||||
|
||||
```php
|
||||
'profiles' => [
|
||||
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
|
||||
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
|
||||
],
|
||||
'profile' => 'indigo', // the default; else the first
|
||||
```
|
||||
|
||||
```bash
|
||||
php artisan material:scheme
|
||||
```
|
||||
|
||||
- Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet.
|
||||
- The application says which profile is active, once, in a service provider. The closure runs every time a colour is drawn (head script, mail, error page), so it may read the database; a name that is not a generated profile, or a closure that throws, falls back to the default:
|
||||
|
||||
```php
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
|
||||
```
|
||||
|
||||
- `<x-theme-script>` writes the active profile to `<html data-scheme>` before the first paint; mails and error pages draw it too. `Scheme::profiles()` lists the generated profiles (name ⇒ label, light and dark roles) and `Scheme::profile()` names the active one — validate a stored choice with `Rule::in(array_keys(Scheme::profiles()))`.
|
||||
- Choose with `<x-scheme-picker wire:model="colorProfile" />` (see Components). Never set `data-scheme` on an element inside the page expecting a different profile there: profiles key on `<html>`.
|
||||
|
||||
## Tokens
|
||||
|
||||
Tailwind's default palette is cleared: every colour class names an M3 role. `text-red-600`, `bg-base-200` or `text-gray-500` compile to nothing.
|
||||
@@ -62,7 +90,7 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
|
||||
|
||||
## Theme
|
||||
|
||||
`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control.
|
||||
`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything.
|
||||
|
||||
## Toasts
|
||||
|
||||
@@ -85,6 +113,59 @@ class Settings extends Component
|
||||
|
||||
The methods are protected. They dispatch a `toast` browser event (`assertDispatched('toast', type: 'success', title: 'Settings saved')` in tests).
|
||||
|
||||
## 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>`
|
||||
@@ -414,7 +495,7 @@ A one-column grid of fields with an `actions` slot at the foot (the slot takes i
|
||||
|
||||
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
|
||||
|
||||
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name (the error replaces the hint, sets `aria-invalid`). `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint and sets `aria-invalid`. `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||
|
||||
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
|
||||
- `<x-password>`: a reveal button; `icon`, `size`.
|
||||
@@ -463,6 +544,50 @@ M3 Expressive's slider on native `<input type="range">`s (one per handle), so th
|
||||
|
||||
Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless a `w-*` class is passed.
|
||||
|
||||
### `<x-datepicker>`
|
||||
|
||||
M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model` without Livewire).
|
||||
|
||||
```blade
|
||||
<x-datepicker label="Expires on" wire:model.live="expiresOn" :min="now()" :max="now()->addMonth()" />
|
||||
<x-datepicker label="Birthday" mode="modal" wire:model="birthday" :max="now()" />
|
||||
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" clearable />
|
||||
```
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `mode` | `docked` | `docked`: type a date (in the locale's numeric format) or pick one from a calendar under the field, which opens as a dialog below `sm`; `modal`: the field opens a calendar dialog; `input`: the dialog opens on a text field. Both dialogs switch between calendar and typing |
|
||||
| `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field |
|
||||
| `min`, `max` | `null` | `Y-m-d` or a date; days outside are disabled and the keyboard stays inside |
|
||||
| `label`, `hint`, `icon`, `variant`, `size` | | the field's |
|
||||
| `value` | `null` | the initial value without `wire:model` |
|
||||
| `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) |
|
||||
| `clearable` | `false` | a button that empties the field (both ends of a range) once it holds a date |
|
||||
|
||||
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()`. Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
|
||||
|
||||
### `<x-timepicker>`
|
||||
|
||||
M3's time picker in a modal dialog, opened from a read-only text field (a press, Enter, Space, ArrowDown or its clock icon). The dial picks the hour, then the minutes, by press or drag; a 24-hour clock puts 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 +617,84 @@ M3 search bar that opens into a search view: docked under the bar from `sm`, ful
|
||||
|
||||
The docked view overlaps what is under it; never place a search inside an element with `overflow-hidden` (a card), which clips it.
|
||||
|
||||
### `<x-app-shell>`
|
||||
|
||||
The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a collapsed rail that opens as a modal to `lg`, an expanded rail the visitor can collapse from `lg`, the page as `<main id="content" wire:transition.navigate>` behind a skip link, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`.
|
||||
|
||||
```blade
|
||||
<x-app-shell :destinations="[
|
||||
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares.index'), 'active' => request()->routeIs('shares.*'), 'badge' => $expiringCount],
|
||||
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
|
||||
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
|
||||
]">
|
||||
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
|
||||
<x-slot:rail-header>
|
||||
<span class="rail-collapsed:hidden"><x-fab label="New share" icon="add" link="{{ route('upload') }}" /></span>
|
||||
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="add" tooltip-right="New share" link="{{ route('upload') }}" /></span>
|
||||
</x-slot:rail-header>
|
||||
<x-slot:rail-footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
|
||||
</x-slot:rail-footer>
|
||||
<x-slot:top>
|
||||
{{-- the page's app bar; its menu button opens the modal rail on a phone --}}
|
||||
<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>
|
||||
</x-slot:top>
|
||||
|
||||
{{ $slot }}
|
||||
</x-app-shell>
|
||||
```
|
||||
|
||||
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the current one), `badge` (`true` for a dot, or a count), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||
- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
|
||||
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`).
|
||||
- Below `sm` the shell sets `--material-bottom-bar`, so the snackbar and a `fab` button clear the bar; pad anything else you pin to the bottom with it.
|
||||
- The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
|
||||
|
||||
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
|
||||
|
||||
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`<x-app-shell>` does):
|
||||
|
||||
```blade
|
||||
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
||||
<x-navigation-bar>
|
||||
<x-navigation-bar-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" :active="request()->routeIs('shares.*')" badge="3" />
|
||||
<x-navigation-bar-item label="Upload" icon="upload" link="{{ route('upload') }}" />
|
||||
</x-navigation-bar>
|
||||
</div>
|
||||
```
|
||||
|
||||
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
|
||||
|
||||
### `<x-navigation-rail>`, `<x-navigation-rail-item>`, `<x-navigation-rail-section>`
|
||||
|
||||
M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (a 56px full-width pill, icon beside label, count at the end).
|
||||
|
||||
```blade
|
||||
<div class="flex min-h-dvh">
|
||||
<x-navigation-rail mode="collapsible">
|
||||
<x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
|
||||
<x-slot:header><x-fab icon="add" tooltip-right="New share" /></x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" active badge="3" />
|
||||
<x-navigation-rail-section label="Admin">
|
||||
<x-navigation-rail-item label="Users" icon="group" link="{{ route('users') }}" />
|
||||
</x-navigation-rail-section>
|
||||
|
||||
<x-slot:footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" />
|
||||
</x-slot:footer>
|
||||
</x-navigation-rail>
|
||||
|
||||
<main class="min-w-0 flex-1">…</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
- `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (`<x-app-shell>`'s: hidden and opened as a modal below `sm`, collapsed and opened as a modal to `lg`, collapsible from `lg`).
|
||||
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`). Slots: `brand` (beside the menu button, expanded only), `header` (a FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
|
||||
- Anything inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
|
||||
- `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way.
|
||||
- `$store.rail`: `collapsed`, `toggle()`, `collapse()`, `expand()` (the remembered choice), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php` → `rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
|
||||
|
||||
### `<x-app-bar>`
|
||||
|
||||
M3 Expressive top app bar, sticky by default (`:sticky="false"` to scroll away), turning surface-container once content scrolls under it. `variant`: `small` (default), `center`, `medium` and `large` (a big title that collapses into the row as the page scrolls — CSS sticky, no layout shift), `search` (put an `<x-search>` in the slot). Props: `title`, `subtitle`, `heading` (`h1` default). Slots: `navigation` (leading icon button), `actions` (trailing icon buttons, avatar).
|
||||
@@ -549,6 +752,14 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
|
||||
|
||||
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (segmented buttons for settings pages). Every toggle on a page shares the store.
|
||||
|
||||
### `<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 +801,7 @@ It fails on maryUI tags, daisyUI classes, colours the theme does not declare, un
|
||||
|
||||
## Conventions
|
||||
|
||||
- Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}name>` when `config('livewire-material.prefix')` is set.
|
||||
- Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}::name>` when `config('livewire-material.prefix')` is set; `<x-livewire-material::name>` always works.
|
||||
- Write class names out whole. Tailwind cannot compile `'text-'.$tone` or `type-{{ $size }}`, and the design guard cannot read them.
|
||||
- The showcase at `/material` (local only, `MATERIAL_SHOWCASE=true` to force it) renders every token and component.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
/*
|
||||
* M3 Expressive navigation: the flexible navigation bar and the navigation rail — collapsed,
|
||||
* expanded and modal.
|
||||
*
|
||||
* Values from androidx Compose Material 3 (Apache-2.0) at androidx-main
|
||||
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: tokens/NavigationBarTokens.kt,
|
||||
* NavigationBarVerticalItemTokens.kt, NavigationBarHorizontalItemTokens.kt,
|
||||
* NavigationRailCollapsedTokens.kt, NavigationRailExpandedTokens.kt,
|
||||
* NavigationRailBaselineItemTokens.kt, NavigationRailVerticalItemTokens.kt,
|
||||
* NavigationRailHorizontalItemTokens.kt, NavigationRailColorTokens.kt, and the layout in
|
||||
* ShortNavigationBar.kt, WideNavigationRail.kt and NavigationItem.kt.
|
||||
*
|
||||
* [data-navigation-bar] surface-container, 64px, the bottom safe area under it
|
||||
* [data-navigation-bar-items] equal widths; centred from a 600px-wide bar
|
||||
* [data-navigation-bar-item] data-active
|
||||
* [data-navigation-pill] icon and label; the indicator itself from 600px
|
||||
* [data-navigation-indicator] the 56×32 indicator around the icon below 600px
|
||||
* [data-navigation-label]
|
||||
*
|
||||
* [data-navigation-rail="collapsed|expanded|collapsible|modal|adaptive"] data-open
|
||||
* [data-navigation-rail-scrim] modal and adaptive rails
|
||||
* [data-navigation-rail-panel] the <nav>
|
||||
* [data-navigation-rail-header] menu button, brand, FAB — never scrolls
|
||||
* [data-navigation-rail-destinations] scrolls when the window is too short
|
||||
* [data-navigation-rail-section] a heading (expanded only) and its items
|
||||
* [data-navigation-rail-item] data-active; the full-width pill when expanded
|
||||
* [data-navigation-indicator] the 56×32 indicator when collapsed
|
||||
* [data-navigation-label]
|
||||
* [data-navigation-rail-footer] never scrolls
|
||||
*
|
||||
* `rail-collapsed:` matches a rail, and everything in it, while it is drawn collapsed — whatever
|
||||
* made it so: its mode, the visitor's choice on <html data-rail> (set before the first paint by
|
||||
* <x-theme-script>), or a window under `lg` for the adaptive rail. A rail item is written once
|
||||
* and takes both shapes from it; so can anything an application puts in a rail
|
||||
* (`<span class="rail-collapsed:hidden">`).
|
||||
*/
|
||||
|
||||
@custom-variant rail-collapsed {
|
||||
&:where([data-navigation-rail='collapsed'], [data-navigation-rail='collapsed'] *) {
|
||||
@slot;
|
||||
}
|
||||
|
||||
&:where([data-rail='collapsed'] [data-navigation-rail='collapsible'], [data-rail='collapsed'] [data-navigation-rail='collapsible'] *) {
|
||||
@slot;
|
||||
}
|
||||
|
||||
&:where([data-navigation-rail='modal']:not([data-open]), [data-navigation-rail='modal']:not([data-open]) *) {
|
||||
@slot;
|
||||
}
|
||||
|
||||
@media (width < 64rem) {
|
||||
&:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 64rem) {
|
||||
&:where([data-rail='collapsed'] [data-navigation-rail='adaptive'], [data-rail='collapsed'] [data-navigation-rail='adaptive'] *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ---------------------------------------------------------------- the navigation bar */
|
||||
|
||||
[data-navigation-bar] {
|
||||
container-type: inline-size;
|
||||
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
[data-navigation-bar-items] {
|
||||
display: flex;
|
||||
min-height: 4rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
[data-navigation-bar-item] {
|
||||
--navigation-layer: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-block: 0.375rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
[data-navigation-bar-item] [data-navigation-pill] {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
font: var(--md-sys-typescale-label-md);
|
||||
letter-spacing: var(--md-sys-typescale-label-md-tracking);
|
||||
}
|
||||
|
||||
[data-navigation-bar-item] [data-navigation-label] {
|
||||
max-width: 100%;
|
||||
padding-inline: 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-navigation-bar-item][data-active] {
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
/* From 600dp (M3's medium window), icon and label side by side in a 40px indicator, and the
|
||||
items centred with the padding ShortNavigationBar's Centered arrangement computes. */
|
||||
@container (width >= 37.5rem) {
|
||||
[data-navigation-bar-items] {
|
||||
width: calc(10% * (var(--navigation-bar-count, 7) + 3));
|
||||
min-width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-navigation-bar-items]:has(> :last-child:nth-child(1)) { --navigation-bar-count: 1; }
|
||||
[data-navigation-bar-items]:has(> :last-child:nth-child(2)) { --navigation-bar-count: 2; }
|
||||
[data-navigation-bar-items]:has(> :last-child:nth-child(3)) { --navigation-bar-count: 3; }
|
||||
[data-navigation-bar-items]:has(> :last-child:nth-child(4)) { --navigation-bar-count: 4; }
|
||||
[data-navigation-bar-items]:has(> :last-child:nth-child(5)) { --navigation-bar-count: 5; }
|
||||
[data-navigation-bar-items]:has(> :last-child:nth-child(6)) { --navigation-bar-count: 6; }
|
||||
|
||||
[data-navigation-bar-item] {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
[data-navigation-bar-item] [data-navigation-pill] {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
flex-direction: row;
|
||||
height: 2.5rem;
|
||||
padding-inline: 1rem;
|
||||
font: var(--md-sys-typescale-label-lg);
|
||||
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
|
||||
}
|
||||
|
||||
[data-navigation-bar-item] [data-navigation-label] {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
[data-navigation-bar-item][data-active] {
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
[data-navigation-bar-item][data-active] [data-navigation-pill] {
|
||||
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- the navigation rail */
|
||||
|
||||
[data-navigation-rail] {
|
||||
--navigation-rail-expanded-width: clamp(13.75rem, var(--navigation-rail-width, 16rem), 22.5rem);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: var(--navigation-rail-expanded-width);
|
||||
transition: width var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
|
||||
|
||||
@variant rail-collapsed {
|
||||
width: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* A modal rail keeps its collapsed width in the layout while it is open over it, as Compose's
|
||||
ModalWideNavigationRail does; the adaptive rail does below lg, and takes no room below sm. */
|
||||
[data-navigation-rail='modal'] {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
@media (width < 64rem) {
|
||||
[data-navigation-rail='adaptive'] {
|
||||
width: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 40rem) {
|
||||
[data-navigation-rail='adaptive'] {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-panel] {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100dvh;
|
||||
/* Clip, not hide: no scroll container, so the destinations below can still scroll and
|
||||
nothing sticky breaks. What only an expanded rail draws — a label, the brand — is drawn
|
||||
at once when the rail expands, while the width is still growing; the clip keeps it
|
||||
from spilling over the page for those frames. */
|
||||
overflow-x: clip;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
background-color: var(--md-sys-color-surface);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
transition:
|
||||
width var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast),
|
||||
background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
||||
}
|
||||
|
||||
/* Open: expanded over a scrim, in surface-container with a large corner at its inner edge. */
|
||||
[data-navigation-rail][data-open] > [data-navigation-rail-panel] {
|
||||
position: fixed;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 50;
|
||||
width: var(--navigation-rail-expanded-width);
|
||||
max-width: calc(100vw - 3.5rem);
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
border-start-end-radius: var(--md-sys-shape-corner-lg);
|
||||
border-end-end-radius: var(--md-sys-shape-corner-lg);
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
}
|
||||
|
||||
/* Below sm there is no collapsed rail to grow out of: the open rail slides in from the edge,
|
||||
on emphasized decelerate rather than a spring, which would overshoot and open a gap. */
|
||||
@media (width < 40rem) {
|
||||
[data-navigation-rail='adaptive'] > [data-navigation-rail-panel] {
|
||||
position: fixed;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 50;
|
||||
display: none;
|
||||
width: var(--navigation-rail-expanded-width);
|
||||
max-width: calc(100vw - 3.5rem);
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
border-start-end-radius: var(--md-sys-shape-corner-lg);
|
||||
border-end-end-radius: var(--md-sys-shape-corner-lg);
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
translate: -100% 0;
|
||||
transition:
|
||||
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
|
||||
display var(--md-sys-motion-effects-default-duration) allow-discrete;
|
||||
|
||||
&:dir(rtl) {
|
||||
translate: 100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail='adaptive'][data-open] > [data-navigation-rail-panel] {
|
||||
display: flex;
|
||||
translate: 0 0;
|
||||
transition:
|
||||
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate),
|
||||
display var(--md-sys-motion-spatial-default-duration) allow-discrete;
|
||||
|
||||
@starting-style {
|
||||
translate: -100% 0;
|
||||
}
|
||||
|
||||
&:dir(rtl) {
|
||||
translate: 0 0;
|
||||
|
||||
@starting-style {
|
||||
translate: 100% 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-scrim] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: none;
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default),
|
||||
display var(--md-sys-motion-effects-default-duration) allow-discrete;
|
||||
}
|
||||
|
||||
[data-navigation-rail][data-open] > [data-navigation-rail-scrim] {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 44px above the header (TopSpace), 40px under it (HeaderSpaceMinimum) — 32 here and 8 as
|
||||
the destinations' own padding, which keeps the first item's focus ring inside the scroller. */
|
||||
[data-navigation-rail-header] {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding-top: calc(env(safe-area-inset-top) + 2.75rem);
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
[data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child {
|
||||
padding-top: calc(env(safe-area-inset-top) + 2.75rem);
|
||||
}
|
||||
|
||||
[data-navigation-rail-destinations] {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-block: 0.5rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
[data-navigation-rail-destinations],
|
||||
[data-navigation-rail-section],
|
||||
[data-navigation-rail-footer] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@variant rail-collapsed {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-footer] {
|
||||
flex-shrink: 0;
|
||||
padding-block: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
[data-navigation-rail-heading] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 3rem;
|
||||
padding-inline: 2.25rem 1.25rem;
|
||||
overflow: hidden;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-title-sm);
|
||||
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@variant rail-collapsed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-section]:not(:first-child) {
|
||||
@variant rail-collapsed {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Expanded: a 56px full-width pill, icon and label 8px apart, label-large. */
|
||||
[data-navigation-rail-item] {
|
||||
--navigation-layer: 0;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
height: 3.5rem;
|
||||
margin-inline: 1.25rem;
|
||||
padding-inline: 1rem;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-label-lg);
|
||||
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
|
||||
text-align: start;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
/* Collapsed: the icon in its 56×32 indicator over a label-medium label, 64px tall. */
|
||||
@variant rail-collapsed {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
height: auto;
|
||||
min-height: 4rem;
|
||||
margin-inline: 0;
|
||||
padding: 0.375rem 0.25rem;
|
||||
border-radius: 0;
|
||||
font: var(--md-sys-typescale-label-md);
|
||||
letter-spacing: var(--md-sys-typescale-label-md-tracking);
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-item] [data-navigation-label] {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@variant rail-collapsed {
|
||||
flex: none;
|
||||
display: -webkit-box;
|
||||
max-width: 100%;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-item][data-active] {
|
||||
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
|
||||
@variant rail-collapsed {
|
||||
background-image: none;
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-item] [data-navigation-indicator] {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
|
||||
@variant rail-collapsed {
|
||||
width: 3.5rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- both: indicator, states */
|
||||
|
||||
[data-navigation-bar-item] [data-navigation-indicator] {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
}
|
||||
|
||||
[data-navigation-bar-item][data-active] [data-navigation-indicator] {
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
@container (width < 37.5rem) {
|
||||
[data-navigation-bar-item][data-active] [data-navigation-indicator] {
|
||||
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-item][data-active] [data-navigation-indicator] {
|
||||
@variant rail-collapsed {
|
||||
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
}
|
||||
|
||||
/* The active indicator is a secondary-container fill painted as a background image, so it can
|
||||
grow out of its centre without stretching the icon: when a page arrives through
|
||||
wire:navigate, resources/js/navigation.js starts it at zero width for a moment, and it
|
||||
springs open on the default spatial spring, as Compose's indicator does when the selection
|
||||
changes (NavigationItem.kt). A full page load draws it at once; reduced motion zeroes it. */
|
||||
[data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill]),
|
||||
[data-navigation-rail-item],
|
||||
[data-navigation-rail-item] [data-navigation-indicator] {
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
transition: background-size var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
|
||||
}
|
||||
|
||||
/* The state layer covers only the indicator: the pill when the indicator holds the label
|
||||
too, the 56×32 shape when it holds the icon alone. Hover only where a pointer can hover. */
|
||||
@media (hover: hover) {
|
||||
:is([data-navigation-bar-item], [data-navigation-rail-item]):hover {
|
||||
--navigation-layer: 0.08;
|
||||
}
|
||||
}
|
||||
|
||||
:is([data-navigation-bar-item], [data-navigation-rail-item]):is(:focus-visible, :active) {
|
||||
--navigation-layer: 0.1;
|
||||
}
|
||||
|
||||
[data-navigation-bar-item] [data-navigation-indicator]::before,
|
||||
[data-navigation-bar-item] [data-navigation-pill]::before,
|
||||
[data-navigation-rail-item] [data-navigation-indicator]::before,
|
||||
[data-navigation-rail-item]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background-color: var(--md-sys-color-on-surface);
|
||||
opacity: var(--navigation-layer);
|
||||
pointer-events: none;
|
||||
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
@container (width < 37.5rem) {
|
||||
[data-navigation-bar-item] [data-navigation-pill]::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width >= 37.5rem) {
|
||||
[data-navigation-bar-item] [data-navigation-indicator]::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The rail item's layer is on the item while expanded and on its indicator while collapsed. A
|
||||
variant cannot follow a pseudo-element in a selector, so the item hands the layer on through
|
||||
variables; each rule below outweighs its line in the list above. */
|
||||
[data-navigation-rail-item] {
|
||||
--navigation-item-layer: var(--navigation-layer);
|
||||
--navigation-indicator-layer: 0;
|
||||
|
||||
@variant rail-collapsed {
|
||||
--navigation-item-layer: 0;
|
||||
--navigation-indicator-layer: var(--navigation-layer);
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-item][data-navigation-rail-item]::before {
|
||||
background-color: var(--md-sys-color-on-secondary-container);
|
||||
opacity: var(--navigation-item-layer);
|
||||
}
|
||||
|
||||
[data-navigation-rail-item][data-navigation-rail-item] [data-navigation-indicator]::before {
|
||||
background-color: var(--md-sys-color-on-secondary-container);
|
||||
opacity: var(--navigation-indicator-layer);
|
||||
}
|
||||
|
||||
/* M3's focus indicator, 3px of secondary 2px out, around the same shape. */
|
||||
@container (width < 37.5rem) {
|
||||
[data-navigation-bar-item]:focus-visible [data-navigation-indicator] {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width >= 37.5rem) {
|
||||
[data-navigation-bar-item]:focus-visible [data-navigation-pill] {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-item]:focus-visible {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
|
||||
@variant rail-collapsed {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-navigation-rail-item]:focus-visible [data-navigation-indicator] {
|
||||
@variant rail-collapsed {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,842 @@
|
||||
/**
|
||||
* `materialDatepicker`: the behaviour of `<x-datepicker>` — M3's docked, modal and modal-input
|
||||
* date pickers on one `<dialog>`.
|
||||
*
|
||||
* The dialog is shown two ways. Docked, it is a `popover="manual"` placed under the field by CSS
|
||||
* anchor positioning; modal (the `modal` and `input` modes, and a docked picker on a compact
|
||||
* window) it is opened with `showModal()`. Everything a person sees in it is drawn by Alpine from
|
||||
* the state below, and the dialog is `wire:ignore`, so a Livewire render never touches it while
|
||||
* it is open.
|
||||
*
|
||||
* Dates are ISO strings (`2026-09-13`) throughout, computed in UTC so no time zone or daylight
|
||||
* saving change can move a day; only "today" is read in the browser's own zone. Month and weekday
|
||||
* names, the week's first day and the typed format come from `Intl` for the locale the server
|
||||
* passes (the application's).
|
||||
*
|
||||
* The keyboard is WAI-ARIA's date picker dialog: arrows move a day or a week, Home and End go to
|
||||
* the start and end of the week, PageUp and PageDown a month (with Shift a year), Space selects,
|
||||
* Enter selects and confirms, Escape closes and hands focus back to the field. Focus never leaves
|
||||
* the `min`–`max` span, so a disabled day cannot be chosen from the keyboard either.
|
||||
*/
|
||||
const COMPACT = '(max-width: 39.99rem)'
|
||||
|
||||
// CLDR's first day of the week by region, for an engine without `Intl.Locale#getWeekInfo`.
|
||||
const WEEK_STARTS_SUNDAY = 'AG AS BD BR BS BT BW BZ CA CN CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW'.split(' ')
|
||||
const WEEK_STARTS_SATURDAY = 'AE AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY'.split(' ')
|
||||
|
||||
const YEARS_FROM = 1900
|
||||
const YEARS_TO = 2100
|
||||
|
||||
function pad(number, length = 2) {
|
||||
return String(number).padStart(length, '0')
|
||||
}
|
||||
|
||||
/** A UTC timestamp for a calendar day; `setUTCFullYear` so years below 100 are not read as 19xx. */
|
||||
function utc(year, month, day) {
|
||||
const date = new Date(0)
|
||||
|
||||
date.setUTCFullYear(year, month - 1, day)
|
||||
|
||||
return date
|
||||
}
|
||||
|
||||
function iso(date) {
|
||||
return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
|
||||
}
|
||||
|
||||
/** The ISO string, or null when it is not a real calendar day. */
|
||||
function valid(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(typeof value === 'string' ? value : '')
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const date = utc(+match[1], +match[2], +match[3])
|
||||
|
||||
return date.getUTCMonth() + 1 === +match[2] && date.getUTCDate() === +match[3] ? match[0] : null
|
||||
}
|
||||
|
||||
function parts(value) {
|
||||
return value.split('-').map(Number)
|
||||
}
|
||||
|
||||
function addDays(value, days) {
|
||||
const [year, month, day] = parts(value)
|
||||
|
||||
return iso(utc(year, month, day + days))
|
||||
}
|
||||
|
||||
/** The same day in another month, or that month's last day when it is shorter. */
|
||||
function addMonths(value, months) {
|
||||
const [year, month, day] = parts(value)
|
||||
const first = utc(year, month + months, 1)
|
||||
const length = utc(first.getUTCFullYear(), first.getUTCMonth() + 2, 0).getUTCDate()
|
||||
|
||||
return iso(utc(first.getUTCFullYear(), first.getUTCMonth() + 1, Math.min(day, length)))
|
||||
}
|
||||
|
||||
function localToday() {
|
||||
const now = new Date()
|
||||
|
||||
return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
}
|
||||
|
||||
/** 0 for Sunday … 6 for Saturday. */
|
||||
function firstDayOfWeek(locale) {
|
||||
try {
|
||||
const tag = new Intl.Locale(locale)
|
||||
const info = typeof tag.getWeekInfo === 'function' ? tag.getWeekInfo() : tag.weekInfo
|
||||
|
||||
if (info?.firstDay) {
|
||||
return info.firstDay % 7
|
||||
}
|
||||
|
||||
const region = tag.maximize().region
|
||||
|
||||
if (WEEK_STARTS_SUNDAY.includes(region)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (WEEK_STARTS_SATURDAY.includes(region)) {
|
||||
return 6
|
||||
}
|
||||
} catch {
|
||||
// An unknown tag falls through to Monday, ISO 8601's first day.
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* The typed format: the locale's short numeric date, reduced to `dd`, `MM` and `yyyy` and one
|
||||
* delimiter — androidx's `datePatternAsInputFormat`, fed from `formatToParts` because `Intl` has
|
||||
* no pattern to give. `de` gives `dd.MM.yyyy`, `en-US` `MM/dd/yyyy`, `ja` `yyyy/MM/dd`.
|
||||
*/
|
||||
function inputFormat(locale) {
|
||||
let pattern = ''
|
||||
|
||||
try {
|
||||
pattern = new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'UTC' })
|
||||
.formatToParts(utc(2026, 11, 22))
|
||||
.map((part) => ({ year: 'y', month: 'M', day: 'd', literal: part.value })[part.type] ?? '')
|
||||
.join('')
|
||||
.replace(/[^dMy/\-.]/g, '')
|
||||
.replace(/d{1,2}/g, 'dd')
|
||||
.replace(/M{1,2}/g, 'MM')
|
||||
.replace(/y{1,4}/g, 'yyyy')
|
||||
.replace('My', 'M/y')
|
||||
.replace(/\.$/, '')
|
||||
} catch {
|
||||
pattern = ''
|
||||
}
|
||||
|
||||
if (!/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern)) {
|
||||
pattern = 'yyyy-MM-dd'
|
||||
}
|
||||
|
||||
return {
|
||||
pattern,
|
||||
placeholder: pattern.toUpperCase(),
|
||||
order: pattern.replace(/[^dMy]/g, '').replace('dd', 'd').replace('MM', 'M').replace('yyyy', 'y'),
|
||||
}
|
||||
}
|
||||
|
||||
function normaliseRange(value) {
|
||||
return { start: valid(value?.start), end: valid(value?.end) }
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialDatepicker', (config) => ({
|
||||
value: config.value,
|
||||
open: false,
|
||||
presentation: 'docked',
|
||||
typing: false,
|
||||
view: 'days',
|
||||
draft: null,
|
||||
focused: null,
|
||||
shown: null,
|
||||
text: '',
|
||||
fieldError: '',
|
||||
entry: '',
|
||||
entryEnd: '',
|
||||
entryError: '',
|
||||
today: localToday(),
|
||||
compact: false,
|
||||
refocus: true,
|
||||
|
||||
init() {
|
||||
const locale = config.locale || document.documentElement.lang || 'en'
|
||||
const format = (options) => new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options })
|
||||
|
||||
this.firstDay = firstDayOfWeek(locale)
|
||||
this.format = inputFormat(locale)
|
||||
this.numbers = new Intl.NumberFormat(locale, { useGrouping: false })
|
||||
this.formats = {
|
||||
monthYear: format({ year: 'numeric', month: 'long' }),
|
||||
month: format({ month: 'short' }),
|
||||
monthLong: format({ month: 'long' }),
|
||||
year: format({ year: 'numeric' }),
|
||||
headline: format({ year: 'numeric', month: 'short', day: 'numeric' }),
|
||||
long: format({ weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
|
||||
weekdayNarrow: format({ weekday: 'narrow' }),
|
||||
weekdayLong: format({ weekday: 'long' }),
|
||||
}
|
||||
|
||||
this.min = valid(config.min)
|
||||
this.max = valid(config.max)
|
||||
// androidx's 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'
|
||||
|
||||
+11
-1
@@ -15,6 +15,7 @@ document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialMenu', () => ({
|
||||
closedAt: -Infinity,
|
||||
returnFocus: true,
|
||||
focusWasInside: false,
|
||||
listeners: [],
|
||||
|
||||
init() {
|
||||
@@ -26,6 +27,13 @@ document.addEventListener('alpine:init', () => {
|
||||
// and close() have already done their part, synchronously, because this event is
|
||||
// queued and a screen reader or a test reading aria-expanded in between would be told
|
||||
// the menu is shut.
|
||||
// Whether focus was in the menu is read before it closes: once closed, a browser may
|
||||
// already have handed focus to what had it before the menu opened (WebKit does, when
|
||||
// that was a focusable region around the trigger).
|
||||
this.listen(menu, 'beforetoggle', (event) => {
|
||||
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
|
||||
})
|
||||
|
||||
this.listen(menu, 'toggle', (event) => {
|
||||
const opened = event.newState === 'open'
|
||||
|
||||
@@ -37,9 +45,11 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
this.closedAt = performance.now()
|
||||
|
||||
if (this.returnFocus && menu.contains(document.activeElement)) {
|
||||
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
|
||||
this.control()?.focus()
|
||||
}
|
||||
|
||||
this.focusWasInside = false
|
||||
})
|
||||
|
||||
// A press outside closes the menu without pulling focus back to the trigger.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -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,146 @@
|
||||
{{-- The adaptive app shell: navigation that changes shape with the window, around the page.
|
||||
|
||||
<x-app-shell :destinations="[
|
||||
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares'), 'active' => request()->routeIs('shares*'), 'badge' => 3],
|
||||
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
|
||||
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
|
||||
]">
|
||||
<x-slot:brand><a href="/" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
|
||||
<x-slot:top>…the page's app bar…</x-slot:top>
|
||||
|
||||
…the page…
|
||||
</x-app-shell>
|
||||
|
||||
- Below `sm`: a navigation bar with the destinations marked `bar`, pinned to the bottom.
|
||||
Everything else is in the modal rail, which slides in when something calls
|
||||
`$store.rail.show()` — put a menu button in the app bar for it, hidden from `sm`:
|
||||
`<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>`.
|
||||
- `sm` to `lg`: the collapsed rail, whose menu button opens it expanded, as a modal.
|
||||
- From `lg`: the expanded rail, collapsed and expanded again by its menu button; the choice is
|
||||
remembered and applied before the first paint (`$store.rail`, <x-theme-script>).
|
||||
|
||||
`destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally
|
||||
`active` (by default: the URL is the current one), `badge` (`true` for a dot, or a count),
|
||||
`section` (a heading the destination is grouped under in the rail; only an expanded rail shows
|
||||
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
|
||||
`navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||
|
||||
Slots, each rendered once: `brand` (beside the rail's menu button while it is expanded),
|
||||
`rail-header` (under it: a FAB — see `<x-navigation-rail>` for its two shapes), `rail-footer`
|
||||
(at the foot of the rail: footer destinations, an account), `actions` (a row of icon buttons at
|
||||
the very foot, stacked when the rail is collapsed: a theme toggle, sign out), `top` (the app
|
||||
bar, above the page at every width) and the page itself. The rail is one element at every
|
||||
width, so what is in it is also in the modal rail a phone opens. `label` names both navigation
|
||||
landmarks ("Main"); `rail-width` is the expanded rail's width.
|
||||
|
||||
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
|
||||
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
|
||||
below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`.
|
||||
|
||||
`max-lg:overflow-x-clip` on the content region is the backstop under every page, and it stays
|
||||
`clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll
|
||||
container and break every `position: sticky` inside it (an app bar, a list-detail pane). Below
|
||||
`lg` only, so a wide window never clips what overhangs on purpose.
|
||||
|
||||
Nothing application-specific belongs in here: an app's destinations and chrome come in through
|
||||
the props and slots. --}}
|
||||
|
||||
@props([
|
||||
'destinations' => [],
|
||||
'label' => null,
|
||||
'railWidth' => '16rem',
|
||||
])
|
||||
|
||||
@php
|
||||
$label ??= __('Main');
|
||||
$current = request()->url();
|
||||
|
||||
$items = collect($destinations)
|
||||
->filter(fn ($item): bool => is_array($item) && filled($item['title'] ?? null))
|
||||
->map(fn (array $item): array => [
|
||||
'title' => (string) $item['title'],
|
||||
'icon' => $item['icon'] ?? null,
|
||||
'url' => $item['url'] ?? null,
|
||||
'active' => (bool) ($item['active'] ?? (filled($item['url'] ?? null) && rtrim(url($item['url']), '/') === rtrim($current, '/'))),
|
||||
'badge' => $item['badge'] ?? null,
|
||||
'section' => filled($item['section'] ?? null) ? (string) $item['section'] : null,
|
||||
'bar' => ($item['bar'] ?? true) !== false,
|
||||
'navigate' => ($item['navigate'] ?? true) !== false,
|
||||
])
|
||||
->values();
|
||||
|
||||
$barItems = $items->where('bar', true)->values();
|
||||
|
||||
// Consecutive destinations under the same heading form one group, in the order given.
|
||||
$groups = $items->chunkWhile(fn (array $item, int $key, $chunk): bool => $item['section'] === $chunk->last()['section']);
|
||||
@endphp
|
||||
|
||||
<div
|
||||
data-app-shell
|
||||
@class([
|
||||
'min-h-dvh bg-surface text-on-surface sm:flex',
|
||||
'max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]' => $barItems->isNotEmpty(),
|
||||
])
|
||||
>
|
||||
<a
|
||||
href="#content"
|
||||
data-skip-link
|
||||
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(env(safe-area-inset-top)+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
|
||||
>{{ __('Skip to content') }}</a>
|
||||
|
||||
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
|
||||
@isset($brand)
|
||||
<x-slot:brand>{{ $brand }}</x-slot:brand>
|
||||
@endisset
|
||||
|
||||
@isset($railHeader)
|
||||
<x-slot:header>{{ $railHeader }}</x-slot:header>
|
||||
@endisset
|
||||
|
||||
@foreach ($groups as $group)
|
||||
@if ($group->first()['section'] !== null)
|
||||
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
|
||||
@foreach ($group as $item)
|
||||
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
||||
@endforeach
|
||||
</x-livewire-material::navigation-rail-section>
|
||||
@else
|
||||
@foreach ($group as $item)
|
||||
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if (isset($railFooter) || isset($actions))
|
||||
<x-slot:footer>
|
||||
{{ $railFooter ?? '' }}
|
||||
|
||||
@isset($actions)
|
||||
<div data-app-shell-actions class="flex items-center gap-1 px-5 pt-2 rail-collapsed:flex-col">
|
||||
{{ $actions }}
|
||||
</div>
|
||||
@endisset
|
||||
</x-slot:footer>
|
||||
@endif
|
||||
</x-livewire-material::navigation-rail>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
{{ $top ?? '' }}
|
||||
|
||||
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 outline-none max-lg:overflow-x-clip max-sm:pb-(--material-bottom-bar)">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@if ($barItems->isNotEmpty())
|
||||
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
||||
<x-livewire-material::navigation-bar :label="$label">
|
||||
@foreach ($barItems as $item)
|
||||
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
||||
@endforeach
|
||||
</x-livewire-material::navigation-bar>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<x-livewire-material::toast />
|
||||
</div>
|
||||
@@ -202,13 +202,13 @@
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($spinnerTarget)
|
||||
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center">
|
||||
<x-loading :class="$iconSize" :label="false" />
|
||||
<x-livewire-material::loading :class="$iconSize" :label="false" />
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if ($icon)
|
||||
<span class="contents" @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif>
|
||||
<x-icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-sm:size-6' => $fab])" />
|
||||
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-sm:size-6' => $fab])" />
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@@ -217,10 +217,10 @@
|
||||
@endunless
|
||||
|
||||
@if ($iconRight)
|
||||
<x-icon :name="$iconRight" :class="$iconSize" />
|
||||
<x-livewire-material::icon :name="$iconRight" :class="$iconSize" />
|
||||
@endif
|
||||
|
||||
@if ($tip !== null)
|
||||
<x-tooltip :text="$tip" :side="$tipSide" :anchor="$anchor" />
|
||||
<x-livewire-material::tooltip :text="$tip" :side="$tipSide" :anchor="$anchor" />
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</div>
|
||||
|
||||
@if ($separator)
|
||||
<x-divider class="mt-4" />
|
||||
<x-livewire-material::divider class="mt-4" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
'hidden pointer-fine:flex' => $controls === null,
|
||||
'flex' => $controls === true,
|
||||
])>
|
||||
<x-button
|
||||
<x-livewire-material::button
|
||||
icon="chevron_left"
|
||||
variant="tonal"
|
||||
:tooltip="__('Previous')"
|
||||
@@ -145,7 +145,7 @@
|
||||
x-on:click="previous()"
|
||||
class="rtl:-scale-x-100"
|
||||
/>
|
||||
<x-button
|
||||
<x-livewire-material::button
|
||||
icon="chevron_right"
|
||||
variant="tonal"
|
||||
:tooltip="__('Next')"
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$id = $attributes->get('id') ?? 'check-'.substr(md5($model.'|'.$label.'|'.$attributes->get('value')), 0, 12);
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}>
|
||||
@@ -36,8 +38,8 @@
|
||||
@if ($indeterminate) data-indeterminate @endif
|
||||
@if ($messages !== []) aria-invalid="true" aria-describedby="{{ $id }}-support" @endif
|
||||
/>
|
||||
<x-icon name="check" class="size-4" data-check />
|
||||
<x-icon name="remove" class="size-4" data-mixed />
|
||||
<x-livewire-material::icon name="check" class="size-4" data-check />
|
||||
<x-livewire-material::icon name="remove" class="size-4" data-mixed />
|
||||
</span>
|
||||
|
||||
@if (filled($label) || filled($hint))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,12 +28,12 @@
|
||||
'px-4' => $variant === 'filled',
|
||||
])>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" class="size-6 text-on-surface-variant" />
|
||||
<x-livewire-material::icon :name="$icon" class="size-6 text-on-surface-variant" />
|
||||
@endif
|
||||
|
||||
<span class="min-w-0 flex-1">{{ $heading ?? $title }}</span>
|
||||
|
||||
<x-icon name="expand_more" class="size-6 text-on-surface-variant transition-[rotate] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast group-open/collapse:rotate-180" />
|
||||
<x-livewire-material::icon name="expand_more" class="size-6 text-on-surface-variant transition-[rotate] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast group-open/collapse:rotate-180" />
|
||||
</summary>
|
||||
|
||||
<div @class(['pb-4 type-body-md text-on-surface-variant', 'px-4' => $variant === 'filled', 'pt-1'])>
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
{{-- A date field with M3's date pickers: docked under the field, modal, or modal text input.
|
||||
|
||||
`mode` picks M3's three:
|
||||
|
||||
- `docked` (the default): a text field that takes a typed date in the locale's numeric format
|
||||
(`13.09.2026` in `de`, `09/13/2026` in `en-US`), with the calendar dropping open under it —
|
||||
on a press of the field, its calendar button, or ArrowDown. On a compact window (below
|
||||
`sm`) the calendar opens as the modal picker instead, where a docked one would not fit.
|
||||
- `modal`: the field only shows the date; pressing it (or Enter, Space, ArrowDown) opens the
|
||||
calendar in a dialog, with a pencil to switch to typing.
|
||||
- `input`: the same dialog, opened on its text field, with a calendar icon to switch back.
|
||||
|
||||
The calendar and the dialog's text field pick a draft; OK (or Enter on a day) makes it the
|
||||
value, Cancel or Escape leaves the value alone and returns focus to the field. What is typed
|
||||
into the docked field is the value as soon as it is a whole, allowed date.
|
||||
|
||||
`wire:model` stores `Y-m-d` strings (`x-model` without Livewire). `range` picks a start and an
|
||||
end, bound as one array, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null) —
|
||||
one property rather than two, because a `wire:model` names one property, Livewire sends a
|
||||
range as one update so `after_or_equal:period.start` validates against the end it came
|
||||
with, and the errors for `period`, `period.start` and `period.end` all belong to this field.
|
||||
`min` and `max` (`Y-m-d` or a date object) disable the days outside them and keep the
|
||||
keyboard inside them. `label`, `hint`, `icon`, `variant` (`outlined`, `filled`) and `size`
|
||||
are the field's; `clearable` adds a button that empties it (a date, or both ends of a range)
|
||||
once it holds one; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every
|
||||
other attribute (`required`, `disabled`, `readonly`) reaches the text field. Errors under
|
||||
the `wire:model` name replace the hint, and so does a typed date that cannot be read.
|
||||
|
||||
Month and weekday names, the first day of the week and the typed format come from `Intl` for
|
||||
`app()->getLocale()` (resources/js/datepicker.js). Replaces ReStride's flatpickr picker: its
|
||||
`config` becomes `min`, `max`, `range` and `mode`.
|
||||
|
||||
M3's date pickers (DatePickerModalTokens and DateInputModalTokens from androidx Compose
|
||||
Material 3, androidx commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, with the layout of
|
||||
DatePicker.kt, DateRangePicker.kt and DateInput.kt; the docked picker's values from
|
||||
material-web's md-comp-date-picker-docked tokens, v0_192, as Compose has none; all Apache-2.0):
|
||||
surface-container-high at elevation 3, 360px wide, the extra-large corner (modal) or large
|
||||
(docked), 40px days in 48px cells, today outlined in primary, the chosen day in primary and a
|
||||
range's middle in secondary-container. The styles are resources/css/components/datepicker.css. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'hint' => null,
|
||||
'icon' => null,
|
||||
'size' => 'md',
|
||||
'variant' => null,
|
||||
'mode' => 'docked',
|
||||
'range' => false,
|
||||
'min' => null,
|
||||
'max' => null,
|
||||
'value' => null,
|
||||
'clearable' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$model = $attributes->wire('model')->value() ?: null;
|
||||
$mode = in_array($mode, ['docked', 'modal', 'input'], true) ? $mode : 'docked';
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|datepicker'), 0, 12);
|
||||
$anchor = '--material-datepicker-'.preg_replace('/[^A-Za-z0-9_-]/', '-', $id);
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
||||
: [];
|
||||
$locale = str_replace('_', '-', app()->getLocale());
|
||||
|
||||
$toIso = function (mixed $date): ?string {
|
||||
if ($date instanceof \DateTimeInterface) {
|
||||
return $date->format('Y-m-d');
|
||||
}
|
||||
|
||||
if (is_string($date) && preg_match('/^(\d{4})-(\d{2})-(\d{2})(?:$|[T ])/', $date, $match) === 1 && checkdate((int) $match[2], (int) $match[3], (int) $match[1])) {
|
||||
return substr($date, 0, 10);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Rendered as the bound property already says, so the field does not change once Alpine starts.
|
||||
$current = $value;
|
||||
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
|
||||
$current = data_get($component, $model);
|
||||
}
|
||||
$current = $range
|
||||
? ['start' => $toIso(data_get($current, 'start')), 'end' => $toIso(data_get($current, 'end'))]
|
||||
: $toIso($current);
|
||||
|
||||
// The typed format as resources/js/datepicker.js derives it, from ICU's short date when PHP has intl.
|
||||
$pattern = 'yyyy-MM-dd';
|
||||
if (class_exists(\IntlDateFormatter::class)) {
|
||||
$short = (string) (new \IntlDateFormatter(str_replace('-', '_', $locale), \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE))->getPattern();
|
||||
$candidate = rtrim(str_replace('My', 'M/y', (string) preg_replace(['/[^dMy\/\-.]/', '/d{1,2}/', '/M{1,2}/', '/y{1,4}/'], ['', 'dd', 'MM', 'yyyy'], $short)), '.');
|
||||
if (preg_match('/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[\/\-.][dMy]+[\/\-.][dMy]+$/', $candidate) === 1) {
|
||||
$pattern = $candidate;
|
||||
}
|
||||
}
|
||||
$typed = fn (?string $date): string => $date === null ? '' : strtr($pattern, ['yyyy' => substr($date, 0, 4), 'MM' => substr($date, 5, 2), 'dd' => substr($date, 8, 2)]);
|
||||
$display = $range
|
||||
? ($current['start'] === null && $current['end'] === null ? '' : $typed($current['start']).' – '.$typed($current['end']))
|
||||
: $typed($current);
|
||||
|
||||
$title = $range ? __('Select dates') : __('Select date');
|
||||
$inputTitle = $range ? __('Enter dates') : __('Select date');
|
||||
$fieldName = $attributes->get('name');
|
||||
$invalid = $messages !== [];
|
||||
|
||||
$config = [
|
||||
'locale' => $locale,
|
||||
'mode' => $mode,
|
||||
'range' => (bool) $range,
|
||||
'min' => $toIso($min),
|
||||
'max' => $toIso($max),
|
||||
'disabled' => (bool) $attributes->get('disabled'),
|
||||
'readonly' => (bool) $attributes->get('readonly'),
|
||||
'strings' => [
|
||||
'selected' => __('Selected date'),
|
||||
'entered' => __('Entered date'),
|
||||
'start' => __('Start date'),
|
||||
'end' => __('End date'),
|
||||
'pattern' => __('Date does not match expected pattern: :pattern'),
|
||||
'yearRange' => __('Date out of expected year range :start - :end'),
|
||||
'notAllowed' => __('Date not allowed: :date'),
|
||||
'invalidRange' => __('Invalid date range input'),
|
||||
],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div
|
||||
{{ $attributes->only(['class', 'wire:key', 'x-model']) }}
|
||||
x-data="materialDatepicker({
|
||||
@if ($model !== null) value: @entangle($attributes->wire('model')), @else value: @js($current), @endif
|
||||
...@js($config),
|
||||
})"
|
||||
@if ($model === null) x-modelable="value" @endif
|
||||
x-on:focusout="leave($event)"
|
||||
x-on:pointerdown.outside="open && presentation === 'docked' && cancel(false)"
|
||||
data-datepicker
|
||||
>
|
||||
<div style="anchor-name: {{ $anchor }}">
|
||||
<x-livewire-material::field
|
||||
:$id
|
||||
:$label
|
||||
:$icon
|
||||
:$size
|
||||
:$variant
|
||||
:data-invalid="$invalid ? '' : null"
|
||||
:data-readonly="$attributes->get('readonly') ? '' : null"
|
||||
x-bind:data-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? '' : null"
|
||||
>
|
||||
<input
|
||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['class', 'id', 'wire:key', 'x-model', 'name', 'value', 'placeholder', 'type']) }}
|
||||
x-ref="input"
|
||||
id="{{ $id }}"
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="{{ $id }}-picker"
|
||||
aria-expanded="false"
|
||||
x-bind:aria-expanded="open.toString()"
|
||||
aria-describedby="{{ $id }}-support"
|
||||
@if ($invalid) aria-invalid="true" @endif
|
||||
x-bind:aria-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? 'true' : null"
|
||||
autocomplete="off"
|
||||
value="{{ $display }}"
|
||||
placeholder=" "
|
||||
class="field-control"
|
||||
x-model="text"
|
||||
@if ($mode === 'docked')
|
||||
x-bind:placeholder="placeholder"
|
||||
x-on:input="typeInField()"
|
||||
x-on:change="commitField()"
|
||||
x-on:keydown.enter="commitField()"
|
||||
x-on:click="show(false)"
|
||||
x-on:keydown.arrow-down.prevent="open ? focusInside() : show()"
|
||||
x-on:keydown.escape="if (open) { $event.preventDefault(); $event.stopPropagation(); cancel(); }"
|
||||
@else
|
||||
readonly
|
||||
x-on:click="show()"
|
||||
x-on:keydown.enter.prevent="show()"
|
||||
x-on:keydown.space.prevent="show()"
|
||||
x-on:keydown.arrow-down.prevent="show()"
|
||||
@endif
|
||||
/>
|
||||
|
||||
<x-slot:trailing>
|
||||
@if ($clearable)
|
||||
<button
|
||||
type="button"
|
||||
class="field-trailing field-clear field-button"
|
||||
aria-label="{{ __('Clear') }}"
|
||||
x-on:click="clear()"
|
||||
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
|
||||
data-field-clear
|
||||
>
|
||||
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="field-trailing field-button"
|
||||
aria-label="{{ $title }}"
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="{{ $id }}-picker"
|
||||
x-bind:aria-expanded="open.toString()"
|
||||
x-on:click="open ? cancel() : show()"
|
||||
@if ($mode !== 'docked') tabindex="-1" @endif
|
||||
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
|
||||
data-datepicker-toggle
|
||||
>
|
||||
<x-livewire-material::icon name="calendar_today" class="size-(--field-icon)" />
|
||||
</button>
|
||||
</x-slot:trailing>
|
||||
</x-livewire-material::field>
|
||||
</div>
|
||||
|
||||
<div id="{{ $id }}-support" data-datepicker-support data-size="{{ in_array($size, ['sm', 'xs'], true) ? $size : 'md' }}">
|
||||
<p x-cloak x-show="fieldError !== ''" x-text="fieldError" role="alert" data-datepicker-error></p>
|
||||
|
||||
@if ($invalid)
|
||||
<div x-show="fieldError === ''" role="alert" data-datepicker-error>
|
||||
@foreach ($messages as $message)
|
||||
<p>{{ $message }}</p>
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif (filled($hint))
|
||||
<p x-show="fieldError === ''">{{ $hint }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($fieldName))
|
||||
@if ($range)
|
||||
<input type="hidden" name="{{ $fieldName }}[start]" value="{{ $current['start'] }}" x-bind:value="serialised.start ?? ''" />
|
||||
<input type="hidden" name="{{ $fieldName }}[end]" value="{{ $current['end'] }}" x-bind:value="serialised.end ?? ''" />
|
||||
@else
|
||||
<input type="hidden" name="{{ $fieldName }}" value="{{ $current }}" x-bind:value="serialised" />
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<dialog
|
||||
wire:ignore
|
||||
x-ref="dialog"
|
||||
id="{{ $id }}-picker"
|
||||
popover="manual"
|
||||
aria-label="{{ $title }}"
|
||||
style="position-anchor: {{ $anchor }}"
|
||||
x-bind:data-presentation="presentation"
|
||||
x-on:cancel.prevent="cancel()"
|
||||
x-on:click.self="presentation === 'modal' && cancel()"
|
||||
x-on:keydown.escape.prevent.stop="cancel()"
|
||||
data-datepicker-picker
|
||||
>
|
||||
<div tabindex="-1" data-datepicker-surface>
|
||||
<div data-datepicker-header x-show="presentation === 'modal'" @if ($range) data-range @endif>
|
||||
<p data-datepicker-title x-text="typing ? @js($inputTitle) : @js($title)">{{ $title }}</p>
|
||||
|
||||
<div data-datepicker-headline-row>
|
||||
<p data-datepicker-headline aria-live="polite" x-text="headline"></p>
|
||||
|
||||
<span x-show="! typing">
|
||||
<x-livewire-material::button icon="edit" :tooltip="__('Switch to text input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
|
||||
</span>
|
||||
<span x-show="typing">
|
||||
<x-livewire-material::button icon="date_range" :tooltip="__('Switch to calendar input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="! typing" data-datepicker-calendar>
|
||||
<span id="{{ $id }}-month" class="sr-only" aria-live="polite" x-text="monthYear"></span>
|
||||
|
||||
<div data-datepicker-nav x-show="presentation === 'modal'">
|
||||
<button
|
||||
type="button"
|
||||
data-datepicker-menu-button
|
||||
x-on:click="toggleView('years')"
|
||||
x-bind:aria-expanded="(view === 'years').toString()"
|
||||
x-bind:aria-label="monthYear + ', ' + @js(__('Switch to selecting a year'))"
|
||||
>
|
||||
<span x-text="monthYear"></span>
|
||||
<x-livewire-material::icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
|
||||
</button>
|
||||
|
||||
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
|
||||
<x-livewire-material::button icon="chevron_left" :tooltip="__('Previous month')" x-on:click="step(-1)" x-bind:disabled="view !== 'days' || ! canStep(-1)" data-datepicker-previous />
|
||||
<x-livewire-material::button icon="chevron_right" :tooltip="__('Next month')" x-on:click="step(1)" x-bind:disabled="view !== 'days' || ! canStep(1)" data-datepicker-next />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div data-datepicker-nav data-docked x-show="presentation === 'docked'">
|
||||
<span data-datepicker-stepper>
|
||||
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
|
||||
<x-livewire-material::button icon="chevron_left" :tooltip="__('Previous month')" x-on:click="step(-1)" x-bind:disabled="view !== 'days' || ! canStep(-1)" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-datepicker-menu-button
|
||||
x-on:click="toggleView('months')"
|
||||
x-bind:aria-expanded="(view === 'months').toString()"
|
||||
x-bind:aria-label="monthLabel + ', ' + @js(__('Switch to selecting a month'))"
|
||||
>
|
||||
<span x-text="monthLabel"></span>
|
||||
<x-livewire-material::icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
|
||||
</button>
|
||||
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
|
||||
<x-livewire-material::button icon="chevron_right" :tooltip="__('Next month')" x-on:click="step(1)" x-bind:disabled="view !== 'days' || ! canStep(1)" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span data-datepicker-stepper>
|
||||
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
|
||||
<x-livewire-material::button icon="chevron_left" :tooltip="__('Previous year')" x-on:click="step(-12)" x-bind:disabled="view !== 'days' || ! canStep(-12)" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-datepicker-menu-button
|
||||
x-on:click="toggleView('years')"
|
||||
x-bind:aria-expanded="(view === 'years').toString()"
|
||||
x-bind:aria-label="yearLabel + ', ' + @js(__('Switch to selecting a year'))"
|
||||
>
|
||||
<span x-text="yearLabel"></span>
|
||||
<x-livewire-material::icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
|
||||
</button>
|
||||
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
|
||||
<x-livewire-material::button icon="chevron_right" :tooltip="__('Next year')" x-on:click="step(12)" x-bind:disabled="view !== 'days' || ! canStep(12)" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<table role="grid" aria-labelledby="{{ $id }}-month" x-show="view === 'days'" x-on:keydown="gridKey($event)" data-datepicker-grid>
|
||||
<thead>
|
||||
<tr>
|
||||
<template x-for="weekday in weekdays" :key="weekday.long">
|
||||
<th scope="col" x-bind:abbr="weekday.long" x-text="weekday.narrow"></th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="(week, row) in weeks" :key="row">
|
||||
<tr>
|
||||
<template x-for="(cell, column) in week" :key="column">
|
||||
<td
|
||||
role="gridcell"
|
||||
x-bind:tabindex="cell.blank ? null : (cell.focused ? 0 : -1)"
|
||||
x-bind:aria-label="cell.blank ? null : cell.name"
|
||||
x-bind:aria-selected="! cell.blank && (cell.selected || cell.between) ? 'true' : null"
|
||||
x-bind:aria-disabled="! cell.blank && cell.disabled ? 'true' : null"
|
||||
x-bind:aria-current="! cell.blank && cell.today ? 'date' : null"
|
||||
x-bind:data-value="cell.blank ? null : cell.value"
|
||||
x-bind:data-blank="cell.blank ? '' : null"
|
||||
x-bind:data-outside="cell.outside ? '' : null"
|
||||
x-bind:data-today="cell.today ? '' : null"
|
||||
x-bind:data-selected="cell.selected ? '' : null"
|
||||
x-bind:data-start="cell.start ? '' : null"
|
||||
x-bind:data-end="cell.end ? '' : null"
|
||||
x-bind:data-between="cell.between ? '' : null"
|
||||
x-on:click="choose(cell)"
|
||||
data-datepicker-day
|
||||
><span x-text="cell.blank ? '' : cell.label"></span></td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div role="listbox" aria-label="{{ __('Years') }}" x-show="view === 'years' && presentation === 'modal'" x-on:keydown="listKey($event, 3)" data-datepicker-list data-datepicker-years>
|
||||
<template x-for="year in years" :key="year.value">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
x-bind:aria-selected="year.selected.toString()"
|
||||
x-bind:tabindex="year.selected ? 0 : -1"
|
||||
x-bind:data-current="year.current ? '' : null"
|
||||
x-on:click="showMonthOf(year.value, +shown.slice(5, 7))"
|
||||
x-text="year.label"
|
||||
data-datepicker-year
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div role="listbox" aria-label="{{ __('Months') }}" x-show="view === 'months' && presentation === 'docked'" x-on:keydown="listKey($event)" data-datepicker-list data-datepicker-menu>
|
||||
<template x-for="month in months" :key="month.value">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
x-bind:aria-selected="month.selected.toString()"
|
||||
x-bind:aria-disabled="month.disabled ? 'true' : null"
|
||||
x-bind:tabindex="month.selected ? 0 : -1"
|
||||
x-on:click="month.disabled || showMonthOf(+shown.slice(0, 4), month.value)"
|
||||
data-datepicker-option
|
||||
>
|
||||
<x-livewire-material::icon name="check" data-datepicker-check />
|
||||
<span x-text="month.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div role="listbox" aria-label="{{ __('Years') }}" x-show="view === 'years' && presentation === 'docked'" x-on:keydown="listKey($event)" data-datepicker-list data-datepicker-menu>
|
||||
<template x-for="year in years" :key="year.value">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
x-bind:aria-selected="year.selected.toString()"
|
||||
x-bind:tabindex="year.selected ? 0 : -1"
|
||||
x-on:click="showMonthOf(year.value, +shown.slice(5, 7))"
|
||||
data-datepicker-option
|
||||
>
|
||||
<x-livewire-material::icon name="check" data-datepicker-check />
|
||||
<span x-text="year.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="typing" data-datepicker-entry>
|
||||
<div @if ($range) data-range @endif data-datepicker-entry-fields>
|
||||
<x-livewire-material::field id="{{ $id }}-entry" :label="$range ? __('Start date') : __('Date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null">
|
||||
<input
|
||||
id="{{ $id }}-entry"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder=" "
|
||||
x-bind:placeholder="format.placeholder"
|
||||
x-bind:aria-invalid="entryError !== '' ? 'true' : null"
|
||||
aria-describedby="{{ $id }}-entry-support"
|
||||
x-ref="entry"
|
||||
x-model="entry"
|
||||
x-on:input="typeEntry()"
|
||||
x-on:keydown.enter.prevent="confirm()"
|
||||
class="field-control"
|
||||
/>
|
||||
</x-livewire-material::field>
|
||||
|
||||
@if ($range)
|
||||
<x-livewire-material::field id="{{ $id }}-entry-end" :label="__('End date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null">
|
||||
<input
|
||||
id="{{ $id }}-entry-end"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder=" "
|
||||
x-bind:placeholder="format.placeholder"
|
||||
x-bind:aria-invalid="entryError !== '' ? 'true' : null"
|
||||
aria-describedby="{{ $id }}-entry-support"
|
||||
x-model="entryEnd"
|
||||
x-on:input="typeEntry()"
|
||||
x-on:keydown.enter.prevent="confirm()"
|
||||
class="field-control"
|
||||
/>
|
||||
</x-livewire-material::field>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div id="{{ $id }}-entry-support" data-datepicker-support>
|
||||
<p x-show="entryError !== ''" x-text="entryError" role="alert" data-datepicker-error></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="view === 'days' || typing || presentation === 'modal'" data-datepicker-actions>
|
||||
<x-livewire-material::button :label="__('Cancel')" x-on:click="cancel()" data-datepicker-cancel />
|
||||
<x-livewire-material::button :label="__('OK')" x-on:click="confirm()" data-datepicker-confirm />
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
@@ -113,13 +113,13 @@
|
||||
|
||||
@if ($withCloseButton)
|
||||
<span class="-me-3 -mt-2 inline-flex shrink-0">
|
||||
<x-button icon="close" :tooltip-left="__('Close')" x-on:click="close()" />
|
||||
<x-livewire-material::button icon="close" :tooltip-left="__('Close')" x-on:click="close()" />
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($separator)
|
||||
<x-divider class="mt-4" />
|
||||
<x-livewire-material::divider class="mt-4" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
|
||||
<div {{ $attributes->class('flex flex-col items-center gap-4 px-4 py-10 text-center') }}>
|
||||
<div class="relative grid size-28 place-items-center">
|
||||
<x-shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" />
|
||||
<x-icon :name="$icon" class="relative size-12 text-on-secondary-container" />
|
||||
<x-livewire-material::shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" />
|
||||
<x-livewire-material::icon :name="$icon" class="relative size-12 text-on-secondary-container" />
|
||||
</div>
|
||||
|
||||
@if ($title)
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" class="size-6" />
|
||||
<x-livewire-material::icon :name="$icon" class="size-6" />
|
||||
@endif
|
||||
|
||||
<span>{{ $label ?? $slot }}</span>
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
$colours,
|
||||
])
|
||||
>
|
||||
<x-icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" />
|
||||
<x-icon name="close" class="hidden size-5 group-aria-expanded/fab:block" />
|
||||
<x-livewire-material::icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" />
|
||||
<x-livewire-material::icon name="close" class="hidden size-5 group-aria-expanded/fab:block" />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -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']) }}>
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
@php
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$name ??= $model;
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
|
||||
|
||||
$segment = [
|
||||
@@ -79,7 +81,7 @@
|
||||
/>
|
||||
|
||||
@if (filled(data_get($option, $optionIcon)))
|
||||
<x-icon :name="data_get($option, $optionIcon)" :class="$iconSize" />
|
||||
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :class="$iconSize" />
|
||||
@endif
|
||||
|
||||
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
|
||||
|
||||
@@ -30,10 +30,12 @@
|
||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|'.$placeholder), 0, 12);
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
@endphp
|
||||
|
||||
<x-field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
|
||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
|
||||
<input
|
||||
{{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }}
|
||||
id="{{ $id }}"
|
||||
@@ -54,7 +56,7 @@
|
||||
aria-label="{{ __('Clear') }}"
|
||||
data-field-clear
|
||||
>
|
||||
<x-icon name="close" class="size-(--field-icon)" />
|
||||
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
|
||||
</button>
|
||||
@endif
|
||||
|
||||
@@ -67,13 +69,13 @@
|
||||
aria-label="{{ __('Copy') }}"
|
||||
data-field-copy
|
||||
>
|
||||
<x-icon name="content_copy" class="size-(--field-icon)" />
|
||||
<x-livewire-material::icon name="content_copy" class="size-(--field-icon)" />
|
||||
</button>
|
||||
@endif
|
||||
|
||||
@if ($iconRight)
|
||||
<x-icon :name="$iconRight" class="field-trailing size-(--field-icon)" />
|
||||
<x-livewire-material::icon :name="$iconRight" class="field-trailing size-(--field-icon)" />
|
||||
@endif
|
||||
</x-slot:trailing>
|
||||
@endif
|
||||
</x-field>
|
||||
</x-livewire-material::field>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
@elseif ($image)
|
||||
<img src="{{ $image }}" alt="" class="size-14 shrink-0 rounded-corner-sm object-cover" />
|
||||
@elseif ($icon)
|
||||
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
|
||||
<x-livewire-material::icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
|
||||
@endif
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -94,6 +94,6 @@
|
||||
@endif
|
||||
|
||||
@if ($iconRight)
|
||||
<x-icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
|
||||
<x-livewire-material::icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" :filled="$selected === true" :class="'size-5 '.$iconInk" />
|
||||
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="'size-5 '.$iconInk" />
|
||||
@endif
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
@@ -76,6 +76,6 @@
|
||||
@endif
|
||||
|
||||
@if ($iconRight)
|
||||
<x-icon :name="$iconRight" :class="'size-5 '.$iconInk" />
|
||||
<x-livewire-material::icon :name="$iconRight" :class="'size-5 '.$iconInk" />
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
])>
|
||||
@if ($fullscreen)
|
||||
<div class="flex h-16 shrink-0 items-center gap-1 px-1 sm:hidden">
|
||||
<x-button icon="close" :tooltip="__('Close')" x-on:click="close()" />
|
||||
<x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="close()" />
|
||||
|
||||
@if (filled($title))
|
||||
<span class="truncate type-title-lg">{{ $title }}</span>
|
||||
@@ -78,22 +78,23 @@
|
||||
@endif
|
||||
|
||||
<div @class(['min-h-0 flex-1', 'max-sm:overflow-y-auto max-sm:px-6 max-sm:pb-6' => $fullscreen])>
|
||||
@if (filled($title) || $icon)
|
||||
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon, 'text-center' => $icon])>
|
||||
@if (filled($title) || filled($subtitle) || $icon)
|
||||
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
|
||||
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
|
||||
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
|
||||
@endif
|
||||
|
||||
@if (filled($title))
|
||||
<h2 id="{{ $id }}-title" class="type-headline-sm">{{ $title }}</h2>
|
||||
<h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-sm:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
|
||||
@endif
|
||||
|
||||
@if (filled($subtitle))
|
||||
<p class="mt-4 type-body-md text-on-surface-variant">{{ $subtitle }}</p>
|
||||
<p @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-sm:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p>
|
||||
@endif
|
||||
|
||||
@if ($separator)
|
||||
<x-divider class="mt-4" />
|
||||
<x-livewire-material::divider class="mt-4" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -33,21 +33,21 @@
|
||||
<div {{ $attributes->class(['min-w-0']) }} data-section-nav>
|
||||
@if ($current)
|
||||
<div class="sm:hidden" data-section-picker>
|
||||
<x-menu :$label position="bottom-start">
|
||||
<x-livewire-material::menu :$label position="bottom-start">
|
||||
<x-slot:trigger>
|
||||
<button type="button" class="focus-ring flex h-12 w-[min(20rem,calc(100vw-2rem))] cursor-pointer items-center gap-3 rounded-corner-xs border border-outline px-4 text-start type-body-lg text-on-surface">
|
||||
@isset($current['icon'])
|
||||
<x-icon :name="$current['icon']" class="size-5 text-on-surface-variant" />
|
||||
<x-livewire-material::icon :name="$current['icon']" class="size-5 text-on-surface-variant" />
|
||||
@endisset
|
||||
<span class="min-w-0 flex-1 truncate">{{ $current['title'] }}</span>
|
||||
<x-icon name="arrow_drop_down" class="size-6 text-on-surface-variant" />
|
||||
<x-livewire-material::icon name="arrow_drop_down" class="size-6 text-on-surface-variant" />
|
||||
</button>
|
||||
</x-slot:trigger>
|
||||
|
||||
@foreach ($items as $item)
|
||||
<x-menu-item :label="$item['title']" :icon="$item['icon'] ?? null" :link="$item['url']" :selected="$isCurrent($item)" :no-wire-navigate="$noWireNavigate" />
|
||||
<x-livewire-material::menu-item :label="$item['title']" :icon="$item['icon'] ?? null" :link="$item['url']" :selected="$isCurrent($item)" :no-wire-navigate="$noWireNavigate" />
|
||||
@endforeach
|
||||
</x-menu>
|
||||
</x-livewire-material::menu>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -65,11 +65,11 @@
|
||||
>
|
||||
<span data-tab-content>
|
||||
@isset($item['icon'])
|
||||
<x-icon :name="$item['icon']" :filled="$on" class="size-5" />
|
||||
<x-livewire-material::icon :name="$item['icon']" :filled="$on" class="size-5" />
|
||||
@endisset
|
||||
<span class="truncate">{{ $item['title'] }}</span>
|
||||
@if (filled($item['badge'] ?? null))
|
||||
<x-badge :value="$item['badge']" />
|
||||
<x-livewire-material::badge :value="$item['badge']" />
|
||||
@endif
|
||||
<span data-tab-indicator aria-hidden="true"></span>
|
||||
</span>
|
||||
|
||||
@@ -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,15 +11,34 @@
|
||||
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.
|
||||
|
||||
wire:navigate swaps the body, merges the head without running this again, and gives <html>
|
||||
the next page's attributes — which the server rendered without any of these, so Livewire
|
||||
removes them. They are put back as the new page is swapped in (`onSwap`, in the same task,
|
||||
before anything paints), so this only has to run on a full load. --}}
|
||||
|
||||
@php
|
||||
$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',
|
||||
],
|
||||
];
|
||||
@endphp
|
||||
|
||||
@@ -28,8 +48,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 +83,30 @@
|
||||
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);
|
||||
|
||||
document.addEventListener('livewire:navigating', function (event) {
|
||||
var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) {
|
||||
return [name, root.getAttribute(name)];
|
||||
});
|
||||
|
||||
event.detail.onSwap(function () {
|
||||
kept.forEach(function (attribute) {
|
||||
if (attribute[1] !== null) {
|
||||
root.setAttribute(attribute[0], attribute[1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
})(@json($settings));
|
||||
</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>
|
||||
@@ -47,10 +47,10 @@
|
||||
'text-inverse-warning': current.type === 'warning',
|
||||
'text-inverse-info': current.type === 'info',
|
||||
}">
|
||||
<span x-show="current.type === 'success'"><x-icon name="check_circle" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'error'"><x-icon name="error" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'warning'"><x-icon name="warning" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'info'"><x-icon name="info" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'success'"><x-livewire-material::icon name="check_circle" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'error'"><x-livewire-material::icon name="error" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'warning'"><x-livewire-material::icon name="warning" filled class="size-6" /></span>
|
||||
<span x-show="current.type === 'info'"><x-livewire-material::icon name="info" filled class="size-6" /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
<template x-if="current.action || ! current.timeout">
|
||||
<button type="button" class="state-layer focus-ring inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="dismiss()">
|
||||
<x-icon name="close" class="size-5" />
|
||||
<x-livewire-material::icon name="close" class="size-5" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
<x-button label="With an action" variant="tonal" x-on:click="materialToast('Share deleted', { action: { label: 'Undo', handler: () => materialToast('Share restored', { type: 'info' }) } })" />
|
||||
<x-button label="Until dismissed" variant="tonal" x-on:click="materialToast('Your storage is almost full', { type: 'warning', timeout: 0 })" />
|
||||
BLADE,
|
||||
'Plain tooltips' => <<<'BLADE'
|
||||
<x-button icon="content_copy" tooltip="Copy link" />
|
||||
<x-button icon="qr_code_2" tooltip-bottom="Show QR code" />
|
||||
<x-tooltip text="Expires in 3 days" side="right">
|
||||
<span tabindex="0" class="focus-ring rounded-corner-xs type-body-md text-on-surface-variant">holiday-photos.zip</span>
|
||||
</x-tooltip>
|
||||
BLADE,
|
||||
'Rich tooltips' => <<<'BLADE'
|
||||
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time. Admins can change the longest time allowed.">
|
||||
<x-button icon="help" aria-label="About expiry" />
|
||||
@@ -59,7 +66,7 @@
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><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)
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<label class="flex min-w-72 flex-1 items-center gap-2 rounded-corner-xs border border-outline px-3 py-2 focus-within:outline-3 focus-within:outline-secondary">
|
||||
<x-icon name="search" class="size-5 text-on-surface-variant" />
|
||||
<x-livewire-material::icon name="search" class="size-5 text-on-surface-variant" />
|
||||
<input type="search" x-model.debounce.150ms="query" placeholder="Search {{ number_format(count(\NoNameWeb\LivewireMaterial\Support\SvgFile::symbolNames())) }} symbols" class="w-full bg-transparent type-body-lg outline-none" />
|
||||
</label>
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
@php
|
||||
$examples = [
|
||||
'Navigation bar' => <<<'BLADE'
|
||||
<div class="w-full space-y-6">
|
||||
<div class="max-w-[25rem] space-y-2">
|
||||
<p class="type-label-lg text-on-surface-variant">Compact: icon over label</p>
|
||||
<div class="flex h-56 flex-col overflow-hidden rounded-corner-lg border border-outline-variant bg-surface">
|
||||
<div class="flex-1 p-4 type-body-md text-on-surface-variant">The page</div>
|
||||
<x-navigation-bar>
|
||||
<x-navigation-bar-item label="Shares" icon="folder_shared" link="#navigation" no-wire-navigate active />
|
||||
<x-navigation-bar-item label="Upload" icon="upload" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-bar-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate badge="3" />
|
||||
<x-navigation-bar-item label="Account" icon="account_circle" link="#navigation" no-wire-navigate :badge="true" badge-label="Needs attention" />
|
||||
</x-navigation-bar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-[50rem] space-y-2">
|
||||
<p class="type-label-lg text-on-surface-variant">From 600px wide: icon beside label, centred</p>
|
||||
<div class="overflow-x-auto rounded-corner-lg border border-outline-variant">
|
||||
<div class="flex h-56 min-w-[37.5rem] flex-col bg-surface">
|
||||
<div class="flex-1 p-4 type-body-md text-on-surface-variant">The page</div>
|
||||
<x-navigation-bar>
|
||||
<x-navigation-bar-item label="Shares" icon="folder_shared" link="#navigation" no-wire-navigate active />
|
||||
<x-navigation-bar-item label="Upload" icon="upload" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-bar-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate badge="3" />
|
||||
<x-navigation-bar-item label="Account" icon="account_circle" link="#navigation" no-wire-navigate />
|
||||
</x-navigation-bar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Collapsed and expanded rails' => <<<'BLADE'
|
||||
<div class="flex w-full flex-wrap items-start gap-6">
|
||||
<div class="flex h-[36rem] overflow-hidden rounded-corner-lg border border-outline-variant">
|
||||
<x-navigation-rail mode="collapsed" label="Collapsed example">
|
||||
<x-slot:header>
|
||||
<x-fab icon="edit" tooltip-right="Compose" />
|
||||
</x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
|
||||
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-rail-section label="Labels">
|
||||
<x-navigation-rail-item label="Travel" icon="flight" link="#navigation" no-wire-navigate :badge="true" badge-label="New" />
|
||||
<x-navigation-rail-item label="Receipts" icon="receipt_long" link="#navigation" no-wire-navigate />
|
||||
</x-navigation-rail-section>
|
||||
|
||||
<x-slot:footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="#navigation" no-wire-navigate />
|
||||
</x-slot:footer>
|
||||
</x-navigation-rail>
|
||||
</div>
|
||||
|
||||
<div class="flex h-[36rem] overflow-hidden rounded-corner-lg border border-outline-variant">
|
||||
<x-navigation-rail mode="expanded" label="Expanded example">
|
||||
<x-slot:header>
|
||||
<x-fab label="Compose" icon="edit" />
|
||||
</x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
|
||||
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-rail-section label="Labels">
|
||||
<x-navigation-rail-item label="Travel" icon="flight" link="#navigation" no-wire-navigate :badge="true" badge-label="New" />
|
||||
<x-navigation-rail-item label="Receipts" icon="receipt_long" link="#navigation" no-wire-navigate />
|
||||
</x-navigation-rail-section>
|
||||
|
||||
<x-slot:footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="#navigation" no-wire-navigate />
|
||||
</x-slot:footer>
|
||||
</x-navigation-rail>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Collapsible rail' => <<<'BLADE'
|
||||
<div class="flex h-[30rem] w-full overflow-hidden rounded-corner-lg border border-outline-variant">
|
||||
<x-navigation-rail label="Collapsible example">
|
||||
<x-slot:brand>
|
||||
<span class="block truncate type-title-lg">Mail</span>
|
||||
</x-slot:brand>
|
||||
|
||||
<x-slot:header>
|
||||
<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>
|
||||
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>
|
||||
</x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
|
||||
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
|
||||
</x-navigation-rail>
|
||||
|
||||
<div class="min-w-0 flex-1 bg-surface-container-low p-6 type-body-md text-on-surface-variant">
|
||||
The menu button collapses and expands the rail. The choice is remembered and applied before the next page paints — every collapsible rail follows it, <code><x-app-shell></code>'s from <code>lg</code> too.
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section id="navigation" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Navigation</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><x-navigation-bar></code> and <code><x-navigation-bar-item></code>, <code><x-navigation-rail></code>, <code><x-navigation-rail-item></code> and <code><x-navigation-rail-section></code>,
|
||||
and <code><x-app-shell></code>, which puts them together: a bar below <code>sm</code>, a collapsed rail that opens as a modal to <code>lg</code>, a collapsible rail from there.
|
||||
<a href="{{ route('livewire-material.shell') }}" class="link text-primary">Open the app shell</a> and change the window's width.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
<x-showcase::example :$title :$code />
|
||||
@endforeach
|
||||
</section>
|
||||
@@ -0,0 +1,16 @@
|
||||
<section id="pages" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Pages</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
The error pages, raised for real so an app's own <code>resources/views/errors</code> still wins, and the Markdown mail theme
|
||||
(<code>livewire-material::mail.theme</code>) on a sample mail, both in this application's scheme.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@foreach (['401', '402', '403', '404', '419', '429', '500', '503'] as $code)
|
||||
<x-livewire-material::button :link="route('livewire-material.error', $code)" :label="$code" variant="tonal" no-wire-navigate />
|
||||
@endforeach
|
||||
|
||||
<x-livewire-material::button :link="route('livewire-material.mail')" label="Sample mail" icon="mail" variant="outlined" no-wire-navigate />
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,71 @@
|
||||
@php
|
||||
$examples = [
|
||||
'Docked' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ expires: '{{ now()->addWeek()->format('Y-m-d') }}', starts: null }">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Expires on" clearable x-model="expires" hint="Type a date or pick one" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(expires)"></code></p>
|
||||
</div>
|
||||
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Starts on" variant="filled" icon="event" x-model="starts" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(starts)"></code></p>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Modal and modal input' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ birthday: '1990-05-17', delivery: null }">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Birthday" mode="modal" x-model="birthday" :max="now()" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(birthday)"></code></p>
|
||||
</div>
|
||||
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Delivery" mode="input" x-model="delivery" hint="Opens on a text field" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(delivery)"></code></p>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Range' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ trip: { start: '{{ now()->addDays(3)->format('Y-m-d') }}', end: '{{ now()->addDays(9)->format('Y-m-d') }}' }, leave: { start: null, end: null } }">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Trip" range x-model="trip" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(trip)"></code></p>
|
||||
</div>
|
||||
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Leave" range mode="modal" x-model="leave" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(leave)"></code></p>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Limits, errors and states' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Within the next 30 days" :min="now()" :max="now()->addDays(30)" hint="Days outside are disabled" />
|
||||
<x-datepicker label="Required" required />
|
||||
</div>
|
||||
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Disabled" value="2026-09-13" disabled />
|
||||
<x-datepicker label="Read-only" value="2026-09-13" readonly />
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<x-datepicker size="sm" aria-label="From" class="w-48" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section id="pickers" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Date pickers</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><x-datepicker></code> — docked, modal and modal input; single dates and ranges. Month and weekday names and the first day of the week follow the application's locale.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
<x-showcase::example :$title :$code />
|
||||
@endforeach
|
||||
</section>
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="grid grid-cols-3 gap-4 sm:grid-cols-5 lg:grid-cols-7">
|
||||
@foreach (\NoNameWeb\LivewireMaterial\Support\SvgFile::shapeNames() as $shape)
|
||||
<div class="space-y-2 text-center">
|
||||
<x-shape :name="$shape" class="mx-auto size-20 text-secondary-container" />
|
||||
<x-livewire-material::shape :name="$shape" class="mx-auto size-20 text-secondary-container" />
|
||||
<code class="type-label-md text-on-surface-variant">{{ $shape }}</code>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
@php
|
||||
$examples = [
|
||||
'12 and 24 hours, outlined and filled' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-timepicker label="Meeting starts" value="09:30" hint="The locale's clock" />
|
||||
<x-timepicker label="Pick-up" value="17:15" format="12" icon="local_shipping" clearable />
|
||||
<x-timepicker label="Disabled" value="12:00" disabled />
|
||||
</div>
|
||||
|
||||
<div class="grid content-start gap-4">
|
||||
<x-timepicker variant="filled" label="Alarm" value="06:45" format="24" icon="alarm" />
|
||||
<x-timepicker variant="filled" label="Reminder" format="24" hint="Opens at the current time" />
|
||||
<x-timepicker variant="filled" label="Required" format="24" required />
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Steps and limits' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-timepicker label="Appointment" value="10:00" format="24" step="15" min="08:00" max="17:30" hint="Quarter hours from 08:00 to 17:30" />
|
||||
<x-timepicker label="Night shift starts" value="23:00" format="24" min="22:00" max="06:00" hint="From 22:00, across midnight, to 06:00" />
|
||||
</div>
|
||||
|
||||
<div class="grid content-start gap-4" x-data="{ time: '14:05' }">
|
||||
<x-timepicker label="Bound with x-model" x-model="time" format="12" step="5" />
|
||||
<p class="type-body-md text-on-surface-variant">Stored as <code x-text="time ?? 'null'"></code></p>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section id="timepickers" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Time pickers</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><x-timepicker></code> — M3's dial and input time picker in a modal dialog, opened from a text field.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
<x-showcase::example :$title :$code />
|
||||
@endforeach
|
||||
</section>
|
||||
@@ -0,0 +1,87 @@
|
||||
{{-- The app shell, as a whole page: /material/shell and its sibling pages, so the rail, the bar, the
|
||||
modal rail and wire:navigate between pages can be tried at any window width. --}}
|
||||
|
||||
@php
|
||||
$page ??= 'inbox';
|
||||
|
||||
$pages = [
|
||||
'inbox' => ['title' => 'Inbox', 'icon' => 'inbox', 'badge' => 12],
|
||||
'starred' => ['title' => 'Starred', 'icon' => 'star'],
|
||||
'sent' => ['title' => 'Sent', 'icon' => 'send'],
|
||||
'drafts' => ['title' => 'Drafts', 'icon' => 'draft', 'badge' => true],
|
||||
'travel' => ['title' => 'Travel', 'icon' => 'flight', 'section' => 'Labels', 'bar' => false],
|
||||
'receipts' => ['title' => 'Receipts', 'icon' => 'receipt_long', 'section' => 'Labels', 'bar' => false],
|
||||
];
|
||||
|
||||
$destinations = collect($pages)
|
||||
->map(fn (array $destination, string $key): array => $destination + [
|
||||
'url' => route('livewire-material.shell', $key === 'inbox' ? [] : ['page' => $key]),
|
||||
'active' => $key === $page,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$current = $pages[$page];
|
||||
@endphp
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex" />
|
||||
|
||||
<title>{{ $current['title'] }} · App shell · Livewire Material</title>
|
||||
|
||||
<x-livewire-material::theme-script />
|
||||
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface font-sans text-on-surface antialiased">
|
||||
<x-livewire-material::app-shell :destinations="$destinations">
|
||||
<x-slot:brand>
|
||||
<a href="{{ route('livewire-material.showcase') }}" class="block truncate rounded-corner-xs type-title-lg focus-ring">Livewire Material</a>
|
||||
</x-slot:brand>
|
||||
|
||||
<x-slot:rail-header>
|
||||
<span class="rail-collapsed:hidden"><x-livewire-material::fab label="Compose" icon="edit" x-on:click="materialToast('Compose opens here')" /></span>
|
||||
<span class="hidden rail-collapsed:inline-flex"><x-livewire-material::fab icon="edit" tooltip-right="Compose" x-on:click="materialToast('Compose opens here')" /></span>
|
||||
</x-slot:rail-header>
|
||||
|
||||
<x-slot:rail-footer>
|
||||
<x-livewire-material::navigation-rail-item label="Settings" icon="settings" link="#settings" no-wire-navigate />
|
||||
</x-slot:rail-footer>
|
||||
|
||||
<x-slot:actions>
|
||||
<x-livewire-material::button icon="dark_mode" tooltip-right="Switch theme" x-data x-on:click="$store.theme.toggle()" />
|
||||
<x-livewire-material::button icon="help" tooltip-right="Help" x-on:click="materialToast('Help opens here')" />
|
||||
</x-slot:actions>
|
||||
|
||||
<x-slot:top>
|
||||
<header class="sticky top-0 z-20 flex h-16 items-center gap-1 bg-surface px-1 pt-[env(safe-area-inset-top)] sm:px-4">
|
||||
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="shell-menu" /></span>
|
||||
<h1 class="min-w-0 flex-1 truncate px-3 type-title-lg sm:px-0">{{ $current['title'] }}</h1>
|
||||
<x-livewire-material::button icon="search" tooltip="Search" />
|
||||
</header>
|
||||
</x-slot:top>
|
||||
|
||||
<div class="mx-auto w-full max-w-5xl space-y-4 px-4 pb-6 sm:px-6">
|
||||
<p class="type-body-md text-on-surface-variant" data-test="shell-page">This is the {{ strtolower($current['title']) }} page.</p>
|
||||
|
||||
<x-livewire-material::list segmented>
|
||||
@foreach (range(1, 14) as $index)
|
||||
<x-livewire-material::list-item :title="$current['title'].' item '.$index" description="A line of supporting text for this item" :icon="$current['icon']" trailing="{{ $index }}h" wire:key="item-{{ $index }}" />
|
||||
@endforeach
|
||||
</x-livewire-material::list>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-livewire-material::button label="Show a toast" variant="tonal" x-data x-on:click="materialToast('Moved to archive', { action: { label: 'Undo', handler: () => {} } })" />
|
||||
<x-livewire-material::button label="Back to the showcase" link="{{ route('livewire-material.section', 'navigation') }}" no-wire-navigate />
|
||||
</div>
|
||||
</div>
|
||||
</x-livewire-material::app-shell>
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
+19
-1
@@ -1,9 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseController;
|
||||
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcasePageController;
|
||||
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseSymbolController;
|
||||
use NoNameWeb\LivewireMaterial\Showcase\Sections;
|
||||
|
||||
Route::view('/', 'livewire-material::showcase.index')->name('showcase');
|
||||
Route::get('/', [ShowcaseController::class, 'index'])->name('showcase');
|
||||
|
||||
Route::get('symbols.json', [ShowcaseSymbolController::class, 'index'])->name('symbols');
|
||||
Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol');
|
||||
|
||||
Route::view('shell/{page?}', 'livewire-material::showcase.shell')
|
||||
->whereIn('page', ['inbox', 'starred', 'sent', 'drafts', 'travel', 'receipts'])
|
||||
->name('shell');
|
||||
|
||||
Route::get('errors/{code}', [ShowcasePageController::class, 'error'])
|
||||
->whereIn('code', ['401', '402', '403', '404', '419', '429', '500', '503'])
|
||||
->name('error');
|
||||
Route::get('mail', [ShowcasePageController::class, 'mail'])->name('mail');
|
||||
|
||||
Route::get('search.json', [ShowcaseController::class, 'search'])->name('search');
|
||||
|
||||
Route::get('{section}', [ShowcaseController::class, 'section'])
|
||||
->whereIn('section', array_keys(Sections::all()))
|
||||
->name('section');
|
||||
|
||||
+142
-28
@@ -5,6 +5,7 @@ namespace NoNameWeb\LivewireMaterial\Console;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
use JsonException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
|
||||
@@ -15,7 +16,7 @@ class SchemeCommand extends Command
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'material:scheme
|
||||
{seed : The source colour, as #rrggbb}
|
||||
{seed? : The source colour, as #rrggbb; without it, every profile in livewire-material.profiles}
|
||||
{--variant=tonal-spot : tonal-spot, vibrant, expressive, neutral, fidelity, content, monochrome, rainbow or fruit-salad}
|
||||
{--contrast=0 : The contrast level, from -1 to 1}
|
||||
{--success=#22a06b : The source of the success colour}
|
||||
@@ -26,20 +27,73 @@ class SchemeCommand extends Command
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour';
|
||||
protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour, or every configured colour profile';
|
||||
|
||||
public function handle(Filesystem $files): int
|
||||
{
|
||||
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
|
||||
$data = preg_replace('/\.css$/', '', $stylesheet).'.json';
|
||||
|
||||
if (filled($this->argument('seed'))) {
|
||||
$scheme = $this->generate((string) $this->argument('seed'), (string) $this->option('variant'), (float) $this->option('contrast'));
|
||||
|
||||
if ($scheme === null) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return $this->write($files, $stylesheet, $data, $this->stylesheet($scheme), $scheme);
|
||||
}
|
||||
|
||||
$profiles = config('livewire-material.profiles');
|
||||
|
||||
if (! is_array($profiles) || $profiles === []) {
|
||||
$this->components->error('Give a seed colour (php artisan material:scheme "#4f46e5"), or list colour profiles in livewire-material.profiles.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$generated = [];
|
||||
|
||||
foreach ($profiles as $name => $profile) {
|
||||
if (! is_string($name) || preg_match('/^[a-z0-9-]+$/', $name) !== 1) {
|
||||
$this->components->error("A profile's name is lowercase letters, digits and dashes; \"{$name}\" is not.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$scheme = $this->generate((string) ($profile['seed'] ?? ''), (string) ($profile['variant'] ?? 'tonal-spot'), (float) ($profile['contrast'] ?? 0), "Profile \"{$name}\": ");
|
||||
|
||||
if ($scheme === null) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$generated[$name] = ['label' => (string) ($profile['label'] ?? Str::headline($name)), ...$scheme];
|
||||
}
|
||||
|
||||
$configured = config('livewire-material.profile');
|
||||
$default = is_string($configured) && isset($generated[$configured]) ? $configured : array_key_first($generated);
|
||||
|
||||
return $this->write($files, $stylesheet, $data, $this->profilesStylesheet($generated, $default), [
|
||||
...collect($generated[$default])->except('label')->all(),
|
||||
'default' => $default,
|
||||
'profiles' => $generated,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One scheme from Google's colour utilities, or null once the reason has been shown.
|
||||
*
|
||||
* @return array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}|null
|
||||
*/
|
||||
protected function generate(string $seed, string $variant, float $contrast, string $context = ''): ?array
|
||||
{
|
||||
$result = Process::run([
|
||||
config('livewire-material.node', 'node'),
|
||||
__DIR__.'/../../resources/node/scheme.mjs',
|
||||
json_encode([
|
||||
'seed' => $this->argument('seed'),
|
||||
'variant' => $this->option('variant'),
|
||||
'contrast' => (float) $this->option('contrast'),
|
||||
'seed' => $seed,
|
||||
'variant' => $variant,
|
||||
'contrast' => $contrast,
|
||||
'success' => $this->option('success'),
|
||||
'warning' => $this->option('warning'),
|
||||
'info' => $this->option('info'),
|
||||
@@ -47,22 +101,27 @@ class SchemeCommand extends Command
|
||||
]);
|
||||
|
||||
if ($result->failed()) {
|
||||
$this->components->error(trim($result->errorOutput()) ?: 'Node could not run the scheme generator. Is `node` installed and on the PATH?');
|
||||
$this->components->error($context.(trim($result->errorOutput()) ?: 'Node could not run the scheme generator. Is `node` installed and on the PATH?'));
|
||||
|
||||
return self::FAILURE;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme */
|
||||
$scheme = json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
|
||||
return json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
$this->components->error('The scheme generator answered with something other than JSON.');
|
||||
$this->components->error($context.'The scheme generator answered with something other than JSON.');
|
||||
|
||||
return self::FAILURE;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $scheme
|
||||
*/
|
||||
protected function write(Filesystem $files, string $stylesheet, string $data, string $css, array $scheme): int
|
||||
{
|
||||
$files->ensureDirectoryExists(dirname($stylesheet));
|
||||
$files->put($stylesheet, $this->stylesheet($scheme));
|
||||
$files->put($stylesheet, $css);
|
||||
$files->put($data, json_encode($scheme, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
|
||||
|
||||
$this->components->info("Wrote {$stylesheet} and {$data}.");
|
||||
@@ -82,9 +141,7 @@ class SchemeCommand extends Command
|
||||
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
|
||||
);
|
||||
|
||||
$block = fn (array $roles): string => collect($roles)
|
||||
->map(fn (string $hex, string $role): string => " --md-sys-color-{$role}: {$hex};")
|
||||
->implode("\n");
|
||||
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
|
||||
|
||||
return <<<CSS
|
||||
/*
|
||||
@@ -97,19 +154,76 @@ class SchemeCommand extends Command
|
||||
* first paint; the light block also stands without it.
|
||||
*/
|
||||
|
||||
:root,
|
||||
[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
{$block($scheme['light'])}
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
{$block($scheme['dark'])}
|
||||
}
|
||||
|
||||
{$blocks}
|
||||
CSS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default profile as the plain blocks, then every profile under its own data-scheme. A
|
||||
* profile's two-attribute selectors outrank the plain ones, and its one-attribute selector
|
||||
* comes after `:root`, which it only ties with: the order is part of the format. The
|
||||
* descendant selectors keep a nested `data-theme` panel (a light card on a dark page) in the
|
||||
* page's profile.
|
||||
*
|
||||
* @param array<string, array{label: string, seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}> $profiles
|
||||
*/
|
||||
protected function profilesStylesheet(array $profiles, string $default): string
|
||||
{
|
||||
$list = collect($profiles)
|
||||
->map(fn (array $profile, string $name): string => sprintf(
|
||||
' * %-12s %s, %s%s',
|
||||
$name,
|
||||
$profile['seed'],
|
||||
$profile['variant'],
|
||||
$profile['contrast'] != 0 ? ', contrast '.$profile['contrast'] : '',
|
||||
))
|
||||
->implode("\n");
|
||||
|
||||
$css = <<<CSS
|
||||
/*
|
||||
* Material 3 colour profiles, generated by Google's material-color-utilities (spec {$profiles[$default]['spec']}).
|
||||
*
|
||||
* php artisan material:scheme
|
||||
*
|
||||
* From livewire-material.profiles. "{$default}" is the default, and also stands without a
|
||||
* data-scheme attribute:
|
||||
*
|
||||
{$list}
|
||||
*
|
||||
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
|
||||
* M3's contrast guarantee only as generated. The head script sets data-theme and data-scheme
|
||||
* before the first paint.
|
||||
*/
|
||||
|
||||
CSS;
|
||||
|
||||
$css .= "\n".$this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $profiles[$default]);
|
||||
|
||||
foreach ($profiles as $name => $profile) {
|
||||
$css .= "\n".$this->blocks(
|
||||
["[data-scheme='{$name}']", "[data-scheme='{$name}'][data-theme='light']", "[data-scheme='{$name}'] [data-theme='light']"],
|
||||
["[data-scheme='{$name}'][data-theme='dark']", "[data-scheme='{$name}'] [data-theme='dark']"],
|
||||
$profile,
|
||||
);
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
/**
|
||||
* A light and a dark block of roles under the given selectors.
|
||||
*
|
||||
* @param list<string> $light
|
||||
* @param list<string> $dark
|
||||
* @param array{light: array<string, string>, dark: array<string, string>} $scheme
|
||||
*/
|
||||
protected function blocks(array $light, array $dark, array $scheme): string
|
||||
{
|
||||
$roles = fn (array $roles): string => collect($roles)
|
||||
->map(fn (string $hex, string $role): string => " --md-sys-color-{$role}: {$hex};")
|
||||
->implode("\n");
|
||||
|
||||
return implode(",\n", $light)." {\n color-scheme: light;\n\n".$roles($scheme['light'])."\n}\n\n"
|
||||
.implode(",\n", $dark)." {\n color-scheme: dark;\n\n".$roles($scheme['dark'])."\n}\n";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Http\Controllers;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use NoNameWeb\LivewireMaterial\Showcase\Sections;
|
||||
|
||||
/**
|
||||
* The showcase: an overview of every section, and a page for each.
|
||||
*/
|
||||
class ShowcaseController
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('livewire-material::showcase.index', ['sections' => Sections::all()]);
|
||||
}
|
||||
|
||||
public function section(string $section): View
|
||||
{
|
||||
return view('livewire-material::showcase.section', [
|
||||
'sections' => Sections::all(),
|
||||
'section' => $section,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The search's index, fetched when the search is first focused rather than written into every page.
|
||||
*/
|
||||
public function search(): JsonResponse
|
||||
{
|
||||
return response()->json(Sections::index());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Mail\Markdown;
|
||||
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
||||
|
||||
/**
|
||||
* Previews of the pages the showcase cannot draw inline: an error page, raised for real so the
|
||||
* exception handler picks the view exactly as it would in the application (an app's own
|
||||
* `resources/views/errors` included), and a sample Markdown mail in the package theme.
|
||||
*/
|
||||
class ShowcasePageController
|
||||
{
|
||||
public function error(string $code): never
|
||||
{
|
||||
abort((int) $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sample mail, with the package's header and message after the application's own mail
|
||||
* components — what an application gets with `livewire-material.mail.components` on.
|
||||
*/
|
||||
public function mail(): Response
|
||||
{
|
||||
$markdown = new Markdown(view(), [
|
||||
'theme' => 'livewire-material::mail.theme',
|
||||
'paths' => array_values(array_unique([
|
||||
...(array) config('mail.markdown.paths', []),
|
||||
LivewireMaterialServiceProvider::mailComponentPath(),
|
||||
])),
|
||||
]);
|
||||
|
||||
return response($markdown->render('livewire-material::showcase.mail')->toHtml());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace NoNameWeb\LivewireMaterial;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
@@ -14,6 +15,8 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
$this->mergeConfigFrom(__DIR__.'/../config/livewire-material.php', 'livewire-material');
|
||||
|
||||
$this->disableBladeIconsComponent();
|
||||
$this->registerErrorViews();
|
||||
$this->registerMailComponents();
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
@@ -33,18 +36,106 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
$this->publishes([
|
||||
__DIR__.'/../config/livewire-material.php' => config_path('livewire-material.php'),
|
||||
], 'livewire-material-config');
|
||||
|
||||
$this->publishes([
|
||||
static::errorViewPath().'/errors' => resource_path('views/errors'),
|
||||
], 'livewire-material-errors');
|
||||
|
||||
$this->publishes([
|
||||
static::mailComponentPath().'/html' => resource_path('views/vendor/mail/html'),
|
||||
static::mailComponentPath().'/text' => resource_path('views/vendor/mail/text'),
|
||||
], 'livewire-material-mail');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the components as anonymous Blade components under the configured prefix.
|
||||
* The anonymous components. Blade names their view namespace after a hash of this string, and
|
||||
* compiled views keep that name, so it stays written the way it always was.
|
||||
*/
|
||||
public static function componentPath(): string
|
||||
{
|
||||
return __DIR__.'/../resources/views/components';
|
||||
}
|
||||
|
||||
/**
|
||||
* The error-view root: a folder holding only `errors/`.
|
||||
*/
|
||||
public static function errorViewPath(): string
|
||||
{
|
||||
return dirname(__DIR__).'/resources/views/error-pages';
|
||||
}
|
||||
|
||||
/**
|
||||
* The mail component root, laid out like the framework's: `html/` and `text/`.
|
||||
*/
|
||||
public static function mailComponentPath(): string
|
||||
{
|
||||
return dirname(__DIR__).'/resources/views/mail';
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the error-view root to `view.paths`, after the application's own.
|
||||
*
|
||||
* The exception handler replaces the `errors` namespace each time it renders an HTTP
|
||||
* exception, with every entry of `view.paths` plus `/errors` and the framework's views last
|
||||
* (RegisterErrorViewPaths), so a namespace added with addNamespace() is gone by then; only a
|
||||
* view path survives. That config is read at render time, but also once when the view
|
||||
* finder is built: appended here, in register(), before any boot() resolves the view
|
||||
* factory, the finder and `view:cache` see the same paths the handler does. Appending keeps
|
||||
* `view.paths[0]` — Laravel's viewPath() — the application's, and puts the application's
|
||||
* own `resources/views/errors` first. Idempotent, since a cached config already holds it.
|
||||
*/
|
||||
protected function registerErrorViews(): void
|
||||
{
|
||||
$paths = (array) config('view.paths', []);
|
||||
|
||||
if (! in_array(static::errorViewPath(), $paths, true)) {
|
||||
config(['view.paths' => [...$paths, static::errorViewPath()]]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the package's mail header and message after the application's own mail components,
|
||||
* when `livewire-material.mail.components` asks for it. Off by default: it changes every
|
||||
* Markdown mail the application sends, theme or not, and the framework reads
|
||||
* `mail.markdown.paths` once, when the Markdown renderer is first made — after register().
|
||||
*/
|
||||
protected function registerMailComponents(): void
|
||||
{
|
||||
if (! config('livewire-material.mail.components')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$paths = (array) config('mail.markdown.paths', []);
|
||||
|
||||
if (! in_array(static::mailComponentPath(), $paths, true)) {
|
||||
config(['mail.markdown.paths' => [...$paths, static::mailComponentPath()]]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the components as anonymous Blade components under the configured prefix, or
|
||||
* under `livewire-material` without one.
|
||||
*
|
||||
* A compiled `<x-button>` names the view namespace Blade derives from the prefix, or from the
|
||||
* component folder's absolute path when there is none. That path differs between a laptop and
|
||||
* a container sharing `storage/framework/views`, or between release directories, while the
|
||||
* compiled file's name does not — so a view compiled on one showed `a1b2…::button` as text on
|
||||
* the other. A prefix does not stop an unprefixed tag resolving. The path's namespace stays
|
||||
* registered, so views compiled before this release still render until they are recompiled.
|
||||
*
|
||||
* The package's own views never go through this registration: they write
|
||||
* `<x-livewire-material::button>`, which resolves through the view namespace whatever the
|
||||
* prefix, and which an application's own `<x-button>` cannot shadow.
|
||||
*/
|
||||
protected function registerComponents(): void
|
||||
{
|
||||
Blade::anonymousComponentPath(
|
||||
__DIR__.'/../resources/views/components',
|
||||
filled(config('livewire-material.prefix')) ? config('livewire-material.prefix') : null,
|
||||
static::componentPath(),
|
||||
filled(config('livewire-material.prefix')) ? config('livewire-material.prefix') : 'livewire-material',
|
||||
);
|
||||
|
||||
View::addNamespace(hash('xxh128', static::componentPath()), static::componentPath());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Showcase;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
||||
|
||||
/**
|
||||
* The showcase's pages: one per section, grouped as the rail groups them.
|
||||
*/
|
||||
class Sections
|
||||
{
|
||||
/**
|
||||
* What the showcase's search looks through: every section, every example in it (linked to the
|
||||
* example's anchor), and every component, linked to the first section that introduces it.
|
||||
*
|
||||
* @return list<array{title: string, kind: string, context: string, url: string, keywords: string}>
|
||||
*/
|
||||
public static function index(): array
|
||||
{
|
||||
$sections = static::all();
|
||||
$components = collect(glob(LivewireMaterialServiceProvider::componentPath().'/*.blade.php'))
|
||||
->map(fn (string $path): string => basename($path, '.blade.php'))
|
||||
->all();
|
||||
$sources = collect($sections)->map(fn (array $section, string $key): string => (string) file_get_contents(static::path($key)));
|
||||
|
||||
$entries = [];
|
||||
$homes = [];
|
||||
|
||||
foreach ($sections as $key => $section) {
|
||||
$url = route('livewire-material.section', $key, false);
|
||||
|
||||
$entries[] = ['title' => $section['title'], 'kind' => 'Section', 'context' => $section['group'], 'url' => $url, 'keywords' => $section['description']];
|
||||
|
||||
preg_match_all("/^\\s*'((?:[^'\\\\]|\\\\.)*)'\\s*=>\\s*<<<'BLADE'/m", $sources[$key], $examples);
|
||||
|
||||
foreach ($examples[1] as $title) {
|
||||
$title = stripslashes($title);
|
||||
$entries[] = ['title' => $title, 'kind' => 'Example', 'context' => $section['title'], 'url' => $url.'#'.static::anchor($title), 'keywords' => ''];
|
||||
}
|
||||
}
|
||||
|
||||
// A section that names a component in its introduction is its home; otherwise the first that uses it.
|
||||
foreach (['/<x-([a-z0-9-]+)>/', '/<x-(?:livewire-material::)?([a-z0-9-]+)(?=[\\s\\/>])/'] as $pattern) {
|
||||
foreach ($sources as $key => $source) {
|
||||
preg_match_all($pattern, $source, $tags);
|
||||
|
||||
foreach ($tags[1] as $tag) {
|
||||
if (in_array($tag, $components, true)) {
|
||||
$homes[$tag] ??= $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($homes as $tag => $key) {
|
||||
$entries[] = ['title' => "<x-{$tag}>", 'kind' => 'Component', 'context' => $sections[$key]['title'], 'url' => route('livewire-material.section', $key, false), 'keywords' => str_replace('-', ' ', $tag)];
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* The anchor of an example on its section's page.
|
||||
*/
|
||||
public static function anchor(string $title): string
|
||||
{
|
||||
return 'example-'.Str::slug($title);
|
||||
}
|
||||
|
||||
/**
|
||||
* A section's view file.
|
||||
*/
|
||||
public static function path(string $key): string
|
||||
{
|
||||
return dirname(__DIR__, 2).'/resources/views/showcase/sections/'.$key.'.blade.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{title: string, icon: string, group: string, description: string}>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
'colour' => ['title' => 'Colour', 'icon' => 'palette', 'group' => 'Foundations', 'description' => 'Every colour role of the generated scheme, light and dark.'],
|
||||
'type' => ['title' => 'Type', 'icon' => 'text_fields', 'group' => 'Foundations', 'description' => 'The typescale, emphasized styles included, in Google Sans Flex.'],
|
||||
'shape' => ['title' => 'Shape', 'icon' => 'interests', 'group' => 'Foundations', 'description' => 'Corner radii and the 35 M3 Expressive shapes.'],
|
||||
'elevation' => ['title' => 'Elevation', 'icon' => 'layers', 'group' => 'Foundations', 'description' => 'Shadows for what floats over content.'],
|
||||
'motion' => ['title' => 'Motion', 'icon' => 'animation', 'group' => 'Foundations', 'description' => 'Spatial springs and effects easings.'],
|
||||
'icons' => ['title' => 'Icons', 'icon' => 'emoji_symbols', 'group' => 'Foundations', 'description' => 'Every Material Symbol, searchable, outlined and filled.'],
|
||||
'buttons' => ['title' => 'Buttons', 'icon' => 'smart_button', 'group' => 'Actions', 'description' => 'Buttons, icon buttons, groups, split buttons and FABs.'],
|
||||
'menus' => ['title' => 'Menus', 'icon' => 'menu_open', 'group' => 'Actions', 'description' => 'Menus with items, groups, choices and shortcuts.'],
|
||||
'chips' => ['title' => 'Chips', 'icon' => 'sell', 'group' => 'Actions', 'description' => 'Assist, filter, input and suggestion chips.'],
|
||||
'communication' => ['title' => 'Communication', 'icon' => 'notifications', 'group' => 'Communication', 'description' => 'Badges, snackbars, tooltips, alerts, stats and empty states.'],
|
||||
'progress' => ['title' => 'Progress', 'icon' => 'progress_activity', 'group' => 'Communication', 'description' => 'Linear, circular and wavy progress, and the loading indicator.'],
|
||||
'containment' => ['title' => 'Containment', 'icon' => 'web_asset', 'group' => 'Containment', 'description' => 'Cards, lists, dividers, dialogs and sheets.'],
|
||||
'carousel' => ['title' => 'Carousel', 'icon' => 'view_carousel', 'group' => 'Containment', 'description' => 'Multi-browse, hero, uncontained and full-screen carousels.'],
|
||||
'fields' => ['title' => 'Text fields', 'icon' => 'edit_note', 'group' => 'Inputs', 'description' => 'Text fields, selects, checkboxes, radios, switches, choices and search.'],
|
||||
'sliders' => ['title' => 'Sliders', 'icon' => 'tune', 'group' => 'Inputs', 'description' => 'Standard, centered and range sliders in five sizes.'],
|
||||
'pickers' => ['title' => 'Date pickers', 'icon' => 'calendar_month', 'group' => 'Inputs', 'description' => 'Docked, modal and input date pickers, single and range.'],
|
||||
'timepickers' => ['title' => 'Time pickers', 'icon' => 'schedule', 'group' => 'Inputs', 'description' => 'The dial and input time picker.'],
|
||||
'bars' => ['title' => 'App bars and tabs', 'icon' => 'toolbar', 'group' => 'Navigation', 'description' => 'Top app bars, toolbars, tabs, section navigation and the account menu.'],
|
||||
'navigation' => ['title' => 'Navigation', 'icon' => 'explore', 'group' => 'Navigation', 'description' => 'The navigation bar, the navigation rail and the app shell.'],
|
||||
'data' => ['title' => 'Data', 'icon' => 'table_chart', 'group' => 'Data and pages', 'description' => 'Data tables, sort headers and pagination.'],
|
||||
'pages' => ['title' => 'Error pages and mail', 'icon' => 'page_info', 'group' => 'Data and pages', 'description' => 'The HTTP error pages and the Markdown mail theme.'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user