Compare commits
9
Commits
eca6b8fb05
...
1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d805dbc407 | ||
|
|
fb42f004b1 | ||
|
|
7798352cfc | ||
|
|
cb3aa798a0 | ||
|
|
648a4cfe0f | ||
|
|
51bf034b74 | ||
|
|
7ebcb0565f | ||
|
|
889f8a8aa1 | ||
|
|
23218431bf |
@@ -74,6 +74,10 @@ php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
|||||||
|
|
||||||
Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--contrast` runs from -1 to 1; `--success`, `--warning` and `--info` seed the state colours. Regenerate instead of editing the file.
|
Variants: `tonal-spot`, `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--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
|
### Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -82,6 +86,7 @@ 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.
|
- `prefix` — components are `<x-button>`, `<x-card>`… Set `'m'` when a name clashes with the application's own components, and they become `<x-m::button>`. `<x-livewire-material::button>` always works.
|
||||||
- `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys` (an earlier toggle's localStorage keys, adopted once).
|
- `theme.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`.
|
- `fields.variant` — text fields `outlined` (default) or `filled`.
|
||||||
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
|
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
|
||||||
- `showcase.enabled`, `showcase.path`, `showcase.middleware`, `showcase.vite`.
|
- `showcase.enabled`, `showcase.path`, `showcase.middleware`, `showcase.vite`.
|
||||||
@@ -124,7 +129,7 @@ Every component, prop and slot is documented in the Boost skill (`resources/boos
|
|||||||
|
|
||||||
## Showcase
|
## Showcase
|
||||||
|
|
||||||
While the application runs locally (or with `MATERIAL_SHOWCASE=true`), `/material` renders every token and component, in every variant, in the application's own scheme and theme.
|
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
|
## Testing the design
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,24 @@ return [
|
|||||||
|
|
||||||
'scheme' => resource_path('css/material-scheme.json'),
|
'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
|
| Mail
|
||||||
|
|||||||
@@ -589,12 +589,22 @@ pages and the mail theme by an agent in its own worktree. What changed from the
|
|||||||
`__DIR__.'/../resources/views/components'`: Blade names the view namespace after its hash, and
|
`__DIR__.'/../resources/views/components'`: Blade names the view namespace after its hash, and
|
||||||
compiled views keep that name.
|
compiled views keep that name.
|
||||||
- **A test checks that every component appears in the showcase.**
|
- **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
|
- **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`):
|
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 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.
|
a toast works without console errors; `/material` and the 404 page render in its scheme.
|
||||||
- **Known gap:** `<x-datepicker>` has no clear button (`<x-timepicker clearable>` does); a date can be
|
- **`<x-datepicker clearable>`** empties a date, or both ends of a range, as `<x-timepicker clearable>`
|
||||||
emptied through its text input.
|
does.
|
||||||
|
|
||||||
### Phase 11 — SealShare 2.0.0
|
### Phase 11 — SealShare 2.0.0
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ This application uses `nonameweb/livewire-material`: Material 3 Expressive compo
|
|||||||
|
|
||||||
- Components are anonymous Blade components, unprefixed unless `config/livewire-material.php` sets a `prefix`. Before writing or changing a view that uses them, activate the `livewire-material-development` skill for the props, slots and traps of each component.
|
- Components are anonymous Blade components, unprefixed unless `config/livewire-material.php` sets a `prefix`. Before writing or changing a view that uses them, activate the `livewire-material-development` skill for the props, slots and traps of each component.
|
||||||
- Never write maryUI tags (`<x-mary-*>`) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`…). They compile to nothing and fail silently.
|
- Never write maryUI tags (`<x-mary-*>`) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`…). They compile to nothing and fail silently.
|
||||||
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand.
|
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand. With colour profiles (`livewire-material.profiles`), run it without a seed after changing them; the active profile comes from `Scheme::resolveProfileUsing()`.
|
||||||
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
|
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
|
||||||
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
|
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
|
||||||
@endverbatim
|
@endverbatim
|
||||||
|
|||||||
@@ -46,6 +46,34 @@ php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
|||||||
|
|
||||||
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The command also writes `material-scheme.json` beside the stylesheet.
|
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The command also writes `material-scheme.json` beside the stylesheet.
|
||||||
|
|
||||||
|
### Colour profiles
|
||||||
|
|
||||||
|
An installation that switches between several schemes lists them in `config/livewire-material.php` and runs the command without a seed, which generates every profile into the same stylesheet, keyed by `<html data-scheme>`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'profiles' => [
|
||||||
|
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
|
||||||
|
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
|
||||||
|
],
|
||||||
|
'profile' => 'indigo', // the default; else the first
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan material:scheme
|
||||||
|
```
|
||||||
|
|
||||||
|
- Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet.
|
||||||
|
- The application says which profile is active, once, in a service provider. The closure runs every time a colour is drawn (head script, mail, error page), so it may read the database; a name that is not a generated profile, or a closure that throws, falls back to the default:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||||
|
|
||||||
|
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<x-theme-script>` writes the active profile to `<html data-scheme>` before the first paint; mails and error pages draw it too. `Scheme::profiles()` lists the generated profiles (name ⇒ label, light and dark roles) and `Scheme::profile()` names the active one — validate a stored choice with `Rule::in(array_keys(Scheme::profiles()))`.
|
||||||
|
- Choose with `<x-scheme-picker wire:model="colorProfile" />` (see Components). Never set `data-scheme` on an element inside the page expecting a different profile there: profiles key on `<html>`.
|
||||||
|
|
||||||
## Tokens
|
## Tokens
|
||||||
|
|
||||||
Tailwind's default palette is cleared: every colour class names an M3 role. `text-red-600`, `bg-base-200` or `text-gray-500` compile to nothing.
|
Tailwind's default palette is cleared: every colour class names an M3 role. `text-red-600`, `bg-base-200` or `text-gray-500` compile to nothing.
|
||||||
@@ -62,7 +90,7 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
|
|||||||
|
|
||||||
## Theme
|
## Theme
|
||||||
|
|
||||||
`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control.
|
`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything.
|
||||||
|
|
||||||
## Toasts
|
## Toasts
|
||||||
|
|
||||||
@@ -467,7 +495,7 @@ A one-column grid of fields with an `actions` slot at the foot (the slot takes i
|
|||||||
|
|
||||||
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
|
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
|
||||||
|
|
||||||
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name (the error replaces the hint, sets `aria-invalid`). `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint and sets `aria-invalid`. `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||||
|
|
||||||
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
|
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
|
||||||
- `<x-password>`: a reveal button; `icon`, `size`.
|
- `<x-password>`: a reveal button; `icon`, `size`.
|
||||||
@@ -523,7 +551,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
|
|||||||
```blade
|
```blade
|
||||||
<x-datepicker label="Expires on" wire:model.live="expiresOn" :min="now()" :max="now()->addMonth()" />
|
<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="Birthday" mode="modal" wire:model="birthday" :max="now()" />
|
||||||
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" />
|
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" clearable />
|
||||||
```
|
```
|
||||||
|
|
||||||
| Prop | Default | |
|
| Prop | Default | |
|
||||||
@@ -534,6 +562,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
|
|||||||
| `label`, `hint`, `icon`, `variant`, `size` | | the field's |
|
| `label`, `hint`, `icon`, `variant`, `size` | | the field's |
|
||||||
| `value` | `null` | the initial value without `wire:model` |
|
| `value` | `null` | the initial value without `wire:model` |
|
||||||
| `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) |
|
| `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.
|
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()`. Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
|
||||||
|
|
||||||
@@ -723,6 +752,14 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
|
|||||||
|
|
||||||
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (segmented buttons for settings pages). Every toggle on a page shares the store.
|
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (segmented buttons for settings pages). Every toggle on a page shares the store.
|
||||||
|
|
||||||
|
### `<x-scheme-picker>`
|
||||||
|
|
||||||
|
A choice of colour profile (see Colour profiles): a swatch per generated profile — its name and its primary, secondary and tertiary colour — over native radios. `wire:model` or `x-model` (with `name`) binds the chosen name; choosing previews it on the page at once; storing it is the application's. `label`, `hint`, `name`, `profiles` (default `Scheme::profiles()`). A validation error for the bound property replaces the hint. Without profiles it renders nothing.
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-scheme-picker :label="__('Colour profile')" wire:model="colorProfile" :hint="__('Applies to every page after saving')" />
|
||||||
|
```
|
||||||
|
|
||||||
### `<x-table>`, `<x-sort-header>`
|
### `<x-table>`, `<x-sort-header>`
|
||||||
|
|
||||||
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>` (`size="xs"` for a dense one); cell utilities (`text-end`, `whitespace-nowrap`) always win. Scrolling is yours: wrap it in `<div class="overflow-x-auto">`. A row that opens something is `data-list-row` with one `data-list-open` control; a selected row is `aria-selected="true"`.
|
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>` (`size="xs"` for a dense one); cell utilities (`text-end`, `whitespace-nowrap`) always win. Scrolling is yours: wrap it in `<div class="overflow-x-auto">`. A row that opens something is `data-list-row` with one `data-list-open` control; a selected row is `aria-selected="true"`.
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ const MEDIUM_ITEM_FLEX_PERCENTAGE = 0.1
|
|||||||
/** A programmatic scroll still counts as where the carousel is going for this long. */
|
/** A programmatic scroll still counts as where the carousel is going for this long. */
|
||||||
const TARGET_MS = 700
|
const TARGET_MS = 700
|
||||||
|
|
||||||
|
// How long the row must go without a scroll event to count as having come to rest.
|
||||||
|
const SETTLE_MS = 150
|
||||||
|
|
||||||
const ITEM = '[data-material-carousel-item]'
|
const ITEM = '[data-material-carousel-item]'
|
||||||
const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable], [tabindex]:not([tabindex="-1"])'
|
const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable], [tabindex]:not([tabindex="-1"])'
|
||||||
|
|
||||||
@@ -805,6 +808,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
frame: null,
|
frame: null,
|
||||||
target: null,
|
target: null,
|
||||||
targetAt: 0,
|
targetAt: 0,
|
||||||
|
settle: null,
|
||||||
listeners: [],
|
listeners: [],
|
||||||
mutations: null,
|
mutations: null,
|
||||||
resizes: null,
|
resizes: null,
|
||||||
@@ -817,7 +821,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
state.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
state.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||||
|
|
||||||
this.listen(scroller, 'scroll', () => this.schedule(), { passive: true })
|
this.listen(scroller, 'scroll', () => this.scrolled(), { passive: true })
|
||||||
|
|
||||||
|
// A scroll the person makes themselves is theirs to end wherever it ends.
|
||||||
|
for (const type of ['pointerdown', 'wheel', 'touchstart']) {
|
||||||
|
this.listen(scroller, type, () => (state.target = null), { passive: true })
|
||||||
|
}
|
||||||
this.listen(scroller, 'keydown', (event) => this.navigate(event))
|
this.listen(scroller, 'keydown', (event) => this.navigate(event))
|
||||||
this.listen(scroller, 'focusin', (event) => this.reveal(event))
|
this.listen(scroller, 'focusin', (event) => this.reveal(event))
|
||||||
this.listen(scroller, 'click', (event) => this.open(event))
|
this.listen(scroller, 'click', (event) => this.open(event))
|
||||||
@@ -913,6 +922,33 @@ document.addEventListener('alpine:init', () => {
|
|||||||
state.frame ??= requestAnimationFrame(() => this.render())
|
state.frame ??= requestAnimationFrame(() => this.render())
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each scroll frame, and once the row comes to rest: a scroll the buttons or keys started
|
||||||
|
* must end on its item. WebKit on Linux can re-snap a smooth scroll to the item it left
|
||||||
|
* when the masks change the layout under it, so an arrival somewhere else is sent on
|
||||||
|
* again, once, at once.
|
||||||
|
*/
|
||||||
|
scrolled() {
|
||||||
|
this.schedule()
|
||||||
|
|
||||||
|
clearTimeout(state.settle)
|
||||||
|
state.settle = setTimeout(() => {
|
||||||
|
const target = state.target
|
||||||
|
|
||||||
|
if (target === null || performance.now() - state.targetAt > TARGET_MS * 3) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.target = null
|
||||||
|
|
||||||
|
const left = state.snaps[target]
|
||||||
|
|
||||||
|
if (left !== undefined && Math.abs(this.scrollOffset() - left) > 1) {
|
||||||
|
this.$refs.scroller.scrollTo({ left: state.rtl ? -left : left, behavior: 'instant' })
|
||||||
|
}
|
||||||
|
}, SETTLE_MS)
|
||||||
|
},
|
||||||
|
|
||||||
/** Carousel.kt's carouselItem layer block, for every item. */
|
/** Carousel.kt's carouselItem layer block, for every item. */
|
||||||
render() {
|
render() {
|
||||||
cancelAnimationFrame(state.frame)
|
cancelAnimationFrame(state.frame)
|
||||||
@@ -1076,6 +1112,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
cancelAnimationFrame(state.frame)
|
cancelAnimationFrame(state.frame)
|
||||||
|
clearTimeout(state.settle)
|
||||||
state.listeners.forEach((remove) => remove())
|
state.listeners.forEach((remove) => remove())
|
||||||
state.resizes?.disconnect()
|
state.resizes?.disconnect()
|
||||||
state.mutations?.disconnect()
|
state.mutations?.disconnect()
|
||||||
|
|||||||
@@ -366,6 +366,18 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** The clear button: no date (no start and no end), closed, and focus back in the field. */
|
||||||
|
clear() {
|
||||||
|
if (this.open) {
|
||||||
|
this.cancel(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fieldError = ''
|
||||||
|
this.text = ''
|
||||||
|
this.write(null)
|
||||||
|
this.$refs.input.focus()
|
||||||
|
},
|
||||||
|
|
||||||
fieldProblem(value) {
|
fieldProblem(value) {
|
||||||
if (!config.range) {
|
if (!config.range) {
|
||||||
return this.problem(value)
|
return this.problem(value)
|
||||||
|
|||||||
+11
-1
@@ -15,6 +15,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
window.Alpine.data('materialMenu', () => ({
|
window.Alpine.data('materialMenu', () => ({
|
||||||
closedAt: -Infinity,
|
closedAt: -Infinity,
|
||||||
returnFocus: true,
|
returnFocus: true,
|
||||||
|
focusWasInside: false,
|
||||||
listeners: [],
|
listeners: [],
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -26,6 +27,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// and close() have already done their part, synchronously, because this event is
|
// and close() have already done their part, synchronously, because this event is
|
||||||
// queued and a screen reader or a test reading aria-expanded in between would be told
|
// queued and a screen reader or a test reading aria-expanded in between would be told
|
||||||
// the menu is shut.
|
// the menu is shut.
|
||||||
|
// Whether focus was in the menu is read before it closes: once closed, a browser may
|
||||||
|
// already have handed focus to what had it before the menu opened (WebKit does, when
|
||||||
|
// that was a focusable region around the trigger).
|
||||||
|
this.listen(menu, 'beforetoggle', (event) => {
|
||||||
|
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
|
||||||
|
})
|
||||||
|
|
||||||
this.listen(menu, 'toggle', (event) => {
|
this.listen(menu, 'toggle', (event) => {
|
||||||
const opened = event.newState === 'open'
|
const opened = event.newState === 'open'
|
||||||
|
|
||||||
@@ -37,9 +45,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
this.closedAt = performance.now()
|
this.closedAt = performance.now()
|
||||||
|
|
||||||
if (this.returnFocus && menu.contains(document.activeElement)) {
|
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
|
||||||
this.control()?.focus()
|
this.control()?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.focusWasInside = false
|
||||||
})
|
})
|
||||||
|
|
||||||
// A press outside closes the menu without pulling focus back to the trigger.
|
// A press outside closes the menu without pulling focus back to the trigger.
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
*
|
*
|
||||||
* `value` is an accessor, so binding a control with `x-model="$store.theme.value"` goes
|
* `value` is an accessor, so binding a control with `x-model="$store.theme.value"` goes
|
||||||
* through the same write as `set()` and `toggle()`: the attributes, then localStorage.
|
* through the same write as `set()` and `toggle()`: the attributes, then localStorage.
|
||||||
|
*
|
||||||
|
* `scheme` is the colour profile on screen (<html data-scheme>, which the server chose), and
|
||||||
|
* `previewScheme(name)` shows another one on this page without storing anything — the
|
||||||
|
* application saves a choice itself, and the next full load draws what it saved.
|
||||||
*/
|
*/
|
||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
@@ -18,6 +22,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
window.Alpine.store('theme', {
|
window.Alpine.store('theme', {
|
||||||
choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system',
|
choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system',
|
||||||
resolved: root.dataset.theme === 'dark' ? 'dark' : 'light',
|
resolved: root.dataset.theme === 'dark' ? 'dark' : 'light',
|
||||||
|
scheme: root.dataset.scheme || null,
|
||||||
|
|
||||||
get value() {
|
get value() {
|
||||||
return this.choice
|
return this.choice
|
||||||
@@ -48,6 +53,15 @@ document.addEventListener('alpine:init', () => {
|
|||||||
toggle() {
|
toggle() {
|
||||||
this.set(this.resolved === 'dark' ? 'light' : 'dark')
|
this.set(this.resolved === 'dark' ? 'light' : 'dark')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
previewScheme(name) {
|
||||||
|
if (typeof name !== 'string' || !/^[a-z0-9-]+$/.test(name)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scheme = name
|
||||||
|
root.dataset.scheme = name
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// The head script repaints on an OS change while the choice is `system`; this keeps the
|
// The head script repaints on an OS change while the choice is `system`; this keeps the
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
@php
|
@php
|
||||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||||
$id = $attributes->get('id') ?? 'check-'.substr(md5($model.'|'.$label.'|'.$attributes->get('value')), 0, 12);
|
$id = $attributes->get('id') ?? 'check-'.substr(md5($model.'|'.$label.'|'.$attributes->get('value')), 0, 12);
|
||||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||||
|
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||||
|
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}>
|
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}>
|
||||||
|
|||||||
@@ -35,8 +35,10 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$model = $attributes->wire('model')->value() ?: null;
|
$model = $attributes->wire('model')->value() ?: null;
|
||||||
$messages = $model !== null && isset($errors)
|
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
|
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||||
|
$messages = $errorKey !== null && isset($errors)
|
||||||
|
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
||||||
: [];
|
: [];
|
||||||
$choices = collect($options)->map(fn ($option): array => [
|
$choices = collect($options)->map(fn ($option): array => [
|
||||||
'value' => data_get($option, $optionValue),
|
'value' => data_get($option, $optionValue),
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
with, and the errors for `period`, `period.start` and `period.end` all belong to this field.
|
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
|
`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`
|
keyboard inside them. `label`, `hint`, `icon`, `variant` (`outlined`, `filled`) and `size`
|
||||||
are the field's; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every
|
are the field's; `clearable` adds a button that empties it (a date, or both ends of a range)
|
||||||
|
once it holds one; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every
|
||||||
other attribute (`required`, `disabled`, `readonly`) reaches the text field. Errors under
|
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.
|
the `wire:model` name replace the hint, and so does a typed date that cannot be read.
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
'min' => null,
|
'min' => null,
|
||||||
'max' => null,
|
'max' => null,
|
||||||
'value' => null,
|
'value' => null,
|
||||||
|
'clearable' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
@@ -55,8 +57,10 @@
|
|||||||
$mode = in_array($mode, ['docked', 'modal', 'input'], true) ? $mode : 'docked';
|
$mode = in_array($mode, ['docked', 'modal', 'input'], true) ? $mode : 'docked';
|
||||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|datepicker'), 0, 12);
|
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|datepicker'), 0, 12);
|
||||||
$anchor = '--material-datepicker-'.preg_replace('/[^A-Za-z0-9_-]/', '-', $id);
|
$anchor = '--material-datepicker-'.preg_replace('/[^A-Za-z0-9_-]/', '-', $id);
|
||||||
$messages = $model !== null && isset($errors)
|
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
|
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||||
|
$messages = $errorKey !== null && isset($errors)
|
||||||
|
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
||||||
: [];
|
: [];
|
||||||
$locale = str_replace('_', '-', app()->getLocale());
|
$locale = str_replace('_', '-', app()->getLocale());
|
||||||
|
|
||||||
@@ -179,6 +183,19 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<x-slot:trailing>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="field-trailing field-button"
|
class="field-trailing field-button"
|
||||||
|
|||||||
@@ -21,8 +21,10 @@
|
|||||||
@php
|
@php
|
||||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||||
$id = $attributes->get('id') ?? 'field-'.substr(md5('file|'.$model.'|'.$label), 0, 12);
|
$id = $attributes->get('id') ?? 'field-'.substr(md5('file|'.$model.'|'.$label), 0, 12);
|
||||||
$messages = $model !== null && isset($errors)
|
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
|
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||||
|
$messages = $errorKey !== null && isset($errors)
|
||||||
|
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
||||||
: [];
|
: [];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,9 @@
|
|||||||
@php
|
@php
|
||||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||||
$name ??= $model;
|
$name ??= $model;
|
||||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||||
|
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||||
|
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||||
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
|
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
|
||||||
|
|
||||||
$segment = [
|
$segment = [
|
||||||
|
|||||||
@@ -30,7 +30,9 @@
|
|||||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||||
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
|
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
|
||||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|'.$placeholder), 0, 12);
|
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|'.$placeholder), 0, 12);
|
||||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||||
|
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||||
|
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-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">
|
<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">
|
||||||
|
|||||||
@@ -78,18 +78,19 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div @class(['min-h-0 flex-1', 'max-sm:overflow-y-auto max-sm:px-6 max-sm:pb-6' => $fullscreen])>
|
<div @class(['min-h-0 flex-1', 'max-sm:overflow-y-auto max-sm:px-6 max-sm:pb-6' => $fullscreen])>
|
||||||
@if (filled($title) || $icon)
|
@if (filled($title) || filled($subtitle) || $icon)
|
||||||
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon, 'text-center' => $icon])>
|
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
|
||||||
|
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])>
|
||||||
@if ($icon)
|
@if ($icon)
|
||||||
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
|
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (filled($title))
|
@if (filled($title))
|
||||||
<h2 id="{{ $id }}-title" class="type-headline-sm">{{ $title }}</h2>
|
<h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-sm:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (filled($subtitle))
|
@if (filled($subtitle))
|
||||||
<p class="mt-4 type-body-md text-on-surface-variant">{{ $subtitle }}</p>
|
<p @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-sm:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($separator)
|
@if ($separator)
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
@php
|
@php
|
||||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|password'), 0, 12);
|
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|password'), 0, 12);
|
||||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||||
|
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||||
|
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-livewire-material::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 }">
|
||||||
|
|||||||
@@ -23,7 +23,9 @@
|
|||||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||||
$name = $attributes->get('name') ?? $model ?? 'radio-'.substr(md5($label.'|'.json_encode($options)), 0, 12);
|
$name = $attributes->get('name') ?? $model ?? 'radio-'.substr(md5($label.'|'.json_encode($options)), 0, 12);
|
||||||
$id = 'radio-'.substr(md5($name.'|'.$label), 0, 12);
|
$id = 'radio-'.substr(md5($name.'|'.$label), 0, 12);
|
||||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||||
|
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||||
|
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<fieldset
|
<fieldset
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{{-- A choice of colour profile: one swatch per profile `material:scheme` generated from
|
||||||
|
`livewire-material.profiles`, each its name and its primary, secondary and tertiary colour.
|
||||||
|
|
||||||
|
<x-scheme-picker label="Colour profile" wire:model="colorProfile" hint="Applies to every page after saving" />
|
||||||
|
|
||||||
|
Native radios under the swatches, so `wire:model` and `x-model` bind as on any input and the
|
||||||
|
arrow keys move the choice. Choosing one shows it on the page at once
|
||||||
|
(`$store.theme.previewScheme`); storing it — and telling `Scheme::resolveProfileUsing()` — is
|
||||||
|
the application's. The dots are the only colours not drawn from tokens: they show other
|
||||||
|
profiles than the page's, so they are custom properties set inline from the scheme file's
|
||||||
|
checked hexes, light or dark with the page. Without profiles it renders nothing.
|
||||||
|
|
||||||
|
Props: `label`, `hint`, `name` (needed with `x-model`), `profiles` (default: every generated
|
||||||
|
profile). A validation message for the bound property replaces the hint. --}}
|
||||||
|
|
||||||
|
@props([
|
||||||
|
'label' => null,
|
||||||
|
'hint' => null,
|
||||||
|
'name' => null,
|
||||||
|
'profiles' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$profiles ??= \NoNameWeb\LivewireMaterial\Support\Scheme::profiles();
|
||||||
|
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||||
|
$name ??= $model ?: 'scheme';
|
||||||
|
$errorKey = $model ?: (filled($attributes->get('name')) ? (string) $attributes->get('name') : null);
|
||||||
|
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($profiles !== [])
|
||||||
|
<fieldset x-data data-scheme-picker {{ $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except('name')->class('min-w-0') }}>
|
||||||
|
@if (filled($label))
|
||||||
|
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
@foreach ($profiles as $profile => $scheme)
|
||||||
|
<label
|
||||||
|
data-scheme-option="{{ $profile }}"
|
||||||
|
class="state-layer flex min-w-0 cursor-pointer select-none flex-col items-start gap-2 rounded-corner-lg border border-outline-variant bg-surface-container-low p-3 text-on-surface transition-[background-color,border-color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
|
||||||
|
type="radio"
|
||||||
|
name="{{ $name }}"
|
||||||
|
value="{{ $profile }}"
|
||||||
|
x-on:change="$store.theme.previewScheme($event.target.value)"
|
||||||
|
class="peer sr-only"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span class="flex shrink-0 -space-x-1.5" aria-hidden="true">
|
||||||
|
@foreach (['primary', 'secondary', 'tertiary'] as $role)
|
||||||
|
<span
|
||||||
|
style="--swatch-light: {{ $scheme['light'][$role] }}; --swatch-dark: {{ $scheme['dark'][$role] }}"
|
||||||
|
class="size-5 rounded-full bg-(--swatch-light) ring-2 ring-surface-container-low in-has-checked:ring-secondary-container dark:bg-(--swatch-dark)"
|
||||||
|
></span>
|
||||||
|
@endforeach
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="w-full truncate pe-6 type-label-lg">{{ __($scheme['label']) }}</span>
|
||||||
|
|
||||||
|
{{-- In the corner, so a long name keeps its room. The wrapper carries the position. --}}
|
||||||
|
<span class="pointer-events-none absolute end-2 top-2 hidden peer-checked:block">
|
||||||
|
<x-livewire-material::icon name="check" class="size-5" />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($messages !== [])
|
||||||
|
@foreach ($messages as $message)
|
||||||
|
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
||||||
|
@endforeach
|
||||||
|
@elseif (filled($hint))
|
||||||
|
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
|
||||||
|
@endif
|
||||||
|
</fieldset>
|
||||||
|
@endif
|
||||||
@@ -29,7 +29,9 @@
|
|||||||
@php
|
@php
|
||||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|select'), 0, 12);
|
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|select'), 0, 12);
|
||||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||||
|
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||||
|
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-livewire-material::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')">
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
$model = $attributes->whereStartsWith('wire:model')->first();
|
$model = $attributes->whereStartsWith('wire:model')->first();
|
||||||
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
|
$placeholder = filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ';
|
||||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|textarea'), 0, 12);
|
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|textarea'), 0, 12);
|
||||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||||
|
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||||
|
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-livewire-material::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">
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
JSON-encoded (maryUI's `"dark"`) — is adopted once and removed. Nothing is written for a
|
JSON-encoded (maryUI's `"dark"`) — is adopted once and removed. Nothing is written for a
|
||||||
visitor who never chose, so changing `theme.default` later reaches them too.
|
visitor who never chose, so changing `theme.default` later reaches them too.
|
||||||
|
|
||||||
|
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`
|
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
|
(`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's width is CSS keyed on it (the `rail-collapsed:` variant). Set any later, a collapsed
|
||||||
@@ -27,6 +31,7 @@
|
|||||||
$rail = config('livewire-material.rail');
|
$rail = config('livewire-material.rail');
|
||||||
|
|
||||||
$settings = [
|
$settings = [
|
||||||
|
'scheme' => \NoNameWeb\LivewireMaterial\Support\Scheme::profile(),
|
||||||
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
|
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
|
||||||
'key' => $theme['storage_key'] ?? 'material-theme',
|
'key' => $theme['storage_key'] ?? 'material-theme',
|
||||||
'legacy' => array_values($theme['legacy_keys'] ?? []),
|
'legacy' => array_values($theme['legacy_keys'] ?? []),
|
||||||
@@ -78,6 +83,10 @@
|
|||||||
root.setAttribute('data-theme', current === 'system' ? (media.matches ? 'dark' : 'light') : current);
|
root.setAttribute('data-theme', current === 'system' ? (media.matches ? 'dark' : 'light') : current);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (settings.scheme) {
|
||||||
|
root.setAttribute('data-scheme', settings.scheme);
|
||||||
|
}
|
||||||
|
|
||||||
root.setAttribute('data-theme-key', settings.key);
|
root.setAttribute('data-theme-key', settings.key);
|
||||||
root.setAttribute('data-theme-choice', choice);
|
root.setAttribute('data-theme-choice', choice);
|
||||||
root.setAttribute('data-rail-key', settings.rail.key);
|
root.setAttribute('data-rail-key', settings.rail.key);
|
||||||
@@ -87,7 +96,7 @@
|
|||||||
media.addEventListener('change', apply);
|
media.addEventListener('change', apply);
|
||||||
|
|
||||||
document.addEventListener('livewire:navigating', function (event) {
|
document.addEventListener('livewire:navigating', function (event) {
|
||||||
var kept = ['data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) {
|
var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) {
|
||||||
return [name, root.getAttribute(name)];
|
return [name, root.getAttribute(name)];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,9 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$model = $attributes->wire('model')->value() ?: null;
|
$model = $attributes->wire('model')->value() ?: null;
|
||||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||||
|
$errorKey = $model ?? (filled($name) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $name) : null);
|
||||||
|
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||||
$id = $attributes->get('id') ?? 'timepicker-'.substr(md5($model.'|'.$label.'|'.$name.'|timepicker'), 0, 12);
|
$id = $attributes->get('id') ?? 'timepicker-'.substr(md5($model.'|'.$label.'|'.$name.'|timepicker'), 0, 12);
|
||||||
|
|
||||||
$cycle = in_array((string) $format, ['12', '24'], true) ? (int) $format : null;
|
$cycle = in_array((string) $format, ['12', '24'], true) ? (int) $format : null;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
}
|
}
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="space-y-4 rounded-corner-lg bg-surface-container p-4">
|
<div @if ($title) id="{{ \NoNameWeb\LivewireMaterial\Showcase\Sections::anchor($title) }}" @endif class="scroll-mt-24 space-y-4 rounded-corner-lg bg-surface-container p-4">
|
||||||
@if ($title)
|
@if ($title)
|
||||||
<h3 class="type-title-md">{{ $title }}</h3>
|
<h3 class="type-title-md">{{ $title }}</h3>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -1,31 +1,36 @@
|
|||||||
@extends('livewire-material::showcase.layout')
|
@extends('livewire-material::showcase.layout')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<main class="mx-auto max-w-6xl space-y-16 px-4 py-10">
|
<div class="mx-auto w-full max-w-6xl space-y-12 px-4 pt-4 pb-16 sm:px-6">
|
||||||
<p class="max-w-3xl type-body-lg text-on-surface-variant">
|
<div class="max-w-3xl space-y-3">
|
||||||
Every token and component, in this application's own scheme.
|
<h1 class="type-headline-lg">Livewire Material</h1>
|
||||||
</p>
|
<p class="type-title-lg">Material 3 Expressive for Laravel and Livewire.</p>
|
||||||
|
<p class="type-body-lg text-on-surface-variant">
|
||||||
|
Every token and component, rendered in this application's own scheme and theme. Pick a section in the
|
||||||
|
navigation, search for one above (or press <kbd class="rounded-corner-xs bg-surface-container-highest px-1.5 type-label-md">/</kbd>), or start below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
@include('livewire-material::showcase.sections.colour')
|
@foreach (collect($sections)->groupBy('group', preserveKeys: true) as $group => $entries)
|
||||||
@include('livewire-material::showcase.sections.type')
|
<section class="space-y-4" aria-labelledby="group-{{ \Illuminate\Support\Str::slug($group) }}">
|
||||||
@include('livewire-material::showcase.sections.shape')
|
<h2 id="group-{{ \Illuminate\Support\Str::slug($group) }}" class="type-title-lg">{{ $group }}</h2>
|
||||||
@include('livewire-material::showcase.sections.elevation')
|
|
||||||
@include('livewire-material::showcase.sections.motion')
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
@include('livewire-material::showcase.sections.icons')
|
@foreach ($entries as $key => $entry)
|
||||||
@include('livewire-material::showcase.sections.buttons')
|
<x-livewire-material::card variant="outlined" data-list-row wire:key="section-{{ $key }}">
|
||||||
@include('livewire-material::showcase.sections.menus')
|
<div class="flex items-start gap-4">
|
||||||
@include('livewire-material::showcase.sections.communication')
|
<span class="grid size-12 shrink-0 place-items-center rounded-corner-lg bg-secondary-container text-on-secondary-container">
|
||||||
@include('livewire-material::showcase.sections.progress')
|
<x-livewire-material::icon :name="$entry['icon']" />
|
||||||
@include('livewire-material::showcase.sections.containment')
|
</span>
|
||||||
@include('livewire-material::showcase.sections.carousel')
|
<div class="min-w-0">
|
||||||
@include('livewire-material::showcase.sections.fields')
|
<a href="{{ route('livewire-material.section', $key) }}" wire:navigate data-list-open class="type-title-md">{{ $entry['title'] }}</a>
|
||||||
@include('livewire-material::showcase.sections.chips')
|
<p class="mt-1 type-body-md text-on-surface-variant">{{ $entry['description'] }}</p>
|
||||||
@include('livewire-material::showcase.sections.sliders')
|
</div>
|
||||||
@include('livewire-material::showcase.sections.pickers')
|
</div>
|
||||||
@include('livewire-material::showcase.sections.timepickers')
|
</x-livewire-material::card>
|
||||||
@include('livewire-material::showcase.sections.bars')
|
@endforeach
|
||||||
@include('livewire-material::showcase.sections.navigation')
|
</div>
|
||||||
@include('livewire-material::showcase.sections.data')
|
</section>
|
||||||
@include('livewire-material::showcase.sections.pages')
|
@endforeach
|
||||||
</main>
|
</div>
|
||||||
@endsection
|
@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>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
|
|
||||||
<title>@yield('title', 'Livewire Material')</title>
|
<title>{{ $section !== null ? $title.' · Livewire Material' : 'Livewire Material' }}</title>
|
||||||
|
|
||||||
<x-livewire-material::theme-script />
|
<x-livewire-material::theme-script />
|
||||||
|
|
||||||
@vite(config('livewire-material.showcase.vite'))
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
@livewireStyles
|
@livewireStyles
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-surface font-sans text-on-surface antialiased">
|
<body class="bg-surface font-sans text-on-surface antialiased">
|
||||||
<header class="sticky top-0 z-10 border-b border-divider bg-surface-container">
|
<x-livewire-material::app-shell :destinations="$destinations" label="Showcase" rail-width="17rem">
|
||||||
<div class="mx-auto flex max-w-6xl items-center gap-x-6 px-4 py-3">
|
<x-slot:brand>
|
||||||
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
|
<a href="{{ route('livewire-material.showcase') }}" wire:navigate class="block truncate rounded-corner-xs type-title-lg focus-ring">Livewire Material</a>
|
||||||
|
</x-slot:brand>
|
||||||
|
|
||||||
<nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections">
|
<x-slot:top>
|
||||||
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'pickers' => 'Date pickers', 'timepickers' => 'Time pickers', 'bars' => 'Bars', 'navigation' => 'Navigation', 'data' => 'Data', 'pages' => 'Pages'] as $anchor => $section)
|
<x-livewire-material::app-bar variant="search">
|
||||||
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
|
<x-slot:navigation>
|
||||||
@endforeach
|
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="showcase-menu" /></span>
|
||||||
</nav>
|
</x-slot:navigation>
|
||||||
|
|
||||||
<div class="ms-auto flex shrink-0 rounded-corner-full border border-outline" role="group" aria-label="Theme" x-data x-cloak>
|
{{-- The search looks through every section, example and component: the index is
|
||||||
@foreach (['light' => 'Light', 'dark' => 'Dark', 'system' => 'System'] as $choice => $label)
|
fetched on first focus, and / or Ctrl+K (⌘K) reaches it from anywhere. Its names
|
||||||
<button
|
keep clear of <x-search>'s own (`results`, `open`), which the slot also sees. --}}
|
||||||
type="button"
|
<div
|
||||||
data-test="theme-{{ $choice }}"
|
class="mx-auto w-full max-w-2xl"
|
||||||
class="state-layer focus-ring px-4 py-1.5 type-label-lg first:rounded-s-corner-full last:rounded-e-corner-full"
|
x-data="{
|
||||||
x-on:click="$store.theme.set('{{ $choice }}')"
|
query: '',
|
||||||
x-bind:class="$store.theme.choice === '{{ $choice }}' && 'bg-secondary-container text-on-secondary-container'"
|
entries: null,
|
||||||
x-bind:aria-pressed="($store.theme.choice === '{{ $choice }}').toString()"
|
async load() {
|
||||||
>{{ $label }}</button>
|
this.entries ??= await (await fetch(@js(route('livewire-material.search', [], false)))).json();
|
||||||
@endforeach
|
},
|
||||||
</div>
|
get matches() {
|
||||||
</div>
|
const words = this.query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
</header>
|
if (! this.entries || words.length === 0) return [];
|
||||||
|
const phrase = words.join(' ');
|
||||||
|
return this.entries
|
||||||
|
.map((entry) => {
|
||||||
|
const title = entry.title.toLowerCase().replace(/[<>]/g, '');
|
||||||
|
const text = `${title} ${entry.context} ${entry.kind} ${entry.keywords}`.toLowerCase();
|
||||||
|
if (! words.every((word) => text.includes(word))) return null;
|
||||||
|
const rank = title === phrase ? 0 : title.startsWith(phrase) ? 1 : title.includes(phrase) ? 2 : 3;
|
||||||
|
return { ...entry, rank };
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.rank - b.rank)
|
||||||
|
.slice(0, 30);
|
||||||
|
},
|
||||||
|
go(event, result) {
|
||||||
|
if (! result || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || (event.button ?? 0) !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const url = new URL(result.url, window.location.href);
|
||||||
|
const search = this.$root.querySelector('[data-search]');
|
||||||
|
this.query = '';
|
||||||
|
if (search) window.Alpine.$data(search).close();
|
||||||
|
if (url.pathname === window.location.pathname) {
|
||||||
|
window.location.hash = url.hash;
|
||||||
|
document.getElementById(url.hash.slice(1))?.scrollIntoView({ block: 'start' });
|
||||||
|
} else {
|
||||||
|
window.Livewire.navigate(url.pathname + url.hash);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shortcut(event) {
|
||||||
|
const typing = event.target.closest('input, textarea, select, [contenteditable]');
|
||||||
|
if ((event.key === '/' && ! typing) || (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey))) {
|
||||||
|
event.preventDefault();
|
||||||
|
document.getElementById('showcase-search').focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
x-on:keydown.window="shortcut($event)"
|
||||||
|
>
|
||||||
|
<x-livewire-material::search
|
||||||
|
id="showcase-search"
|
||||||
|
placeholder="Search components, examples and sections"
|
||||||
|
x-model="query"
|
||||||
|
x-on:focus.once="load()"
|
||||||
|
x-on:keydown.enter.prevent="go($event, matches[0])"
|
||||||
|
>
|
||||||
|
<p x-show="query.trim() === ''" class="px-4 py-3 type-body-md text-on-surface-variant">
|
||||||
|
Type a component (<code>datepicker</code>), an example or a section. Press <kbd class="rounded-corner-xs bg-surface-container-highest px-1.5 type-label-md">/</kbd> to search from anywhere.
|
||||||
|
</p>
|
||||||
|
<p x-cloak x-show="query.trim() !== '' && entries !== null && matches.length === 0" class="px-4 py-3 type-body-md text-on-surface-variant">
|
||||||
|
Nothing matches “<span x-text="query.trim()"></span>”.
|
||||||
|
</p>
|
||||||
|
<template x-for="result in matches" x-bind:key="result.url + result.title">
|
||||||
|
<a
|
||||||
|
x-bind:href="result.url"
|
||||||
|
x-on:click="go($event, result)"
|
||||||
|
class="state-layer focus-ring flex min-h-14 flex-col justify-center px-4 py-2 outline-none"
|
||||||
|
data-showcase-result
|
||||||
|
>
|
||||||
|
<span class="truncate type-body-lg text-on-surface" x-text="result.title"></span>
|
||||||
|
<span class="truncate type-body-sm text-on-surface-variant" x-text="`${result.kind} · ${result.context}`"></span>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</x-livewire-material::search>
|
||||||
|
</div>
|
||||||
|
|
||||||
@yield('content')
|
<x-slot:actions>
|
||||||
|
{{-- With colour profiles, a preview of each on this page; nothing is stored. --}}
|
||||||
|
@if (($profiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles()) !== [])
|
||||||
|
<x-livewire-material::menu label="Colour profile" position="bottom-end" data-test="showcase-profiles">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<x-livewire-material::button icon="palette" tooltip="Colour profile" />
|
||||||
|
</x-slot:trigger>
|
||||||
|
|
||||||
<x-livewire-material::toast />
|
@foreach ($profiles as $name => $profile)
|
||||||
|
<x-livewire-material::menu-item :label="$profile['label']" x-on:click="$store.theme.previewScheme('{{ $name }}')" data-scheme-preview="{{ $name }}" />
|
||||||
|
@endforeach
|
||||||
|
</x-livewire-material::menu>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<span class="ms-2 me-3 max-md:hidden"><x-livewire-material::theme-toggle mode="picker" /></span>
|
||||||
|
<span class="md:hidden"><x-livewire-material::theme-toggle mode="cycle" /></span>
|
||||||
|
</x-slot:actions>
|
||||||
|
</x-livewire-material::app-bar>
|
||||||
|
</x-slot:top>
|
||||||
|
|
||||||
|
@yield('content')
|
||||||
|
</x-livewire-material::app-shell>
|
||||||
|
|
||||||
@livewireScripts
|
@livewireScripts
|
||||||
|
|
||||||
|
{{-- wire:navigate keeps the URL's hash but not the scroll to it: a search result that opens an
|
||||||
|
example on another page lands on the example. --}}
|
||||||
|
<script data-navigate-once>
|
||||||
|
document.addEventListener('livewire:navigated', () => {
|
||||||
|
const target = () => (window.location.hash.length > 1 ? document.getElementById(decodeURIComponent(window.location.hash.slice(1))) : null);
|
||||||
|
const land = () => target()?.scrollIntoView({ block: 'start' });
|
||||||
|
|
||||||
|
// After the swap's own scroll to the top and the page transition, and once more when
|
||||||
|
// the page's components have settled their size.
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(land));
|
||||||
|
setTimeout(() => {
|
||||||
|
const box = target()?.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (box && (box.top < 0 || box.top > window.innerHeight / 2)) {
|
||||||
|
land();
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -10,6 +10,14 @@
|
|||||||
'Surface' => ['bg-surface', 'bg-surface-dim', 'bg-surface-bright', 'bg-surface-container-lowest', 'bg-surface-container-low', 'bg-surface-container', 'bg-surface-container-high', 'bg-surface-container-highest', 'bg-on-surface', 'bg-on-surface-variant', 'bg-inverse-surface', 'bg-inverse-on-surface', 'bg-outline', 'bg-outline-variant'],
|
'Surface' => ['bg-surface', 'bg-surface-dim', 'bg-surface-bright', 'bg-surface-container-lowest', 'bg-surface-container-low', 'bg-surface-container', 'bg-surface-container-high', 'bg-surface-container-highest', 'bg-on-surface', 'bg-on-surface-variant', 'bg-inverse-surface', 'bg-inverse-on-surface', 'bg-outline', 'bg-outline-variant'],
|
||||||
'Ink and lines' => ['bg-body', 'bg-meta', 'bg-quiet', 'bg-structure', 'bg-chrome', 'bg-divider'],
|
'Ink and lines' => ['bg-body', 'bg-meta', 'bg-quiet', 'bg-structure', 'bg-chrome', 'bg-divider'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$examples = [
|
||||||
|
'Colour profiles' => <<<'BLADE'
|
||||||
|
<div x-data="{ profile: $store.theme.scheme }" class="w-full max-w-3xl">
|
||||||
|
<x-scheme-picker label="Colour profile" name="profile" x-model="profile" hint="Previews on this page; an application stores the choice and names it with Scheme::resolveProfileUsing()." />
|
||||||
|
</div>
|
||||||
|
BLADE,
|
||||||
|
];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<section id="colour" class="scroll-mt-24 space-y-6">
|
<section id="colour" class="scroll-mt-24 space-y-6">
|
||||||
@@ -20,6 +28,16 @@
|
|||||||
<code>php artisan material:scheme</code>. Both themes side by side, whatever the page is showing.
|
<code>php artisan material:scheme</code>. Both themes side by side, whatever the page is showing.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||||
|
With colour profiles in <code>livewire-material.profiles</code>, the command generates each one under
|
||||||
|
<code><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">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
@foreach (['light', 'dark'] as $theme)
|
@foreach (['light', 'dark'] as $theme)
|
||||||
<div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface">
|
<div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface">
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
@php
|
@php
|
||||||
$examples = [
|
$examples = [
|
||||||
'docked' => <<<'BLADE'
|
'Docked' => <<<'BLADE'
|
||||||
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ expires: '{{ now()->addWeek()->format('Y-m-d') }}', starts: null }">
|
<div class="grid 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">
|
<div class="grid content-start gap-4">
|
||||||
<x-datepicker label="Expires on" x-model="expires" hint="Type a date or pick one" />
|
<x-datepicker label="Expires on" clearable x-model="expires" hint="Type a date or pick one" />
|
||||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(expires)"></code></p>
|
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(expires)"></code></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
BLADE,
|
BLADE,
|
||||||
'modal and modal input' => <<<'BLADE'
|
'Modal and modal input' => <<<'BLADE'
|
||||||
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ birthday: '1990-05-17', delivery: null }">
|
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ birthday: '1990-05-17', delivery: null }">
|
||||||
<div class="grid content-start gap-4">
|
<div class="grid content-start gap-4">
|
||||||
<x-datepicker label="Birthday" mode="modal" x-model="birthday" :max="now()" />
|
<x-datepicker label="Birthday" mode="modal" x-model="birthday" :max="now()" />
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
BLADE,
|
BLADE,
|
||||||
'range' => <<<'BLADE'
|
'Range' => <<<'BLADE'
|
||||||
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ trip: { start: '{{ now()->addDays(3)->format('Y-m-d') }}', end: '{{ now()->addDays(9)->format('Y-m-d') }}' }, leave: { start: null, end: null } }">
|
<div class="grid 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">
|
<div class="grid content-start gap-4">
|
||||||
<x-datepicker label="Trip" range x-model="trip" />
|
<x-datepicker label="Trip" range x-model="trip" />
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
BLADE,
|
BLADE,
|
||||||
'limits, errors and states' => <<<'BLADE'
|
'Limits, errors and states' => <<<'BLADE'
|
||||||
<div class="grid w-full gap-6 md:grid-cols-2">
|
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||||
<div class="grid content-start gap-4">
|
<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="Within the next 30 days" :min="now()" :max="now()->addDays(30)" hint="Days outside are disabled" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@php
|
@php
|
||||||
$examples = [
|
$examples = [
|
||||||
'Time pickers: 12 and 24 hours, outlined and filled' => <<<'BLADE'
|
'12 and 24 hours, outlined and filled' => <<<'BLADE'
|
||||||
<div class="grid w-full gap-6 md:grid-cols-2">
|
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||||
<div class="grid content-start gap-4">
|
<div class="grid content-start gap-4">
|
||||||
<x-timepicker label="Meeting starts" value="09:30" hint="The locale's clock" />
|
<x-timepicker label="Meeting starts" value="09:30" hint="The locale's clock" />
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
BLADE,
|
BLADE,
|
||||||
'Time pickers: steps and limits' => <<<'BLADE'
|
'Steps and limits' => <<<'BLADE'
|
||||||
<div class="grid w-full gap-6 md:grid-cols-2">
|
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||||
<div class="grid content-start gap-4">
|
<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="Appointment" value="10:00" format="24" step="15" min="08:00" max="17:30" hint="Quarter hours from 08:00 to 17:30" />
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<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="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.showcase') }}#navigation" no-wire-navigate />
|
<x-livewire-material::button label="Back to the showcase" link="{{ route('livewire-material.section', 'navigation') }}" no-wire-navigate />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-livewire-material::app-shell>
|
</x-livewire-material::app-shell>
|
||||||
|
|||||||
+9
-1
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseController;
|
||||||
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcasePageController;
|
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcasePageController;
|
||||||
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseSymbolController;
|
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseSymbolController;
|
||||||
|
use NoNameWeb\LivewireMaterial\Showcase\Sections;
|
||||||
|
|
||||||
Route::view('/', 'livewire-material::showcase.index')->name('showcase');
|
Route::get('/', [ShowcaseController::class, 'index'])->name('showcase');
|
||||||
|
|
||||||
Route::get('symbols.json', [ShowcaseSymbolController::class, 'index'])->name('symbols');
|
Route::get('symbols.json', [ShowcaseSymbolController::class, 'index'])->name('symbols');
|
||||||
Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol');
|
Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol');
|
||||||
@@ -17,3 +19,9 @@ Route::get('errors/{code}', [ShowcasePageController::class, 'error'])
|
|||||||
->whereIn('code', ['401', '402', '403', '404', '419', '429', '500', '503'])
|
->whereIn('code', ['401', '402', '403', '404', '419', '429', '500', '503'])
|
||||||
->name('error');
|
->name('error');
|
||||||
Route::get('mail', [ShowcasePageController::class, 'mail'])->name('mail');
|
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\Console\Command;
|
||||||
use Illuminate\Filesystem\Filesystem;
|
use Illuminate\Filesystem\Filesystem;
|
||||||
use Illuminate\Support\Facades\Process;
|
use Illuminate\Support\Facades\Process;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use JsonException;
|
use JsonException;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ class SchemeCommand extends Command
|
|||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $signature = 'material:scheme
|
protected $signature = 'material:scheme
|
||||||
{seed : The source colour, as #rrggbb}
|
{seed? : The source colour, as #rrggbb; without it, every profile in livewire-material.profiles}
|
||||||
{--variant=tonal-spot : tonal-spot, vibrant, expressive, neutral, fidelity, content, monochrome, rainbow or fruit-salad}
|
{--variant=tonal-spot : tonal-spot, vibrant, expressive, neutral, fidelity, content, monochrome, rainbow or fruit-salad}
|
||||||
{--contrast=0 : The contrast level, from -1 to 1}
|
{--contrast=0 : The contrast level, from -1 to 1}
|
||||||
{--success=#22a06b : The source of the success colour}
|
{--success=#22a06b : The source of the success colour}
|
||||||
@@ -26,20 +27,73 @@ class SchemeCommand extends Command
|
|||||||
/**
|
/**
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour';
|
protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour, or every configured colour profile';
|
||||||
|
|
||||||
public function handle(Filesystem $files): int
|
public function handle(Filesystem $files): int
|
||||||
{
|
{
|
||||||
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
|
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
|
||||||
$data = preg_replace('/\.css$/', '', $stylesheet).'.json';
|
$data = preg_replace('/\.css$/', '', $stylesheet).'.json';
|
||||||
|
|
||||||
|
if (filled($this->argument('seed'))) {
|
||||||
|
$scheme = $this->generate((string) $this->argument('seed'), (string) $this->option('variant'), (float) $this->option('contrast'));
|
||||||
|
|
||||||
|
if ($scheme === null) {
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->write($files, $stylesheet, $data, $this->stylesheet($scheme), $scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
$profiles = config('livewire-material.profiles');
|
||||||
|
|
||||||
|
if (! is_array($profiles) || $profiles === []) {
|
||||||
|
$this->components->error('Give a seed colour (php artisan material:scheme "#4f46e5"), or list colour profiles in livewire-material.profiles.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$generated = [];
|
||||||
|
|
||||||
|
foreach ($profiles as $name => $profile) {
|
||||||
|
if (! is_string($name) || preg_match('/^[a-z0-9-]+$/', $name) !== 1) {
|
||||||
|
$this->components->error("A profile's name is lowercase letters, digits and dashes; \"{$name}\" is not.");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = $this->generate((string) ($profile['seed'] ?? ''), (string) ($profile['variant'] ?? 'tonal-spot'), (float) ($profile['contrast'] ?? 0), "Profile \"{$name}\": ");
|
||||||
|
|
||||||
|
if ($scheme === null) {
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$generated[$name] = ['label' => (string) ($profile['label'] ?? Str::headline($name)), ...$scheme];
|
||||||
|
}
|
||||||
|
|
||||||
|
$configured = config('livewire-material.profile');
|
||||||
|
$default = is_string($configured) && isset($generated[$configured]) ? $configured : array_key_first($generated);
|
||||||
|
|
||||||
|
return $this->write($files, $stylesheet, $data, $this->profilesStylesheet($generated, $default), [
|
||||||
|
...collect($generated[$default])->except('label')->all(),
|
||||||
|
'default' => $default,
|
||||||
|
'profiles' => $generated,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One scheme from Google's colour utilities, or null once the reason has been shown.
|
||||||
|
*
|
||||||
|
* @return array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}|null
|
||||||
|
*/
|
||||||
|
protected function generate(string $seed, string $variant, float $contrast, string $context = ''): ?array
|
||||||
|
{
|
||||||
$result = Process::run([
|
$result = Process::run([
|
||||||
config('livewire-material.node', 'node'),
|
config('livewire-material.node', 'node'),
|
||||||
__DIR__.'/../../resources/node/scheme.mjs',
|
__DIR__.'/../../resources/node/scheme.mjs',
|
||||||
json_encode([
|
json_encode([
|
||||||
'seed' => $this->argument('seed'),
|
'seed' => $seed,
|
||||||
'variant' => $this->option('variant'),
|
'variant' => $variant,
|
||||||
'contrast' => (float) $this->option('contrast'),
|
'contrast' => $contrast,
|
||||||
'success' => $this->option('success'),
|
'success' => $this->option('success'),
|
||||||
'warning' => $this->option('warning'),
|
'warning' => $this->option('warning'),
|
||||||
'info' => $this->option('info'),
|
'info' => $this->option('info'),
|
||||||
@@ -47,22 +101,27 @@ class SchemeCommand extends Command
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if ($result->failed()) {
|
if ($result->failed()) {
|
||||||
$this->components->error(trim($result->errorOutput()) ?: 'Node could not run the scheme generator. Is `node` installed and on the PATH?');
|
$this->components->error($context.(trim($result->errorOutput()) ?: 'Node could not run the scheme generator. Is `node` installed and on the PATH?'));
|
||||||
|
|
||||||
return self::FAILURE;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
/** @var array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme */
|
return json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
|
||||||
$scheme = json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
} catch (JsonException) {
|
} catch (JsonException) {
|
||||||
$this->components->error('The scheme generator answered with something other than JSON.');
|
$this->components->error($context.'The scheme generator answered with something other than JSON.');
|
||||||
|
|
||||||
return self::FAILURE;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $scheme
|
||||||
|
*/
|
||||||
|
protected function write(Filesystem $files, string $stylesheet, string $data, string $css, array $scheme): int
|
||||||
|
{
|
||||||
$files->ensureDirectoryExists(dirname($stylesheet));
|
$files->ensureDirectoryExists(dirname($stylesheet));
|
||||||
$files->put($stylesheet, $this->stylesheet($scheme));
|
$files->put($stylesheet, $css);
|
||||||
$files->put($data, json_encode($scheme, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
|
$files->put($data, json_encode($scheme, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
|
||||||
|
|
||||||
$this->components->info("Wrote {$stylesheet} and {$data}.");
|
$this->components->info("Wrote {$stylesheet} and {$data}.");
|
||||||
@@ -82,9 +141,7 @@ class SchemeCommand extends Command
|
|||||||
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
|
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
|
||||||
);
|
);
|
||||||
|
|
||||||
$block = fn (array $roles): string => collect($roles)
|
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
|
||||||
->map(fn (string $hex, string $role): string => " --md-sys-color-{$role}: {$hex};")
|
|
||||||
->implode("\n");
|
|
||||||
|
|
||||||
return <<<CSS
|
return <<<CSS
|
||||||
/*
|
/*
|
||||||
@@ -97,19 +154,76 @@ class SchemeCommand extends Command
|
|||||||
* first paint; the light block also stands without it.
|
* first paint; the light block also stands without it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:root,
|
{$blocks}
|
||||||
[data-theme='light'] {
|
|
||||||
color-scheme: light;
|
|
||||||
|
|
||||||
{$block($scheme['light'])}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme='dark'] {
|
|
||||||
color-scheme: dark;
|
|
||||||
|
|
||||||
{$block($scheme['dark'])}
|
|
||||||
}
|
|
||||||
|
|
||||||
CSS;
|
CSS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default profile as the plain blocks, then every profile under its own data-scheme. A
|
||||||
|
* profile's two-attribute selectors outrank the plain ones, and its one-attribute selector
|
||||||
|
* comes after `:root`, which it only ties with: the order is part of the format. The
|
||||||
|
* descendant selectors keep a nested `data-theme` panel (a light card on a dark page) in the
|
||||||
|
* page's profile.
|
||||||
|
*
|
||||||
|
* @param array<string, array{label: string, seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}> $profiles
|
||||||
|
*/
|
||||||
|
protected function profilesStylesheet(array $profiles, string $default): string
|
||||||
|
{
|
||||||
|
$list = collect($profiles)
|
||||||
|
->map(fn (array $profile, string $name): string => sprintf(
|
||||||
|
' * %-12s %s, %s%s',
|
||||||
|
$name,
|
||||||
|
$profile['seed'],
|
||||||
|
$profile['variant'],
|
||||||
|
$profile['contrast'] != 0 ? ', contrast '.$profile['contrast'] : '',
|
||||||
|
))
|
||||||
|
->implode("\n");
|
||||||
|
|
||||||
|
$css = <<<CSS
|
||||||
|
/*
|
||||||
|
* Material 3 colour profiles, generated by Google's material-color-utilities (spec {$profiles[$default]['spec']}).
|
||||||
|
*
|
||||||
|
* php artisan material:scheme
|
||||||
|
*
|
||||||
|
* From livewire-material.profiles. "{$default}" is the default, and also stands without a
|
||||||
|
* data-scheme attribute:
|
||||||
|
*
|
||||||
|
{$list}
|
||||||
|
*
|
||||||
|
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
|
||||||
|
* M3's contrast guarantee only as generated. The head script sets data-theme and data-scheme
|
||||||
|
* before the first paint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
CSS;
|
||||||
|
|
||||||
|
$css .= "\n".$this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $profiles[$default]);
|
||||||
|
|
||||||
|
foreach ($profiles as $name => $profile) {
|
||||||
|
$css .= "\n".$this->blocks(
|
||||||
|
["[data-scheme='{$name}']", "[data-scheme='{$name}'][data-theme='light']", "[data-scheme='{$name}'] [data-theme='light']"],
|
||||||
|
["[data-scheme='{$name}'][data-theme='dark']", "[data-scheme='{$name}'] [data-theme='dark']"],
|
||||||
|
$profile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $css;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A light and a dark block of roles under the given selectors.
|
||||||
|
*
|
||||||
|
* @param list<string> $light
|
||||||
|
* @param list<string> $dark
|
||||||
|
* @param array{light: array<string, string>, dark: array<string, string>} $scheme
|
||||||
|
*/
|
||||||
|
protected function blocks(array $light, array $dark, array $scheme): string
|
||||||
|
{
|
||||||
|
$roles = fn (array $roles): string => collect($roles)
|
||||||
|
->map(fn (string $hex, string $role): string => " --md-sys-color-{$role}: {$hex};")
|
||||||
|
->implode("\n");
|
||||||
|
|
||||||
|
return implode(",\n", $light)." {\n color-scheme: light;\n\n".$roles($scheme['light'])."\n}\n\n"
|
||||||
|
.implode(",\n", $dark)." {\n color-scheme: dark;\n\n".$roles($scheme['dark'])."\n}\n";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace NoNameWeb\LivewireMaterial\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use NoNameWeb\LivewireMaterial\Showcase\Sections;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The showcase: an overview of every section, and a page for each.
|
||||||
|
*/
|
||||||
|
class ShowcaseController
|
||||||
|
{
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
return view('livewire-material::showcase.index', ['sections' => Sections::all()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function section(string $section): View
|
||||||
|
{
|
||||||
|
return view('livewire-material::showcase.section', [
|
||||||
|
'sections' => Sections::all(),
|
||||||
|
'section' => $section,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The search's index, fetched when the search is first focused rather than written into every page.
|
||||||
|
*/
|
||||||
|
public function search(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(Sections::index());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace NoNameWeb\LivewireMaterial;
|
|||||||
use Illuminate\Contracts\View\Factory;
|
use Illuminate\Contracts\View\Factory;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class LivewireMaterialServiceProvider extends ServiceProvider
|
class LivewireMaterialServiceProvider extends ServiceProvider
|
||||||
@@ -113,7 +114,15 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register the components as anonymous Blade components under the configured prefix.
|
* Register the components as anonymous Blade components under the configured prefix, or
|
||||||
|
* under `livewire-material` without one.
|
||||||
|
*
|
||||||
|
* A compiled `<x-button>` names the view namespace Blade derives from the prefix, or from the
|
||||||
|
* component folder's absolute path when there is none. That path differs between a laptop and
|
||||||
|
* a container sharing `storage/framework/views`, or between release directories, while the
|
||||||
|
* compiled file's name does not — so a view compiled on one showed `a1b2…::button` as text on
|
||||||
|
* the other. A prefix does not stop an unprefixed tag resolving. The path's namespace stays
|
||||||
|
* registered, so views compiled before this release still render until they are recompiled.
|
||||||
*
|
*
|
||||||
* The package's own views never go through this registration: they write
|
* The package's own views never go through this registration: they write
|
||||||
* `<x-livewire-material::button>`, which resolves through the view namespace whatever the
|
* `<x-livewire-material::button>`, which resolves through the view namespace whatever the
|
||||||
@@ -123,8 +132,10 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
Blade::anonymousComponentPath(
|
Blade::anonymousComponentPath(
|
||||||
static::componentPath(),
|
static::componentPath(),
|
||||||
filled(config('livewire-material.prefix')) ? config('livewire-material.prefix') : null,
|
filled(config('livewire-material.prefix')) ? config('livewire-material.prefix') : 'livewire-material',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
View::addNamespace(hash('xxh128', static::componentPath()), static::componentPath());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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.'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
-14
@@ -2,9 +2,14 @@
|
|||||||
|
|
||||||
namespace NoNameWeb\LivewireMaterial\Support;
|
namespace NoNameWeb\LivewireMaterial\Support;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The colour scheme as data, for the places CSS custom properties cannot reach: a mail client
|
* The colour scheme as data, for the places CSS custom properties cannot reach: a mail client
|
||||||
* resolves none, and an error page whose build is missing has no stylesheet to declare them.
|
* resolves none, an error page whose build is missing has no stylesheet to declare them, and the
|
||||||
|
* head script has to name the active colour profile before the stylesheet applies.
|
||||||
*
|
*
|
||||||
* The data is the JSON `php artisan material:scheme` writes beside the stylesheet
|
* The data is the JSON `php artisan material:scheme` writes beside the stylesheet
|
||||||
* (`livewire-material.scheme`, resources/css/material-scheme.json by default). Without that
|
* (`livewire-material.scheme`, resources/css/material-scheme.json by default). Without that
|
||||||
@@ -13,18 +18,51 @@ namespace NoNameWeb\LivewireMaterial\Support;
|
|||||||
* the role existed) is taken from the default, and a value that is not a #rrggbb hex is
|
* the role existed) is taken from the default, and a value that is not a #rrggbb hex is
|
||||||
* refused, so nothing but a colour ever reaches a stylesheet.
|
* refused, so nothing but a colour ever reaches a stylesheet.
|
||||||
*
|
*
|
||||||
* Read on every call and never kept: a regenerated scheme applies at once, and a long-running
|
* A file generated from `livewire-material.profiles` holds every profile under `profiles`, its
|
||||||
* worker holds nothing.
|
* default under `default`, and the default's roles at the top level as a single scheme does. Which
|
||||||
|
* profile is active is the application's to say, through resolveProfileUsing(); the resolver is
|
||||||
|
* asked on every call, and a name it gives that is not in the file — or a resolver that throws, as
|
||||||
|
* one reading a database may while an error page for that very database renders — falls back to
|
||||||
|
* the file's default.
|
||||||
|
*
|
||||||
|
* Read on every call and never kept: a regenerated scheme or a newly chosen profile applies at
|
||||||
|
* once, and a long-running worker holds nothing but the resolver itself.
|
||||||
*/
|
*/
|
||||||
class Scheme
|
class Scheme
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
* @var (Closure(): ?string)|null
|
||||||
|
*/
|
||||||
|
protected static ?Closure $profileResolver = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name the active colour profile with the given closure, asked each time a colour is drawn.
|
||||||
|
*
|
||||||
|
* @param (Closure(): ?string)|null $resolver
|
||||||
|
*/
|
||||||
|
public static function resolveProfileUsing(?Closure $resolver): void
|
||||||
|
{
|
||||||
|
static::$profileResolver = $resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roles to draw: the given profile's, the active profile's, or the single scheme's.
|
||||||
|
*
|
||||||
* @return array{light: array<string, string>, dark: array<string, string>}
|
* @return array{light: array<string, string>, dark: array<string, string>}
|
||||||
*/
|
*/
|
||||||
public static function load(?string $path = null): array
|
public static function load(?string $path = null, ?string $profile = null): array
|
||||||
{
|
{
|
||||||
$default = static::read(dirname(__DIR__, 2).'/resources/css/tokens/scheme.json');
|
$data = static::data($path);
|
||||||
$scheme = static::read($path ?? (string) config('livewire-material.scheme'));
|
$profiles = static::profilesFrom($data);
|
||||||
|
|
||||||
|
if ($profiles !== []) {
|
||||||
|
$name = $profile !== null && isset($profiles[$profile]) ? $profile : static::activeFrom($data, $profiles);
|
||||||
|
|
||||||
|
return ['light' => $profiles[$name]['light'], 'dark' => $profiles[$name]['dark']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$default = static::defaultScheme();
|
||||||
|
$scheme = ['light' => static::roles($data['light'] ?? null), 'dark' => static::roles($data['dark'] ?? null)];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'light' => [...$default['light'], ...$scheme['light']],
|
'light' => [...$default['light'], ...$scheme['light']],
|
||||||
@@ -37,22 +75,102 @@ class Scheme
|
|||||||
*
|
*
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
public static function light(?string $path = null): array
|
public static function light(?string $path = null, ?string $profile = null): array
|
||||||
{
|
{
|
||||||
return static::load($path)['light'];
|
return static::load($path, $profile)['light'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{light: array<string, string>, dark: array<string, string>}
|
* The generated colour profiles, in the order they were configured; empty for a single scheme.
|
||||||
|
*
|
||||||
|
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
|
||||||
*/
|
*/
|
||||||
protected static function read(string $path): array
|
public static function profiles(?string $path = null): array
|
||||||
{
|
{
|
||||||
|
return static::profilesFrom(static::data($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active profile's name, or null for a single scheme.
|
||||||
|
*/
|
||||||
|
public static function profile(?string $path = null): ?string
|
||||||
|
{
|
||||||
|
$data = static::data($path);
|
||||||
|
$profiles = static::profilesFrom($data);
|
||||||
|
|
||||||
|
return $profiles === [] ? null : static::activeFrom($data, $profiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<mixed>
|
||||||
|
*/
|
||||||
|
protected static function data(?string $path): array
|
||||||
|
{
|
||||||
|
$path ??= (string) config('livewire-material.scheme');
|
||||||
$data = is_file($path) ? json_decode((string) file_get_contents($path), true) : null;
|
$data = is_file($path) ? json_decode((string) file_get_contents($path), true) : null;
|
||||||
|
|
||||||
return [
|
return is_array($data) ? $data : [];
|
||||||
'light' => static::roles($data['light'] ?? null),
|
}
|
||||||
'dark' => static::roles($data['dark'] ?? null),
|
|
||||||
];
|
/**
|
||||||
|
* @param array<mixed> $data
|
||||||
|
* @return array<string, array{label: string, light: array<string, string>, dark: array<string, string>}>
|
||||||
|
*/
|
||||||
|
protected static function profilesFrom(array $data): array
|
||||||
|
{
|
||||||
|
if (! is_array($data['profiles'] ?? null)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$default = static::defaultScheme();
|
||||||
|
$profiles = [];
|
||||||
|
|
||||||
|
foreach ($data['profiles'] as $name => $profile) {
|
||||||
|
if (! is_string($name) || preg_match('/^[a-z0-9-]+$/', $name) !== 1 || ! is_array($profile)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$profiles[$name] = [
|
||||||
|
'label' => is_string($profile['label'] ?? null) ? $profile['label'] : Str::headline($name),
|
||||||
|
'light' => [...$default['light'], ...static::roles($profile['light'] ?? null)],
|
||||||
|
'dark' => [...$default['dark'], ...static::roles($profile['dark'] ?? null)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<mixed> $data
|
||||||
|
* @param non-empty-array<string, mixed> $profiles
|
||||||
|
*/
|
||||||
|
protected static function activeFrom(array $data, array $profiles): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$resolved = static::$profileResolver ? (static::$profileResolver)() : null;
|
||||||
|
} catch (Throwable) {
|
||||||
|
$resolved = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([$resolved, $data['default'] ?? null] as $candidate) {
|
||||||
|
if (is_string($candidate) && isset($profiles[$candidate])) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) array_key_first($profiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The package's own scheme, which fills any role a file lacks.
|
||||||
|
*
|
||||||
|
* @return array{light: array<string, string>, dark: array<string, string>}
|
||||||
|
*/
|
||||||
|
protected static function defaultScheme(): array
|
||||||
|
{
|
||||||
|
$data = json_decode((string) file_get_contents(dirname(__DIR__, 2).'/resources/css/tokens/scheme.json'), true);
|
||||||
|
|
||||||
|
return ['light' => static::roles($data['light'] ?? null), 'dark' => static::roles($data['dark'] ?? null)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
const MORE = '#menus [aria-label="More"]';
|
const MORE = '#menus [aria-label="More"]';
|
||||||
|
|
||||||
function showcase()
|
function showcase(string $section = 'buttons')
|
||||||
{
|
{
|
||||||
return visit('/material')->waitForEvent('networkidle')
|
return visit("/material/{$section}")->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ function focused(string $expression): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('opens a menu on the first item and walks it with the keyboard', function () {
|
it('opens a menu on the first item and walks it with the keyboard', function () {
|
||||||
$page = showcase()
|
$page = showcase('menus')
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->click(MORE)
|
->click(MORE)
|
||||||
->assertAttribute(MORE, 'aria-expanded', 'true')
|
->assertAttribute(MORE, 'aria-expanded', 'true')
|
||||||
@@ -38,7 +38,7 @@ it('opens a menu on the first item and walks it with the keyboard', function ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('opens a menu from the keyboard, on the last item with ArrowUp', function () {
|
it('opens a menu from the keyboard, on the last item with ArrowUp', function () {
|
||||||
$page = showcase();
|
$page = showcase('menus');
|
||||||
|
|
||||||
$page->script("document.querySelector('".MORE."').focus()");
|
$page->script("document.querySelector('".MORE."').focus()");
|
||||||
|
|
||||||
@@ -50,13 +50,13 @@ it('opens a menu from the keyboard, on the last item with ArrowUp', function ()
|
|||||||
it('opens a menu the moment the page can be used', function () {
|
it('opens a menu the moment the page can be used', function () {
|
||||||
// The guard against a light-dismiss press reopening the menu once measured from the page's
|
// The guard against a light-dismiss press reopening the menu once measured from the page's
|
||||||
// time origin, and swallowed every click in the first quarter second.
|
// time origin, and swallowed every click in the first quarter second.
|
||||||
visit('/material')
|
visit('/material/menus')
|
||||||
->click(MORE)
|
->click(MORE)
|
||||||
->assertAttribute(MORE, 'aria-expanded', 'true');
|
->assertAttribute(MORE, 'aria-expanded', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes a menu when an item is chosen, but not one that keeps it open', function () {
|
it('closes a menu when an item is chosen, but not one that keeps it open', function () {
|
||||||
$page = showcase();
|
$page = showcase('menus');
|
||||||
|
|
||||||
$page->click(MORE)
|
$page->click(MORE)
|
||||||
->click('#menus [role="menuitem"]:has-text("Download")')
|
->click('#menus [role="menuitem"]:has-text("Download")')
|
||||||
@@ -76,7 +76,7 @@ it('shows a tooltip on keyboard focus and hides it on Escape', function () {
|
|||||||
// Firefox only counts a scripted focus as :focus-visible after one. Then focused directly
|
// Firefox only counts a scripted focus as :focus-visible after one. Then focused directly
|
||||||
// rather than tabbed to: WebKit, like Safari on macOS, leaves buttons out of the Tab order
|
// rather than tabbed to: WebKit, like Safari on macOS, leaves buttons out of the Tab order
|
||||||
// unless full keyboard access is on.
|
// unless full keyboard access is on.
|
||||||
$page->keys('body', 'Tab');
|
$page->keys('#content', 'Tab');
|
||||||
$page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()");
|
$page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()");
|
||||||
$page->assertScript(focused("getAttribute('aria-label') === 'Tonal'"))
|
$page->assertScript(focused("getAttribute('aria-label') === 'Tonal'"))
|
||||||
->assertScript("{$tooltip}.matches(':popover-open')");
|
->assertScript("{$tooltip}.matches(':popover-open')");
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const FIRST_SCROLLER = '#carousel [role="region"] >> nth=0';
|
|||||||
|
|
||||||
function carouselShowcase(array $options = [])
|
function carouselShowcase(array $options = [])
|
||||||
{
|
{
|
||||||
return visit('/material', $options)
|
return visit('/material/carousel', $options)
|
||||||
->waitForEvent('networkidle')
|
->waitForEvent('networkidle')
|
||||||
->assertScript("typeof window.Alpine !== 'undefined'");
|
->assertScript("typeof window.Alpine !== 'undefined'");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ function chipProbe()
|
|||||||
|
|
||||||
function chipShowcase()
|
function chipShowcase()
|
||||||
{
|
{
|
||||||
return visit('/material')->waitForEvent('networkidle')
|
return visit('/material/chips')->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ it('toggles a filter chip with a click and with Space, and grows its check in',
|
|||||||
|
|
||||||
// A key press first, so the browser is in keyboard modality; focused directly, not tabbed to,
|
// A key press first, so the browser is in keyboard modality; focused directly, not tabbed to,
|
||||||
// because WebKit leaves form controls out of the Tab order unless full keyboard access is on.
|
// because WebKit leaves form controls out of the Tab order unless full keyboard access is on.
|
||||||
$page->keys('body', 'Tab');
|
$page->keys('#content', 'Tab');
|
||||||
$page->script(filterInput('archives').'.focus()');
|
$page->script(filterInput('archives').'.focus()');
|
||||||
$page->keys(':focus', 'Space')
|
$page->keys(':focus', 'Space')
|
||||||
->assertScript(filterInput('archives').'.checked')
|
->assertScript(filterInput('archives').'.checked')
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||||
|
|
||||||
|
use function Orchestra\Testbench\workbench_path;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The Workbench's three profiles (baseline, teal, rose), generated into its stylesheet. Tests other
|
||||||
|
* than these run without profiles, so each one here points the package at the scheme file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
config(['livewire-material.scheme' => workbench_path('resources/css/material-scheme.json')]);
|
||||||
|
|
||||||
|
$this->profiles = json_decode(file_get_contents(workbench_path('resources/css/material-scheme.json')), true)['profiles'];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
Scheme::resolveProfileUsing(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A #rrggbb hex as the rgb() a computed style reports.
|
||||||
|
*/
|
||||||
|
function rgb(string $hex): string
|
||||||
|
{
|
||||||
|
[$red, $green, $blue] = sscanf($hex, '#%02x%02x%02x');
|
||||||
|
|
||||||
|
return "rgb({$red}, {$green}, {$blue})";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page's primary colour role, as <html> resolves it.
|
||||||
|
*/
|
||||||
|
function pagePrimary(): string
|
||||||
|
{
|
||||||
|
return "getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-primary').trim()";
|
||||||
|
}
|
||||||
|
|
||||||
|
function profilesReady(mixed $page): mixed
|
||||||
|
{
|
||||||
|
return $page->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
}
|
||||||
|
|
||||||
|
it('draws each profile in light and dark, and the default without the attribute', function () {
|
||||||
|
$page = profilesReady(visit('/material/colour')->inLightMode())
|
||||||
|
->assertScript("document.documentElement.getAttribute('data-scheme') === 'baseline'")
|
||||||
|
->assertScript(pagePrimary()." === '{$this->profiles['baseline']['light']['primary']}'");
|
||||||
|
|
||||||
|
$page->script("document.documentElement.setAttribute('data-scheme', 'teal')");
|
||||||
|
$page->assertScript(pagePrimary()." === '{$this->profiles['teal']['light']['primary']}'");
|
||||||
|
|
||||||
|
$page->script("document.documentElement.setAttribute('data-theme', 'dark')");
|
||||||
|
$page->assertScript(pagePrimary()." === '{$this->profiles['teal']['dark']['primary']}'")
|
||||||
|
// A panel that sets its own theme keeps the page's profile.
|
||||||
|
->assertScript("getComputedStyle(document.querySelector('#colour [data-theme=\"light\"] .bg-primary')).backgroundColor === '".rgb($this->profiles['teal']['light']['primary'])."'");
|
||||||
|
|
||||||
|
$page->script("document.documentElement.removeAttribute('data-scheme')");
|
||||||
|
$page->assertScript(pagePrimary()." === '{$this->profiles['baseline']['dark']['primary']}'")
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('previews a profile from the picker and the showcase menu, and keeps it through wire:navigate', function () {
|
||||||
|
$page = profilesReady(visit('/material/colour')->inLightMode());
|
||||||
|
|
||||||
|
$page->click('#colour [data-scheme-option="rose"]')
|
||||||
|
->assertScript("document.documentElement.getAttribute('data-scheme') === 'rose'")
|
||||||
|
->assertScript(pagePrimary()." === '{$this->profiles['rose']['light']['primary']}'")
|
||||||
|
->assertScript("window.eval(\"Alpine.store('theme').scheme\") === 'rose'");
|
||||||
|
|
||||||
|
$page->click('[data-test="showcase-profiles"] [aria-haspopup="menu"]')
|
||||||
|
->click('[data-scheme-preview="teal"]')
|
||||||
|
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'");
|
||||||
|
|
||||||
|
$page->click('[data-navigation-rail-panel] a[href$="/material/buttons"]')
|
||||||
|
->assertScript("location.pathname.endsWith('/material/buttons')")
|
||||||
|
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'")
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paints the profile the application resolves from the first frame', function () {
|
||||||
|
Scheme::resolveProfileUsing(fn (): string => 'rose');
|
||||||
|
|
||||||
|
profilesReady(visit('/material')->inDarkMode())
|
||||||
|
->assertScript("document.documentElement.getAttribute('data-scheme') === 'rose'")
|
||||||
|
->assertScript(pagePrimary()." === '{$this->profiles['rose']['dark']['primary']}'");
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
|
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
|
||||||
|
|
||||||
it('shows a toast as a snackbar, then the next in turn', function () {
|
it('shows a toast as a snackbar, then the next in turn', function () {
|
||||||
$page = visit('/material')->waitForEvent('networkidle')
|
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
|
||||||
$page->script("materialToast('First', { type: 'success', timeout: 600 }); materialToast('Second', { type: 'error' })");
|
$page->script("materialToast('First', { type: 'success', timeout: 600 }); materialToast('Second', { type: 'error' })");
|
||||||
@@ -18,7 +18,7 @@ it('shows a toast as a snackbar, then the next in turn', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows a toast dispatched as a browser event, as the Toasts concern does', function () {
|
it('shows a toast dispatched as a browser event, as the Toasts concern does', function () {
|
||||||
$page = visit('/material')->waitForEvent('networkidle')
|
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
|
||||||
// Through Livewire, in the page's own realm: an event built inside Playwright's evaluate
|
// Through Livewire, in the page's own realm: an event built inside Playwright's evaluate
|
||||||
@@ -29,7 +29,7 @@ it('shows a toast dispatched as a browser event, as the Toasts concern does', fu
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('runs a toast\'s action and dismisses it', function () {
|
it('runs a toast\'s action and dismisses it', function () {
|
||||||
$page = visit('/material')->waitForEvent('networkidle')
|
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
|
||||||
$page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })");
|
$page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })");
|
||||||
@@ -42,7 +42,7 @@ it('runs a toast\'s action and dismisses it', function () {
|
|||||||
it('opens a persistent rich tooltip on press', function () {
|
it('opens a persistent rich tooltip on press', function () {
|
||||||
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
|
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
|
||||||
|
|
||||||
visit('/material')->waitForEvent('networkidle')
|
visit('/material/communication')->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
||||||
->click('#communication button:has-text("Press for details")')
|
->click('#communication button:has-text("Press for details")')
|
||||||
->assertScript("{$bubble}.matches(':popover-open')");
|
->assertScript("{$bubble}.matches(':popover-open')");
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function overlayProbe()
|
|||||||
|
|
||||||
function containment()
|
function containment()
|
||||||
{
|
{
|
||||||
return visit('/material')->waitForEvent('networkidle')
|
return visit('/material/containment')->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ class DateProbe extends Component
|
|||||||
<p>trip: <span id="trip">{{ json_encode($trip) }}</span></p>
|
<p>trip: <span id="trip">{{ json_encode($trip) }}</span></p>
|
||||||
<p>renders: <span id="renders">{{ $renders }}</span></p>
|
<p>renders: <span id="renders">{{ $renders }}</span></p>
|
||||||
|
|
||||||
<x-datepicker id="expires-field" label="Expires" wire:model.live="expires" min="2026-09-10" max="2026-10-20" />
|
<x-datepicker id="expires-field" label="Expires" wire:model.live="expires" min="2026-09-10" max="2026-10-20" clearable />
|
||||||
<x-datepicker id="birthday-field" label="Birthday" mode="modal" wire:model.live="birthday" />
|
<x-datepicker id="birthday-field" label="Birthday" mode="modal" wire:model.live="birthday" />
|
||||||
<x-datepicker id="delivery-field" label="Delivery" mode="input" wire:model.live="delivery" min="2026-01-01" />
|
<x-datepicker id="delivery-field" label="Delivery" mode="input" wire:model.live="delivery" min="2026-01-01" />
|
||||||
<x-datepicker id="trip-field" label="Trip" range wire:model.live="trip" />
|
<x-datepicker id="trip-field" label="Trip" range wire:model.live="trip" clearable />
|
||||||
</div>
|
</div>
|
||||||
BLADE;
|
BLADE;
|
||||||
}
|
}
|
||||||
@@ -296,3 +296,18 @@ it('opens a docked picker as a modal one on a compact window', function () {
|
|||||||
->assertScript("document.querySelector('#expires-field-picker').matches(':modal')")
|
->assertScript("document.querySelector('#expires-field-picker').matches(':modal')")
|
||||||
->assertScript("getComputedStyle(document.querySelector('#expires-field-picker [data-datepicker-header]')).display !== 'none'");
|
->assertScript("getComputedStyle(document.querySelector('#expires-field-picker [data-datepicker-header]')).display !== 'none'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('empties a date, or both ends of a range, with its clear button', function () {
|
||||||
|
$page = dateProbe()
|
||||||
|
->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('.field').querySelector('[data-field-clear]')).display !== 'none'");
|
||||||
|
|
||||||
|
$page->click('[data-datepicker]:has(#expires-field) [data-field-clear]')
|
||||||
|
->assertScript("document.querySelector('#expires').textContent === ''")
|
||||||
|
->assertValue('#expires-field', '')
|
||||||
|
->assertScript("document.activeElement.id === 'expires-field'")
|
||||||
|
->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-field-clear]')).display === 'none'");
|
||||||
|
|
||||||
|
$page->click('[data-datepicker]:has(#trip-field) [data-field-clear]')
|
||||||
|
->assertSeeIn('#trip', '{"start":null,"end":null}')
|
||||||
|
->assertValue('#trip-field', '');
|
||||||
|
});
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function progressShowcase(array $options = [])
|
|||||||
{
|
{
|
||||||
// networkidle alone can return before a repeated visit has even loaded in Firefox; the
|
// networkidle alone can return before a repeated visit has even loaded in Firefox; the
|
||||||
// assertion retries until the page is complete and Alpine has started.
|
// assertion retries until the page is complete and Alpine has started.
|
||||||
return visit('/material', $options)->waitForEvent('networkidle')
|
return visit('/material/progress', $options)->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,51 @@ it('opens the showcase in a real browser', function () {
|
|||||||
->assertSee('Livewire Material')
|
->assertSee('Livewire Material')
|
||||||
->assertNoJavaScriptErrors();
|
->assertNoJavaScriptErrors();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('moves between sections through the rail and the next-section link, keeping the rail', function () {
|
||||||
|
$page = visit('/material')->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
|
||||||
|
$page->click('[data-navigation-rail-panel] a[href$="/material/buttons"]')
|
||||||
|
->assertSee('Variants')
|
||||||
|
->assertScript("document.querySelector('[data-navigation-rail-panel] [aria-current=\"page\"]').getAttribute('href').endsWith('/material/buttons')")
|
||||||
|
->assertScript("document.title === 'Buttons · Livewire Material'");
|
||||||
|
|
||||||
|
$page->click('[data-test="next-section"]')
|
||||||
|
->assertScript("location.pathname.endsWith('/material/menus')")
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds a component from anywhere and opens its section', function () {
|
||||||
|
$page = visit('/material/buttons')->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
|
||||||
|
$page->keys('#content', '/')
|
||||||
|
->assertScript("document.activeElement.id === 'showcase-search'");
|
||||||
|
|
||||||
|
$page->type('#showcase-search', 'datepicker')
|
||||||
|
->assertSeeIn('[data-search-view]', '<x-datepicker>');
|
||||||
|
|
||||||
|
$page->keys('#showcase-search', 'Enter')
|
||||||
|
->assertScript("location.pathname.endsWith('/material/pickers')")
|
||||||
|
->assertScript("document.title === 'Date pickers · Livewire Material'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens an example on another page at the example', function () {
|
||||||
|
$page = visit('/material')->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
|
||||||
|
$page->click('#showcase-search')
|
||||||
|
->type('#showcase-search', 'split button')
|
||||||
|
->click('[data-showcase-result]:has-text("Example")')
|
||||||
|
->assertScript("location.pathname.endsWith('/material/buttons') && location.hash.startsWith('#example-')")
|
||||||
|
->assertScript('(() => { const box = document.getElementById(decodeURIComponent(location.hash.slice(1))).getBoundingClientRect(); return box.top >= 0 && box.top < innerHeight; })()');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when nothing matches', function () {
|
||||||
|
visit('/material')->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
||||||
|
->click('#showcase-search')
|
||||||
|
->type('#showcase-search', 'zzzz')
|
||||||
|
->assertSeeIn('[data-search-view]', 'Nothing matches');
|
||||||
|
});
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function sliderProbe()
|
|||||||
|
|
||||||
function sliderShowcase()
|
function sliderShowcase()
|
||||||
{
|
{
|
||||||
return visit('/material')->waitForEvent('networkidle')
|
return visit('/material/sliders')->waitForEvent('networkidle')
|
||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ it('keeps the visitor\'s choice over the operating system', function () {
|
|||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
||||||
->assertScript(theme('data-theme', 'dark'));
|
->assertScript(theme('data-theme', 'dark'));
|
||||||
|
|
||||||
$page->click('@theme-light')
|
$page->click('[data-theme-option="light"]')
|
||||||
->assertScript(theme('data-theme', 'light'))
|
->assertScript(theme('data-theme', 'light'))
|
||||||
->assertAttribute('@theme-light', 'aria-pressed', 'true')
|
->assertAttribute('[data-theme-option="light"]', 'aria-checked', 'true')
|
||||||
->assertScript("localStorage.getItem('material-theme') === 'light'");
|
->assertScript("localStorage.getItem('material-theme') === 'light'");
|
||||||
|
|
||||||
$page->refresh()
|
$page->refresh()
|
||||||
@@ -52,7 +52,7 @@ it('adopts an earlier toggle\'s choice once', function () {
|
|||||||
it('repaints a section that sets its own theme', function () {
|
it('repaints a section that sets its own theme', function () {
|
||||||
$swatch = fn (int $index): string => "getComputedStyle(document.querySelectorAll('#colour [data-theme] .bg-primary')[{$index}]).backgroundColor";
|
$swatch = fn (int $index): string => "getComputedStyle(document.querySelectorAll('#colour [data-theme] .bg-primary')[{$index}]).backgroundColor";
|
||||||
|
|
||||||
visit('/material')->inLightMode()
|
visit('/material/colour')->inLightMode()
|
||||||
->assertScript(theme('data-theme', 'light'))
|
->assertScript(theme('data-theme', 'light'))
|
||||||
->assertScript("{$swatch(0)} !== {$swatch(1)}");
|
->assertScript("{$swatch(0)} !== {$swatch(1)}");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -163,3 +163,12 @@ it('replaces the hint with the errors for the property and its start and end', f
|
|||||||
->toContain('data-datepicker-error')
|
->toContain('data-datepicker-error')
|
||||||
->and(substr_count($html, 'data-invalid=""'))->toBe(2);
|
->and(substr_count($html, 'data-invalid=""'))->toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers a clear button on request', function () {
|
||||||
|
expect((string) $this->blade('<x-datepicker label="Expires" clearable />'))
|
||||||
|
->toContain('data-field-clear')
|
||||||
|
->toContain('x-on:click="clear()"')
|
||||||
|
->toContain('aria-label="Clear"')
|
||||||
|
->and((string) $this->blade('<x-datepicker label="Expires" />'))
|
||||||
|
->not->toContain('data-field-clear');
|
||||||
|
});
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ it('uses the surrounding Alpine scope without wire:model, and stays open when pe
|
|||||||
->toContain('aria-label="Close"');
|
->toContain('aria-label="Close"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the title', function () {
|
||||||
|
$html = (string) $this->blade('<x-modal title="Enable 2FA" subtitle="Scan the code" fullscreen>Text</x-modal>');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-sm:hidden">/')
|
||||||
|
->toContain('<p class="type-body-md text-on-surface-variant mt-4 max-sm:mt-0">Scan the code</p>')
|
||||||
|
->not->toContain('<div class="mb-4 max-sm:hidden">')
|
||||||
|
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="mb-4 max-sm:hidden">')
|
||||||
|
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))->toContain('<p class="type-body-md text-on-surface-variant">Only a subtitle</p>');
|
||||||
|
});
|
||||||
|
|
||||||
it('slides a side sheet in from either edge, and is a pane from xl when asked', function () {
|
it('slides a side sheet in from either edge, and is a pane from xl when asked', function () {
|
||||||
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
|
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
|
||||||
->toContain('x-trap.inert.noscroll="open && ! wide"')
|
->toContain('x-trap.inert.noscroll="open && ! wide"')
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('shows the errors of a plain form field under its name', function (string $blade, string $key) {
|
||||||
|
$html = (string) $this->withViewErrors([$key => ['Something is wrong here.']])->blade($blade);
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('Something is wrong here.')
|
||||||
|
->toContain('aria-invalid="true"');
|
||||||
|
})->with([
|
||||||
|
'input' => ['<x-input name="email" label="Email" />', 'email'],
|
||||||
|
'password' => ['<x-password name="password" label="Password" />', 'password'],
|
||||||
|
'textarea' => ['<x-textarea name="message" label="Message" />', 'message'],
|
||||||
|
'select' => ['<x-select name="hours" label="Expires" :options="[[\'id\' => 1, \'name\' => \'1 hour\']]" />', 'hours'],
|
||||||
|
'checkbox' => ['<x-checkbox name="terms" label="Terms" />', 'terms'],
|
||||||
|
'radio' => ['<x-radio name="audience" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />', 'audience'],
|
||||||
|
'file with brackets' => ['<x-file name="photos[]" label="Photos" multiple />', 'photos'],
|
||||||
|
'nested name' => ['<x-input name="address[city]" label="City" />', 'address.city'],
|
||||||
|
'timepicker' => ['<x-timepicker name="starts_at" label="Starts at" />', 'starts_at'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('keeps reading the wire:model name when both are there', function () {
|
||||||
|
expect((string) $this->withViewErrors(['form.email' => ['Taken.']])->blade('<x-input name="email" wire:model="form.email" label="Email" />'))
|
||||||
|
->toContain('Taken.');
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->path = sys_get_temp_dir().'/scheme-picker-'.uniqid().'.json';
|
||||||
|
File::put($this->path, json_encode([
|
||||||
|
'default' => 'indigo',
|
||||||
|
'profiles' => [
|
||||||
|
'indigo' => ['label' => 'Indigo', 'light' => ['primary' => '#4f46e5', 'secondary' => '#5b5d72', 'tertiary' => '#77536d'], 'dark' => ['primary' => '#c0c1ff', 'secondary' => '#c4c5dd', 'tertiary' => '#e6bad7']],
|
||||||
|
'teal' => ['label' => 'Teal', 'light' => ['primary' => '#00897b', 'secondary' => '#4a635f', 'tertiary' => '#456179'], 'dark' => ['primary' => '#80cbc4', 'secondary' => '#b1ccc6', 'tertiary' => '#adcae5']],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
config(['livewire-material.scheme' => $this->path]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
File::delete($this->path);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a radio per generated profile, bound, named and previewing on change', function () {
|
||||||
|
$html = (string) $this->blade('<x-scheme-picker label="Colour profile" hint="Applies after saving" wire:model="colorProfile" />');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Colour profile</legend>')
|
||||||
|
->toContain('Applies after saving')
|
||||||
|
->toContain('data-scheme-option="indigo"')
|
||||||
|
->toContain('data-scheme-option="teal"')
|
||||||
|
->toMatch('/<input\s+wire:model="colorProfile"\s+type="radio"\s+name="colorProfile"\s+value="teal"/')
|
||||||
|
->toContain('x-on:change="$store.theme.previewScheme($event.target.value)"')
|
||||||
|
->toContain('>Teal</span>')
|
||||||
|
->toContain('style="--swatch-light: #00897b; --swatch-dark: #80cbc4"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts its other attributes on the group and the binding on the radios', function () {
|
||||||
|
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" data-test="color-profile" class="mt-4" />');
|
||||||
|
|
||||||
|
expect($html)->toMatch('/<fieldset x-data data-scheme-picker class="min-w-0 mt-4" data-test="color-profile">/')
|
||||||
|
->and(substr_count($html, 'wire:model="colorProfile"'))->toBe(2)
|
||||||
|
->and(substr_count($html, 'data-test="color-profile"'))->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps its inline styles to the scheme file\'s checked colours', function () {
|
||||||
|
File::put($this->path, json_encode([
|
||||||
|
'default' => 'indigo',
|
||||||
|
'profiles' => ['indigo' => ['label' => 'Indigo', 'light' => ['primary' => 'red; background: url(x)'], 'dark' => []]],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" />');
|
||||||
|
|
||||||
|
preg_match_all('/style="([^"]*)"/', $html, $styles);
|
||||||
|
|
||||||
|
expect($styles[1])->not->toBeEmpty()
|
||||||
|
->each->toMatch('/^--swatch-light: #[0-9a-fA-F]{6}; --swatch-dark: #[0-9a-fA-F]{6}$/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a validation message for the bound property instead of the hint', function () {
|
||||||
|
$this->withViewErrors(['colorProfile' => ['Choose one of the colour profiles.']])
|
||||||
|
->blade('<x-scheme-picker wire:model="colorProfile" hint="Applies after saving" />')
|
||||||
|
->assertSee('Choose one of the colour profiles.')
|
||||||
|
->assertDontSee('Applies after saving');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing for a single scheme', function () {
|
||||||
|
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => []]));
|
||||||
|
|
||||||
|
expect(trim((string) $this->blade('<x-scheme-picker wire:model="colorProfile" />')))->toBe('');
|
||||||
|
});
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||||
|
|
||||||
it('follows the operating system until the visitor chooses, by default', function () {
|
it('follows the operating system until the visitor chooses, by default', function () {
|
||||||
$this->blade('<x-theme-script />')
|
$this->blade('<x-theme-script />')
|
||||||
->assertSee('({"default":"system","key":"material-theme","legacy":[],"rail":{"default":"expanded","key":"material-rail"}})', false);
|
->assertSee('({"scheme":null,"default":"system","key":"material-theme","legacy":[],"rail":{"default":"expanded","key":"material-rail"}})', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('takes the application\'s default, key and legacy keys', function () {
|
it('takes the application\'s default, key and legacy keys', function () {
|
||||||
@@ -39,6 +42,28 @@ it('starts a collapsible rail as the application says, expanded otherwise', func
|
|||||||
it('puts its attributes back on <html> when wire:navigate swaps the page', function () {
|
it('puts its attributes back on <html> when wire:navigate swaps the page', function () {
|
||||||
$this->blade('<x-theme-script />')
|
$this->blade('<x-theme-script />')
|
||||||
->assertSee("document.addEventListener('livewire:navigating'", false)
|
->assertSee("document.addEventListener('livewire:navigating'", false)
|
||||||
->assertSee("['data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key']", false)
|
->assertSee("['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key']", false)
|
||||||
->assertSee('event.detail.onSwap(', false);
|
->assertSee('event.detail.onSwap(', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('names the active colour profile for <html data-scheme>, and none for a single scheme', function () {
|
||||||
|
$path = sys_get_temp_dir().'/theme-script-profiles-'.uniqid().'.json';
|
||||||
|
File::put($path, json_encode([
|
||||||
|
'default' => 'indigo',
|
||||||
|
'profiles' => ['indigo' => ['label' => 'Indigo', 'light' => [], 'dark' => []], 'teal' => ['label' => 'Teal', 'light' => [], 'dark' => []]],
|
||||||
|
]));
|
||||||
|
config(['livewire-material.scheme' => $path]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->blade('<x-theme-script />')
|
||||||
|
->assertSee('({"scheme":"indigo",', false)
|
||||||
|
->assertSee("root.setAttribute('data-scheme', settings.scheme);", false);
|
||||||
|
|
||||||
|
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||||
|
|
||||||
|
$this->blade('<x-theme-script />')->assertSee('({"scheme":"teal",', false);
|
||||||
|
} finally {
|
||||||
|
Scheme::resolveProfileUsing(null);
|
||||||
|
File::delete($path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route;
|
|||||||
use Illuminate\Support\Facades\Vite;
|
use Illuminate\Support\Facades\Vite;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
||||||
|
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
$this->temporary = sys_get_temp_dir().'/livewire-material-errors-'.Str::random(8);
|
$this->temporary = sys_get_temp_dir().'/livewire-material-errors-'.Str::random(8);
|
||||||
@@ -128,6 +129,33 @@ it('still renders, in the application\'s scheme, when the Vite manifest is missi
|
|||||||
->assertDontSee('/build/', false);
|
->assertDontSee('/build/', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('draws the active colour profile when the Vite manifest is missing', function () {
|
||||||
|
File::put($this->temporary.'/material-scheme.json', json_encode([
|
||||||
|
'light' => ['primary' => '#4f46e5'],
|
||||||
|
'dark' => ['primary' => '#aaaaff'],
|
||||||
|
'default' => 'indigo',
|
||||||
|
'profiles' => [
|
||||||
|
'indigo' => ['label' => 'Indigo', 'light' => ['primary' => '#4f46e5'], 'dark' => ['primary' => '#aaaaff']],
|
||||||
|
'teal' => ['label' => 'Teal', 'light' => ['primary' => '#00897b'], 'dark' => ['primary' => '#80cbc4']],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
|
||||||
|
app()->usePublicPath($this->temporary);
|
||||||
|
Vite::useHotFile($this->temporary.'/hot');
|
||||||
|
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->get('/abort/500')
|
||||||
|
->assertStatus(500)
|
||||||
|
->assertSee('--md-sys-color-primary: #00897b;', false)
|
||||||
|
->assertSee('--md-sys-color-primary: #80cbc4;', false)
|
||||||
|
->assertDontSee('#4f46e5', false);
|
||||||
|
} finally {
|
||||||
|
Scheme::resolveProfileUsing(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('shows the message an application passed for 403 and 503', function (int $code, string $message) {
|
it('shows the message an application passed for 403 and 503', function (int $code, string $message) {
|
||||||
withoutSkeletonErrorViews();
|
withoutSkeletonErrorViews();
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Illuminate\Support\Facades\File;
|
|||||||
use Illuminate\Support\Facades\View;
|
use Illuminate\Support\Facades\View;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
|
||||||
|
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||||
|
|
||||||
class ThemedProbeMail extends Mailable
|
class ThemedProbeMail extends Mailable
|
||||||
{
|
{
|
||||||
@@ -113,6 +114,27 @@ it('inlines the application\'s scheme onto the mail', function () {
|
|||||||
->not->toContain('color-mix');
|
->not->toContain('color-mix');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('inlines the active colour profile onto the mail', function () {
|
||||||
|
File::put($this->temporary.'/material-scheme.json', json_encode([
|
||||||
|
'light' => ['primary' => '#4f46e5'],
|
||||||
|
'dark' => ['primary' => '#aaaaff'],
|
||||||
|
'default' => 'indigo',
|
||||||
|
'profiles' => [
|
||||||
|
'indigo' => ['label' => 'Indigo', 'light' => ['primary' => '#4f46e5'], 'dark' => ['primary' => '#aaaaff']],
|
||||||
|
'rose' => ['label' => 'Rose', 'light' => ['primary' => '#c2185b'], 'dark' => ['primary' => '#ffb0c8']],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
|
||||||
|
Scheme::resolveProfileUsing(fn (): string => 'rose');
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(inlineStyle((new ThemedProbeMail)->render(), 'button-primary'))->toContain('background-color: #c2185b');
|
||||||
|
} finally {
|
||||||
|
Scheme::resolveProfileUsing(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('takes the theme from a mailable too', function () {
|
it('takes the theme from a mailable too', function () {
|
||||||
config(['mail.markdown.theme' => 'default']);
|
config(['mail.markdown.theme' => 'default']);
|
||||||
|
|
||||||
|
|||||||
@@ -78,3 +78,68 @@ it('fails without writing anything when node cannot run', function () {
|
|||||||
|
|
||||||
expect(File::exists($this->stylesheet))->toBeFalse();
|
expect(File::exists($this->stylesheet))->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('generates every configured profile into one stylesheet keyed by data-scheme', function () {
|
||||||
|
config([
|
||||||
|
'livewire-material.profiles' => [
|
||||||
|
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
|
||||||
|
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
|
||||||
|
'graphite' => ['seed' => '#5f6368', 'variant' => 'neutral'],
|
||||||
|
],
|
||||||
|
'livewire-material.profile' => 'teal',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('material:scheme', ['--output' => $this->stylesheet])->assertSuccessful();
|
||||||
|
|
||||||
|
$scheme = json_decode(File::get($this->data), true);
|
||||||
|
$stylesheet = File::get($this->stylesheet);
|
||||||
|
|
||||||
|
expect($scheme['default'])->toBe('teal')
|
||||||
|
->and(array_keys($scheme['profiles']))->toBe(['indigo', 'teal', 'graphite'])
|
||||||
|
->and($scheme['profiles']['graphite'])->label->toBe('Graphite')->variant->toBe('neutral')
|
||||||
|
->and($scheme['profiles']['indigo']['light']['primary'])->not->toBe($scheme['profiles']['teal']['light']['primary'])
|
||||||
|
// The top level is the default profile, as a 1.0 reader expects.
|
||||||
|
->and($scheme['light'])->toBe($scheme['profiles']['teal']['light'])
|
||||||
|
->and($scheme['dark'])->toBe($scheme['profiles']['teal']['dark'])
|
||||||
|
->and($scheme['seed'])->toBe('#00897b')
|
||||||
|
->and($stylesheet)->toContain('php artisan material:scheme'."\n");
|
||||||
|
|
||||||
|
// The default's plain blocks come first, so a profile's single-attribute selector follows :root.
|
||||||
|
expect(strpos($stylesheet, ":root,\n[data-theme='light'] {"))->toBeLessThan(strpos($stylesheet, "[data-scheme='indigo'],"));
|
||||||
|
|
||||||
|
foreach ($scheme['profiles'] as $name => $profile) {
|
||||||
|
foreach (['light' => "[data-scheme='{$name}'] [data-theme='light'] {", 'dark' => "[data-scheme='{$name}'] [data-theme='dark'] {"] as $theme => $selector) {
|
||||||
|
$block = Str::of($stylesheet)->after($selector)->before('}')->toString();
|
||||||
|
|
||||||
|
expect($block)->toContain("color-scheme: {$theme};")
|
||||||
|
->toContain("--md-sys-color-primary: {$profile[$theme]['primary']};")
|
||||||
|
->toContain("--md-sys-color-surface: {$profile[$theme]['surface']};");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the profile a generator error belongs to', function () {
|
||||||
|
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5'], 'broken' => ['seed' => 'teal']]]);
|
||||||
|
|
||||||
|
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
|
||||||
|
->expectsOutputToContain('Profile "broken"')
|
||||||
|
->assertFailed();
|
||||||
|
|
||||||
|
expect(File::exists($this->stylesheet))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for a seed or profiles when it has neither', function () {
|
||||||
|
config(['livewire-material.profiles' => []]);
|
||||||
|
|
||||||
|
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
|
||||||
|
->expectsOutputToContain('livewire-material.profiles')
|
||||||
|
->assertFailed();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a profile name that cannot be an attribute value', function () {
|
||||||
|
config(['livewire-material.profiles' => ['Ocean Blue' => ['seed' => '#0b57d0']]]);
|
||||||
|
|
||||||
|
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
|
||||||
|
->expectsOutputToContain('"Ocean Blue" is not')
|
||||||
|
->assertFailed();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scheme file with colour profiles, as `material:scheme` writes one from config.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $primaries profile => light primary
|
||||||
|
*/
|
||||||
|
function writeProfiles(string $path, array $primaries, string $default): void
|
||||||
|
{
|
||||||
|
$profiles = collect($primaries)->map(fn (string $primary, string $name): array => [
|
||||||
|
'label' => Str::headline($name),
|
||||||
|
'seed' => $primary,
|
||||||
|
'variant' => 'vibrant',
|
||||||
|
'spec' => '2025',
|
||||||
|
'contrast' => 0,
|
||||||
|
'light' => ['primary' => $primary, 'surface' => '#fafafa'],
|
||||||
|
'dark' => ['primary' => '#eeeeee', 'surface' => '#111111'],
|
||||||
|
])->all();
|
||||||
|
|
||||||
|
File::put($path, json_encode([...collect($profiles[$default])->except('label')->all(), 'default' => $default, 'profiles' => $profiles]));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->path = sys_get_temp_dir().'/material-profiles-'.Str::random(8).'.json';
|
||||||
|
writeProfiles($this->path, ['indigo' => '#4f46e5', 'teal' => '#00897b', 'rose' => '#c2185b'], 'indigo');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
Scheme::resolveProfileUsing(null);
|
||||||
|
File::delete($this->path);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists the generated profiles with their labels and roles, filling missing roles from the default', function () {
|
||||||
|
$profiles = Scheme::profiles($this->path);
|
||||||
|
|
||||||
|
expect(array_keys($profiles))->toBe(['indigo', 'teal', 'rose'])
|
||||||
|
->and($profiles['teal']['label'])->toBe('Teal')
|
||||||
|
->and($profiles['teal']['light']['primary'])->toBe('#00897b')
|
||||||
|
->and($profiles['teal']['light'])->toHaveKey('on-surface-variant')
|
||||||
|
->and(Scheme::profiles(sys_get_temp_dir().'/missing-scheme.json'))->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows the resolver to the active profile, and falls back to the default', function () {
|
||||||
|
expect(Scheme::profile($this->path))->toBe('indigo');
|
||||||
|
|
||||||
|
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||||
|
expect(Scheme::profile($this->path))->toBe('teal')
|
||||||
|
->and(Scheme::light($this->path)['primary'])->toBe('#00897b')
|
||||||
|
->and(Scheme::load($this->path)['dark']['surface'])->toBe('#111111');
|
||||||
|
|
||||||
|
Scheme::resolveProfileUsing(fn (): string => 'ocean');
|
||||||
|
expect(Scheme::profile($this->path))->toBe('indigo');
|
||||||
|
|
||||||
|
Scheme::resolveProfileUsing(fn (): ?string => null);
|
||||||
|
expect(Scheme::profile($this->path))->toBe('indigo');
|
||||||
|
|
||||||
|
Scheme::resolveProfileUsing(fn () => throw new RuntimeException('The database is down.'));
|
||||||
|
expect(Scheme::profile($this->path))->toBe('indigo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads a profile by name, whatever is active', function () {
|
||||||
|
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||||
|
|
||||||
|
expect(Scheme::light($this->path, 'rose')['primary'])->toBe('#c2185b')
|
||||||
|
->and(Scheme::light($this->path, 'ocean')['primary'])->toBe('#00897b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no profile for a single scheme, and loads it as before', function () {
|
||||||
|
File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => ['primary' => '#abcdef']]));
|
||||||
|
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||||
|
|
||||||
|
expect(Scheme::profile($this->path))->toBeNull()
|
||||||
|
->and(Scheme::profiles($this->path))->toBe([])
|
||||||
|
->and(Scheme::light($this->path)['primary'])->toBe('#123456')
|
||||||
|
->and(Scheme::light($this->path))->toHaveKey('on-surface');
|
||||||
|
});
|
||||||
@@ -20,9 +20,23 @@ function packageComponentPaths(): Collection
|
|||||||
->values();
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
it('registers the components without a prefix', function () {
|
it('registers the components under its own name when no prefix is configured', function () {
|
||||||
expect(packageComponentPaths())->toHaveCount(1)
|
expect(packageComponentPaths())->toHaveCount(1)
|
||||||
->and(packageComponentPaths()->first()['prefix'])->toBeNull();
|
->and(packageComponentPaths()->first()['prefix'])->toBe('livewire-material')
|
||||||
|
->and(Blade::render('<x-badge value="New" tonal />'))->toContain('New');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compiles an unprefixed component to the same view on every machine', function () {
|
||||||
|
$compiled = Blade::compileString('<x-input label="Name" />');
|
||||||
|
|
||||||
|
expect($compiled)->toContain("'view' => '".hash('xxh128', 'livewire-material')."::input'")
|
||||||
|
->not->toContain(hash('xxh128', LivewireMaterialServiceProvider::componentPath()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still renders a view compiled when the namespace came from the component path', function () {
|
||||||
|
$legacy = hash('xxh128', LivewireMaterialServiceProvider::componentPath());
|
||||||
|
|
||||||
|
expect(view()->exists($legacy.'::input'))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('registers the components under a configured prefix', function () {
|
it('registers the components under a configured prefix', function () {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
|
use NoNameWeb\LivewireMaterial\Showcase\Sections;
|
||||||
use NoNameWeb\LivewireMaterial\Support\SvgFile;
|
use NoNameWeb\LivewireMaterial\Support\SvgFile;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,6 +29,42 @@ it('mounts the showcase when enabled', function () {
|
|||||||
->assertSee('Livewire Material');
|
->assertSee('Livewire Material');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('gives every section a page of its own, linked from the overview and the rail', function () {
|
||||||
|
$overview = $this->withoutVite()->get('/material')->assertOk();
|
||||||
|
|
||||||
|
foreach (Sections::all() as $key => $section) {
|
||||||
|
$overview->assertSee(route('livewire-material.section', $key), false);
|
||||||
|
|
||||||
|
$this->withoutVite()
|
||||||
|
->get("/material/{$key}")
|
||||||
|
->assertOk()
|
||||||
|
->assertSee("<title>{$section['title']} · Livewire Material</title>", false)
|
||||||
|
->assertSee('id="'.$key.'"', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->get('/material/not-a-section')->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('indexes every section, example and component for the search, each linking to a place that exists', function () {
|
||||||
|
$index = collect($this->getJson('/material/search.json')->assertOk()->json());
|
||||||
|
|
||||||
|
expect($index->where('kind', 'Section')->pluck('title')->all())
|
||||||
|
->toBe(collect(Sections::all())->pluck('title')->all())
|
||||||
|
->and($index->where('kind', 'Component')->pluck('title')->all())
|
||||||
|
->toContain('<x-datepicker>', '<x-button>', '<x-app-shell>')
|
||||||
|
->and($index->firstWhere('title', '<x-datepicker>')['url'])->toBe('/material/pickers');
|
||||||
|
|
||||||
|
$index->where('kind', 'Example')
|
||||||
|
->groupBy(fn (array $entry): string => strtok($entry['url'], '#'))
|
||||||
|
->each(function ($examples, string $page): void {
|
||||||
|
$html = $this->withoutVite()->get($page)->assertOk()->getContent();
|
||||||
|
|
||||||
|
foreach ($examples as $example) {
|
||||||
|
expect($html)->toContain('id="'.substr($example['url'], strpos($example['url'], '#') + 1).'"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('does not mount the showcase when disabled', function () {
|
it('does not mount the showcase when disabled', function () {
|
||||||
rebootWithShowcase(false);
|
rebootWithShowcase(false);
|
||||||
|
|
||||||
@@ -77,7 +114,7 @@ it('shows every component somewhere in the showcase', function () {
|
|||||||
|
|
||||||
$absent = collect(File::files(__DIR__.'/../../resources/views/components'))
|
$absent = collect(File::files(__DIR__.'/../../resources/views/components'))
|
||||||
->map(fn (SplFileInfo $file): string => $file->getBasename('.blade.php'))
|
->map(fn (SplFileInfo $file): string => $file->getBasename('.blade.php'))
|
||||||
->reject(fn (string $name): bool => preg_match('/<x-(?:livewire-material::)?'.preg_quote($name, '/').'[\s\/>]/', $showcase) === 1)
|
->reject(fn (string $name): bool => preg_match('/(?:<|<)x-(?:livewire-material::)?'.preg_quote($name, '/').'(?:[\s\/>]|>)/', $showcase) === 1)
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,23 @@ class WorkbenchServiceProvider extends ServiceProvider
|
|||||||
'workbench/resources/js/app.js',
|
'workbench/resources/js/app.js',
|
||||||
]]);
|
]]);
|
||||||
|
|
||||||
|
// Three colour profiles, generated into workbench/resources/css/material-scheme.css with
|
||||||
|
// `vendor/bin/testbench material:scheme --output=workbench/resources/css/material-scheme.css`.
|
||||||
|
// Baseline is the package's own default, so the stylesheet draws it as before; the showcase
|
||||||
|
// offers the others. Tests start without profiles and point at the file when they need it.
|
||||||
|
config([
|
||||||
|
'livewire-material.profiles' => [
|
||||||
|
'baseline' => ['label' => 'Baseline', 'seed' => '#6750a4', 'variant' => 'tonal-spot'],
|
||||||
|
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
|
||||||
|
'rose' => ['label' => 'Rose', 'seed' => '#c2185b', 'variant' => 'vibrant'],
|
||||||
|
],
|
||||||
|
'livewire-material.profile' => 'baseline',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $this->app->runningUnitTests()) {
|
||||||
|
config(['livewire-material.scheme' => workbench_path('resources/css/material-scheme.json')]);
|
||||||
|
}
|
||||||
|
|
||||||
Vite::useHotFile(workbench_path('public/hot'));
|
Vite::useHotFile(workbench_path('public/hot'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
sources so the plans in docs/ are not scanned for class names. */
|
sources so the plans in docs/ are not scanned for class names. */
|
||||||
@import 'tailwindcss' source(none);
|
@import 'tailwindcss' source(none);
|
||||||
@import '../../../resources/css/material.css';
|
@import '../../../resources/css/material.css';
|
||||||
|
@import './material-scheme.css';
|
||||||
|
|
||||||
@source '../../../resources/views';
|
@source '../../../resources/views';
|
||||||
@source '../../../src';
|
@source '../../../src';
|
||||||
|
|||||||
@@ -0,0 +1,586 @@
|
|||||||
|
/*
|
||||||
|
* Material 3 colour profiles, generated by Google's material-color-utilities (spec 2025).
|
||||||
|
*
|
||||||
|
* php artisan material:scheme
|
||||||
|
*
|
||||||
|
* From livewire-material.profiles. "baseline" is the default, and also stands without a
|
||||||
|
* data-scheme attribute:
|
||||||
|
*
|
||||||
|
* baseline #6750a4, tonal-spot
|
||||||
|
* teal #00897b, vibrant
|
||||||
|
* rose #c2185b, vibrant
|
||||||
|
*
|
||||||
|
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
|
||||||
|
* M3's contrast guarantee only as generated. The head script sets data-theme and data-scheme
|
||||||
|
* before the first paint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root,
|
||||||
|
[data-theme='light'] {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--md-sys-color-background: #fdf7fe;
|
||||||
|
--md-sys-color-on-background: #34313a;
|
||||||
|
--md-sys-color-surface: #fdf7fe;
|
||||||
|
--md-sys-color-surface-dim: #ded8e4;
|
||||||
|
--md-sys-color-surface-bright: #fdf7fe;
|
||||||
|
--md-sys-color-surface-container-lowest: #ffffff;
|
||||||
|
--md-sys-color-surface-container-low: #f8f1fa;
|
||||||
|
--md-sys-color-surface-container: #f2ecf5;
|
||||||
|
--md-sys-color-surface-container-high: #ece6f0;
|
||||||
|
--md-sys-color-surface-container-highest: #e7e0ec;
|
||||||
|
--md-sys-color-on-surface: #34313a;
|
||||||
|
--md-sys-color-on-surface-variant: #615d68;
|
||||||
|
--md-sys-color-outline: #7d7983;
|
||||||
|
--md-sys-color-outline-variant: #b5b0bb;
|
||||||
|
--md-sys-color-inverse-surface: #0f0d12;
|
||||||
|
--md-sys-color-inverse-on-surface: #a09ba1;
|
||||||
|
--md-sys-color-primary: #655789;
|
||||||
|
--md-sys-color-primary-dim: #594b7c;
|
||||||
|
--md-sys-color-on-primary: #fdf7ff;
|
||||||
|
--md-sys-color-primary-container: #d4c3fd;
|
||||||
|
--md-sys-color-on-primary-container: #493c6c;
|
||||||
|
--md-sys-color-primary-fixed: #d4c3fd;
|
||||||
|
--md-sys-color-primary-fixed-dim: #c6b6ee;
|
||||||
|
--md-sys-color-on-primary-fixed: #352857;
|
||||||
|
--md-sys-color-on-primary-fixed-variant: #524576;
|
||||||
|
--md-sys-color-inverse-primary: #d4c3fd;
|
||||||
|
--md-sys-color-secondary: #625c71;
|
||||||
|
--md-sys-color-secondary-dim: #565065;
|
||||||
|
--md-sys-color-on-secondary: #fdf7ff;
|
||||||
|
--md-sys-color-secondary-container: #e8def8;
|
||||||
|
--md-sys-color-on-secondary-container: #554f63;
|
||||||
|
--md-sys-color-secondary-fixed: #e8def8;
|
||||||
|
--md-sys-color-secondary-fixed-dim: #dad0ea;
|
||||||
|
--md-sys-color-on-secondary-fixed: #423c50;
|
||||||
|
--md-sys-color-on-secondary-fixed-variant: #5f586e;
|
||||||
|
--md-sys-color-tertiary: #7b5270;
|
||||||
|
--md-sys-color-tertiary-dim: #6e4664;
|
||||||
|
--md-sys-color-on-tertiary: #fff7f9;
|
||||||
|
--md-sys-color-tertiary-container: #f4bfe3;
|
||||||
|
--md-sys-color-on-tertiary-container: #5f3956;
|
||||||
|
--md-sys-color-tertiary-fixed: #f4bfe3;
|
||||||
|
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
|
||||||
|
--md-sys-color-on-tertiary-fixed: #4a2642;
|
||||||
|
--md-sys-color-on-tertiary-fixed-variant: #694260;
|
||||||
|
--md-sys-color-error: #a8364b;
|
||||||
|
--md-sys-color-error-dim: #6b0221;
|
||||||
|
--md-sys-color-on-error: #fff7f7;
|
||||||
|
--md-sys-color-error-container: #f97386;
|
||||||
|
--md-sys-color-on-error-container: #6e0523;
|
||||||
|
--md-sys-color-success: #006c45;
|
||||||
|
--md-sys-color-on-success: #ffffff;
|
||||||
|
--md-sys-color-success-container: #86f9bc;
|
||||||
|
--md-sys-color-on-success-container: #002112;
|
||||||
|
--md-sys-color-warning: #7c5800;
|
||||||
|
--md-sys-color-on-warning: #ffffff;
|
||||||
|
--md-sys-color-warning-container: #ffdea6;
|
||||||
|
--md-sys-color-on-warning-container: #271900;
|
||||||
|
--md-sys-color-info: #005ac4;
|
||||||
|
--md-sys-color-on-info: #ffffff;
|
||||||
|
--md-sys-color-info-container: #d8e2ff;
|
||||||
|
--md-sys-color-on-info-container: #001a42;
|
||||||
|
--md-sys-color-inverse-error: #f97386;
|
||||||
|
--md-sys-color-inverse-success: #69dca1;
|
||||||
|
--md-sys-color-inverse-warning: #fdbb28;
|
||||||
|
--md-sys-color-inverse-info: #aec6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--md-sys-color-background: #0f0d12;
|
||||||
|
--md-sys-color-on-background: #eae3ef;
|
||||||
|
--md-sys-color-surface: #0f0d12;
|
||||||
|
--md-sys-color-surface-dim: #0f0d12;
|
||||||
|
--md-sys-color-surface-bright: #2e2b34;
|
||||||
|
--md-sys-color-surface-container-lowest: #000000;
|
||||||
|
--md-sys-color-surface-container-low: #141218;
|
||||||
|
--md-sys-color-surface-container: #1b181f;
|
||||||
|
--md-sys-color-surface-container-high: #211e26;
|
||||||
|
--md-sys-color-surface-container-highest: #27242d;
|
||||||
|
--md-sys-color-on-surface: #eae3ef;
|
||||||
|
--md-sys-color-on-surface-variant: #aea9b4;
|
||||||
|
--md-sys-color-outline: #78737e;
|
||||||
|
--md-sys-color-outline-variant: #4a4650;
|
||||||
|
--md-sys-color-inverse-surface: #fdf7fe;
|
||||||
|
--md-sys-color-inverse-on-surface: #575459;
|
||||||
|
--md-sys-color-primary: #cdc0ec;
|
||||||
|
--md-sys-color-primary-dim: #bfb2de;
|
||||||
|
--md-sys-color-on-primary: #443a5f;
|
||||||
|
--md-sys-color-primary-container: #574d72;
|
||||||
|
--md-sys-color-on-primary-container: #e9deff;
|
||||||
|
--md-sys-color-primary-fixed: #ded0fe;
|
||||||
|
--md-sys-color-primary-fixed-dim: #d0c3ef;
|
||||||
|
--md-sys-color-on-primary-fixed: #3c3256;
|
||||||
|
--md-sys-color-on-primary-fixed-variant: #594e74;
|
||||||
|
--md-sys-color-inverse-primary: #645980;
|
||||||
|
--md-sys-color-secondary: #cbc2db;
|
||||||
|
--md-sys-color-secondary-dim: #beb5cd;
|
||||||
|
--md-sys-color-on-secondary: #433d51;
|
||||||
|
--md-sys-color-secondary-container: #3e384c;
|
||||||
|
--md-sys-color-on-secondary-container: #c4bbd4;
|
||||||
|
--md-sys-color-secondary-fixed: #e8def8;
|
||||||
|
--md-sys-color-secondary-fixed-dim: #dad0ea;
|
||||||
|
--md-sys-color-on-secondary-fixed: #423c50;
|
||||||
|
--md-sys-color-on-secondary-fixed-variant: #5f586e;
|
||||||
|
--md-sys-color-tertiary: #ffcfef;
|
||||||
|
--md-sys-color-tertiary-dim: #f4bfe3;
|
||||||
|
--md-sys-color-on-tertiary: #69415f;
|
||||||
|
--md-sys-color-tertiary-container: #f4bfe3;
|
||||||
|
--md-sys-color-on-tertiary-container: #5f3956;
|
||||||
|
--md-sys-color-tertiary-fixed: #f4bfe3;
|
||||||
|
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
|
||||||
|
--md-sys-color-on-tertiary-fixed: #4a2642;
|
||||||
|
--md-sys-color-on-tertiary-fixed-variant: #694260;
|
||||||
|
--md-sys-color-error: #f97386;
|
||||||
|
--md-sys-color-error-dim: #c44b5f;
|
||||||
|
--md-sys-color-on-error: #490013;
|
||||||
|
--md-sys-color-error-container: #871c34;
|
||||||
|
--md-sys-color-on-error-container: #ff97a3;
|
||||||
|
--md-sys-color-success: #69dca1;
|
||||||
|
--md-sys-color-on-success: #003822;
|
||||||
|
--md-sys-color-success-container: #005233;
|
||||||
|
--md-sys-color-on-success-container: #86f9bc;
|
||||||
|
--md-sys-color-warning: #fdbb28;
|
||||||
|
--md-sys-color-on-warning: #412d00;
|
||||||
|
--md-sys-color-warning-container: #5e4200;
|
||||||
|
--md-sys-color-on-warning-container: #ffdea6;
|
||||||
|
--md-sys-color-info: #aec6ff;
|
||||||
|
--md-sys-color-on-info: #002e6a;
|
||||||
|
--md-sys-color-info-container: #004396;
|
||||||
|
--md-sys-color-on-info-container: #d8e2ff;
|
||||||
|
--md-sys-color-inverse-error: #a8364b;
|
||||||
|
--md-sys-color-inverse-success: #006c45;
|
||||||
|
--md-sys-color-inverse-warning: #7c5800;
|
||||||
|
--md-sys-color-inverse-info: #005ac4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scheme='baseline'],
|
||||||
|
[data-scheme='baseline'][data-theme='light'],
|
||||||
|
[data-scheme='baseline'] [data-theme='light'] {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--md-sys-color-background: #fdf7fe;
|
||||||
|
--md-sys-color-on-background: #34313a;
|
||||||
|
--md-sys-color-surface: #fdf7fe;
|
||||||
|
--md-sys-color-surface-dim: #ded8e4;
|
||||||
|
--md-sys-color-surface-bright: #fdf7fe;
|
||||||
|
--md-sys-color-surface-container-lowest: #ffffff;
|
||||||
|
--md-sys-color-surface-container-low: #f8f1fa;
|
||||||
|
--md-sys-color-surface-container: #f2ecf5;
|
||||||
|
--md-sys-color-surface-container-high: #ece6f0;
|
||||||
|
--md-sys-color-surface-container-highest: #e7e0ec;
|
||||||
|
--md-sys-color-on-surface: #34313a;
|
||||||
|
--md-sys-color-on-surface-variant: #615d68;
|
||||||
|
--md-sys-color-outline: #7d7983;
|
||||||
|
--md-sys-color-outline-variant: #b5b0bb;
|
||||||
|
--md-sys-color-inverse-surface: #0f0d12;
|
||||||
|
--md-sys-color-inverse-on-surface: #a09ba1;
|
||||||
|
--md-sys-color-primary: #655789;
|
||||||
|
--md-sys-color-primary-dim: #594b7c;
|
||||||
|
--md-sys-color-on-primary: #fdf7ff;
|
||||||
|
--md-sys-color-primary-container: #d4c3fd;
|
||||||
|
--md-sys-color-on-primary-container: #493c6c;
|
||||||
|
--md-sys-color-primary-fixed: #d4c3fd;
|
||||||
|
--md-sys-color-primary-fixed-dim: #c6b6ee;
|
||||||
|
--md-sys-color-on-primary-fixed: #352857;
|
||||||
|
--md-sys-color-on-primary-fixed-variant: #524576;
|
||||||
|
--md-sys-color-inverse-primary: #d4c3fd;
|
||||||
|
--md-sys-color-secondary: #625c71;
|
||||||
|
--md-sys-color-secondary-dim: #565065;
|
||||||
|
--md-sys-color-on-secondary: #fdf7ff;
|
||||||
|
--md-sys-color-secondary-container: #e8def8;
|
||||||
|
--md-sys-color-on-secondary-container: #554f63;
|
||||||
|
--md-sys-color-secondary-fixed: #e8def8;
|
||||||
|
--md-sys-color-secondary-fixed-dim: #dad0ea;
|
||||||
|
--md-sys-color-on-secondary-fixed: #423c50;
|
||||||
|
--md-sys-color-on-secondary-fixed-variant: #5f586e;
|
||||||
|
--md-sys-color-tertiary: #7b5270;
|
||||||
|
--md-sys-color-tertiary-dim: #6e4664;
|
||||||
|
--md-sys-color-on-tertiary: #fff7f9;
|
||||||
|
--md-sys-color-tertiary-container: #f4bfe3;
|
||||||
|
--md-sys-color-on-tertiary-container: #5f3956;
|
||||||
|
--md-sys-color-tertiary-fixed: #f4bfe3;
|
||||||
|
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
|
||||||
|
--md-sys-color-on-tertiary-fixed: #4a2642;
|
||||||
|
--md-sys-color-on-tertiary-fixed-variant: #694260;
|
||||||
|
--md-sys-color-error: #a8364b;
|
||||||
|
--md-sys-color-error-dim: #6b0221;
|
||||||
|
--md-sys-color-on-error: #fff7f7;
|
||||||
|
--md-sys-color-error-container: #f97386;
|
||||||
|
--md-sys-color-on-error-container: #6e0523;
|
||||||
|
--md-sys-color-success: #006c45;
|
||||||
|
--md-sys-color-on-success: #ffffff;
|
||||||
|
--md-sys-color-success-container: #86f9bc;
|
||||||
|
--md-sys-color-on-success-container: #002112;
|
||||||
|
--md-sys-color-warning: #7c5800;
|
||||||
|
--md-sys-color-on-warning: #ffffff;
|
||||||
|
--md-sys-color-warning-container: #ffdea6;
|
||||||
|
--md-sys-color-on-warning-container: #271900;
|
||||||
|
--md-sys-color-info: #005ac4;
|
||||||
|
--md-sys-color-on-info: #ffffff;
|
||||||
|
--md-sys-color-info-container: #d8e2ff;
|
||||||
|
--md-sys-color-on-info-container: #001a42;
|
||||||
|
--md-sys-color-inverse-error: #f97386;
|
||||||
|
--md-sys-color-inverse-success: #69dca1;
|
||||||
|
--md-sys-color-inverse-warning: #fdbb28;
|
||||||
|
--md-sys-color-inverse-info: #aec6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scheme='baseline'][data-theme='dark'],
|
||||||
|
[data-scheme='baseline'] [data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--md-sys-color-background: #0f0d12;
|
||||||
|
--md-sys-color-on-background: #eae3ef;
|
||||||
|
--md-sys-color-surface: #0f0d12;
|
||||||
|
--md-sys-color-surface-dim: #0f0d12;
|
||||||
|
--md-sys-color-surface-bright: #2e2b34;
|
||||||
|
--md-sys-color-surface-container-lowest: #000000;
|
||||||
|
--md-sys-color-surface-container-low: #141218;
|
||||||
|
--md-sys-color-surface-container: #1b181f;
|
||||||
|
--md-sys-color-surface-container-high: #211e26;
|
||||||
|
--md-sys-color-surface-container-highest: #27242d;
|
||||||
|
--md-sys-color-on-surface: #eae3ef;
|
||||||
|
--md-sys-color-on-surface-variant: #aea9b4;
|
||||||
|
--md-sys-color-outline: #78737e;
|
||||||
|
--md-sys-color-outline-variant: #4a4650;
|
||||||
|
--md-sys-color-inverse-surface: #fdf7fe;
|
||||||
|
--md-sys-color-inverse-on-surface: #575459;
|
||||||
|
--md-sys-color-primary: #cdc0ec;
|
||||||
|
--md-sys-color-primary-dim: #bfb2de;
|
||||||
|
--md-sys-color-on-primary: #443a5f;
|
||||||
|
--md-sys-color-primary-container: #574d72;
|
||||||
|
--md-sys-color-on-primary-container: #e9deff;
|
||||||
|
--md-sys-color-primary-fixed: #ded0fe;
|
||||||
|
--md-sys-color-primary-fixed-dim: #d0c3ef;
|
||||||
|
--md-sys-color-on-primary-fixed: #3c3256;
|
||||||
|
--md-sys-color-on-primary-fixed-variant: #594e74;
|
||||||
|
--md-sys-color-inverse-primary: #645980;
|
||||||
|
--md-sys-color-secondary: #cbc2db;
|
||||||
|
--md-sys-color-secondary-dim: #beb5cd;
|
||||||
|
--md-sys-color-on-secondary: #433d51;
|
||||||
|
--md-sys-color-secondary-container: #3e384c;
|
||||||
|
--md-sys-color-on-secondary-container: #c4bbd4;
|
||||||
|
--md-sys-color-secondary-fixed: #e8def8;
|
||||||
|
--md-sys-color-secondary-fixed-dim: #dad0ea;
|
||||||
|
--md-sys-color-on-secondary-fixed: #423c50;
|
||||||
|
--md-sys-color-on-secondary-fixed-variant: #5f586e;
|
||||||
|
--md-sys-color-tertiary: #ffcfef;
|
||||||
|
--md-sys-color-tertiary-dim: #f4bfe3;
|
||||||
|
--md-sys-color-on-tertiary: #69415f;
|
||||||
|
--md-sys-color-tertiary-container: #f4bfe3;
|
||||||
|
--md-sys-color-on-tertiary-container: #5f3956;
|
||||||
|
--md-sys-color-tertiary-fixed: #f4bfe3;
|
||||||
|
--md-sys-color-tertiary-fixed-dim: #e5b2d5;
|
||||||
|
--md-sys-color-on-tertiary-fixed: #4a2642;
|
||||||
|
--md-sys-color-on-tertiary-fixed-variant: #694260;
|
||||||
|
--md-sys-color-error: #f97386;
|
||||||
|
--md-sys-color-error-dim: #c44b5f;
|
||||||
|
--md-sys-color-on-error: #490013;
|
||||||
|
--md-sys-color-error-container: #871c34;
|
||||||
|
--md-sys-color-on-error-container: #ff97a3;
|
||||||
|
--md-sys-color-success: #69dca1;
|
||||||
|
--md-sys-color-on-success: #003822;
|
||||||
|
--md-sys-color-success-container: #005233;
|
||||||
|
--md-sys-color-on-success-container: #86f9bc;
|
||||||
|
--md-sys-color-warning: #fdbb28;
|
||||||
|
--md-sys-color-on-warning: #412d00;
|
||||||
|
--md-sys-color-warning-container: #5e4200;
|
||||||
|
--md-sys-color-on-warning-container: #ffdea6;
|
||||||
|
--md-sys-color-info: #aec6ff;
|
||||||
|
--md-sys-color-on-info: #002e6a;
|
||||||
|
--md-sys-color-info-container: #004396;
|
||||||
|
--md-sys-color-on-info-container: #d8e2ff;
|
||||||
|
--md-sys-color-inverse-error: #a8364b;
|
||||||
|
--md-sys-color-inverse-success: #006c45;
|
||||||
|
--md-sys-color-inverse-warning: #7c5800;
|
||||||
|
--md-sys-color-inverse-info: #005ac4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scheme='teal'],
|
||||||
|
[data-scheme='teal'][data-theme='light'],
|
||||||
|
[data-scheme='teal'] [data-theme='light'] {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--md-sys-color-background: #d3fffd;
|
||||||
|
--md-sys-color-on-background: #003534;
|
||||||
|
--md-sys-color-surface: #d3fffd;
|
||||||
|
--md-sys-color-surface-dim: #87e4e1;
|
||||||
|
--md-sys-color-surface-bright: #d3fffd;
|
||||||
|
--md-sys-color-surface-container-lowest: #ffffff;
|
||||||
|
--md-sys-color-surface-container-low: #bafdfa;
|
||||||
|
--md-sys-color-surface-container: #adf5f2;
|
||||||
|
--md-sys-color-surface-container-high: #a2f0ed;
|
||||||
|
--md-sys-color-surface-container-highest: #96ece8;
|
||||||
|
--md-sys-color-on-surface: #003534;
|
||||||
|
--md-sys-color-on-surface-variant: #296463;
|
||||||
|
--md-sys-color-outline: #46807e;
|
||||||
|
--md-sys-color-outline-variant: #7db7b5;
|
||||||
|
--md-sys-color-inverse-surface: #001111;
|
||||||
|
--md-sys-color-inverse-on-surface: #6da7a5;
|
||||||
|
--md-sys-color-primary: #00675c;
|
||||||
|
--md-sys-color-primary-dim: #005a50;
|
||||||
|
--md-sys-color-on-primary: #c0fff3;
|
||||||
|
--md-sys-color-primary-container: #00f7df;
|
||||||
|
--md-sys-color-on-primary-container: #00594f;
|
||||||
|
--md-sys-color-primary-fixed: #00f7df;
|
||||||
|
--md-sys-color-primary-fixed-dim: #00e8d1;
|
||||||
|
--md-sys-color-on-primary-fixed: #00443c;
|
||||||
|
--md-sys-color-on-primary-fixed-variant: #006359;
|
||||||
|
--md-sys-color-inverse-primary: #00fee5;
|
||||||
|
--md-sys-color-secondary: #006765;
|
||||||
|
--md-sys-color-secondary-dim: #005958;
|
||||||
|
--md-sys-color-on-secondary: #bcfffc;
|
||||||
|
--md-sys-color-secondary-container: #38fbf7;
|
||||||
|
--md-sys-color-on-secondary-container: #005c5a;
|
||||||
|
--md-sys-color-secondary-fixed: #38fbf7;
|
||||||
|
--md-sys-color-secondary-fixed-dim: #10ece8;
|
||||||
|
--md-sys-color-on-secondary-fixed: #004746;
|
||||||
|
--md-sys-color-on-secondary-fixed-variant: #006765;
|
||||||
|
--md-sys-color-tertiary: #006386;
|
||||||
|
--md-sys-color-tertiary-dim: #005675;
|
||||||
|
--md-sys-color-on-tertiary: #e7f5ff;
|
||||||
|
--md-sys-color-tertiary-container: #20c0ff;
|
||||||
|
--md-sys-color-on-tertiary-container: #00374d;
|
||||||
|
--md-sys-color-tertiary-fixed: #20c0ff;
|
||||||
|
--md-sys-color-tertiary-fixed-dim: #00b2ee;
|
||||||
|
--md-sys-color-on-tertiary-fixed: #001e2b;
|
||||||
|
--md-sys-color-on-tertiary-fixed-variant: #004059;
|
||||||
|
--md-sys-color-error: #b31b25;
|
||||||
|
--md-sys-color-error-dim: #9f0519;
|
||||||
|
--md-sys-color-on-error: #ffefee;
|
||||||
|
--md-sys-color-error-container: #fb5151;
|
||||||
|
--md-sys-color-on-error-container: #570008;
|
||||||
|
--md-sys-color-success: #006c45;
|
||||||
|
--md-sys-color-on-success: #ffffff;
|
||||||
|
--md-sys-color-success-container: #86f9bc;
|
||||||
|
--md-sys-color-on-success-container: #002112;
|
||||||
|
--md-sys-color-warning: #7c5800;
|
||||||
|
--md-sys-color-on-warning: #ffffff;
|
||||||
|
--md-sys-color-warning-container: #ffdea6;
|
||||||
|
--md-sys-color-on-warning-container: #271900;
|
||||||
|
--md-sys-color-info: #005ac4;
|
||||||
|
--md-sys-color-on-info: #ffffff;
|
||||||
|
--md-sys-color-info-container: #d8e2ff;
|
||||||
|
--md-sys-color-on-info-container: #001a42;
|
||||||
|
--md-sys-color-inverse-error: #ff716c;
|
||||||
|
--md-sys-color-inverse-success: #69dca1;
|
||||||
|
--md-sys-color-inverse-warning: #fdbb28;
|
||||||
|
--md-sys-color-inverse-info: #aec6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scheme='teal'][data-theme='dark'],
|
||||||
|
[data-scheme='teal'] [data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--md-sys-color-background: #001111;
|
||||||
|
--md-sys-color-on-background: #b8f3f1;
|
||||||
|
--md-sys-color-surface: #001111;
|
||||||
|
--md-sys-color-surface-dim: #001111;
|
||||||
|
--md-sys-color-surface-bright: #003231;
|
||||||
|
--md-sys-color-surface-container-lowest: #000000;
|
||||||
|
--md-sys-color-surface-container-low: #001716;
|
||||||
|
--md-sys-color-surface-container: #001e1d;
|
||||||
|
--md-sys-color-surface-container-high: #002424;
|
||||||
|
--md-sys-color-surface-container-highest: #002b2a;
|
||||||
|
--md-sys-color-on-surface: #b8f3f1;
|
||||||
|
--md-sys-color-on-surface-variant: #7bb5b3;
|
||||||
|
--md-sys-color-outline: #457f7d;
|
||||||
|
--md-sys-color-outline-variant: #0e504f;
|
||||||
|
--md-sys-color-inverse-surface: #e3fffd;
|
||||||
|
--md-sys-color-inverse-on-surface: #215e5c;
|
||||||
|
--md-sys-color-primary: #b4fff1;
|
||||||
|
--md-sys-color-primary-dim: #00fee5;
|
||||||
|
--md-sys-color-on-primary: #00665b;
|
||||||
|
--md-sys-color-primary-container: #00fee5;
|
||||||
|
--md-sys-color-on-primary-container: #005c53;
|
||||||
|
--md-sys-color-primary-fixed: #00f7df;
|
||||||
|
--md-sys-color-primary-fixed-dim: #00e8d1;
|
||||||
|
--md-sys-color-on-primary-fixed: #00443c;
|
||||||
|
--md-sys-color-on-primary-fixed-variant: #006359;
|
||||||
|
--md-sys-color-inverse-primary: #006b60;
|
||||||
|
--md-sys-color-secondary: #38fbf7;
|
||||||
|
--md-sys-color-secondary-dim: #10ece8;
|
||||||
|
--md-sys-color-on-secondary: #005c5a;
|
||||||
|
--md-sys-color-secondary-container: #006a68;
|
||||||
|
--md-sys-color-on-secondary-container: #dafffd;
|
||||||
|
--md-sys-color-secondary-fixed: #38fbf7;
|
||||||
|
--md-sys-color-secondary-fixed-dim: #10ece8;
|
||||||
|
--md-sys-color-on-secondary-fixed: #004746;
|
||||||
|
--md-sys-color-on-secondary-fixed-variant: #006765;
|
||||||
|
--md-sys-color-tertiary: #68ccff;
|
||||||
|
--md-sys-color-tertiary-dim: #20c0ff;
|
||||||
|
--md-sys-color-on-tertiary: #00415a;
|
||||||
|
--md-sys-color-tertiary-container: #20c0ff;
|
||||||
|
--md-sys-color-on-tertiary-container: #00374d;
|
||||||
|
--md-sys-color-tertiary-fixed: #20c0ff;
|
||||||
|
--md-sys-color-tertiary-fixed-dim: #00b2ee;
|
||||||
|
--md-sys-color-on-tertiary-fixed: #001e2b;
|
||||||
|
--md-sys-color-on-tertiary-fixed-variant: #004059;
|
||||||
|
--md-sys-color-error: #ff716c;
|
||||||
|
--md-sys-color-error-dim: #d7383b;
|
||||||
|
--md-sys-color-on-error: #490006;
|
||||||
|
--md-sys-color-error-container: #9f0519;
|
||||||
|
--md-sys-color-on-error-container: #ffa8a3;
|
||||||
|
--md-sys-color-success: #69dca1;
|
||||||
|
--md-sys-color-on-success: #003822;
|
||||||
|
--md-sys-color-success-container: #005233;
|
||||||
|
--md-sys-color-on-success-container: #86f9bc;
|
||||||
|
--md-sys-color-warning: #fdbb28;
|
||||||
|
--md-sys-color-on-warning: #412d00;
|
||||||
|
--md-sys-color-warning-container: #5e4200;
|
||||||
|
--md-sys-color-on-warning-container: #ffdea6;
|
||||||
|
--md-sys-color-info: #aec6ff;
|
||||||
|
--md-sys-color-on-info: #002e6a;
|
||||||
|
--md-sys-color-info-container: #004396;
|
||||||
|
--md-sys-color-on-info-container: #d8e2ff;
|
||||||
|
--md-sys-color-inverse-error: #b31b25;
|
||||||
|
--md-sys-color-inverse-success: #006c45;
|
||||||
|
--md-sys-color-inverse-warning: #7c5800;
|
||||||
|
--md-sys-color-inverse-info: #005ac4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scheme='rose'],
|
||||||
|
[data-scheme='rose'][data-theme='light'],
|
||||||
|
[data-scheme='rose'] [data-theme='light'] {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--md-sys-color-background: #fff4f6;
|
||||||
|
--md-sys-color-on-background: #492136;
|
||||||
|
--md-sys-color-surface: #fff4f6;
|
||||||
|
--md-sys-color-surface-dim: #ffc4de;
|
||||||
|
--md-sys-color-surface-bright: #fff4f6;
|
||||||
|
--md-sys-color-surface-container-lowest: #ffffff;
|
||||||
|
--md-sys-color-surface-container-low: #ffecf2;
|
||||||
|
--md-sys-color-surface-container: #ffe0ec;
|
||||||
|
--md-sys-color-surface-container-high: #ffd8e8;
|
||||||
|
--md-sys-color-surface-container-highest: #ffd0e4;
|
||||||
|
--md-sys-color-on-surface: #492136;
|
||||||
|
--md-sys-color-on-surface-variant: #7c4d64;
|
||||||
|
--md-sys-color-outline: #9b6880;
|
||||||
|
--md-sys-color-outline-variant: #d69db7;
|
||||||
|
--md-sys-color-inverse-surface: #220215;
|
||||||
|
--md-sys-color-inverse-on-surface: #c58da6;
|
||||||
|
--md-sys-color-primary: #ae1d53;
|
||||||
|
--md-sys-color-primary-dim: #9e0b47;
|
||||||
|
--md-sys-color-on-primary: #ffeff0;
|
||||||
|
--md-sys-color-primary-container: #ff7198;
|
||||||
|
--md-sys-color-on-primary-container: #4d001e;
|
||||||
|
--md-sys-color-primary-fixed: #ff7198;
|
||||||
|
--md-sys-color-primary-fixed-dim: #f95a8a;
|
||||||
|
--md-sys-color-on-primary-fixed: #000000;
|
||||||
|
--md-sys-color-on-primary-fixed-variant: #5e0027;
|
||||||
|
--md-sys-color-inverse-primary: #f65787;
|
||||||
|
--md-sys-color-secondary: #99366e;
|
||||||
|
--md-sys-color-secondary-dim: #8a2a62;
|
||||||
|
--md-sys-color-on-secondary: #ffeff3;
|
||||||
|
--md-sys-color-secondary-container: #ffc0dc;
|
||||||
|
--md-sys-color-on-secondary-container: #7f2159;
|
||||||
|
--md-sys-color-secondary-fixed: #ffc0dc;
|
||||||
|
--md-sys-color-secondary-fixed-dim: #ffabd2;
|
||||||
|
--md-sys-color-on-secondary-fixed: #670845;
|
||||||
|
--md-sys-color-on-secondary-fixed-variant: #8b2b63;
|
||||||
|
--md-sys-color-tertiary: #5c4bb4;
|
||||||
|
--md-sys-color-tertiary-dim: #503ea7;
|
||||||
|
--md-sys-color-on-tertiary: #f6f0ff;
|
||||||
|
--md-sys-color-tertiary-container: #b3a5ff;
|
||||||
|
--md-sys-color-on-tertiary-container: #301887;
|
||||||
|
--md-sys-color-tertiary-fixed: #b3a5ff;
|
||||||
|
--md-sys-color-tertiary-fixed-dim: #a595ff;
|
||||||
|
--md-sys-color-on-tertiary-fixed: #170059;
|
||||||
|
--md-sys-color-on-tertiary-fixed-variant: #39248f;
|
||||||
|
--md-sys-color-error: #b31b25;
|
||||||
|
--md-sys-color-error-dim: #9f0519;
|
||||||
|
--md-sys-color-on-error: #ffefee;
|
||||||
|
--md-sys-color-error-container: #fb5151;
|
||||||
|
--md-sys-color-on-error-container: #570008;
|
||||||
|
--md-sys-color-success: #006c45;
|
||||||
|
--md-sys-color-on-success: #ffffff;
|
||||||
|
--md-sys-color-success-container: #86f9bc;
|
||||||
|
--md-sys-color-on-success-container: #002112;
|
||||||
|
--md-sys-color-warning: #7c5800;
|
||||||
|
--md-sys-color-on-warning: #ffffff;
|
||||||
|
--md-sys-color-warning-container: #ffdea6;
|
||||||
|
--md-sys-color-on-warning-container: #271900;
|
||||||
|
--md-sys-color-info: #005ac4;
|
||||||
|
--md-sys-color-on-info: #ffffff;
|
||||||
|
--md-sys-color-info-container: #d8e2ff;
|
||||||
|
--md-sys-color-on-info-container: #001a42;
|
||||||
|
--md-sys-color-inverse-error: #ff716c;
|
||||||
|
--md-sys-color-inverse-success: #69dca1;
|
||||||
|
--md-sys-color-inverse-warning: #fdbb28;
|
||||||
|
--md-sys-color-inverse-info: #aec6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-scheme='rose'][data-theme='dark'],
|
||||||
|
[data-scheme='rose'] [data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--md-sys-color-background: #220215;
|
||||||
|
--md-sys-color-on-background: #ffdcea;
|
||||||
|
--md-sys-color-surface: #220215;
|
||||||
|
--md-sys-color-surface-dim: #220215;
|
||||||
|
--md-sys-color-surface-bright: #4e1737;
|
||||||
|
--md-sys-color-surface-container-lowest: #000000;
|
||||||
|
--md-sys-color-surface-container-low: #2a041b;
|
||||||
|
--md-sys-color-surface-container: #330822;
|
||||||
|
--md-sys-color-surface-container-high: #3c0d28;
|
||||||
|
--md-sys-color-surface-container-highest: #45122f;
|
||||||
|
--md-sys-color-on-surface: #ffdcea;
|
||||||
|
--md-sys-color-on-surface-variant: #d49bb4;
|
||||||
|
--md-sys-color-outline: #99667e;
|
||||||
|
--md-sys-color-outline-variant: #663a50;
|
||||||
|
--md-sys-color-inverse-surface: #fff8f8;
|
||||||
|
--md-sys-color-inverse-on-surface: #75475d;
|
||||||
|
--md-sys-color-primary: #ff8aa7;
|
||||||
|
--md-sys-color-primary-dim: #ff6b95;
|
||||||
|
--md-sys-color-on-primary: #620029;
|
||||||
|
--md-sys-color-primary-container: #ff7198;
|
||||||
|
--md-sys-color-on-primary-container: #4d001e;
|
||||||
|
--md-sys-color-primary-fixed: #ff7198;
|
||||||
|
--md-sys-color-primary-fixed-dim: #f95a8a;
|
||||||
|
--md-sys-color-on-primary-fixed: #000000;
|
||||||
|
--md-sys-color-on-primary-fixed-variant: #5e0027;
|
||||||
|
--md-sys-color-inverse-primary: #b32257;
|
||||||
|
--md-sys-color-secondary: #ff8ac6;
|
||||||
|
--md-sys-color-secondary-dim: #ef7db9;
|
||||||
|
--md-sys-color-on-secondary: #620241;
|
||||||
|
--md-sys-color-secondary-container: #7f2058;
|
||||||
|
--md-sys-color-on-secondary-container: #ffbedb;
|
||||||
|
--md-sys-color-secondary-fixed: #ffc0dc;
|
||||||
|
--md-sys-color-secondary-fixed-dim: #ffabd2;
|
||||||
|
--md-sys-color-on-secondary-fixed: #670845;
|
||||||
|
--md-sys-color-on-secondary-fixed-variant: #8b2b63;
|
||||||
|
--md-sys-color-tertiary: #aa9bff;
|
||||||
|
--md-sys-color-tertiary-dim: #9f8ffc;
|
||||||
|
--md-sys-color-on-tertiary: #290c80;
|
||||||
|
--md-sys-color-tertiary-container: #9d8cfa;
|
||||||
|
--md-sys-color-on-tertiary-container: #1c0068;
|
||||||
|
--md-sys-color-tertiary-fixed: #b3a5ff;
|
||||||
|
--md-sys-color-tertiary-fixed-dim: #a595ff;
|
||||||
|
--md-sys-color-on-tertiary-fixed: #170059;
|
||||||
|
--md-sys-color-on-tertiary-fixed-variant: #39248f;
|
||||||
|
--md-sys-color-error: #ff716c;
|
||||||
|
--md-sys-color-error-dim: #d7383b;
|
||||||
|
--md-sys-color-on-error: #490006;
|
||||||
|
--md-sys-color-error-container: #9f0519;
|
||||||
|
--md-sys-color-on-error-container: #ffa8a3;
|
||||||
|
--md-sys-color-success: #69dca1;
|
||||||
|
--md-sys-color-on-success: #003822;
|
||||||
|
--md-sys-color-success-container: #005233;
|
||||||
|
--md-sys-color-on-success-container: #86f9bc;
|
||||||
|
--md-sys-color-warning: #fdbb28;
|
||||||
|
--md-sys-color-on-warning: #412d00;
|
||||||
|
--md-sys-color-warning-container: #5e4200;
|
||||||
|
--md-sys-color-on-warning-container: #ffdea6;
|
||||||
|
--md-sys-color-info: #aec6ff;
|
||||||
|
--md-sys-color-on-info: #002e6a;
|
||||||
|
--md-sys-color-info-container: #004396;
|
||||||
|
--md-sys-color-on-info-container: #d8e2ff;
|
||||||
|
--md-sys-color-inverse-error: #b31b25;
|
||||||
|
--md-sys-color-inverse-success: #006c45;
|
||||||
|
--md-sys-color-inverse-warning: #7c5800;
|
||||||
|
--md-sys-color-inverse-info: #005ac4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
{
|
||||||
|
"seed": "#6750a4",
|
||||||
|
"variant": "tonal-spot",
|
||||||
|
"spec": "2025",
|
||||||
|
"contrast": 0,
|
||||||
|
"light": {
|
||||||
|
"background": "#fdf7fe",
|
||||||
|
"on-background": "#34313a",
|
||||||
|
"surface": "#fdf7fe",
|
||||||
|
"surface-dim": "#ded8e4",
|
||||||
|
"surface-bright": "#fdf7fe",
|
||||||
|
"surface-container-lowest": "#ffffff",
|
||||||
|
"surface-container-low": "#f8f1fa",
|
||||||
|
"surface-container": "#f2ecf5",
|
||||||
|
"surface-container-high": "#ece6f0",
|
||||||
|
"surface-container-highest": "#e7e0ec",
|
||||||
|
"on-surface": "#34313a",
|
||||||
|
"on-surface-variant": "#615d68",
|
||||||
|
"outline": "#7d7983",
|
||||||
|
"outline-variant": "#b5b0bb",
|
||||||
|
"inverse-surface": "#0f0d12",
|
||||||
|
"inverse-on-surface": "#a09ba1",
|
||||||
|
"primary": "#655789",
|
||||||
|
"primary-dim": "#594b7c",
|
||||||
|
"on-primary": "#fdf7ff",
|
||||||
|
"primary-container": "#d4c3fd",
|
||||||
|
"on-primary-container": "#493c6c",
|
||||||
|
"primary-fixed": "#d4c3fd",
|
||||||
|
"primary-fixed-dim": "#c6b6ee",
|
||||||
|
"on-primary-fixed": "#352857",
|
||||||
|
"on-primary-fixed-variant": "#524576",
|
||||||
|
"inverse-primary": "#d4c3fd",
|
||||||
|
"secondary": "#625c71",
|
||||||
|
"secondary-dim": "#565065",
|
||||||
|
"on-secondary": "#fdf7ff",
|
||||||
|
"secondary-container": "#e8def8",
|
||||||
|
"on-secondary-container": "#554f63",
|
||||||
|
"secondary-fixed": "#e8def8",
|
||||||
|
"secondary-fixed-dim": "#dad0ea",
|
||||||
|
"on-secondary-fixed": "#423c50",
|
||||||
|
"on-secondary-fixed-variant": "#5f586e",
|
||||||
|
"tertiary": "#7b5270",
|
||||||
|
"tertiary-dim": "#6e4664",
|
||||||
|
"on-tertiary": "#fff7f9",
|
||||||
|
"tertiary-container": "#f4bfe3",
|
||||||
|
"on-tertiary-container": "#5f3956",
|
||||||
|
"tertiary-fixed": "#f4bfe3",
|
||||||
|
"tertiary-fixed-dim": "#e5b2d5",
|
||||||
|
"on-tertiary-fixed": "#4a2642",
|
||||||
|
"on-tertiary-fixed-variant": "#694260",
|
||||||
|
"error": "#a8364b",
|
||||||
|
"error-dim": "#6b0221",
|
||||||
|
"on-error": "#fff7f7",
|
||||||
|
"error-container": "#f97386",
|
||||||
|
"on-error-container": "#6e0523",
|
||||||
|
"success": "#006c45",
|
||||||
|
"on-success": "#ffffff",
|
||||||
|
"success-container": "#86f9bc",
|
||||||
|
"on-success-container": "#002112",
|
||||||
|
"warning": "#7c5800",
|
||||||
|
"on-warning": "#ffffff",
|
||||||
|
"warning-container": "#ffdea6",
|
||||||
|
"on-warning-container": "#271900",
|
||||||
|
"info": "#005ac4",
|
||||||
|
"on-info": "#ffffff",
|
||||||
|
"info-container": "#d8e2ff",
|
||||||
|
"on-info-container": "#001a42",
|
||||||
|
"inverse-error": "#f97386",
|
||||||
|
"inverse-success": "#69dca1",
|
||||||
|
"inverse-warning": "#fdbb28",
|
||||||
|
"inverse-info": "#aec6ff"
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"background": "#0f0d12",
|
||||||
|
"on-background": "#eae3ef",
|
||||||
|
"surface": "#0f0d12",
|
||||||
|
"surface-dim": "#0f0d12",
|
||||||
|
"surface-bright": "#2e2b34",
|
||||||
|
"surface-container-lowest": "#000000",
|
||||||
|
"surface-container-low": "#141218",
|
||||||
|
"surface-container": "#1b181f",
|
||||||
|
"surface-container-high": "#211e26",
|
||||||
|
"surface-container-highest": "#27242d",
|
||||||
|
"on-surface": "#eae3ef",
|
||||||
|
"on-surface-variant": "#aea9b4",
|
||||||
|
"outline": "#78737e",
|
||||||
|
"outline-variant": "#4a4650",
|
||||||
|
"inverse-surface": "#fdf7fe",
|
||||||
|
"inverse-on-surface": "#575459",
|
||||||
|
"primary": "#cdc0ec",
|
||||||
|
"primary-dim": "#bfb2de",
|
||||||
|
"on-primary": "#443a5f",
|
||||||
|
"primary-container": "#574d72",
|
||||||
|
"on-primary-container": "#e9deff",
|
||||||
|
"primary-fixed": "#ded0fe",
|
||||||
|
"primary-fixed-dim": "#d0c3ef",
|
||||||
|
"on-primary-fixed": "#3c3256",
|
||||||
|
"on-primary-fixed-variant": "#594e74",
|
||||||
|
"inverse-primary": "#645980",
|
||||||
|
"secondary": "#cbc2db",
|
||||||
|
"secondary-dim": "#beb5cd",
|
||||||
|
"on-secondary": "#433d51",
|
||||||
|
"secondary-container": "#3e384c",
|
||||||
|
"on-secondary-container": "#c4bbd4",
|
||||||
|
"secondary-fixed": "#e8def8",
|
||||||
|
"secondary-fixed-dim": "#dad0ea",
|
||||||
|
"on-secondary-fixed": "#423c50",
|
||||||
|
"on-secondary-fixed-variant": "#5f586e",
|
||||||
|
"tertiary": "#ffcfef",
|
||||||
|
"tertiary-dim": "#f4bfe3",
|
||||||
|
"on-tertiary": "#69415f",
|
||||||
|
"tertiary-container": "#f4bfe3",
|
||||||
|
"on-tertiary-container": "#5f3956",
|
||||||
|
"tertiary-fixed": "#f4bfe3",
|
||||||
|
"tertiary-fixed-dim": "#e5b2d5",
|
||||||
|
"on-tertiary-fixed": "#4a2642",
|
||||||
|
"on-tertiary-fixed-variant": "#694260",
|
||||||
|
"error": "#f97386",
|
||||||
|
"error-dim": "#c44b5f",
|
||||||
|
"on-error": "#490013",
|
||||||
|
"error-container": "#871c34",
|
||||||
|
"on-error-container": "#ff97a3",
|
||||||
|
"success": "#69dca1",
|
||||||
|
"on-success": "#003822",
|
||||||
|
"success-container": "#005233",
|
||||||
|
"on-success-container": "#86f9bc",
|
||||||
|
"warning": "#fdbb28",
|
||||||
|
"on-warning": "#412d00",
|
||||||
|
"warning-container": "#5e4200",
|
||||||
|
"on-warning-container": "#ffdea6",
|
||||||
|
"info": "#aec6ff",
|
||||||
|
"on-info": "#002e6a",
|
||||||
|
"info-container": "#004396",
|
||||||
|
"on-info-container": "#d8e2ff",
|
||||||
|
"inverse-error": "#a8364b",
|
||||||
|
"inverse-success": "#006c45",
|
||||||
|
"inverse-warning": "#7c5800",
|
||||||
|
"inverse-info": "#005ac4"
|
||||||
|
},
|
||||||
|
"default": "baseline",
|
||||||
|
"profiles": {
|
||||||
|
"baseline": {
|
||||||
|
"label": "Baseline",
|
||||||
|
"seed": "#6750a4",
|
||||||
|
"variant": "tonal-spot",
|
||||||
|
"spec": "2025",
|
||||||
|
"contrast": 0,
|
||||||
|
"light": {
|
||||||
|
"background": "#fdf7fe",
|
||||||
|
"on-background": "#34313a",
|
||||||
|
"surface": "#fdf7fe",
|
||||||
|
"surface-dim": "#ded8e4",
|
||||||
|
"surface-bright": "#fdf7fe",
|
||||||
|
"surface-container-lowest": "#ffffff",
|
||||||
|
"surface-container-low": "#f8f1fa",
|
||||||
|
"surface-container": "#f2ecf5",
|
||||||
|
"surface-container-high": "#ece6f0",
|
||||||
|
"surface-container-highest": "#e7e0ec",
|
||||||
|
"on-surface": "#34313a",
|
||||||
|
"on-surface-variant": "#615d68",
|
||||||
|
"outline": "#7d7983",
|
||||||
|
"outline-variant": "#b5b0bb",
|
||||||
|
"inverse-surface": "#0f0d12",
|
||||||
|
"inverse-on-surface": "#a09ba1",
|
||||||
|
"primary": "#655789",
|
||||||
|
"primary-dim": "#594b7c",
|
||||||
|
"on-primary": "#fdf7ff",
|
||||||
|
"primary-container": "#d4c3fd",
|
||||||
|
"on-primary-container": "#493c6c",
|
||||||
|
"primary-fixed": "#d4c3fd",
|
||||||
|
"primary-fixed-dim": "#c6b6ee",
|
||||||
|
"on-primary-fixed": "#352857",
|
||||||
|
"on-primary-fixed-variant": "#524576",
|
||||||
|
"inverse-primary": "#d4c3fd",
|
||||||
|
"secondary": "#625c71",
|
||||||
|
"secondary-dim": "#565065",
|
||||||
|
"on-secondary": "#fdf7ff",
|
||||||
|
"secondary-container": "#e8def8",
|
||||||
|
"on-secondary-container": "#554f63",
|
||||||
|
"secondary-fixed": "#e8def8",
|
||||||
|
"secondary-fixed-dim": "#dad0ea",
|
||||||
|
"on-secondary-fixed": "#423c50",
|
||||||
|
"on-secondary-fixed-variant": "#5f586e",
|
||||||
|
"tertiary": "#7b5270",
|
||||||
|
"tertiary-dim": "#6e4664",
|
||||||
|
"on-tertiary": "#fff7f9",
|
||||||
|
"tertiary-container": "#f4bfe3",
|
||||||
|
"on-tertiary-container": "#5f3956",
|
||||||
|
"tertiary-fixed": "#f4bfe3",
|
||||||
|
"tertiary-fixed-dim": "#e5b2d5",
|
||||||
|
"on-tertiary-fixed": "#4a2642",
|
||||||
|
"on-tertiary-fixed-variant": "#694260",
|
||||||
|
"error": "#a8364b",
|
||||||
|
"error-dim": "#6b0221",
|
||||||
|
"on-error": "#fff7f7",
|
||||||
|
"error-container": "#f97386",
|
||||||
|
"on-error-container": "#6e0523",
|
||||||
|
"success": "#006c45",
|
||||||
|
"on-success": "#ffffff",
|
||||||
|
"success-container": "#86f9bc",
|
||||||
|
"on-success-container": "#002112",
|
||||||
|
"warning": "#7c5800",
|
||||||
|
"on-warning": "#ffffff",
|
||||||
|
"warning-container": "#ffdea6",
|
||||||
|
"on-warning-container": "#271900",
|
||||||
|
"info": "#005ac4",
|
||||||
|
"on-info": "#ffffff",
|
||||||
|
"info-container": "#d8e2ff",
|
||||||
|
"on-info-container": "#001a42",
|
||||||
|
"inverse-error": "#f97386",
|
||||||
|
"inverse-success": "#69dca1",
|
||||||
|
"inverse-warning": "#fdbb28",
|
||||||
|
"inverse-info": "#aec6ff"
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"background": "#0f0d12",
|
||||||
|
"on-background": "#eae3ef",
|
||||||
|
"surface": "#0f0d12",
|
||||||
|
"surface-dim": "#0f0d12",
|
||||||
|
"surface-bright": "#2e2b34",
|
||||||
|
"surface-container-lowest": "#000000",
|
||||||
|
"surface-container-low": "#141218",
|
||||||
|
"surface-container": "#1b181f",
|
||||||
|
"surface-container-high": "#211e26",
|
||||||
|
"surface-container-highest": "#27242d",
|
||||||
|
"on-surface": "#eae3ef",
|
||||||
|
"on-surface-variant": "#aea9b4",
|
||||||
|
"outline": "#78737e",
|
||||||
|
"outline-variant": "#4a4650",
|
||||||
|
"inverse-surface": "#fdf7fe",
|
||||||
|
"inverse-on-surface": "#575459",
|
||||||
|
"primary": "#cdc0ec",
|
||||||
|
"primary-dim": "#bfb2de",
|
||||||
|
"on-primary": "#443a5f",
|
||||||
|
"primary-container": "#574d72",
|
||||||
|
"on-primary-container": "#e9deff",
|
||||||
|
"primary-fixed": "#ded0fe",
|
||||||
|
"primary-fixed-dim": "#d0c3ef",
|
||||||
|
"on-primary-fixed": "#3c3256",
|
||||||
|
"on-primary-fixed-variant": "#594e74",
|
||||||
|
"inverse-primary": "#645980",
|
||||||
|
"secondary": "#cbc2db",
|
||||||
|
"secondary-dim": "#beb5cd",
|
||||||
|
"on-secondary": "#433d51",
|
||||||
|
"secondary-container": "#3e384c",
|
||||||
|
"on-secondary-container": "#c4bbd4",
|
||||||
|
"secondary-fixed": "#e8def8",
|
||||||
|
"secondary-fixed-dim": "#dad0ea",
|
||||||
|
"on-secondary-fixed": "#423c50",
|
||||||
|
"on-secondary-fixed-variant": "#5f586e",
|
||||||
|
"tertiary": "#ffcfef",
|
||||||
|
"tertiary-dim": "#f4bfe3",
|
||||||
|
"on-tertiary": "#69415f",
|
||||||
|
"tertiary-container": "#f4bfe3",
|
||||||
|
"on-tertiary-container": "#5f3956",
|
||||||
|
"tertiary-fixed": "#f4bfe3",
|
||||||
|
"tertiary-fixed-dim": "#e5b2d5",
|
||||||
|
"on-tertiary-fixed": "#4a2642",
|
||||||
|
"on-tertiary-fixed-variant": "#694260",
|
||||||
|
"error": "#f97386",
|
||||||
|
"error-dim": "#c44b5f",
|
||||||
|
"on-error": "#490013",
|
||||||
|
"error-container": "#871c34",
|
||||||
|
"on-error-container": "#ff97a3",
|
||||||
|
"success": "#69dca1",
|
||||||
|
"on-success": "#003822",
|
||||||
|
"success-container": "#005233",
|
||||||
|
"on-success-container": "#86f9bc",
|
||||||
|
"warning": "#fdbb28",
|
||||||
|
"on-warning": "#412d00",
|
||||||
|
"warning-container": "#5e4200",
|
||||||
|
"on-warning-container": "#ffdea6",
|
||||||
|
"info": "#aec6ff",
|
||||||
|
"on-info": "#002e6a",
|
||||||
|
"info-container": "#004396",
|
||||||
|
"on-info-container": "#d8e2ff",
|
||||||
|
"inverse-error": "#a8364b",
|
||||||
|
"inverse-success": "#006c45",
|
||||||
|
"inverse-warning": "#7c5800",
|
||||||
|
"inverse-info": "#005ac4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"teal": {
|
||||||
|
"label": "Teal",
|
||||||
|
"seed": "#00897b",
|
||||||
|
"variant": "vibrant",
|
||||||
|
"spec": "2025",
|
||||||
|
"contrast": 0,
|
||||||
|
"light": {
|
||||||
|
"background": "#d3fffd",
|
||||||
|
"on-background": "#003534",
|
||||||
|
"surface": "#d3fffd",
|
||||||
|
"surface-dim": "#87e4e1",
|
||||||
|
"surface-bright": "#d3fffd",
|
||||||
|
"surface-container-lowest": "#ffffff",
|
||||||
|
"surface-container-low": "#bafdfa",
|
||||||
|
"surface-container": "#adf5f2",
|
||||||
|
"surface-container-high": "#a2f0ed",
|
||||||
|
"surface-container-highest": "#96ece8",
|
||||||
|
"on-surface": "#003534",
|
||||||
|
"on-surface-variant": "#296463",
|
||||||
|
"outline": "#46807e",
|
||||||
|
"outline-variant": "#7db7b5",
|
||||||
|
"inverse-surface": "#001111",
|
||||||
|
"inverse-on-surface": "#6da7a5",
|
||||||
|
"primary": "#00675c",
|
||||||
|
"primary-dim": "#005a50",
|
||||||
|
"on-primary": "#c0fff3",
|
||||||
|
"primary-container": "#00f7df",
|
||||||
|
"on-primary-container": "#00594f",
|
||||||
|
"primary-fixed": "#00f7df",
|
||||||
|
"primary-fixed-dim": "#00e8d1",
|
||||||
|
"on-primary-fixed": "#00443c",
|
||||||
|
"on-primary-fixed-variant": "#006359",
|
||||||
|
"inverse-primary": "#00fee5",
|
||||||
|
"secondary": "#006765",
|
||||||
|
"secondary-dim": "#005958",
|
||||||
|
"on-secondary": "#bcfffc",
|
||||||
|
"secondary-container": "#38fbf7",
|
||||||
|
"on-secondary-container": "#005c5a",
|
||||||
|
"secondary-fixed": "#38fbf7",
|
||||||
|
"secondary-fixed-dim": "#10ece8",
|
||||||
|
"on-secondary-fixed": "#004746",
|
||||||
|
"on-secondary-fixed-variant": "#006765",
|
||||||
|
"tertiary": "#006386",
|
||||||
|
"tertiary-dim": "#005675",
|
||||||
|
"on-tertiary": "#e7f5ff",
|
||||||
|
"tertiary-container": "#20c0ff",
|
||||||
|
"on-tertiary-container": "#00374d",
|
||||||
|
"tertiary-fixed": "#20c0ff",
|
||||||
|
"tertiary-fixed-dim": "#00b2ee",
|
||||||
|
"on-tertiary-fixed": "#001e2b",
|
||||||
|
"on-tertiary-fixed-variant": "#004059",
|
||||||
|
"error": "#b31b25",
|
||||||
|
"error-dim": "#9f0519",
|
||||||
|
"on-error": "#ffefee",
|
||||||
|
"error-container": "#fb5151",
|
||||||
|
"on-error-container": "#570008",
|
||||||
|
"success": "#006c45",
|
||||||
|
"on-success": "#ffffff",
|
||||||
|
"success-container": "#86f9bc",
|
||||||
|
"on-success-container": "#002112",
|
||||||
|
"warning": "#7c5800",
|
||||||
|
"on-warning": "#ffffff",
|
||||||
|
"warning-container": "#ffdea6",
|
||||||
|
"on-warning-container": "#271900",
|
||||||
|
"info": "#005ac4",
|
||||||
|
"on-info": "#ffffff",
|
||||||
|
"info-container": "#d8e2ff",
|
||||||
|
"on-info-container": "#001a42",
|
||||||
|
"inverse-error": "#ff716c",
|
||||||
|
"inverse-success": "#69dca1",
|
||||||
|
"inverse-warning": "#fdbb28",
|
||||||
|
"inverse-info": "#aec6ff"
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"background": "#001111",
|
||||||
|
"on-background": "#b8f3f1",
|
||||||
|
"surface": "#001111",
|
||||||
|
"surface-dim": "#001111",
|
||||||
|
"surface-bright": "#003231",
|
||||||
|
"surface-container-lowest": "#000000",
|
||||||
|
"surface-container-low": "#001716",
|
||||||
|
"surface-container": "#001e1d",
|
||||||
|
"surface-container-high": "#002424",
|
||||||
|
"surface-container-highest": "#002b2a",
|
||||||
|
"on-surface": "#b8f3f1",
|
||||||
|
"on-surface-variant": "#7bb5b3",
|
||||||
|
"outline": "#457f7d",
|
||||||
|
"outline-variant": "#0e504f",
|
||||||
|
"inverse-surface": "#e3fffd",
|
||||||
|
"inverse-on-surface": "#215e5c",
|
||||||
|
"primary": "#b4fff1",
|
||||||
|
"primary-dim": "#00fee5",
|
||||||
|
"on-primary": "#00665b",
|
||||||
|
"primary-container": "#00fee5",
|
||||||
|
"on-primary-container": "#005c53",
|
||||||
|
"primary-fixed": "#00f7df",
|
||||||
|
"primary-fixed-dim": "#00e8d1",
|
||||||
|
"on-primary-fixed": "#00443c",
|
||||||
|
"on-primary-fixed-variant": "#006359",
|
||||||
|
"inverse-primary": "#006b60",
|
||||||
|
"secondary": "#38fbf7",
|
||||||
|
"secondary-dim": "#10ece8",
|
||||||
|
"on-secondary": "#005c5a",
|
||||||
|
"secondary-container": "#006a68",
|
||||||
|
"on-secondary-container": "#dafffd",
|
||||||
|
"secondary-fixed": "#38fbf7",
|
||||||
|
"secondary-fixed-dim": "#10ece8",
|
||||||
|
"on-secondary-fixed": "#004746",
|
||||||
|
"on-secondary-fixed-variant": "#006765",
|
||||||
|
"tertiary": "#68ccff",
|
||||||
|
"tertiary-dim": "#20c0ff",
|
||||||
|
"on-tertiary": "#00415a",
|
||||||
|
"tertiary-container": "#20c0ff",
|
||||||
|
"on-tertiary-container": "#00374d",
|
||||||
|
"tertiary-fixed": "#20c0ff",
|
||||||
|
"tertiary-fixed-dim": "#00b2ee",
|
||||||
|
"on-tertiary-fixed": "#001e2b",
|
||||||
|
"on-tertiary-fixed-variant": "#004059",
|
||||||
|
"error": "#ff716c",
|
||||||
|
"error-dim": "#d7383b",
|
||||||
|
"on-error": "#490006",
|
||||||
|
"error-container": "#9f0519",
|
||||||
|
"on-error-container": "#ffa8a3",
|
||||||
|
"success": "#69dca1",
|
||||||
|
"on-success": "#003822",
|
||||||
|
"success-container": "#005233",
|
||||||
|
"on-success-container": "#86f9bc",
|
||||||
|
"warning": "#fdbb28",
|
||||||
|
"on-warning": "#412d00",
|
||||||
|
"warning-container": "#5e4200",
|
||||||
|
"on-warning-container": "#ffdea6",
|
||||||
|
"info": "#aec6ff",
|
||||||
|
"on-info": "#002e6a",
|
||||||
|
"info-container": "#004396",
|
||||||
|
"on-info-container": "#d8e2ff",
|
||||||
|
"inverse-error": "#b31b25",
|
||||||
|
"inverse-success": "#006c45",
|
||||||
|
"inverse-warning": "#7c5800",
|
||||||
|
"inverse-info": "#005ac4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rose": {
|
||||||
|
"label": "Rose",
|
||||||
|
"seed": "#c2185b",
|
||||||
|
"variant": "vibrant",
|
||||||
|
"spec": "2025",
|
||||||
|
"contrast": 0,
|
||||||
|
"light": {
|
||||||
|
"background": "#fff4f6",
|
||||||
|
"on-background": "#492136",
|
||||||
|
"surface": "#fff4f6",
|
||||||
|
"surface-dim": "#ffc4de",
|
||||||
|
"surface-bright": "#fff4f6",
|
||||||
|
"surface-container-lowest": "#ffffff",
|
||||||
|
"surface-container-low": "#ffecf2",
|
||||||
|
"surface-container": "#ffe0ec",
|
||||||
|
"surface-container-high": "#ffd8e8",
|
||||||
|
"surface-container-highest": "#ffd0e4",
|
||||||
|
"on-surface": "#492136",
|
||||||
|
"on-surface-variant": "#7c4d64",
|
||||||
|
"outline": "#9b6880",
|
||||||
|
"outline-variant": "#d69db7",
|
||||||
|
"inverse-surface": "#220215",
|
||||||
|
"inverse-on-surface": "#c58da6",
|
||||||
|
"primary": "#ae1d53",
|
||||||
|
"primary-dim": "#9e0b47",
|
||||||
|
"on-primary": "#ffeff0",
|
||||||
|
"primary-container": "#ff7198",
|
||||||
|
"on-primary-container": "#4d001e",
|
||||||
|
"primary-fixed": "#ff7198",
|
||||||
|
"primary-fixed-dim": "#f95a8a",
|
||||||
|
"on-primary-fixed": "#000000",
|
||||||
|
"on-primary-fixed-variant": "#5e0027",
|
||||||
|
"inverse-primary": "#f65787",
|
||||||
|
"secondary": "#99366e",
|
||||||
|
"secondary-dim": "#8a2a62",
|
||||||
|
"on-secondary": "#ffeff3",
|
||||||
|
"secondary-container": "#ffc0dc",
|
||||||
|
"on-secondary-container": "#7f2159",
|
||||||
|
"secondary-fixed": "#ffc0dc",
|
||||||
|
"secondary-fixed-dim": "#ffabd2",
|
||||||
|
"on-secondary-fixed": "#670845",
|
||||||
|
"on-secondary-fixed-variant": "#8b2b63",
|
||||||
|
"tertiary": "#5c4bb4",
|
||||||
|
"tertiary-dim": "#503ea7",
|
||||||
|
"on-tertiary": "#f6f0ff",
|
||||||
|
"tertiary-container": "#b3a5ff",
|
||||||
|
"on-tertiary-container": "#301887",
|
||||||
|
"tertiary-fixed": "#b3a5ff",
|
||||||
|
"tertiary-fixed-dim": "#a595ff",
|
||||||
|
"on-tertiary-fixed": "#170059",
|
||||||
|
"on-tertiary-fixed-variant": "#39248f",
|
||||||
|
"error": "#b31b25",
|
||||||
|
"error-dim": "#9f0519",
|
||||||
|
"on-error": "#ffefee",
|
||||||
|
"error-container": "#fb5151",
|
||||||
|
"on-error-container": "#570008",
|
||||||
|
"success": "#006c45",
|
||||||
|
"on-success": "#ffffff",
|
||||||
|
"success-container": "#86f9bc",
|
||||||
|
"on-success-container": "#002112",
|
||||||
|
"warning": "#7c5800",
|
||||||
|
"on-warning": "#ffffff",
|
||||||
|
"warning-container": "#ffdea6",
|
||||||
|
"on-warning-container": "#271900",
|
||||||
|
"info": "#005ac4",
|
||||||
|
"on-info": "#ffffff",
|
||||||
|
"info-container": "#d8e2ff",
|
||||||
|
"on-info-container": "#001a42",
|
||||||
|
"inverse-error": "#ff716c",
|
||||||
|
"inverse-success": "#69dca1",
|
||||||
|
"inverse-warning": "#fdbb28",
|
||||||
|
"inverse-info": "#aec6ff"
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"background": "#220215",
|
||||||
|
"on-background": "#ffdcea",
|
||||||
|
"surface": "#220215",
|
||||||
|
"surface-dim": "#220215",
|
||||||
|
"surface-bright": "#4e1737",
|
||||||
|
"surface-container-lowest": "#000000",
|
||||||
|
"surface-container-low": "#2a041b",
|
||||||
|
"surface-container": "#330822",
|
||||||
|
"surface-container-high": "#3c0d28",
|
||||||
|
"surface-container-highest": "#45122f",
|
||||||
|
"on-surface": "#ffdcea",
|
||||||
|
"on-surface-variant": "#d49bb4",
|
||||||
|
"outline": "#99667e",
|
||||||
|
"outline-variant": "#663a50",
|
||||||
|
"inverse-surface": "#fff8f8",
|
||||||
|
"inverse-on-surface": "#75475d",
|
||||||
|
"primary": "#ff8aa7",
|
||||||
|
"primary-dim": "#ff6b95",
|
||||||
|
"on-primary": "#620029",
|
||||||
|
"primary-container": "#ff7198",
|
||||||
|
"on-primary-container": "#4d001e",
|
||||||
|
"primary-fixed": "#ff7198",
|
||||||
|
"primary-fixed-dim": "#f95a8a",
|
||||||
|
"on-primary-fixed": "#000000",
|
||||||
|
"on-primary-fixed-variant": "#5e0027",
|
||||||
|
"inverse-primary": "#b32257",
|
||||||
|
"secondary": "#ff8ac6",
|
||||||
|
"secondary-dim": "#ef7db9",
|
||||||
|
"on-secondary": "#620241",
|
||||||
|
"secondary-container": "#7f2058",
|
||||||
|
"on-secondary-container": "#ffbedb",
|
||||||
|
"secondary-fixed": "#ffc0dc",
|
||||||
|
"secondary-fixed-dim": "#ffabd2",
|
||||||
|
"on-secondary-fixed": "#670845",
|
||||||
|
"on-secondary-fixed-variant": "#8b2b63",
|
||||||
|
"tertiary": "#aa9bff",
|
||||||
|
"tertiary-dim": "#9f8ffc",
|
||||||
|
"on-tertiary": "#290c80",
|
||||||
|
"tertiary-container": "#9d8cfa",
|
||||||
|
"on-tertiary-container": "#1c0068",
|
||||||
|
"tertiary-fixed": "#b3a5ff",
|
||||||
|
"tertiary-fixed-dim": "#a595ff",
|
||||||
|
"on-tertiary-fixed": "#170059",
|
||||||
|
"on-tertiary-fixed-variant": "#39248f",
|
||||||
|
"error": "#ff716c",
|
||||||
|
"error-dim": "#d7383b",
|
||||||
|
"on-error": "#490006",
|
||||||
|
"error-container": "#9f0519",
|
||||||
|
"on-error-container": "#ffa8a3",
|
||||||
|
"success": "#69dca1",
|
||||||
|
"on-success": "#003822",
|
||||||
|
"success-container": "#005233",
|
||||||
|
"on-success-container": "#86f9bc",
|
||||||
|
"warning": "#fdbb28",
|
||||||
|
"on-warning": "#412d00",
|
||||||
|
"warning-container": "#5e4200",
|
||||||
|
"on-warning-container": "#ffdea6",
|
||||||
|
"info": "#aec6ff",
|
||||||
|
"on-info": "#002e6a",
|
||||||
|
"info-container": "#004396",
|
||||||
|
"on-info-container": "#d8e2ff",
|
||||||
|
"inverse-error": "#b31b25",
|
||||||
|
"inverse-success": "#006c45",
|
||||||
|
"inverse-warning": "#7c5800",
|
||||||
|
"inverse-info": "#005ac4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user