Compare commits
@@ -72,11 +72,11 @@ Generate the scheme from a seed colour. It writes `resources/css/material-scheme
|
|||||||
php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
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`. `--spec` is the colour spec: `2025` (M3 Expressive, the default) or `2021` (M3 as it first shipped, for a palette generated before Expressive). `--contrast` runs from -1 to 1; `--success`, `--warning` and `--info` seed the state colours. The stylesheet's header records the command that regenerates it; regenerate instead of editing the file.
|
||||||
|
|
||||||
#### Colour profiles
|
#### 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.
|
To let an installation switch between several schemes, list them as `profiles` in the config (name ⇒ `label`, `seed`, `variant`, and optionally `contrast`, `spec`, `success`, `warning`, `info`, which otherwise come from the command's options) and run `php artisan material:scheme` without a seed: every profile lands in the same stylesheet under `<html data-scheme>`. Tell the package which one is active — `Scheme::resolveProfileUsing(fn () => Setting::get('color_profile'))` in a service provider — and the head script, mails and error pages follow it. `<x-scheme-picker wire:model="colorProfile" />` lets someone choose, previewing each profile on the page.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
@@ -86,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).
|
||||||
|
- `theme.meta` — keep `<meta name="theme-color">` (an installed web app's or a mobile browser's bar) on the resolved theme's `surface` and the active colour profile, before the first paint and after every change, `wire:navigate` included; one is added when the page has none (default `false`).
|
||||||
- `profiles`, `profile` — colour profiles and the default one (see Colour profiles).
|
- `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`).
|
||||||
|
|||||||
+8
-3
@@ -6,7 +6,8 @@
|
|||||||
* nothing but `node`. (The published library imports without file extensions, which
|
* nothing but `node`. (The published library imports without file extensions, which
|
||||||
* plain Node refuses, so it cannot be run unbundled anyway.)
|
* plain Node refuses, so it cannot be run unbundled anyway.)
|
||||||
*
|
*
|
||||||
* Input: one JSON argument — {seed, variant, contrast, success, warning, info}.
|
* Input: one JSON argument — {seed, variant, spec, contrast, success, warning, info}. `spec` is
|
||||||
|
* the colour spec, '2025' (M3 Expressive, the default) or '2021' (M3 as it first shipped).
|
||||||
* Output: JSON on stdout — {seed, variant, spec, contrast, light: {role: hex}, dark: {role: hex}}.
|
* Output: JSON on stdout — {seed, variant, spec, contrast, light: {role: hex}, dark: {role: hex}}.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
@@ -56,9 +57,12 @@ try {
|
|||||||
|
|
||||||
const hex = /^#[0-9a-f]{6}$/i
|
const hex = /^#[0-9a-f]{6}$/i
|
||||||
const Scheme = VARIANTS[input.variant]
|
const Scheme = VARIANTS[input.variant]
|
||||||
|
const SPECS = ['2021', '2025']
|
||||||
|
const spec = input.spec ?? '2025'
|
||||||
|
|
||||||
if (!hex.test(input.seed ?? '')) fail(`The seed must be a #rrggbb colour, "${input.seed}" given.`)
|
if (!hex.test(input.seed ?? '')) fail(`The seed must be a #rrggbb colour, "${input.seed}" given.`)
|
||||||
if (!Scheme) fail(`Unknown variant "${input.variant}". Use one of: ${Object.keys(VARIANTS).join(', ')}.`)
|
if (!Scheme) fail(`Unknown variant "${input.variant}". Use one of: ${Object.keys(VARIANTS).join(', ')}.`)
|
||||||
|
if (!SPECS.includes(spec)) fail(`Unknown spec "${spec}". Use one of: ${SPECS.join(', ')}.`)
|
||||||
|
|
||||||
for (const state of ['success', 'warning', 'info']) {
|
for (const state of ['success', 'warning', 'info']) {
|
||||||
if (!hex.test(input[state] ?? '')) fail(`The ${state} colour must be a #rrggbb colour, "${input[state]}" given.`)
|
if (!hex.test(input[state] ?? '')) fail(`The ${state} colour must be a #rrggbb colour, "${input[state]}" given.`)
|
||||||
@@ -73,8 +77,9 @@ const colors = new MaterialDynamicColors()
|
|||||||
|
|
||||||
function roles(isDark) {
|
function roles(isDark) {
|
||||||
// The 2025 spec is M3 Expressive's colour; the library falls back to 2021 for the
|
// The 2025 spec is M3 Expressive's colour; the library falls back to 2021 for the
|
||||||
// variants the new spec does not define (fidelity, content, monochrome, …).
|
// variants the new spec does not define (fidelity, content, monochrome, …). 2021 is
|
||||||
const scheme = new Scheme(source, isDark, contrast, '2025')
|
// M3's original colour, for an application whose palette was generated with it.
|
||||||
|
const scheme = new Scheme(source, isDark, contrast, spec)
|
||||||
const out = {}
|
const out = {}
|
||||||
|
|
||||||
for (const color of colors.allColors) {
|
for (const color of colors.allColors) {
|
||||||
|
|||||||
@@ -28,12 +28,20 @@ return [
|
|||||||
| localStorage under 'storage_key'; values found under 'legacy_keys' (an
|
| localStorage under 'storage_key'; values found under 'legacy_keys' (an
|
||||||
| earlier theme toggle's key) are adopted once and then removed.
|
| earlier theme toggle's key) are adopted once and then removed.
|
||||||
|
|
|
|
||||||
|
| 'meta' keeps <meta name="theme-color"> (the colour an installed web app
|
||||||
|
| or a mobile browser gives its bar) on the resolved theme's surface, and
|
||||||
|
| the active colour profile's: the head script sets it before the first
|
||||||
|
| paint, adds one when the page has none, and follows every later change,
|
||||||
|
| wire:navigate included. A theme-color meta with a `media` attribute is
|
||||||
|
| left alone.
|
||||||
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'theme' => [
|
'theme' => [
|
||||||
'default' => 'system',
|
'default' => 'system',
|
||||||
'storage_key' => 'material-theme',
|
'storage_key' => 'material-theme',
|
||||||
'legacy_keys' => [],
|
'legacy_keys' => [],
|
||||||
|
'meta' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -113,7 +121,9 @@ return [
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
|
||||||
| Named schemes an installation can switch between. Each one is a 'label',
|
| Named schemes an installation can switch between. Each one is a 'label',
|
||||||
| a 'seed' (#rrggbb), a 'variant' and an optional 'contrast'. Without a
|
| a 'seed' (#rrggbb), a 'variant' and an optional 'contrast'; 'spec'
|
||||||
|
| ('2025' or '2021') and the 'success', 'warning' and 'info' sources are
|
||||||
|
| optional too, taken from the command's options when left out. Without a
|
||||||
| seed, `php artisan material:scheme` generates every profile into one
|
| seed, `php artisan material:scheme` generates every profile into one
|
||||||
| stylesheet keyed by <html data-scheme>. 'profile' names the default one
|
| stylesheet keyed by <html data-scheme>. 'profile' names the default one
|
||||||
| (else the first); the application says which is active with
|
| (else the first); the application says which is active with
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ The scheme is generated, never hand-edited. Regenerate it with the seed and vari
|
|||||||
php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
||||||
```
|
```
|
||||||
|
|
||||||
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The command also writes `material-scheme.json` beside the stylesheet.
|
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (default, M3 Expressive) or `2021` (M3's original colour — keep it for a palette generated before Expressive; the library itself uses 2021 for variants 2025 does not define, and the header records the spec actually used). `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The header of the stylesheet records the whole command, every option that differs from its default included. The command also writes `material-scheme.json` beside the stylesheet.
|
||||||
|
|
||||||
### Colour profiles
|
### Colour profiles
|
||||||
|
|
||||||
@@ -62,6 +62,7 @@ An installation that switches between several schemes lists them in `config/live
|
|||||||
php artisan material:scheme
|
php artisan material:scheme
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- Each profile: `seed`, and optionally `label` (default: the name as a headline), `variant` (default `tonal-spot`), `contrast` (default 0), `spec`, `success`, `warning`, `info` (for these four, without the key the command's `--spec`, `--success`, `--warning`, `--info` or their defaults apply).
|
||||||
- Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet.
|
- 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:
|
- 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:
|
||||||
|
|
||||||
@@ -90,7 +91,15 @@ 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. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything.
|
`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`, `theme.meta`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything.
|
||||||
|
|
||||||
|
`theme.meta` (default `false`) keeps the browser's bar in the page's colour, for an installed web app: the head script sets the `content` of every `<meta name="theme-color">` without a `media` attribute to the resolved theme's `surface` — of the profile in `<html data-scheme>` — before the first paint, adding one to `<head>` when there is none. It follows every later change of `data-theme` or `data-scheme` (`$store.theme.set()`/`toggle()`, an OS change while `system`, `previewScheme()`), and paints the next page's meta after `wire:navigate`. A theme-color meta the layout renders itself goes before `<x-theme-script />` (after it, the script has already added one, and the page ends up with two), or is left out. A `media="(prefers-color-scheme: …)"` pair follows the OS instead of the visitor's choice: drop it when turning this on.
|
||||||
|
|
||||||
|
## Safe areas
|
||||||
|
|
||||||
|
Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through `var(--material-safe-top|bottom|left|right, env(safe-area-inset-…))`. The layout needs `viewport-fit=cover` in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on `<html>` or any ancestor: a browser test fakes a notch with `document.documentElement.style.setProperty('--material-safe-top', '47px')`, and an app that draws its own status strip adds its height.
|
||||||
|
|
||||||
|
`--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `<x-app-shell>` (an offline banner): the shell adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at `bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`, below `sm` only.
|
||||||
|
|
||||||
## Toasts
|
## Toasts
|
||||||
|
|
||||||
@@ -186,7 +195,7 @@ One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, size
|
|||||||
|
|
||||||
### `<x-theme-script>`
|
### `<x-theme-script>`
|
||||||
|
|
||||||
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`.
|
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`. With `theme.meta` on it also paints `<meta name="theme-color">` (see Theme); a layout's own theme-color meta goes before it.
|
||||||
|
|
||||||
### `<x-button>`
|
### `<x-button>`
|
||||||
|
|
||||||
@@ -235,7 +244,7 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
|
|||||||
</x-menu>
|
</x-menu>
|
||||||
```
|
```
|
||||||
|
|
||||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
|
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
|
||||||
|
|
||||||
### `<x-button-group>`
|
### `<x-button-group>`
|
||||||
|
|
||||||
@@ -253,7 +262,7 @@ A choice between a few options as a connected button group of native radios (che
|
|||||||
]" hint="Recipients lose access after that" />
|
]" hint="Recipients lose access after that" />
|
||||||
```
|
```
|
||||||
|
|
||||||
Props: `label`, `hint`, `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
|
Props: `label`, `hint`, `hint-class` (classes for the hint, as on the fields; a colour there paints it), `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
|
||||||
|
|
||||||
### `<x-split-button>`
|
### `<x-split-button>`
|
||||||
|
|
||||||
@@ -280,7 +289,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
|
|||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard as `<x-menu>`.
|
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as `<x-menu>`.
|
||||||
|
|
||||||
### `<x-loading>`
|
### `<x-loading>`
|
||||||
|
|
||||||
@@ -304,6 +313,16 @@ materialToast('Share deleted', { type: 'success', description: null, timeout: 40
|
|||||||
|
|
||||||
`type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer.
|
`type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer.
|
||||||
|
|
||||||
|
- `action`: `label`, plus `handler` (a function) and/or `event` (a name). Pressing it closes the snackbar, calls `handler`, then dispatches `new CustomEvent(event)` on `window`; give both and both run. Use `event` where a function cannot travel, such as a toast built from JSON.
|
||||||
|
- `sticky: true` keeps a toast until it is dismissed or its action is pressed (any `timeout` is ignored), without holding the queue up: a toast dispatched meanwhile shows in its place, and the sticky one comes back once the queue is empty. One sticky toast is kept at a time; a newer one replaces it. Use it for a question that must be answered, not for news:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: 'A new version is ready', sticky: true, action: { label: 'Reload', event: 'app:update' } } }))
|
||||||
|
window.addEventListener('app:update', () => location.reload())
|
||||||
|
```
|
||||||
|
|
||||||
|
- Hooks: `data-toast` on the snackbar on screen, `data-toast-action` on its action button (`[data-toast]` is absent while nothing shows). Target these in tests, not classes.
|
||||||
|
|
||||||
### `<x-progress>`
|
### `<x-progress>`
|
||||||
|
|
||||||
M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless a `size-*` class is passed), flat or `wavy`, determinate with a `value` or indeterminate without one.
|
M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless a `size-*` class is passed), flat or `wavy`, determinate with a `value` or indeterminate without one.
|
||||||
@@ -334,7 +353,10 @@ A value the server changes animates after a morph (the SVG is `wire:ignore`; onl
|
|||||||
### `<x-badge>`
|
### `<x-badge>`
|
||||||
|
|
||||||
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
|
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
|
||||||
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
|
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Built in" color="primary" solid />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container, in the colour itself (`solid`, for a label that has to stand out), or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`, `neutral`, `plain`; an unknown colour is `error`.
|
||||||
|
- `color="neutral"` — neutral ink on every variant: a dot or count in on-surface-variant with surface text, `tonal` in surface-container-high with on-surface-variant text, `outline` in the outline-variant edge with on-surface-variant text.
|
||||||
|
- `color="plain"` — no background, text or border colour in any variant (shape, size and type stay), so the classes you pass paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`. Pass both a background and a text class; an `outline` badge's edge takes the text colour unless you pass a `border-*` colour.
|
||||||
|
- The value is `value` or the slot; the slot renders as HTML: `<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. `value` is escaped. A slot that holds only whitespace or comments is still a dot.
|
||||||
|
|
||||||
### `<x-alert>`
|
### `<x-alert>`
|
||||||
|
|
||||||
@@ -360,7 +382,7 @@ A few lines of context around a trigger, with an optional `title` and `actions`
|
|||||||
</x-rich-tooltip>
|
</x-rich-tooltip>
|
||||||
```
|
```
|
||||||
|
|
||||||
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). `side`: `bottom` (default), `top`, `left`, `right`.
|
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`.
|
||||||
|
|
||||||
### `<x-stat>`
|
### `<x-stat>`
|
||||||
|
|
||||||
@@ -370,6 +392,15 @@ Shows on hover and keyboard focus; `persistent` opens it on press and keeps it u
|
|||||||
|
|
||||||
"Nothing here yet": `icon` on an Expressive `shape` (`cookie-9` by default), `title`, `description` or slot, and an `actions` slot. Use it for an empty collection, not for a filter that matched nothing.
|
"Nothing here yet": `icon` on an Expressive `shape` (`cookie-9` by default), `title`, `description` or slot, and an `actions` slot. Use it for an empty collection, not for a filter that matched nothing.
|
||||||
|
|
||||||
|
The `illustration` slot draws the application's own artwork in place of the shape and icon (`icon` and `shape` are then unused). Size the artwork yourself; the slot's attributes go on the element around it, so its `class` sets the colour `currentColor` takes. Mark decorative SVG `aria-hidden="true"`. A slot holding only whitespace or comments leaves the shape and icon.
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-empty-state title="No routes yet" description="Draw one on the map.">
|
||||||
|
<x-slot:illustration class="text-primary"><svg class="size-32" viewBox="0 0 120 120" aria-hidden="true">…</svg></x-slot:illustration>
|
||||||
|
<x-slot:actions><x-button label="Draw a route" variant="filled" /></x-slot:actions>
|
||||||
|
</x-empty-state>
|
||||||
|
```
|
||||||
|
|
||||||
### `<x-card>`
|
### `<x-card>`
|
||||||
|
|
||||||
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`; slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). Do not pass `bg-*`; use `variant`.
|
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`; slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). Do not pass `bg-*`; use `variant`.
|
||||||
@@ -405,6 +436,15 @@ A card or list item that opens something is a **row**: `data-list-row` on it and
|
|||||||
|
|
||||||
A disclosure on native `<details>`: `<x-collapse title="Advanced" icon="tune" open variant="filled">…</x-collapse>` (`variant` `plain` or `filled`; `heading` slot for rich titles). Keeps its state through a morph.
|
A disclosure on native `<details>`: `<x-collapse title="Advanced" icon="tune" open variant="filled">…</x-collapse>` (`variant` `plain` or `filled`; `heading` slot for rich titles). Keeps its state through a morph.
|
||||||
|
|
||||||
|
Bind the open state to a boolean, both ways, with `wire:model` (any modifiers; `.live` sends each toggle at once) or `x-model`:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-collapse title="Fine-tuning" wire:model="fineTuning">…</x-collapse>
|
||||||
|
<div x-data="{ advanced: false }"><x-collapse title="Advanced" x-model="advanced">…</x-collapse></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Toggling writes the property; changing the property (in an action or in Alpine) opens or closes it. With `wire:model` the server renders it open or closed as the property is, so there is no flash, and `open` is ignored; with `x-model`, `open` is only the first paint until Alpine starts. The bound state is `collapseOpen` in the `<details>` scope. Without a binding there is no Alpine on it.
|
||||||
|
|
||||||
### `<x-modal>`
|
### `<x-modal>`
|
||||||
|
|
||||||
An M3 dialog on native `<dialog>`. Bind with `wire:model` to a flag or an id; closing (Escape, scrim, `close()`) writes back `false` or `null`. Without `wire:model` it uses `open` from the surrounding Alpine scope.
|
An M3 dialog on native `<dialog>`. Bind with `wire:model` to a flag or an id; closing (Escape, scrim, `close()`) writes back `false` or `null`. Without `wire:model` it uses `open` from the surrounding Alpine scope.
|
||||||
@@ -422,7 +462,7 @@ Props: `title`, `subtitle`, `icon` (centred hero icon), `separator`, `persistent
|
|||||||
|
|
||||||
### `<x-drawer>`
|
### `<x-drawer>`
|
||||||
|
|
||||||
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`.
|
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Escape leaves a pane open unless `pane-close-on-escape`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`.
|
||||||
|
|
||||||
### `<x-bottom-sheet>`
|
### `<x-bottom-sheet>`
|
||||||
|
|
||||||
@@ -495,7 +535,7 @@ A one-column grid of fields with an `actions` slot at the foot (the slot takes i
|
|||||||
|
|
||||||
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
|
### `<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, 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.
|
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant` (and all but `<x-file>` a `hint-class`, classes added to the hint: `hint-class="text-warning"`), and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint and sets `aria-invalid`. `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||||
|
|
||||||
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
|
- `<x-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`.
|
||||||
@@ -552,6 +592,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
|
|||||||
<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" clearable />
|
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" clearable />
|
||||||
|
<x-datepicker label="Race day" wire:model="raceDay" :week-start="$user->week_start" :format="$user->date_format" />
|
||||||
```
|
```
|
||||||
|
|
||||||
| Prop | Default | |
|
| Prop | Default | |
|
||||||
@@ -563,8 +604,10 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
|
|||||||
| `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 |
|
| `clearable` | `false` | a button that empties the field (both ends of a range) once it holds a date |
|
||||||
|
| `week-start` | `null` | the first day of the week, `0` (Sunday) to `6` (Saturday), instead of the locale's: the calendar's columns, weekday header and Home/End follow it. Anything else is ignored |
|
||||||
|
| `format` | `null` | the typed and displayed format instead of the locale's: `dd`, `MM` and `yyyy`, each once, around one delimiter (`.`, `/`, `-`) — `dd.MM.yyyy`, `dd/MM/yyyy`, `MM/dd/yyyy`, `yyyy-MM-dd`. The field, the dialog's text fields, a range and the error message follow it; `wire:model` still stores `Y-m-d`. Anything else is ignored |
|
||||||
|
|
||||||
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()`. 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()` (the last two unless `week-start` and `format` say otherwise — for a per-person setting). Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
|
||||||
|
|
||||||
### `<x-timepicker>`
|
### `<x-timepicker>`
|
||||||
|
|
||||||
@@ -644,10 +687,10 @@ The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a co
|
|||||||
</x-app-shell>
|
</x-app-shell>
|
||||||
```
|
```
|
||||||
|
|
||||||
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the current one), `badge` (`true` for a dot, or a count), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
|
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the page's, also during a Livewire update request), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for the badge: "3 unread"), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||||
- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
|
- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
|
||||||
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`).
|
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`).
|
||||||
- Below `sm` the shell sets `--material-bottom-bar`, so the snackbar and a `fab` button clear the bar; pad anything else you pin to the bottom with it.
|
- Below `sm` the shell sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, a `fab` button and the page's bottom padding clear the bar; pad anything else you pin to the bottom with it. See Safe areas.
|
||||||
- The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
|
- The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
|
||||||
|
|
||||||
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
|
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
|
||||||
@@ -733,7 +776,7 @@ M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skip
|
|||||||
|
|
||||||
### `<x-section-nav>`
|
### `<x-section-nav>`
|
||||||
|
|
||||||
Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the request's. `label`, `no-wire-navigate`.
|
Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below, whose items mark the current section as the page (`current`) and carry each section's badge. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). `label`, `no-wire-navigate`.
|
||||||
|
|
||||||
### `<x-account-menu>`
|
### `<x-account-menu>`
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
display: block;
|
display: block;
|
||||||
min-height: var(--app-bar-height);
|
min-height: var(--app-bar-height);
|
||||||
padding-top: env(safe-area-inset-top);
|
padding-top: var(--material-safe-top, env(safe-area-inset-top));
|
||||||
background-color: var(--md-sys-color-surface);
|
background-color: var(--md-sys-color-surface);
|
||||||
color: var(--md-sys-color-on-surface);
|
color: var(--md-sys-color-on-surface);
|
||||||
transition: background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
transition: background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
||||||
|
|||||||
@@ -15,18 +15,25 @@
|
|||||||
*
|
*
|
||||||
* Split button (`<x-split-button>`): the same idea for two halves; the trailing half turns
|
* Split button (`<x-split-button>`): the same idea for two halves; the trailing half turns
|
||||||
* round and its chevron turns over while its menu is open (SplitButton*Tokens).
|
* round and its chevron turns over while its menu is open (SplitButton*Tokens).
|
||||||
|
*
|
||||||
|
* "Full" here is half the size's height (`--group-full`), never `--md-sys-shape-corner-full`'s
|
||||||
|
* 9999px. One element mixes full outer corners with small inner ones, and when a box's radii
|
||||||
|
* add up to more than its side, CSS scales every radius by the same factor: a 9999px corner
|
||||||
|
* beside an 8px one shrank the 8px one to a hundredth of a pixel, so the inner corners drew
|
||||||
|
* square.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
[data-button-group] {
|
[data-button-group] {
|
||||||
--group-inner: var(--md-sys-shape-corner-sm);
|
--group-inner: var(--md-sys-shape-corner-sm);
|
||||||
--group-inner-pressed: var(--md-sys-shape-corner-xs);
|
--group-inner-pressed: var(--md-sys-shape-corner-xs);
|
||||||
|
--group-full: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-button-group][data-size='xs'] { --group-pad: 0.75rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; }
|
[data-button-group][data-size='xs'] { --group-pad: 0.75rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; --group-full: 1rem; }
|
||||||
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; }
|
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; --group-full: 1.25rem; }
|
||||||
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; }
|
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; --group-full: 1.75rem; }
|
||||||
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); }
|
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); --group-full: 3rem; }
|
||||||
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); }
|
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); --group-full: 4.25rem; }
|
||||||
|
|
||||||
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) {
|
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) {
|
||||||
padding-inline: calc(var(--group-pad) + var(--group-grow));
|
padding-inline: calc(var(--group-pad) + var(--group-grow));
|
||||||
@@ -60,19 +67,19 @@
|
|||||||
|
|
||||||
[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)),
|
[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)),
|
||||||
[data-split='trailing'][aria-expanded='true'] {
|
[data-split='trailing'][aria-expanded='true'] {
|
||||||
--group-corner: var(--md-sys-shape-corner-full);
|
--group-corner: var(--group-full);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-button-group='connected'] > :first-child,
|
[data-button-group='connected'] > :first-child,
|
||||||
[data-split='leading'] {
|
[data-split='leading'] {
|
||||||
border-start-start-radius: var(--md-sys-shape-corner-full);
|
border-start-start-radius: var(--group-full);
|
||||||
border-end-start-radius: var(--md-sys-shape-corner-full);
|
border-end-start-radius: var(--group-full);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-button-group='connected'] > :last-child,
|
[data-button-group='connected'] > :last-child,
|
||||||
[data-split='trailing'] {
|
[data-split='trailing'] {
|
||||||
border-start-end-radius: var(--md-sys-shape-corner-full);
|
border-start-end-radius: var(--group-full);
|
||||||
border-end-end-radius: var(--md-sys-shape-corner-full);
|
border-end-end-radius: var(--group-full);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-split='trailing'] svg {
|
[data-split='trailing'] svg {
|
||||||
|
|||||||
@@ -66,8 +66,8 @@
|
|||||||
|
|
||||||
[data-navigation-bar] {
|
[data-navigation-bar] {
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
|
padding-inline: var(--material-safe-left, env(safe-area-inset-left)) var(--material-safe-right, env(safe-area-inset-right));
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
|
||||||
background-color: var(--md-sys-color-surface-container);
|
background-color: var(--md-sys-color-surface-container);
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
at once when the rail expands, while the width is still growing; the clip keeps it
|
at once when the rail expands, while the width is still growing; the clip keeps it
|
||||||
from spilling over the page for those frames. */
|
from spilling over the page for those frames. */
|
||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
|
||||||
background-color: var(--md-sys-color-surface);
|
background-color: var(--md-sys-color-surface);
|
||||||
color: var(--md-sys-color-on-surface);
|
color: var(--md-sys-color-on-surface);
|
||||||
transition:
|
transition:
|
||||||
@@ -307,12 +307,12 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding-top: calc(env(safe-area-inset-top) + 2.75rem);
|
padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 2.75rem);
|
||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child {
|
[data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child {
|
||||||
padding-top: calc(env(safe-area-inset-top) + 2.75rem);
|
padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 2.75rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-navigation-rail-destinations] {
|
[data-navigation-rail-destinations] {
|
||||||
|
|||||||
@@ -165,8 +165,8 @@
|
|||||||
[data-search][data-full-screen] [data-search-bar] {
|
[data-search][data-full-screen] [data-search-bar] {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 0 auto;
|
inset: 0 0 auto;
|
||||||
height: calc(4.5rem + env(safe-area-inset-top));
|
height: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top)));
|
||||||
padding-top: env(safe-area-inset-top);
|
padding-top: var(--material-safe-top, env(safe-area-inset-top));
|
||||||
padding-inline: 0.25rem;
|
padding-inline: 0.25rem;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
padding-top: calc(4.5rem + env(safe-area-inset-top));
|
padding-top: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top)));
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,11 @@
|
|||||||
|
|
||||||
[data-toolbar][data-variant="docked"] {
|
[data-toolbar][data-variant="docked"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: calc(4rem + env(safe-area-inset-bottom));
|
min-height: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
column-gap: clamp(0.25rem, 4vw, 2rem);
|
column-gap: clamp(0.25rem, 4vw, 2rem);
|
||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
|
||||||
background-color: var(--md-sys-color-surface-container);
|
background-color: var(--md-sys-color-surface-container);
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
/* Placed over the page: centred above the bottom edge, or centred against the end edge. */
|
/* Placed over the page: centred above the bottom edge, or centred against the end edge. */
|
||||||
[data-toolbar-place="bottom"] {
|
[data-toolbar-place="bottom"] {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: calc(1rem + env(safe-area-inset-bottom));
|
bottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)));
|
||||||
left: 50%;
|
left: 50%;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
translate: -50% 0;
|
translate: -50% 0;
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
[data-toolbar-place="end"] {
|
[data-toolbar-place="end"] {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
inset-inline-end: calc(1rem + env(safe-area-inset-right));
|
inset-inline-end: calc(1rem + var(--material-safe-right, env(safe-area-inset-right)));
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
translate: 0 -50%;
|
translate: 0 -50%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
collapsed: false,
|
collapsed: false,
|
||||||
height: null,
|
height: null,
|
||||||
frame: null,
|
frame: null,
|
||||||
|
// Set in init(). Declared here, or Alpine writes them to the outermost x-data scope,
|
||||||
|
// where a second app bar in the same page scope would take the first one's observer.
|
||||||
|
schedule: null,
|
||||||
|
resizes: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.measure = this.measure.bind(this)
|
this.measure = this.measure.bind(this)
|
||||||
|
|||||||
+34
-10
@@ -11,7 +11,9 @@
|
|||||||
* Dates are ISO strings (`2026-09-13`) throughout, computed in UTC so no time zone or daylight
|
* Dates are ISO strings (`2026-09-13`) throughout, computed in UTC so no time zone or daylight
|
||||||
* saving change can move a day; only "today" is read in the browser's own zone. Month and weekday
|
* saving change can move a day; only "today" is read in the browser's own zone. Month and weekday
|
||||||
* names, the week's first day and the typed format come from `Intl` for the locale the server
|
* names, the week's first day and the typed format come from `Intl` for the locale the server
|
||||||
* passes (the application's).
|
* passes (the application's), unless the component names the first day (`weekStart`, 0 for Sunday
|
||||||
|
* to 6) or the format (`format`, such as `dd.MM.yyyy`); everything that reads `firstDay` and
|
||||||
|
* `format` below then follows those.
|
||||||
*
|
*
|
||||||
* The keyboard is WAI-ARIA's date picker dialog: arrows move a day or a week, Home and End go to
|
* The keyboard is WAI-ARIA's date picker dialog: arrows move a day or a week, Home and End go to
|
||||||
* the start and end of the week, PageUp and PageDown a month (with Shift a year), Space selects,
|
* the start and end of the week, PageUp and PageDown a month (with Shift a year), Space selects,
|
||||||
@@ -82,8 +84,12 @@ function localToday() {
|
|||||||
return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 0 for Sunday … 6 for Saturday. */
|
/** 0 for Sunday … 6 for Saturday: `weekStart` when it is one of those, otherwise the locale's. */
|
||||||
function firstDayOfWeek(locale) {
|
function firstDayOfWeek(locale, weekStart = null) {
|
||||||
|
if (Number.isInteger(weekStart) && weekStart >= 0 && weekStart <= 6) {
|
||||||
|
return weekStart
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tag = new Intl.Locale(locale)
|
const tag = new Intl.Locale(locale)
|
||||||
const info = typeof tag.getWeekInfo === 'function' ? tag.getWeekInfo() : tag.weekInfo
|
const info = typeof tag.getWeekInfo === 'function' ? tag.getWeekInfo() : tag.weekInfo
|
||||||
@@ -111,9 +117,16 @@ function firstDayOfWeek(locale) {
|
|||||||
/**
|
/**
|
||||||
* The typed format: the locale's short numeric date, reduced to `dd`, `MM` and `yyyy` and one
|
* The typed format: the locale's short numeric date, reduced to `dd`, `MM` and `yyyy` and one
|
||||||
* delimiter — androidx's `datePatternAsInputFormat`, fed from `formatToParts` because `Intl` has
|
* delimiter — androidx's `datePatternAsInputFormat`, fed from `formatToParts` because `Intl` has
|
||||||
* no pattern to give. `de` gives `dd.MM.yyyy`, `en-US` `MM/dd/yyyy`, `ja` `yyyy/MM/dd`.
|
* no pattern to give. `de` gives `dd.MM.yyyy`, `en-US` `MM/dd/yyyy`, `ja` `yyyy/MM/dd`. A `chosen`
|
||||||
|
* pattern of the same shape (each unit once, one delimiter) replaces the locale's.
|
||||||
*/
|
*/
|
||||||
function inputFormat(locale) {
|
function inputFormat(locale, chosen = null) {
|
||||||
|
const units = /^(dd|MM|yyyy)([/\-.])(dd|MM|yyyy)\2(dd|MM|yyyy)$/.exec(typeof chosen === 'string' ? chosen : '')
|
||||||
|
|
||||||
|
if (units && new Set([units[1], units[3], units[4]]).size === 3) {
|
||||||
|
return formatOf(chosen)
|
||||||
|
}
|
||||||
|
|
||||||
let pattern = ''
|
let pattern = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -131,10 +144,11 @@ function inputFormat(locale) {
|
|||||||
pattern = ''
|
pattern = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern)) {
|
return formatOf(/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern) ? pattern : 'yyyy-MM-dd')
|
||||||
pattern = 'yyyy-MM-dd'
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
/** A pattern, the placeholder it shows (`DD.MM.YYYY`) and the order its units are typed in (`dMy`). */
|
||||||
|
function formatOf(pattern) {
|
||||||
return {
|
return {
|
||||||
pattern,
|
pattern,
|
||||||
placeholder: pattern.toUpperCase(),
|
placeholder: pattern.toUpperCase(),
|
||||||
@@ -164,13 +178,23 @@ document.addEventListener('alpine:init', () => {
|
|||||||
today: localToday(),
|
today: localToday(),
|
||||||
compact: false,
|
compact: false,
|
||||||
refocus: true,
|
refocus: true,
|
||||||
|
// Set in init() from the config. Declared here, or Alpine writes them to the outermost
|
||||||
|
// x-data scope, where every picker inside the same page scope would share the last one's.
|
||||||
|
firstDay: 0,
|
||||||
|
format: null,
|
||||||
|
numbers: null,
|
||||||
|
formats: {},
|
||||||
|
min: null,
|
||||||
|
max: null,
|
||||||
|
yearsFrom: null,
|
||||||
|
yearsTo: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
const locale = config.locale || document.documentElement.lang || 'en'
|
const locale = config.locale || document.documentElement.lang || 'en'
|
||||||
const format = (options) => new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options })
|
const format = (options) => new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options })
|
||||||
|
|
||||||
this.firstDay = firstDayOfWeek(locale)
|
this.firstDay = firstDayOfWeek(locale, config.weekStart ?? null)
|
||||||
this.format = inputFormat(locale)
|
this.format = inputFormat(locale, config.format ?? null)
|
||||||
this.numbers = new Intl.NumberFormat(locale, { useGrouping: false })
|
this.numbers = new Intl.NumberFormat(locale, { useGrouping: false })
|
||||||
this.formats = {
|
this.formats = {
|
||||||
monthYear: format({ year: 'numeric', month: 'long' }),
|
monthYear: format({ year: 'numeric', month: 'long' }),
|
||||||
|
|||||||
+78
-7
@@ -3,17 +3,28 @@
|
|||||||
*
|
*
|
||||||
* The menu button is the trigger's first button or link. Its ARIA attributes are written by
|
* The menu button is the trigger's first button or link. Its ARIA attributes are written by
|
||||||
* script, which a Livewire morph removes along with anything else the server did not render,
|
* script, which a Livewire morph removes along with anything else the server did not render,
|
||||||
* so they are written again whenever the trigger is used.
|
* so they are written again whenever the trigger is used and after every morph — an open menu
|
||||||
|
* lives through one (the popover is keyed), and its button must still say so.
|
||||||
|
*
|
||||||
|
* The popover hangs on the menu button by CSS anchor positioning. The server can only name the
|
||||||
|
* wrapper around the trigger slot, and a trigger taken out of the flow — a `position: fixed` FAB
|
||||||
|
* in a corner of the window — leaves that wrapper behind as an empty box where the page put it,
|
||||||
|
* so the menu opened there. Script moves the name onto the menu button, beside any name the button
|
||||||
|
* carries itself (a button's tooltip anchors on it too), and moves it again after every morph,
|
||||||
|
* which puts the server's attributes, and a fresh name, back.
|
||||||
*/
|
*/
|
||||||
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
|
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
|
||||||
|
|
||||||
// A popover="auto" closes on the press that lands on its trigger, and the click that follows
|
// A popover="auto" closes on the press that lands on its trigger, and the click that follows
|
||||||
// would open it again. A close this recent is taken as that press.
|
// would open it again. A close this recent is taken as that press. It is timed from
|
||||||
|
// `beforetoggle`, which fires as the popover closes: `toggle` is queued, and arrives after that
|
||||||
|
// click.
|
||||||
const REOPEN_GUARD_MS = 250
|
const REOPEN_GUARD_MS = 250
|
||||||
|
|
||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
window.Alpine.data('materialMenu', () => ({
|
window.Alpine.data('materialMenu', () => ({
|
||||||
closedAt: -Infinity,
|
closedAt: -Infinity,
|
||||||
|
anchored: null,
|
||||||
returnFocus: true,
|
returnFocus: true,
|
||||||
focusWasInside: false,
|
focusWasInside: false,
|
||||||
listeners: [],
|
listeners: [],
|
||||||
@@ -22,6 +33,25 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const menu = this.$refs.menu
|
const menu = this.$refs.menu
|
||||||
|
|
||||||
this.label()
|
this.label()
|
||||||
|
this.anchor()
|
||||||
|
|
||||||
|
// A morph rewrites the wrapper's style with this render's name and the button's without
|
||||||
|
// it, takes the button's ARIA attributes away and gives the popover a new id; the
|
||||||
|
// observer runs before the next frame is drawn, so an open menu never moves and its
|
||||||
|
// button never shows it shut.
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
this.anchor()
|
||||||
|
this.label()
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(this.$refs.trigger, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style', 'aria-haspopup', 'aria-controls', 'aria-expanded'],
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
})
|
||||||
|
observer.observe(menu, { attributes: true, attributeFilter: ['id'] })
|
||||||
|
this.listeners.push(() => observer.disconnect())
|
||||||
|
|
||||||
// Only closes the browser starts — Escape, a press outside — arrive here alone; open()
|
// Only closes the browser starts — Escape, a press outside — arrive here alone; open()
|
||||||
// and close() have already done their part, synchronously, because this event is
|
// and close() have already done their part, synchronously, because this event is
|
||||||
@@ -32,6 +62,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// that was a focusable region around the trigger).
|
// that was a focusable region around the trigger).
|
||||||
this.listen(menu, 'beforetoggle', (event) => {
|
this.listen(menu, 'beforetoggle', (event) => {
|
||||||
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
|
this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement)
|
||||||
|
|
||||||
|
if (event.newState === 'closed') {
|
||||||
|
this.closedAt = performance.now()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.listen(menu, 'toggle', (event) => {
|
this.listen(menu, 'toggle', (event) => {
|
||||||
@@ -43,8 +77,6 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.closedAt = performance.now()
|
|
||||||
|
|
||||||
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
|
if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) {
|
||||||
this.control()?.focus()
|
this.control()?.focus()
|
||||||
}
|
}
|
||||||
@@ -64,6 +96,40 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return this.$refs.trigger.querySelector('button, a[href], [tabindex]')
|
return this.$refs.trigger.querySelector('button, a[href], [tabindex]')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the anchor name the server gave the wrapper onto the menu button. The wrapper holds a
|
||||||
|
* name only as rendered — this render's, which the popover's `position-anchor` matches — so
|
||||||
|
* it is read there.
|
||||||
|
*/
|
||||||
|
anchor() {
|
||||||
|
const trigger = this.$refs.trigger
|
||||||
|
const control = this.control()
|
||||||
|
const rendered = trigger.style.getPropertyValue('anchor-name').trim()
|
||||||
|
const name = rendered.startsWith('--') ? rendered : this.anchored
|
||||||
|
|
||||||
|
// No menu button, or an engine without anchor positioning: the wrapper keeps the name.
|
||||||
|
if (!control || !name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = control.style
|
||||||
|
.getPropertyValue('anchor-name')
|
||||||
|
.split(',')
|
||||||
|
.map((each) => each.trim())
|
||||||
|
.filter((each) => each.startsWith('--'))
|
||||||
|
|
||||||
|
if (!names.includes(name)) {
|
||||||
|
control.style.setProperty('anchor-name', [...names.filter((each) => each !== this.anchored), name].join(', '))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.anchored = name
|
||||||
|
|
||||||
|
if (rendered !== '') {
|
||||||
|
trigger.style.removeProperty('anchor-name')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Writes only what differs: the observer that calls this watches these same attributes. */
|
||||||
label() {
|
label() {
|
||||||
const control = this.control()
|
const control = this.control()
|
||||||
|
|
||||||
@@ -71,9 +137,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
control.setAttribute('aria-haspopup', 'menu')
|
const attributes = { 'aria-haspopup': 'menu', 'aria-controls': this.$refs.menu.id, 'aria-expanded': String(this.isOpen()) }
|
||||||
control.setAttribute('aria-controls', this.$refs.menu.id)
|
|
||||||
control.setAttribute('aria-expanded', String(this.isOpen()))
|
for (const [name, value] of Object.entries(attributes)) {
|
||||||
|
if (control.getAttribute(name) !== value) {
|
||||||
|
control.setAttribute(name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
isOpen() {
|
isOpen() {
|
||||||
@@ -82,6 +152,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
open(focus = 'first') {
|
open(focus = 'first') {
|
||||||
this.label()
|
this.label()
|
||||||
|
this.anchor()
|
||||||
|
|
||||||
if (!this.isOpen()) {
|
if (!this.isOpen()) {
|
||||||
this.$refs.menu.showPopover()
|
this.$refs.menu.showPopover()
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
* One snackbar at a time, as M3 shows them. Each waits its turn, stays for its timeout (paused
|
* One snackbar at a time, as M3 shows them. Each waits its turn, stays for its timeout (paused
|
||||||
* while hovered or focused, so it is never pulled away from someone reading or reaching for its
|
* while hovered or focused, so it is never pulled away from someone reading or reaching for its
|
||||||
* action) and is replaced by the next.
|
* action) and is replaced by the next.
|
||||||
|
*
|
||||||
|
* A `sticky` toast ("A new version is ready" with a Reload action) stays until it is answered, but
|
||||||
|
* never holds the queue up: it is kept aside rather than queued, a toast that arrives while it shows
|
||||||
|
* takes its place, and it comes back once the queue is empty. Only one is kept — a newer sticky
|
||||||
|
* toast replaces it. Dismissing it, or pressing its action, lets it go.
|
||||||
*/
|
*/
|
||||||
const DEFAULT_TIMEOUT_MS = 4000
|
const DEFAULT_TIMEOUT_MS = 4000
|
||||||
|
|
||||||
@@ -25,6 +30,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
window.Alpine.data('materialSnackbar', () => ({
|
window.Alpine.data('materialSnackbar', () => ({
|
||||||
queue: [],
|
queue: [],
|
||||||
current: null,
|
current: null,
|
||||||
|
sticky: null,
|
||||||
timer: null,
|
timer: null,
|
||||||
remaining: 0,
|
remaining: 0,
|
||||||
startedAt: 0,
|
startedAt: 0,
|
||||||
@@ -44,24 +50,43 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// Livewire dispatches named arguments as the detail object; a positional dispatch
|
// Livewire dispatches named arguments as the detail object; a positional dispatch
|
||||||
// arrives as an array whose first entry is that object.
|
// arrives as an array whose first entry is that object.
|
||||||
const toast = Array.isArray(detail) ? detail[0] : detail
|
const toast = Array.isArray(detail) ? detail[0] : detail
|
||||||
|
const sticky = toast.sticky === true
|
||||||
|
|
||||||
this.queue.push({
|
const entry = {
|
||||||
id: ++sequence,
|
id: ++sequence,
|
||||||
type: toast.type ?? null,
|
type: toast.type ?? null,
|
||||||
title: toast.title ?? '',
|
title: toast.title ?? '',
|
||||||
description: toast.description ?? null,
|
description: toast.description ?? null,
|
||||||
timeout: toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS),
|
timeout: sticky || toast.timeout === 0 || toast.timeout === null ? 0 : (toast.timeout ?? DEFAULT_TIMEOUT_MS),
|
||||||
action: toast.action ?? null,
|
action: toast.action ?? null,
|
||||||
})
|
sticky,
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.current) {
|
if (sticky) {
|
||||||
|
const showing = !this.current || this.current === this.sticky
|
||||||
|
this.sticky = entry
|
||||||
|
|
||||||
|
if (showing) {
|
||||||
|
this.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queue.push(entry)
|
||||||
|
|
||||||
|
// A sticky toast steps aside for it, and comes back from next() once the queue is empty.
|
||||||
|
if (!this.current || this.current === this.sticky) {
|
||||||
this.next()
|
this.next()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
next() {
|
next() {
|
||||||
|
// Cleared and forgotten here, so a toast dismissed early never leaves its timer running
|
||||||
|
// to cut the next one short.
|
||||||
clearTimeout(this.timer)
|
clearTimeout(this.timer)
|
||||||
this.current = this.queue.shift() ?? null
|
this.timer = null
|
||||||
|
this.current = this.queue.shift() ?? this.sticky
|
||||||
|
|
||||||
if (this.current?.timeout) {
|
if (this.current?.timeout) {
|
||||||
this.remaining = this.current.timeout
|
this.remaining = this.current.timeout
|
||||||
@@ -92,13 +117,24 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
dismiss() {
|
dismiss() {
|
||||||
this.timer = null
|
if (this.current && this.current === this.sticky) {
|
||||||
|
this.sticky = null
|
||||||
|
}
|
||||||
|
|
||||||
this.next()
|
this.next()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Closed before the handler and the event run, so a toast either of them shows is not the
|
||||||
|
// one dismissed.
|
||||||
act() {
|
act() {
|
||||||
this.current?.action?.handler?.()
|
const action = this.current?.action
|
||||||
|
|
||||||
this.dismiss()
|
this.dismiss()
|
||||||
|
action?.handler?.()
|
||||||
|
|
||||||
|
if (typeof action?.event === 'string' && action.event !== '') {
|
||||||
|
window.dispatchEvent(new CustomEvent(action.event))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
icon(type) {
|
icon(type) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -20,7 +20,9 @@
|
|||||||
remembered and applied before the first paint (`$store.rail`, <x-theme-script>).
|
remembered and applied before the first paint (`$store.rail`, <x-theme-script>).
|
||||||
|
|
||||||
`destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally
|
`destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally
|
||||||
`active` (by default: the URL is the current one), `badge` (`true` for a dot, or a count),
|
`active` (by default: the URL is the page's; during a Livewire update request, the page the
|
||||||
|
component was rendered on rather than the update endpoint), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for
|
||||||
|
the badge instead: "3 unread"),
|
||||||
`section` (a heading the destination is grouped under in the rail; only an expanded rail shows
|
`section` (a heading the destination is grouped under in the rail; only an expanded rail shows
|
||||||
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
|
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
|
||||||
`navigate` (`false` for a full page load instead of `wire:navigate`).
|
`navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||||
@@ -35,7 +37,10 @@
|
|||||||
|
|
||||||
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
|
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
|
||||||
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
|
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) is part of the shell;
|
||||||
below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`.
|
below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`:
|
||||||
|
the bar's 64px, the bottom safe area (`--material-safe-bottom`, else the device's inset) and
|
||||||
|
`--material-bottom-extra` (0px unless the application docks something, an offline banner, on
|
||||||
|
top of the bar).
|
||||||
|
|
||||||
`max-lg:overflow-x-clip` on the content region is the backstop under every page, and it stays
|
`max-lg:overflow-x-clip` on the content region is the backstop under every page, and it stays
|
||||||
`clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll
|
`clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll
|
||||||
@@ -53,7 +58,7 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$label ??= __('Main');
|
$label ??= __('Main');
|
||||||
$current = request()->url();
|
$current = \Livewire\Livewire::isLivewireRequest() ? \Livewire\Livewire::originalUrl() : request()->url();
|
||||||
|
|
||||||
$items = collect($destinations)
|
$items = collect($destinations)
|
||||||
->filter(fn ($item): bool => is_array($item) && filled($item['title'] ?? null))
|
->filter(fn ($item): bool => is_array($item) && filled($item['title'] ?? null))
|
||||||
@@ -63,6 +68,7 @@
|
|||||||
'url' => $item['url'] ?? null,
|
'url' => $item['url'] ?? null,
|
||||||
'active' => (bool) ($item['active'] ?? (filled($item['url'] ?? null) && rtrim(url($item['url']), '/') === rtrim($current, '/'))),
|
'active' => (bool) ($item['active'] ?? (filled($item['url'] ?? null) && rtrim(url($item['url']), '/') === rtrim($current, '/'))),
|
||||||
'badge' => $item['badge'] ?? null,
|
'badge' => $item['badge'] ?? null,
|
||||||
|
'badgeLabel' => filled($item['badgeLabel'] ?? null) ? (string) $item['badgeLabel'] : null,
|
||||||
'section' => filled($item['section'] ?? null) ? (string) $item['section'] : null,
|
'section' => filled($item['section'] ?? null) ? (string) $item['section'] : null,
|
||||||
'bar' => ($item['bar'] ?? true) !== false,
|
'bar' => ($item['bar'] ?? true) !== false,
|
||||||
'navigate' => ($item['navigate'] ?? true) !== false,
|
'navigate' => ($item['navigate'] ?? true) !== false,
|
||||||
@@ -79,13 +85,13 @@
|
|||||||
data-app-shell
|
data-app-shell
|
||||||
@class([
|
@class([
|
||||||
'min-h-dvh bg-surface text-on-surface sm:flex',
|
'min-h-dvh bg-surface text-on-surface sm:flex',
|
||||||
'max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]' => $barItems->isNotEmpty(),
|
'max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty(),
|
||||||
])
|
])
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="#content"
|
href="#content"
|
||||||
data-skip-link
|
data-skip-link
|
||||||
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(env(safe-area-inset-top)+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
|
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
|
||||||
>{{ __('Skip to content') }}</a>
|
>{{ __('Skip to content') }}</a>
|
||||||
|
|
||||||
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
|
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
|
||||||
@@ -101,12 +107,12 @@
|
|||||||
@if ($group->first()['section'] !== null)
|
@if ($group->first()['section'] !== null)
|
||||||
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
|
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
|
||||||
@foreach ($group as $item)
|
@foreach ($group as $item)
|
||||||
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||||||
@endforeach
|
@endforeach
|
||||||
</x-livewire-material::navigation-rail-section>
|
</x-livewire-material::navigation-rail-section>
|
||||||
@else
|
@else
|
||||||
@foreach ($group as $item)
|
@foreach ($group as $item)
|
||||||
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||||||
@endforeach
|
@endforeach
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
@@ -136,7 +142,7 @@
|
|||||||
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
||||||
<x-livewire-material::navigation-bar :label="$label">
|
<x-livewire-material::navigation-bar :label="$label">
|
||||||
@foreach ($barItems as $item)
|
@foreach ($barItems as $item)
|
||||||
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||||||
@endforeach
|
@endforeach
|
||||||
</x-livewire-material::navigation-bar>
|
</x-livewire-material::navigation-bar>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,8 +10,18 @@
|
|||||||
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
|
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
|
||||||
|
|
||||||
The status label is not an M3 badge but every app needs one: `tonal` draws the value in the
|
The status label is not an M3 badge but every app needs one: `tonal` draws the value in the
|
||||||
colour's container ("Expired" in error-container), `outline` in a neutral edge. `color` (alias
|
colour's container ("Expired" in error-container), `solid` in the colour itself (a label that
|
||||||
`tone`): `error` (the default), `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
|
has to stand out, "Built in" in primary), `outline` in a neutral edge. `color` (alias
|
||||||
|
`tone`): `error` (the default), `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`,
|
||||||
|
and two without a hue of their own:
|
||||||
|
- `neutral` — neutral ink on every variant: on-surface-variant with surface text as a dot or
|
||||||
|
count (the ink an outline badge already writes in), surface-container-high with
|
||||||
|
on-surface-variant text when `tonal`, the outline-variant edge when `outline`.
|
||||||
|
- `plain` — no background, text or border colour at all, only shape, size and type, so the
|
||||||
|
caller's classes paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`.
|
||||||
|
|
||||||
|
The value is `value`, or the slot, which renders as HTML — an icon beside the word:
|
||||||
|
`<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. With neither it is a dot.
|
||||||
|
|
||||||
A count or dot says nothing to a screen reader on its own: give the icon's control a label
|
A count or dot says nothing to a screen reader on its own: give the icon's control a label
|
||||||
that includes it ("Notifications, 4 new"), or pass `label` here. --}}
|
that includes it ("Notifications, 4 new"), or pass `label` here. --}}
|
||||||
@@ -22,29 +32,41 @@
|
|||||||
'color' => null,
|
'color' => null,
|
||||||
'tone' => null,
|
'tone' => null,
|
||||||
'tonal' => false,
|
'tonal' => false,
|
||||||
|
'solid' => false,
|
||||||
'outline' => false,
|
'outline' => false,
|
||||||
'floating' => false,
|
'floating' => false,
|
||||||
'label' => null,
|
'label' => null,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$color = in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) ? ($color ?? $tone) : 'error';
|
$color = in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info', 'neutral', 'plain'], true) ? ($color ?? $tone) : 'error';
|
||||||
$text = $value ?? ($slot->isNotEmpty() ? trim((string) $slot) : null);
|
// hasActualContent(): a slot holding only a comment, or an empty @foreach, is still a dot.
|
||||||
|
$markup = $value === null && $slot->hasActualContent();
|
||||||
|
$text = $value ?? ($markup ? trim((string) $slot) : null);
|
||||||
$dot = blank($text);
|
$dot = blank($text);
|
||||||
$status = ($tonal || $outline) && ! $dot;
|
$status = ($tonal || $solid || $outline) && ! $dot;
|
||||||
|
|
||||||
if (! $dot && $max !== null && is_numeric($text) && (int) $text > (int) $max) {
|
if (! $dot && $max !== null && is_numeric($text) && (int) $text > (int) $max) {
|
||||||
$text = $max.'+';
|
$text = $max.'+';
|
||||||
|
$markup = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$filled = [
|
$filled = [
|
||||||
'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary',
|
'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary',
|
||||||
'error' => 'bg-error text-on-error', 'success' => 'bg-success text-on-success', 'warning' => 'bg-warning text-on-warning', 'info' => 'bg-info text-on-info',
|
'error' => 'bg-error text-on-error', 'success' => 'bg-success text-on-success', 'warning' => 'bg-warning text-on-warning', 'info' => 'bg-info text-on-info',
|
||||||
|
'neutral' => 'bg-on-surface-variant text-surface',
|
||||||
];
|
];
|
||||||
$container = [
|
$container = [
|
||||||
'primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container',
|
'primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container',
|
||||||
'error' => 'bg-error-container text-on-error-container', 'success' => 'bg-success-container text-on-success-container', 'warning' => 'bg-warning-container text-on-warning-container', 'info' => 'bg-info-container text-on-info-container',
|
'error' => 'bg-error-container text-on-error-container', 'success' => 'bg-success-container text-on-success-container', 'warning' => 'bg-warning-container text-on-warning-container', 'info' => 'bg-info-container text-on-info-container',
|
||||||
|
'neutral' => 'bg-surface-container-high text-on-surface-variant',
|
||||||
];
|
];
|
||||||
|
$paint = match (true) {
|
||||||
|
$color === 'plain' => '',
|
||||||
|
! $status, $solid => $filled[$color],
|
||||||
|
$tonal => $container[$color],
|
||||||
|
default => 'border-outline-variant text-on-surface-variant',
|
||||||
|
};
|
||||||
|
|
||||||
$attributes = $attributes
|
$attributes = $attributes
|
||||||
->class([
|
->class([
|
||||||
@@ -52,9 +74,8 @@
|
|||||||
'size-1.5 rounded-corner-full' => $dot,
|
'size-1.5 rounded-corner-full' => $dot,
|
||||||
'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums' => ! $dot && ! $status,
|
'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums' => ! $dot && ! $status,
|
||||||
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
|
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
|
||||||
$filled[$color] => ! $status,
|
'border' => $outline && ! $tonal && ! $solid && ! $dot,
|
||||||
$container[$color] => $tonal && ! $dot,
|
$paint => $paint !== '',
|
||||||
'border border-outline-variant text-on-surface-variant' => $outline && ! $tonal && ! $dot,
|
|
||||||
'absolute top-0.5 end-0.5' => $floating && $dot,
|
'absolute top-0.5 end-0.5' => $floating && $dot,
|
||||||
'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
|
'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
|
||||||
])
|
])
|
||||||
@@ -64,4 +85,4 @@
|
|||||||
]));
|
]));
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<span {{ $attributes }}>@unless ($dot){{ $text }}@endunless</span>
|
<span {{ $attributes }}>@unless ($dot)@if ($markup){{ $slot }}@else{{ $text }}@endif@endunless</span>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
||||||
style="--sheet-max-height: {{ $height }}"
|
style="--sheet-max-height: {{ $height }}"
|
||||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
|
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
|
||||||
'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[env(safe-area-inset-bottom)] text-on-surface shadow-elevation-1',
|
'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[var(--material-safe-bottom,env(safe-area-inset-bottom))] text-on-surface shadow-elevation-1',
|
||||||
$attributes->get('class'),
|
$attributes->get('class'),
|
||||||
]) }}
|
]) }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
|
|
||||||
With an `icon` and no label it is an icon button: `width` is `narrow`, `default` or `wide`,
|
With an `icon` and no label it is an icon button: `width` is `narrow`, `default` or `wide`,
|
||||||
`variant="text"` is M3's standard icon button, and the tooltip or label names it for screen
|
`variant="text"` is M3's standard icon button, and the tooltip or label names it for screen
|
||||||
readers. `selected` makes it a toggle: `true` or `false` sets `aria-pressed` and M3's
|
readers. `selected` makes it a toggle: `true` or `false` sets `aria-pressed` (not on a `link`,
|
||||||
selected colours, and a selected round button turns square (a selected square icon button
|
which is no toggle — give it `aria-current` instead) and M3's selected colours, and a selected round button turns square (a selected square icon button
|
||||||
turns round). Text buttons are not toggles in M3; a selected one takes the tonal container.
|
turns round). Text buttons are not toggles in M3; a selected one takes the tonal container.
|
||||||
|
|
||||||
Values from androidx Compose Material 3's tokens (Button*Tokens, *IconButtonTokens,
|
Values from androidx Compose Material 3's tokens (Button*Tokens, *IconButtonTokens,
|
||||||
@@ -191,7 +191,9 @@
|
|||||||
'type' => $isLink ? null : $type,
|
'type' => $isLink ? null : $type,
|
||||||
'disabled' => ! $isLink && $disabled ? true : null,
|
'disabled' => ! $isLink && $disabled ? true : null,
|
||||||
'aria-label' => $iconOnly && ! $attributes->has('aria-label') ? ($label ?? $tip) : null,
|
'aria-label' => $iconOnly && ! $attributes->has('aria-label') ? ($label ?? $tip) : null,
|
||||||
'aria-pressed' => $selected === null ? null : ($selected ? 'true' : 'false'),
|
// A link is not a toggle: ARIA defines aria-pressed for buttons only. A selected link keeps
|
||||||
|
// the selected look; `aria-current` is the caller's to set (`:aria-current="'page'"`).
|
||||||
|
'aria-pressed' => $selected === null || $isLink ? null : ($selected ? 'true' : 'false'),
|
||||||
'data-icon-button' => $iconOnly ? true : null,
|
'data-icon-button' => $iconOnly ? true : null,
|
||||||
'wire:loading.attr' => $spinnerTarget ? 'disabled' : null,
|
'wire:loading.attr' => $spinnerTarget ? 'disabled' : null,
|
||||||
'wire:target' => $spinnerTarget,
|
'wire:target' => $spinnerTarget,
|
||||||
|
|||||||
@@ -44,7 +44,9 @@
|
|||||||
longer slides inside its mask. RTL mirrors the keylines, keys and buttons.
|
longer slides inside its mask. RTL mirrors the keylines, keys and buttons.
|
||||||
|
|
||||||
Re-measures itself when resized, when a Livewire morph resets its styles and when items
|
Re-measures itself when resized, when a Livewire morph resets its styles and when items
|
||||||
come and go. --}}
|
come and go. The row's id, which the buttons control, is new with every render; the row
|
||||||
|
carries a `wire:key` (see `<x-menu>`), so a morph patches it in place — its scroll position
|
||||||
|
and listeners kept — rather than swapping in a copy. --}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
'layout' => 'multi-browse',
|
'layout' => 'multi-browse',
|
||||||
@@ -116,6 +118,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
x-ref="scroller"
|
x-ref="scroller"
|
||||||
|
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-carousel']) }}
|
||||||
id="{{ $scrollerId }}"
|
id="{{ $scrollerId }}"
|
||||||
role="region"
|
role="region"
|
||||||
aria-roledescription="{{ __('carousel') }}"
|
aria-roledescription="{{ __('carousel') }}"
|
||||||
|
|||||||
@@ -6,7 +6,18 @@
|
|||||||
`heading` slot), an optional leading `icon`, a chevron that turns over on the spatial spring,
|
`heading` slot), an optional leading `icon`, a chevron that turns over on the spatial spring,
|
||||||
and — where the browser supports animating `details` content (`interpolate-size`) — a height
|
and — where the browser supports animating `details` content (`interpolate-size`) — a height
|
||||||
that eases open. `open` starts it open. `variant`: `plain` (the default, on the surface around
|
that eases open. `open` starts it open. `variant`: `plain` (the default, on the surface around
|
||||||
it) or `filled` (a surface-container tile with a large corner). --}}
|
it) or `filled` (a surface-container tile with a large corner).
|
||||||
|
|
||||||
|
Its open state can be bound, both ways:
|
||||||
|
- `wire:model` (any modifiers; `.live` tells the server at once) entangles it with a Livewire
|
||||||
|
boolean property. The server renders `open` from the property, so the first paint matches it
|
||||||
|
and `open` is ignored; toggling writes the property, and the property changing opens or
|
||||||
|
closes it.
|
||||||
|
- `x-model` binds an Alpine property through `x-modelable`; `open` is then only the first
|
||||||
|
paint, until Alpine starts and applies the property.
|
||||||
|
The state lives in `collapseOpen` on the `<details>`, a name kept clear of `open`, which a
|
||||||
|
dialog inside the slot may be reading from a scope around it. Without a binding there is no
|
||||||
|
Alpine on it at all. --}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
'title' => null,
|
'title' => null,
|
||||||
@@ -15,10 +26,26 @@
|
|||||||
'variant' => 'plain',
|
'variant' => 'plain',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$model = $attributes->wire('model')->value() ?: null;
|
||||||
|
$bound = $model !== null || count($attributes->whereStartsWith('x-model')->getAttributes()) > 0;
|
||||||
|
$expanded = (bool) $open;
|
||||||
|
|
||||||
|
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
|
||||||
|
$expanded = (bool) data_get($component, $model);
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
<details
|
<details
|
||||||
wire:ignore.self
|
wire:ignore.self
|
||||||
@if ($open) open @endif
|
@if ($bound)
|
||||||
{{ $attributes->class([
|
x-data="{ collapseOpen: @if ($model !== null) @entangle($attributes->wire('model')) @else @js($expanded) @endif }"
|
||||||
|
@if ($model === null) x-modelable="collapseOpen" @endif
|
||||||
|
x-effect="$el.open = collapseOpen"
|
||||||
|
x-on:toggle="collapseOpen = $el.open"
|
||||||
|
@endif
|
||||||
|
@if ($expanded) open @endif
|
||||||
|
{{ $attributes->whereDoesntStartWith('wire:model')->class([
|
||||||
'group/collapse [interpolate-size:allow-keywords]',
|
'group/collapse [interpolate-size:allow-keywords]',
|
||||||
'rounded-corner-lg bg-surface-container' => $variant === 'filled',
|
'rounded-corner-lg bg-surface-container' => $variant === 'filled',
|
||||||
]) }}
|
]) }}
|
||||||
|
|||||||
@@ -27,8 +27,14 @@
|
|||||||
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.
|
||||||
|
|
||||||
Month and weekday names, the first day of the week and the typed format come from `Intl` for
|
Month and weekday names, the first day of the week and the typed format come from `Intl` for
|
||||||
`app()->getLocale()` (resources/js/datepicker.js). Replaces ReStride's flatpickr picker: its
|
`app()->getLocale()` (resources/js/datepicker.js). An application that lets each person choose
|
||||||
`config` becomes `min`, `max`, `range` and `mode`.
|
overrides the last two: `week-start` is the first day of the week, 0 (Sunday) to 6 (Saturday),
|
||||||
|
and `format` the typed and displayed format, `dd`, `MM` and `yyyy` in any order around one
|
||||||
|
delimiter (`.`, `/` or `-`: `dd.MM.yyyy`, `MM/dd/yyyy`, `yyyy-MM-dd`). The field, the typed-date
|
||||||
|
reader, the calendar's columns and weekday header, Home and End, and the dialog's text fields
|
||||||
|
all follow them; the names stay the locale's, and `wire:model` still stores `Y-m-d`. A value
|
||||||
|
that is neither (`week-start="7"`, `format="d.M.yy"`) is ignored, as `null` is. Replaces
|
||||||
|
ReStride's flatpickr picker: its `config` becomes `min`, `max`, `range` and `mode`.
|
||||||
|
|
||||||
M3's date pickers (DatePickerModalTokens and DateInputModalTokens from androidx Compose
|
M3's date pickers (DatePickerModalTokens and DateInputModalTokens from androidx Compose
|
||||||
Material 3, androidx commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, with the layout of
|
Material 3, androidx commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, with the layout of
|
||||||
@@ -50,6 +56,8 @@
|
|||||||
'max' => null,
|
'max' => null,
|
||||||
'value' => null,
|
'value' => null,
|
||||||
'clearable' => false,
|
'clearable' => false,
|
||||||
|
'weekStart' => null,
|
||||||
|
'format' => null,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
@@ -63,6 +71,10 @@
|
|||||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')])))
|
||||||
: [];
|
: [];
|
||||||
$locale = str_replace('_', '-', app()->getLocale());
|
$locale = str_replace('_', '-', app()->getLocale());
|
||||||
|
$weekStart = (is_int($weekStart) || is_string($weekStart)) && preg_match('/^[0-6]\z/', (string) $weekStart) === 1 ? (int) $weekStart : null;
|
||||||
|
$format = is_string($format) && preg_match('/^(dd|MM|yyyy)([.\/-])(dd|MM|yyyy)\2(dd|MM|yyyy)\z/', $format, $units) === 1 && count(array_unique([$units[1], $units[3], $units[4]])) === 3
|
||||||
|
? $format
|
||||||
|
: null;
|
||||||
|
|
||||||
$toIso = function (mixed $date): ?string {
|
$toIso = function (mixed $date): ?string {
|
||||||
if ($date instanceof \DateTimeInterface) {
|
if ($date instanceof \DateTimeInterface) {
|
||||||
@@ -85,9 +97,9 @@
|
|||||||
? ['start' => $toIso(data_get($current, 'start')), 'end' => $toIso(data_get($current, 'end'))]
|
? ['start' => $toIso(data_get($current, 'start')), 'end' => $toIso(data_get($current, 'end'))]
|
||||||
: $toIso($current);
|
: $toIso($current);
|
||||||
|
|
||||||
// The typed format as resources/js/datepicker.js derives it, from ICU's short date when PHP has intl.
|
// The typed format: `format`, or as resources/js/datepicker.js derives it, from ICU's short date when PHP has intl.
|
||||||
$pattern = 'yyyy-MM-dd';
|
$pattern = $format ?? 'yyyy-MM-dd';
|
||||||
if (class_exists(\IntlDateFormatter::class)) {
|
if ($format === null && class_exists(\IntlDateFormatter::class)) {
|
||||||
$short = (string) (new \IntlDateFormatter(str_replace('-', '_', $locale), \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE))->getPattern();
|
$short = (string) (new \IntlDateFormatter(str_replace('-', '_', $locale), \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE))->getPattern();
|
||||||
$candidate = rtrim(str_replace('My', 'M/y', (string) preg_replace(['/[^dMy\/\-.]/', '/d{1,2}/', '/M{1,2}/', '/y{1,4}/'], ['', 'dd', 'MM', 'yyyy'], $short)), '.');
|
$candidate = rtrim(str_replace('My', 'M/y', (string) preg_replace(['/[^dMy\/\-.]/', '/d{1,2}/', '/M{1,2}/', '/y{1,4}/'], ['', 'dd', 'MM', 'yyyy'], $short)), '.');
|
||||||
if (preg_match('/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[\/\-.][dMy]+[\/\-.][dMy]+$/', $candidate) === 1) {
|
if (preg_match('/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[\/\-.][dMy]+[\/\-.][dMy]+$/', $candidate) === 1) {
|
||||||
@@ -110,6 +122,8 @@
|
|||||||
'range' => (bool) $range,
|
'range' => (bool) $range,
|
||||||
'min' => $toIso($min),
|
'min' => $toIso($min),
|
||||||
'max' => $toIso($max),
|
'max' => $toIso($max),
|
||||||
|
'weekStart' => $weekStart,
|
||||||
|
'format' => $format,
|
||||||
'disabled' => (bool) $attributes->get('disabled'),
|
'disabled' => (bool) $attributes->get('disabled'),
|
||||||
'readonly' => (bool) $attributes->get('readonly'),
|
'readonly' => (bool) $attributes->get('readonly'),
|
||||||
'strings' => [
|
'strings' => [
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
As a pane (`pane`, from `xl`) nothing is covered: the page renders the drawer after its list in
|
As a pane (`pane`, from `xl`) nothing is covered: the page renders the drawer after its list in
|
||||||
an `xl:flex xl:items-start xl:gap-6` row, the drawer sticks under the top of the viewport, the
|
an `xl:flex xl:items-start xl:gap-6` row, the drawer sticks under the top of the viewport, the
|
||||||
list stays usable and another row swaps what it shows — no scrim, no trap, no inert page. While
|
list stays usable and another row swaps what it shows — no scrim, no trap, no inert page. While
|
||||||
closed it takes no room. `pane-width` sizes the pane (the sheet's width by default). The body is
|
closed it takes no room. `pane-width` sizes the pane (the sheet's width by default). Escape closes
|
||||||
|
the sheet but leaves a pane open, since the page beside it is still in use; `pane-close-on-escape`
|
||||||
|
closes the pane on Escape too, for a pane that is a transient detail. The body is
|
||||||
a size container, so its contents lay out by the room the sheet or pane actually has (`@md:`),
|
a size container, so its contents lay out by the room the sheet or pane actually has (`@md:`),
|
||||||
never by the viewport.
|
never by the viewport.
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
'width' => '25rem',
|
'width' => '25rem',
|
||||||
'pane' => false,
|
'pane' => false,
|
||||||
'paneWidth' => null,
|
'paneWidth' => null,
|
||||||
|
'paneCloseOnEscape' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
@@ -55,11 +58,11 @@
|
|||||||
},
|
},
|
||||||
@endif
|
@endif
|
||||||
}"
|
}"
|
||||||
@if ($closeOnEscape) x-on:keydown.window.escape="if (open && ! wide) close()" @endif
|
@if ($closeOnEscape) x-on:keydown.window.escape="{{ $paneCloseOnEscape ? 'if (open) close()' : 'if (open && ! wide) close()' }}" @endif
|
||||||
data-sheet="{{ $id }}"
|
data-sheet="{{ $id }}"
|
||||||
@if ($pane)
|
@if ($pane)
|
||||||
x-bind:class="! open && 'xl:hidden'"
|
x-bind:class="! open && 'xl:hidden'"
|
||||||
class="xl:sticky xl:top-[calc(env(safe-area-inset-top)+1.25rem)] xl:shrink-0 xl:self-start"
|
class="xl:sticky xl:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1.25rem)] xl:shrink-0 xl:self-start"
|
||||||
data-pane
|
data-pane
|
||||||
@endif
|
@endif
|
||||||
>
|
>
|
||||||
@@ -90,11 +93,11 @@
|
|||||||
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
||||||
style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth ?? $width }}"
|
style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth ?? $width }}"
|
||||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
|
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
|
||||||
'fixed top-[env(safe-area-inset-top)] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1',
|
'fixed top-[var(--material-safe-top,env(safe-area-inset-top))] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1',
|
||||||
'end-0 sm:rounded-s-corner-lg' => ! $start,
|
'end-0 sm:rounded-s-corner-lg' => ! $start,
|
||||||
'start-0 sm:rounded-e-corner-lg' => $start,
|
'start-0 sm:rounded-e-corner-lg' => $start,
|
||||||
'sm:w-(--sheet-width) sm:max-w-[calc(100vw-4rem)]',
|
'sm:w-(--sheet-width) sm:max-w-[calc(100vw-4rem)]',
|
||||||
'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-env(safe-area-inset-top))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane,
|
'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-var(--material-safe-top,env(safe-area-inset-top)))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane,
|
||||||
$attributes->get('class'),
|
$attributes->get('class'),
|
||||||
]) }}
|
]) }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,7 +8,17 @@
|
|||||||
Not an M3 component; built from M3 Expressive's parts — `shape` (any `<x-shape>` name,
|
Not an M3 component; built from M3 Expressive's parts — `shape` (any `<x-shape>` name,
|
||||||
`cookie-9` by default) in secondary-container behind the `icon` in on-secondary-container,
|
`cookie-9` by default) in secondary-container behind the `icon` in on-secondary-container,
|
||||||
a title-large `title`, body-medium `description` or slot. For "your filter matched nothing"
|
a title-large `title`, body-medium `description` or slot. For "your filter matched nothing"
|
||||||
use a plain line of text instead: a picture there is consolation for a typo. --}}
|
use a plain line of text instead: a picture there is consolation for a typo.
|
||||||
|
|
||||||
|
An application with its own artwork puts it in the `illustration` slot, which is drawn in place
|
||||||
|
of the shape and icon (both props are then unused). The slot sizes itself; its attributes go on
|
||||||
|
the element around it, so `class` can set the colour an SVG's `currentColor` takes:
|
||||||
|
|
||||||
|
<x-empty-state title="No routes yet">
|
||||||
|
<x-slot:illustration class="text-primary"><svg class="size-32" aria-hidden="true">…</svg></x-slot:illustration>
|
||||||
|
</x-empty-state>
|
||||||
|
|
||||||
|
A slot holding only whitespace or comments counts as empty, and the shape and icon stay. --}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
'icon' => 'inbox',
|
'icon' => 'inbox',
|
||||||
@@ -18,10 +28,14 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
<div {{ $attributes->class('flex flex-col items-center gap-4 px-4 py-10 text-center') }}>
|
<div {{ $attributes->class('flex flex-col items-center gap-4 px-4 py-10 text-center') }}>
|
||||||
<div class="relative grid size-28 place-items-center">
|
@if (isset($illustration) && $illustration->hasActualContent())
|
||||||
<x-livewire-material::shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" />
|
<div {{ $illustration->attributes }}>{{ $illustration }}</div>
|
||||||
<x-livewire-material::icon :name="$icon" class="relative size-12 text-on-secondary-container" />
|
@else
|
||||||
</div>
|
<div class="relative grid size-28 place-items-center">
|
||||||
|
<x-livewire-material::shape :name="$shape" class="absolute inset-0 size-full text-secondary-container" />
|
||||||
|
<x-livewire-material::icon :name="$icon" class="relative size-12 text-on-secondary-container" />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($title)
|
@if ($title)
|
||||||
<h3 class="type-title-lg">{{ $title }}</h3>
|
<h3 class="type-title-lg">{{ $title }}</h3>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a
|
Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a
|
||||||
round close button in the colour itself while the list is open above it, end-aligned; the
|
round close button in the colour itself while the list is open above it, end-aligned; the
|
||||||
list is a `popover="auto"` menu with the menu keyboard of `<x-menu>`. `label` names the FAB
|
list is a `popover="auto"` menu with the menu keyboard of `<x-menu>`. `label` names the FAB
|
||||||
for screen readers. Give the items the same `color`.
|
for screen readers. Give the items the same `color`. Like `<x-menu>`'s, the list is keyed for
|
||||||
|
Livewire, so it stays open through a render of the component around it.
|
||||||
|
|
||||||
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px
|
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px
|
||||||
above the close button. --}}
|
above the close button. --}}
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
x-ref="menu"
|
x-ref="menu"
|
||||||
|
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-fab-menu']) }}
|
||||||
id="material-fab-menu-{{ $key }}"
|
id="material-fab-menu-{{ $key }}"
|
||||||
popover="auto"
|
popover="auto"
|
||||||
role="menu"
|
role="menu"
|
||||||
|
|||||||
@@ -12,13 +12,19 @@
|
|||||||
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
|
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
|
||||||
`inline`. An option with `'disabled' => true` greys its own segment.
|
`inline`. An option with `'disabled' => true` greys its own segment.
|
||||||
|
|
||||||
ReStride's props, kept: `label`, `hint`, `name` (needed with `x-model`, which names no
|
ReStride's props, kept: `label`, `hint`, `hint-class`, `name` (needed with `x-model`, which
|
||||||
property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`, `variant`,
|
names no property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`,
|
||||||
`multiple`, `inline`. A validation message for the bound property replaces the hint. --}}
|
`variant`, `multiple`, `inline`. A validation message for the bound property replaces the hint.
|
||||||
|
|
||||||
|
`hint-class` adds classes to the hint, as on `<x-field>`: a colour there paints it
|
||||||
|
(`hint-class="text-warning"` for a hint that warns). The hint's own colour then carries no
|
||||||
|
specificity, as the field's does in the components layer, because which of two colour
|
||||||
|
utilities wins depends on the order Tailwind emits them. --}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
'label' => null,
|
'label' => null,
|
||||||
'hint' => null,
|
'hint' => null,
|
||||||
|
'hintClass' => null,
|
||||||
'name' => null,
|
'name' => null,
|
||||||
'options' => [],
|
'options' => [],
|
||||||
'optionValue' => 'id',
|
'optionValue' => 'id',
|
||||||
@@ -46,6 +52,10 @@
|
|||||||
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
|
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
|
||||||
][$size];
|
][$size];
|
||||||
|
|
||||||
|
$hintClasses = filled($hintClass)
|
||||||
|
? \Illuminate\Support\Arr::toCssClasses(['mt-1 type-body-sm [:where(&)]:text-on-surface-variant', $hintClass])
|
||||||
|
: 'mt-1 type-body-sm text-on-surface-variant';
|
||||||
|
|
||||||
$iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
|
$iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
|
||||||
|
|
||||||
$colours = match ($variant) {
|
$colours = match ($variant) {
|
||||||
@@ -94,6 +104,6 @@
|
|||||||
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
||||||
@endforeach
|
@endforeach
|
||||||
@elseif (filled($hint))
|
@elseif (filled($hint))
|
||||||
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
|
<p class="{{ $hintClasses }}">{{ $hint }}</p>
|
||||||
@endif
|
@endif
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
{{-- One item in an `<x-menu>`: an action, a link, or a choice.
|
{{-- One item in an `<x-menu>`: an action, a link, or a choice.
|
||||||
|
|
||||||
`label`, a leading `icon`, an `icon-right`, a `description` under the label and a
|
`label`, a leading `icon` (`icon-class` adds classes to it), an `icon-right`, a `description`
|
||||||
`shortcut` at the end (M3's trailing supporting text: "⌘C"). `link` makes it an anchor, with
|
under the label and a `shortcut` at the end (M3's trailing supporting text: "⌘C"). `link`
|
||||||
`wire:navigate` unless `external` or `no-wire-navigate`. `selected` (true or false) makes it a
|
makes it an anchor, with `wire:navigate` unless `external` or `no-wire-navigate`. `selected`
|
||||||
`menuitemcheckbox` with `aria-checked`; a selected item takes Expressive's selected shape and
|
(true or false) makes it a `menuitemcheckbox` with `aria-checked`; a selected item takes
|
||||||
tertiary-container. `disabled` keeps it in the list, out of reach. `keep-open` leaves the menu
|
Expressive's selected shape and tertiary-container. `current` is for a menu of places rather
|
||||||
open when it is activated — for a choice the person may want to change twice.
|
than choices — a section picker — and marks the page you are on: `aria-current="page"`, the
|
||||||
|
selected shape in secondary-container, the colour M3 gives the navigation indicator. `badge`
|
||||||
|
draws `<x-badge>` at the end of the row: `true` for a dot, or a count. `disabled` keeps it in the list, out of
|
||||||
|
reach. `keep-open` leaves the menu open when it is activated — for a choice the person may
|
||||||
|
want to change twice.
|
||||||
|
|
||||||
|
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
|
||||||
|
sport's colour (`icon-class="text-sport-run"`). A colour there paints the icon, a selected
|
||||||
|
item's too: the icon's own colour then carries no specificity, because which of two colour
|
||||||
|
utilities wins depends on the order Tailwind emits them. A disabled item's icon stays
|
||||||
|
disabled.
|
||||||
|
|
||||||
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
|
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
|
||||||
12px at the ends of the list. --}}
|
12px at the ends of the list. --}}
|
||||||
@@ -13,6 +23,7 @@
|
|||||||
@props([
|
@props([
|
||||||
'label' => null,
|
'label' => null,
|
||||||
'icon' => null,
|
'icon' => null,
|
||||||
|
'iconClass' => null,
|
||||||
'iconRight' => null,
|
'iconRight' => null,
|
||||||
'description' => null,
|
'description' => null,
|
||||||
'shortcut' => null,
|
'shortcut' => null,
|
||||||
@@ -20,6 +31,8 @@
|
|||||||
'external' => false,
|
'external' => false,
|
||||||
'noWireNavigate' => false,
|
'noWireNavigate' => false,
|
||||||
'selected' => null,
|
'selected' => null,
|
||||||
|
'current' => false,
|
||||||
|
'badge' => null,
|
||||||
'disabled' => false,
|
'disabled' => false,
|
||||||
'keepOpen' => false,
|
'keepOpen' => false,
|
||||||
])
|
])
|
||||||
@@ -36,11 +49,13 @@
|
|||||||
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary',
|
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary',
|
||||||
'py-2' => filled($description),
|
'py-2' => filled($description),
|
||||||
'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true,
|
'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true,
|
||||||
|
'rounded-corner-md bg-secondary-container text-on-secondary-container' => $current && $selected !== true,
|
||||||
'pointer-events-none text-on-surface/38' => $disabled,
|
'pointer-events-none text-on-surface/38' => $disabled,
|
||||||
])
|
])
|
||||||
->merge(array_filter([
|
->merge(array_filter([
|
||||||
'role' => $selected === null ? 'menuitem' : 'menuitemcheckbox',
|
'role' => $selected === null ? 'menuitem' : 'menuitemcheckbox',
|
||||||
'aria-checked' => $selected === null ? null : ($selected ? 'true' : 'false'),
|
'aria-checked' => $selected === null ? null : ($selected ? 'true' : 'false'),
|
||||||
|
'aria-current' => $current ? 'page' : null,
|
||||||
'aria-disabled' => $disabled ? 'true' : null,
|
'aria-disabled' => $disabled ? 'true' : null,
|
||||||
'tabindex' => '-1',
|
'tabindex' => '-1',
|
||||||
'type' => $isLink ? null : 'button',
|
'type' => $isLink ? null : 'button',
|
||||||
@@ -54,13 +69,22 @@
|
|||||||
$iconInk = match (true) {
|
$iconInk = match (true) {
|
||||||
$disabled => 'text-on-surface/38',
|
$disabled => 'text-on-surface/38',
|
||||||
$selected === true => 'text-on-tertiary-container',
|
$selected === true => 'text-on-tertiary-container',
|
||||||
|
$current => 'text-on-secondary-container',
|
||||||
default => 'text-on-surface-variant',
|
default => 'text-on-surface-variant',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$leadingIcon = match (true) {
|
||||||
|
blank($iconClass) => 'size-5 '.$iconInk,
|
||||||
|
$disabled => \Illuminate\Support\Arr::toCssClasses(['size-5', $iconClass, 'text-on-surface/38!']),
|
||||||
|
$selected === true => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-tertiary-container', $iconClass]),
|
||||||
|
$current => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-secondary-container', $iconClass]),
|
||||||
|
default => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-surface-variant', $iconClass]),
|
||||||
|
};
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<{{ $tag }} {{ $attributes }}>
|
<{{ $tag }} {{ $attributes }}>
|
||||||
@if ($icon)
|
@if ($icon)
|
||||||
<x-livewire-material::icon :name="$icon" :filled="$selected === true" :class="'size-5 '.$iconInk" />
|
<x-livewire-material::icon :name="$icon" :filled="$selected === true || $current" :class="$leadingIcon" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<span class="min-w-0 flex-1">
|
<span class="min-w-0 flex-1">
|
||||||
@@ -71,6 +95,9 @@
|
|||||||
@endif
|
@endif
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
@if ($badge !== null && $badge !== false && $badge !== '')
|
||||||
|
<x-livewire-material::badge :value="$badge === true ? null : $badge" class="shrink-0" />
|
||||||
|
@endif
|
||||||
@if ($shortcut)
|
@if ($shortcut)
|
||||||
<span @class(['shrink-0 type-label-sm', $iconInk])>{{ $shortcut }}</span>
|
<span @class(['shrink-0 type-label-sm', $iconInk])>{{ $shortcut }}</span>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -13,10 +13,24 @@
|
|||||||
The trigger's first button or link becomes the menu button (aria-haspopup, aria-expanded,
|
The trigger's first button or link becomes the menu button (aria-haspopup, aria-expanded,
|
||||||
aria-controls). The list is a `popover="auto"` in the top layer, placed by CSS anchor
|
aria-controls). The list is a `popover="auto"` in the top layer, placed by CSS anchor
|
||||||
positioning at `position` (`bottom-start`, `bottom-end`, `top-start`, `top-end`) and flipping
|
positioning at `position` (`bottom-start`, `bottom-end`, `top-start`, `top-end`) and flipping
|
||||||
when there is no room; a click outside or Escape closes it. The keyboard is WAI-ARIA's menu
|
when there is no room — to the other side, the other end, or both, so a menu on a FAB in a
|
||||||
button: Enter, Space or ArrowDown open on the first item, ArrowUp on the last; arrows, Home,
|
corner of the window opens back across it; a click outside or Escape closes it. The keyboard
|
||||||
End and typing a letter move between items; Tab closes; activating an item closes the menu
|
is WAI-ARIA's menu button: Enter, Space or ArrowDown open on the first item, ArrowUp on the
|
||||||
unless the item says `keep-open`, and Escape returns focus to the trigger.
|
last; arrows, Home, End and typing a letter move between items; Tab closes; activating an item
|
||||||
|
closes the menu unless the item says `keep-open`, and Escape returns focus to the trigger.
|
||||||
|
|
||||||
|
The anchor name is rendered on the wrapper around the trigger slot, the only element the
|
||||||
|
server can name, and resources/js/menu.js moves it onto the menu button itself: a trigger
|
||||||
|
that is `position: fixed` (`<x-button fab>` on a phone) leaves the wrapper behind as an empty
|
||||||
|
box where the page put it, and the menu opened there.
|
||||||
|
|
||||||
|
The id and the anchor name are new with every render. The popover carries a `wire:key`, which
|
||||||
|
a Livewire morph matches it by before the id, so a render of the component around an open
|
||||||
|
menu patches it in place — still open, focus and listeners kept — instead of swapping in a
|
||||||
|
closed copy; menu.js then writes the menu button's ARIA attributes again. The key goes
|
||||||
|
through an attribute bag: Livewire compiles a `wire:key` written in a template into the key
|
||||||
|
of the loop iteration around it, which would give every child component after the menu the
|
||||||
|
same key.
|
||||||
|
|
||||||
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
|
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
|
||||||
2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from
|
2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from
|
||||||
@@ -43,6 +57,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
x-ref="menu"
|
x-ref="menu"
|
||||||
|
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-menu']) }}
|
||||||
id="material-menu-{{ $key }}"
|
id="material-menu-{{ $key }}"
|
||||||
popover="auto"
|
popover="auto"
|
||||||
role="menu"
|
role="menu"
|
||||||
@@ -53,7 +68,7 @@
|
|||||||
x-on:click="activate($event)"
|
x-on:click="activate($event)"
|
||||||
@class([
|
@class([
|
||||||
'm-0 min-w-28 max-w-70 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
|
'm-0 min-w-28 max-w-70 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
|
||||||
'my-1 [position-try-fallbacks:flip-block,flip-inline]',
|
'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
|
||||||
'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
|
'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
|
||||||
'bg-surface-container-low text-on-surface' => ! $vibrant,
|
'bg-surface-container-low text-on-surface' => ! $vibrant,
|
||||||
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
|
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
>
|
>
|
||||||
<div @class([
|
<div @class([
|
||||||
'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3',
|
'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3',
|
||||||
'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[env(safe-area-inset-top)]' => $fullscreen,
|
'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen,
|
||||||
$boxClass,
|
$boxClass,
|
||||||
])>
|
])>
|
||||||
@if ($fullscreen)
|
@if ($fullscreen)
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
form M3 asks for when it has actions. The bubble is a popover in surface-container with a
|
form M3 asks for when it has actions. The bubble is a popover in surface-container with a
|
||||||
medium corner and elevation 2, 312px at most, placed by anchor positioning on `side`.
|
medium corner and elevation 2, 312px at most, placed by anchor positioning on `side`.
|
||||||
|
|
||||||
|
The bubble's id and anchor name are new with every render; its `wire:key` (see `<x-menu>`)
|
||||||
|
lets a Livewire morph patch it in place, so an open bubble stays open — through its own
|
||||||
|
action's `wire:click` too — and resources/js/rich-tooltip.js keeps showing and hiding the
|
||||||
|
element on the page rather than one the morph took away.
|
||||||
|
|
||||||
RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
|
RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
|
||||||
text in on-surface-variant, label-large actions in primary. --}}
|
text in on-surface-variant, label-large actions in primary. --}}
|
||||||
|
|
||||||
@@ -35,6 +40,7 @@
|
|||||||
|
|
||||||
<span
|
<span
|
||||||
x-ref="bubble"
|
x-ref="bubble"
|
||||||
|
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-rich-tooltip']) }}
|
||||||
id="material-rich-tooltip-{{ $key }}"
|
id="material-rich-tooltip-{{ $key }}"
|
||||||
popover="{{ $persistent ? 'auto' : 'manual' }}"
|
popover="{{ $persistent ? 'auto' : 'manual' }}"
|
||||||
role="{{ $persistent ? 'dialog' : 'tooltip' }}"
|
role="{{ $persistent ? 'dialog' : 'tooltip' }}"
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
single destination in the app's own navigation.
|
single destination in the app's own navigation.
|
||||||
|
|
||||||
`items`, a list of `['title' => …, 'url' => …]` with an optional `icon`, `active` and `badge`
|
`items`, a list of `['title' => …, 'url' => …]` with an optional `icon`, `active` and `badge`
|
||||||
(an item is current when `active` is true, or when its `url` is the request's). From `sm` they
|
(an item is current when `active` is true, or when its `url` is the page's). From `sm` they
|
||||||
are M3's secondary tabs as links, the current one underlined; below `sm`, where a row of them
|
are M3's secondary tabs as links, the current one underlined; below `sm`, where a row of them
|
||||||
never fits, a button naming the current section opens a menu of all of them. The same list is
|
never fits, a button naming the current section opens a menu of all of them, the current one
|
||||||
|
marked `aria-current="page"` and each with its badge. The same list is
|
||||||
rendered for both, and CSS shows one.
|
rendered for both, and CSS shows one.
|
||||||
|
|
||||||
|
The page's URL is `Livewire::originalUrl()`: while a Livewire component on the page updates, the
|
||||||
|
request is Livewire's update endpoint, and comparing with it left no section lit.
|
||||||
|
|
||||||
A row too long for its column wraps onto a grid rather than scrolling: below `xl` five or six
|
A row too long for its column wraps onto a grid rather than scrolling: below `xl` five or six
|
||||||
sections go 3 + 3 and seven or more go four to a row — tabs that scroll hid the last sections on
|
sections go 3 + 3 and seven or more go four to a row — tabs that scroll hid the last sections on
|
||||||
a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless
|
a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless
|
||||||
@@ -20,7 +24,8 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$label ??= __('Sections');
|
$label ??= __('Sections');
|
||||||
$isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && url()->current() === url($item['url']));
|
$page = \Livewire\Livewire::originalUrl();
|
||||||
|
$isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && $page === url($item['url']));
|
||||||
$current = collect($items)->first($isCurrent) ?? ($items[0] ?? null);
|
$current = collect($items)->first($isCurrent) ?? ($items[0] ?? null);
|
||||||
|
|
||||||
$layout = match (true) {
|
$layout = match (true) {
|
||||||
@@ -45,7 +50,7 @@
|
|||||||
</x-slot:trigger>
|
</x-slot:trigger>
|
||||||
|
|
||||||
@foreach ($items as $item)
|
@foreach ($items as $item)
|
||||||
<x-livewire-material::menu-item :label="$item['title']" :icon="$item['icon'] ?? null" :link="$item['url']" :selected="$isCurrent($item)" :no-wire-navigate="$noWireNavigate" />
|
<x-livewire-material::menu-item :label="$item['title']" :icon="$item['icon'] ?? null" :link="$item['url']" :current="$isCurrent($item)" :badge="$item['badge'] ?? null" :no-wire-navigate="$noWireNavigate" />
|
||||||
@endforeach
|
@endforeach
|
||||||
</x-livewire-material::menu>
|
</x-livewire-material::menu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,10 +21,21 @@
|
|||||||
rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
|
rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
|
||||||
changes it.
|
changes it.
|
||||||
|
|
||||||
|
With `theme.meta` on, the browser's own chrome follows too: the `content` of every
|
||||||
|
<meta name="theme-color"> without a `media` attribute — one is added to <head> when there is
|
||||||
|
none — is the resolved theme's `surface`, from the scheme file (`Scheme`), for the profile in
|
||||||
|
<html data-scheme> (else the active one). A MutationObserver on <html> keeps it in step with
|
||||||
|
whatever changes `data-theme` or `data-scheme` afterwards: `$store.theme.set()` and `toggle()`,
|
||||||
|
an OS change while `system`, a profile preview, the application's own script. A layout's own
|
||||||
|
theme-color meta belongs before this script: one written after it is only painted on
|
||||||
|
DOMContentLoaded, beside the one added here. Off by default, and then none of it is emitted.
|
||||||
|
|
||||||
wire:navigate swaps the body, merges the head without running this again, and gives <html>
|
wire:navigate swaps the body, merges the head without running this again, and gives <html>
|
||||||
the next page's attributes — which the server rendered without any of these, so Livewire
|
the next page's attributes — which the server rendered without any of these, so Livewire
|
||||||
removes them. They are put back as the new page is swapped in (`onSwap`, in the same task,
|
removes them. They are put back as the new page is swapped in (`onSwap`, in the same task,
|
||||||
before anything paints), so this only has to run on a full load. --}}
|
before anything paints), so this only has to run on a full load. The head merge also puts the
|
||||||
|
next page's server-rendered theme-color meta in place of the painted one, so it is painted
|
||||||
|
again there, and once more on `livewire:navigated`. --}}
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$theme = config('livewire-material.theme');
|
$theme = config('livewire-material.theme');
|
||||||
@@ -40,6 +51,18 @@
|
|||||||
'key' => $rail['storage_key'] ?? 'material-rail',
|
'key' => $rail['storage_key'] ?? 'material-rail',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Only the surfaces the meta can show: the active scheme's, and every profile's for a preview.
|
||||||
|
if ((bool) ($theme['meta'] ?? false)) {
|
||||||
|
$surfaces = fn (array $scheme): array => ['light' => $scheme['light']['surface'], 'dark' => $scheme['dark']['surface']];
|
||||||
|
$schemeProfiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles();
|
||||||
|
|
||||||
|
$settings['meta'] = [
|
||||||
|
// PHP 8.5 deprecates a null array offset: without profiles the scheme is null.
|
||||||
|
...$surfaces(($settings['scheme'] !== null ? ($schemeProfiles[$settings['scheme']] ?? null) : null) ?? \NoNameWeb\LivewireMaterial\Support\Scheme::load()),
|
||||||
|
'profiles' => (object) collect($schemeProfiles)->map($surfaces)->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -94,6 +117,39 @@
|
|||||||
apply();
|
apply();
|
||||||
|
|
||||||
media.addEventListener('change', apply);
|
media.addEventListener('change', apply);
|
||||||
|
@if (isset($settings['meta']))
|
||||||
|
|
||||||
|
var paintThemeColor = function () {
|
||||||
|
var theme = root.getAttribute('data-theme');
|
||||||
|
var scheme = root.getAttribute('data-scheme');
|
||||||
|
|
||||||
|
if ((theme !== 'light' && theme !== 'dark') || !document.head) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var colour = (Object.prototype.hasOwnProperty.call(settings.meta.profiles, scheme) ? settings.meta.profiles[scheme] : settings.meta)[theme];
|
||||||
|
var metas = document.head.querySelectorAll('meta[name="theme-color"]:not([media])');
|
||||||
|
|
||||||
|
if (metas.length === 0) {
|
||||||
|
var meta = document.createElement('meta');
|
||||||
|
|
||||||
|
meta.setAttribute('name', 'theme-color');
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
metas = [meta];
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(metas, function (meta) {
|
||||||
|
if (meta.getAttribute('content') !== colour) {
|
||||||
|
meta.setAttribute('content', colour);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
paintThemeColor();
|
||||||
|
new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme'] });
|
||||||
|
document.addEventListener('DOMContentLoaded', paintThemeColor);
|
||||||
|
document.addEventListener('livewire:navigated', paintThemeColor);
|
||||||
|
@endif
|
||||||
|
|
||||||
document.addEventListener('livewire:navigating', function (event) {
|
document.addEventListener('livewire:navigating', function (event) {
|
||||||
var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) {
|
var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) {
|
||||||
@@ -106,6 +162,10 @@
|
|||||||
root.setAttribute(attribute[0], attribute[1]);
|
root.setAttribute(attribute[0], attribute[1]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@if (isset($settings['meta']))
|
||||||
|
|
||||||
|
paintThemeColor();
|
||||||
|
@endif
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})(@json($settings));
|
})(@json($settings));
|
||||||
|
|||||||
@@ -5,9 +5,18 @@
|
|||||||
|
|
||||||
It shows every `toast` browser event — what `NoNameWeb\LivewireMaterial\Concerns\Toasts`
|
It shows every `toast` browser event — what `NoNameWeb\LivewireMaterial\Concerns\Toasts`
|
||||||
dispatches from a Livewire component — and for `window.materialToast(title, options)` from
|
dispatches from a Livewire component — and for `window.materialToast(title, options)` from
|
||||||
JavaScript (`{ type, description, timeout, action: { label, handler } }`). Toasts queue and
|
JavaScript (`{ type, description, timeout, sticky, action: { label, handler, event } }`).
|
||||||
show in turn, each for its `timeout` (4s by default; M3 asks for 4–10s), paused while the
|
Toasts queue and show in turn, each for its `timeout` (4s by default; M3 asks for 4–10s),
|
||||||
pointer or focus is on it. A toast with an action or no timeout gets a close button.
|
paused while the pointer or focus is on it. A toast with an action or no timeout gets a close
|
||||||
|
button. Pressing the action closes the snackbar, calls `handler` and dispatches `event` (a
|
||||||
|
name) on `window`; both may be given.
|
||||||
|
|
||||||
|
`sticky: true` keeps a toast until it is dismissed or its action pressed, without holding up
|
||||||
|
the queue: a toast that arrives meanwhile shows in its place, and the sticky one comes back
|
||||||
|
once the queue is empty. One is kept at a time; a newer sticky toast replaces it.
|
||||||
|
|
||||||
|
Hooks for tests and styling: `data-toast` on the snackbar on screen, `data-toast-action` on its
|
||||||
|
action button.
|
||||||
|
|
||||||
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
|
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
|
||||||
still on screen when the next page arrives.
|
still on screen when the next page arrives.
|
||||||
@@ -32,6 +41,7 @@
|
|||||||
<template x-if="current">
|
<template x-if="current">
|
||||||
<div
|
<div
|
||||||
x-bind:key="current.id"
|
x-bind:key="current.id"
|
||||||
|
data-toast
|
||||||
x-bind:role="current.type === 'error' || current.type === 'warning' ? 'alert' : 'status'"
|
x-bind:role="current.type === 'error' || current.type === 'warning' ? 'alert' : 'status'"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
x-on:mouseenter="pause()"
|
x-on:mouseenter="pause()"
|
||||||
@@ -60,7 +70,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-if="current.action">
|
<template x-if="current.action">
|
||||||
<button type="button" class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
|
<button type="button" data-toast-action class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template x-if="current.action || ! current.timeout">
|
<template x-if="current.action || ! current.timeout">
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
['id' => 'system', 'name' => 'System', 'icon' => 'computer'],
|
['id' => 'system', 'name' => 'System', 'icon' => 'computer'],
|
||||||
]" />
|
]" />
|
||||||
|
|
||||||
<x-group label="Days" name="showcase-days" x-model="days" multiple variant="outlined" hint="Choose any" :options="[
|
<x-group label="Days" name="showcase-days" x-model="days" multiple variant="outlined" hint="Thursday is fully booked" hint-class="text-warning" :options="[
|
||||||
['id' => 'mon', 'name' => 'Mon'],
|
['id' => 'mon', 'name' => 'Mon'],
|
||||||
['id' => 'tue', 'name' => 'Tue'],
|
['id' => 'tue', 'name' => 'Tue'],
|
||||||
['id' => 'wed', 'name' => 'Wed'],
|
['id' => 'wed', 'name' => 'Wed'],
|
||||||
|
|||||||
@@ -7,13 +7,22 @@
|
|||||||
<x-badge value="Expired" tonal />
|
<x-badge value="Expired" tonal />
|
||||||
<x-badge value="Active" color="success" tonal />
|
<x-badge value="Active" color="success" tonal />
|
||||||
<x-badge value="Password" color="info" tonal />
|
<x-badge value="Password" color="info" tonal />
|
||||||
|
<x-badge value="Built in" color="primary" solid />
|
||||||
<x-badge value="Pro" outline />
|
<x-badge value="Pro" outline />
|
||||||
|
<x-badge value="Draft" color="neutral" tonal />
|
||||||
|
<x-badge value="Archived" color="neutral" outline />
|
||||||
|
<span class="relative inline-flex"><x-icon name="chat" /><x-badge value="2" color="neutral" floating /></span>
|
||||||
|
<x-badge tonal color="tertiary"><x-icon name="bolt" filled class="size-3" /> Pro</x-badge>
|
||||||
|
<x-badge value="Beta" tonal color="plain" class="bg-primary-fixed text-on-primary-fixed" />
|
||||||
BLADE,
|
BLADE,
|
||||||
'Snackbars' => <<<'BLADE'
|
'Snackbars' => <<<'BLADE'
|
||||||
<x-button label="Saved" variant="tonal" x-on:click="materialToast('Settings saved', { type: 'success' })" />
|
<div x-data class="flex flex-wrap items-center gap-4">
|
||||||
<x-button label="With a description" variant="tonal" x-on:click="materialToast('Upload failed', { type: 'error', description: 'The file is larger than 4 GB.' })" />
|
<x-button label="Saved" variant="tonal" x-on:click="materialToast('Settings saved', { type: 'success' })" />
|
||||||
<x-button label="With an action" variant="tonal" x-on:click="materialToast('Share deleted', { action: { label: 'Undo', handler: () => materialToast('Share restored', { type: 'info' }) } })" />
|
<x-button label="With a description" variant="tonal" x-on:click="materialToast('Upload failed', { type: 'error', description: 'The file is larger than 4 GB.' })" />
|
||||||
<x-button label="Until dismissed" variant="tonal" x-on:click="materialToast('Your storage is almost full', { type: 'warning', timeout: 0 })" />
|
<x-button label="With an action" variant="tonal" x-on:click="materialToast('Share deleted', { action: { label: 'Undo', handler: () => materialToast('Share restored', { type: 'info' }) } })" />
|
||||||
|
<x-button label="Until dismissed" variant="tonal" x-on:click="materialToast('Your storage is almost full', { type: 'warning', timeout: 0 })" />
|
||||||
|
<x-button label="Sticky, with an event" variant="tonal" x-on:click="materialToast('A new version is ready', { type: 'info', sticky: true, action: { label: 'Reload', event: 'showcase:reload' } })" x-on:showcase:reload.window="materialToast('Reloading…')" />
|
||||||
|
</div>
|
||||||
BLADE,
|
BLADE,
|
||||||
'Plain tooltips' => <<<'BLADE'
|
'Plain tooltips' => <<<'BLADE'
|
||||||
<x-button icon="content_copy" tooltip="Copy link" />
|
<x-button icon="content_copy" tooltip="Copy link" />
|
||||||
@@ -58,6 +67,22 @@
|
|||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-empty-state>
|
</x-empty-state>
|
||||||
BLADE,
|
BLADE,
|
||||||
|
'Empty state with an illustration' => <<<'BLADE'
|
||||||
|
<x-empty-state title="No routes yet" description="Draw a route on the map, or import one from a GPX file." class="w-full">
|
||||||
|
<x-slot:illustration class="text-primary">
|
||||||
|
<svg class="size-32" viewBox="0 0 120 120" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="60" cy="60" r="56" class="fill-primary-container" />
|
||||||
|
<path d="M28 86C40 62 56 92 68 64S86 42 90 46" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-dasharray="1 10" />
|
||||||
|
<circle cx="28" cy="86" r="7" fill="currentColor" />
|
||||||
|
<path d="M90 18a12 12 0 0 1 12 12c0 10-12 22-12 22S78 40 78 30a12 12 0 0 1 12-12Z" class="fill-tertiary" />
|
||||||
|
<circle cx="90" cy="30" r="4" class="fill-on-tertiary" />
|
||||||
|
</svg>
|
||||||
|
</x-slot:illustration>
|
||||||
|
<x-slot:actions>
|
||||||
|
<x-button label="Draw a route" icon="route" variant="filled" />
|
||||||
|
</x-slot:actions>
|
||||||
|
</x-empty-state>
|
||||||
|
BLADE,
|
||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,18 @@
|
|||||||
<div class="flex h-10 items-center gap-4"><span>Left</span><x-divider vertical /><span>Right</span></div>
|
<div class="flex h-10 items-center gap-4"><span>Left</span><x-divider vertical /><span>Right</span></div>
|
||||||
</div>
|
</div>
|
||||||
BLADE,
|
BLADE,
|
||||||
|
'Collapse bound to a property' => <<<'BLADE'
|
||||||
|
<div x-data="{ advanced: false }" class="w-full space-y-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
|
<x-button label="Open" variant="tonal" x-on:click="advanced = true" />
|
||||||
|
<x-button label="Close" variant="tonal" x-on:click="advanced = false" />
|
||||||
|
<span class="type-body-md text-on-surface-variant">advanced: <span x-text="advanced"></span></span>
|
||||||
|
</div>
|
||||||
|
<x-collapse title="Advanced settings" icon="tune" variant="filled" x-model="advanced">
|
||||||
|
Bound with x-model here. In a Livewire view, wire:model="advanced" binds a boolean property the same way, and the page arrives with it open or closed as the property is.
|
||||||
|
</x-collapse>
|
||||||
|
</div>
|
||||||
|
BLADE,
|
||||||
'Dialogs' => <<<'BLADE'
|
'Dialogs' => <<<'BLADE'
|
||||||
<div x-data="{ open: false }">
|
<div x-data="{ open: false }">
|
||||||
<x-button label="Basic dialog" variant="tonal" x-on:click="open = true" />
|
<x-button label="Basic dialog" variant="tonal" x-on:click="open = true" />
|
||||||
|
|||||||
@@ -36,6 +36,18 @@
|
|||||||
<x-menu-item label="Upload a folder" icon="drive_folder_upload" />
|
<x-menu-item label="Upload a folder" icon="drive_folder_upload" />
|
||||||
</x-menu>
|
</x-menu>
|
||||||
BLADE,
|
BLADE,
|
||||||
|
'Icons in their own colour' => <<<'BLADE'
|
||||||
|
<x-menu label="New plan">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<x-button label="New plan" icon="add" variant="filled" />
|
||||||
|
</x-slot:trigger>
|
||||||
|
|
||||||
|
<x-menu-item label="Running" icon="directions_run" icon-class="text-tertiary" />
|
||||||
|
<x-menu-item label="Cycling" icon="directions_bike" icon-class="text-secondary" />
|
||||||
|
<x-menu-item label="Swimming" icon="pool" icon-class="text-info" />
|
||||||
|
<x-menu-item label="Rowing" icon="rowing" icon-class="text-tertiary" disabled />
|
||||||
|
</x-menu>
|
||||||
|
BLADE,
|
||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
BLADE,
|
BLADE,
|
||||||
|
'First day of the week and format' => <<<'BLADE'
|
||||||
|
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ race: '{{ now()->addMonth()->format('Y-m-d') }}', block: { start: null, end: null } }">
|
||||||
|
<div class="grid content-start gap-4">
|
||||||
|
<x-datepicker label="Race day" week-start="0" format="yyyy-MM-dd" x-model="race" hint="Weeks start on Sunday; typed year first" />
|
||||||
|
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(race)"></code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid content-start gap-4">
|
||||||
|
<x-datepicker label="Training block" range mode="modal" week-start="1" format="dd.MM.yyyy" x-model="block" />
|
||||||
|
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(block)"></code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
BLADE,
|
||||||
'Limits, errors and states' => <<<'BLADE'
|
'Limits, errors and states' => <<<'BLADE'
|
||||||
<div class="grid w-full gap-6 md:grid-cols-2">
|
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||||
<div class="grid content-start gap-4">
|
<div class="grid content-start gap-4">
|
||||||
@@ -62,7 +75,7 @@
|
|||||||
<h2 class="type-headline-md">Date pickers</h2>
|
<h2 class="type-headline-md">Date pickers</h2>
|
||||||
|
|
||||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||||
<code><x-datepicker></code> — docked, modal and modal input; single dates and ranges. Month and weekday names and the first day of the week follow the application's locale.
|
<code><x-datepicker></code> — docked, modal and modal input; single dates and ranges. Month and weekday names, the first day of the week and the typed format follow the application's locale; <code>week-start</code> and <code>format</code> set the last two for someone who chose their own.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@foreach ($examples as $title => $code)
|
@foreach ($examples as $title => $code)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
|
|
||||||
<x-slot:top>
|
<x-slot:top>
|
||||||
<header class="sticky top-0 z-20 flex h-16 items-center gap-1 bg-surface px-1 pt-[env(safe-area-inset-top)] sm:px-4">
|
<header class="sticky top-0 z-20 flex h-16 items-center gap-1 bg-surface px-1 pt-[var(--material-safe-top,env(safe-area-inset-top))] sm:px-4">
|
||||||
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="shell-menu" /></span>
|
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="shell-menu" /></span>
|
||||||
<h1 class="min-w-0 flex-1 truncate px-3 type-title-lg sm:px-0">{{ $current['title'] }}</h1>
|
<h1 class="min-w-0 flex-1 truncate px-3 type-title-lg sm:px-0">{{ $current['title'] }}</h1>
|
||||||
<x-livewire-material::button icon="search" tooltip="Search" />
|
<x-livewire-material::button icon="search" tooltip="Search" />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class SchemeCommand extends Command
|
|||||||
protected $signature = 'material:scheme
|
protected $signature = 'material:scheme
|
||||||
{seed? : The source colour, as #rrggbb; without it, every profile in livewire-material.profiles}
|
{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}
|
||||||
|
{--spec=2025 : The colour spec: 2025 (M3 Expressive) or 2021 (M3 as it first shipped)}
|
||||||
{--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}
|
||||||
{--warning=#e2a400 : The source of the warning colour}
|
{--warning=#e2a400 : The source of the warning colour}
|
||||||
@@ -29,19 +30,44 @@ class SchemeCommand extends Command
|
|||||||
*/
|
*/
|
||||||
protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour, or every configured colour profile';
|
protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour, or every configured colour profile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour specs Google's colour utilities know. 2025 is M3 Expressive's colour; the library
|
||||||
|
* falls back to 2021 by itself for the variants 2025 does not define.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected const SPECS = ['2021', '2025'];
|
||||||
|
|
||||||
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';
|
||||||
|
$spec = (string) $this->option('spec');
|
||||||
|
|
||||||
|
if (! in_array($spec, self::SPECS, true)) {
|
||||||
|
$this->components->error("Unknown spec \"{$spec}\". Use one of: ".implode(', ', self::SPECS).'.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
if (filled($this->argument('seed'))) {
|
if (filled($this->argument('seed'))) {
|
||||||
$scheme = $this->generate((string) $this->argument('seed'), (string) $this->option('variant'), (float) $this->option('contrast'));
|
$input = [
|
||||||
|
'seed' => (string) $this->argument('seed'),
|
||||||
|
'variant' => (string) $this->option('variant'),
|
||||||
|
'spec' => $spec,
|
||||||
|
'contrast' => (float) $this->option('contrast'),
|
||||||
|
'success' => (string) $this->option('success'),
|
||||||
|
'warning' => (string) $this->option('warning'),
|
||||||
|
'info' => (string) $this->option('info'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$scheme = $this->generate($input);
|
||||||
|
|
||||||
if ($scheme === null) {
|
if ($scheme === null) {
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->write($files, $stylesheet, $data, $this->stylesheet($scheme), $scheme);
|
return $this->write($files, $stylesheet, $data, $this->stylesheet($scheme, $input), $scheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
$profiles = config('livewire-material.profiles');
|
$profiles = config('livewire-material.profiles');
|
||||||
@@ -61,7 +87,16 @@ class SchemeCommand extends Command
|
|||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$scheme = $this->generate((string) ($profile['seed'] ?? ''), (string) ($profile['variant'] ?? 'tonal-spot'), (float) ($profile['contrast'] ?? 0), "Profile \"{$name}\": ");
|
// A profile's own spec and state colours win; without them, the command's options apply.
|
||||||
|
$scheme = $this->generate([
|
||||||
|
'seed' => (string) ($profile['seed'] ?? ''),
|
||||||
|
'variant' => (string) ($profile['variant'] ?? 'tonal-spot'),
|
||||||
|
'spec' => (string) ($profile['spec'] ?? $spec),
|
||||||
|
'contrast' => (float) ($profile['contrast'] ?? 0),
|
||||||
|
'success' => (string) ($profile['success'] ?? $this->option('success')),
|
||||||
|
'warning' => (string) ($profile['warning'] ?? $this->option('warning')),
|
||||||
|
'info' => (string) ($profile['info'] ?? $this->option('info')),
|
||||||
|
], "Profile \"{$name}\": ");
|
||||||
|
|
||||||
if ($scheme === null) {
|
if ($scheme === null) {
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
@@ -83,21 +118,15 @@ class SchemeCommand extends Command
|
|||||||
/**
|
/**
|
||||||
* One scheme from Google's colour utilities, or null once the reason has been shown.
|
* One scheme from Google's colour utilities, or null once the reason has been shown.
|
||||||
*
|
*
|
||||||
|
* @param array{seed: string, variant: string, spec: string, contrast: float, success: string, warning: string, info: string} $input
|
||||||
* @return array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>}|null
|
* @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
|
protected function generate(array $input, 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($input),
|
||||||
'seed' => $seed,
|
|
||||||
'variant' => $variant,
|
|
||||||
'contrast' => $contrast,
|
|
||||||
'success' => $this->option('success'),
|
|
||||||
'warning' => $this->option('warning'),
|
|
||||||
'info' => $this->option('info'),
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($result->failed()) {
|
if ($result->failed()) {
|
||||||
@@ -130,15 +159,26 @@ class SchemeCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* The stylesheet, headed by the command that regenerates it: every option that differs from
|
||||||
|
* its default is written out.
|
||||||
|
*
|
||||||
* @param array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme
|
* @param array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme
|
||||||
|
* @param array{seed: string, variant: string, spec: string, contrast: float, success: string, warning: string, info: string} $input
|
||||||
*/
|
*/
|
||||||
protected function stylesheet(array $scheme): string
|
protected function stylesheet(array $scheme, array $input): string
|
||||||
{
|
{
|
||||||
|
$states = collect(['success', 'warning', 'info'])
|
||||||
|
->reject(fn (string $state): bool => strtolower($input[$state]) === strtolower((string) $this->getDefinition()->getOption($state)->getDefault()))
|
||||||
|
->map(fn (string $state): string => sprintf(' --%s="%s"', $state, strtolower($input[$state])))
|
||||||
|
->implode('');
|
||||||
|
|
||||||
$command = sprintf(
|
$command = sprintf(
|
||||||
'php artisan material:scheme "%s" --variant=%s%s',
|
'php artisan material:scheme "%s" --variant=%s%s%s%s',
|
||||||
$scheme['seed'],
|
$scheme['seed'],
|
||||||
$scheme['variant'],
|
$scheme['variant'],
|
||||||
|
$input['spec'] !== '2025' ? ' --spec='.$input['spec'] : '',
|
||||||
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
|
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
|
||||||
|
$states,
|
||||||
);
|
);
|
||||||
|
|
||||||
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
|
$blocks = $this->blocks([':root', "[data-theme='light']"], ["[data-theme='dark']"], $scheme);
|
||||||
@@ -171,10 +211,11 @@ class SchemeCommand extends Command
|
|||||||
{
|
{
|
||||||
$list = collect($profiles)
|
$list = collect($profiles)
|
||||||
->map(fn (array $profile, string $name): string => sprintf(
|
->map(fn (array $profile, string $name): string => sprintf(
|
||||||
' * %-12s %s, %s%s',
|
' * %-12s %s, %s%s%s',
|
||||||
$name,
|
$name,
|
||||||
$profile['seed'],
|
$profile['seed'],
|
||||||
$profile['variant'],
|
$profile['variant'],
|
||||||
|
$profile['spec'] !== $profiles[$default]['spec'] ? ', spec '.$profile['spec'] : '',
|
||||||
$profile['contrast'] != 0 ? ', contrast '.$profile['contrast'] : '',
|
$profile['contrast'] != 0 ? ', contrast '.$profile['contrast'] : '',
|
||||||
))
|
))
|
||||||
->implode("\n");
|
->implode("\n");
|
||||||
|
|||||||
@@ -1,7 +1,131 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
const MORE = '#menus [aria-label="More"]';
|
const MORE = '#menus [aria-label="More"]';
|
||||||
|
|
||||||
|
class MenuAnchorProbe extends Component
|
||||||
|
{
|
||||||
|
public int $renders = 0;
|
||||||
|
|
||||||
|
public function touch(): void
|
||||||
|
{
|
||||||
|
$this->renders++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return <<<'BLADE'
|
||||||
|
<div class="grid justify-items-start gap-6 p-4">
|
||||||
|
<p>renders: <span id="renders">{{ $renders }}</span></p>
|
||||||
|
|
||||||
|
<x-menu label="Share actions">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<x-button icon="more_vert" tooltip="More" data-test="more" />
|
||||||
|
</x-slot:trigger>
|
||||||
|
|
||||||
|
<x-menu-item label="Copy link" icon="content_copy" />
|
||||||
|
<x-menu-item label="Download" icon="download" />
|
||||||
|
</x-menu>
|
||||||
|
|
||||||
|
<x-menu label="Create">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<x-button fab icon="add" label="New plan" data-test="fab" />
|
||||||
|
</x-slot:trigger>
|
||||||
|
|
||||||
|
<x-menu-item label="Running plan" icon="directions_run" />
|
||||||
|
<x-menu-item label="Cycling plan" icon="directions_bike" />
|
||||||
|
</x-menu>
|
||||||
|
</div>
|
||||||
|
BLADE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Livewire component that renders again while its menus are open: `touch` from outside, a
|
||||||
|
* `keep-open` item's action, or a FAB menu item's action.
|
||||||
|
*/
|
||||||
|
class MenuMorphProbe extends Component
|
||||||
|
{
|
||||||
|
public int $renders = 0;
|
||||||
|
|
||||||
|
public string $sort = 'newest';
|
||||||
|
|
||||||
|
public string $created = '';
|
||||||
|
|
||||||
|
public function touch(): void
|
||||||
|
{
|
||||||
|
$this->renders++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sortBy(string $sort): void
|
||||||
|
{
|
||||||
|
$this->sort = $sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(string $kind): void
|
||||||
|
{
|
||||||
|
$this->created = $kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return <<<'BLADE'
|
||||||
|
<div class="p-4">
|
||||||
|
<p id="outside">renders: <span id="renders">{{ $renders }}</span>, created: <span id="created">{{ $created }}</span></p>
|
||||||
|
|
||||||
|
<x-menu label="Sort">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<x-button label="Sort" data-test="sort" />
|
||||||
|
</x-slot:trigger>
|
||||||
|
|
||||||
|
<x-menu-item label="Newest" :selected="$sort === 'newest'" wire:click="sortBy('newest')" keep-open />
|
||||||
|
<x-menu-item label="Largest" :selected="$sort === 'largest'" wire:click="sortBy('largest')" keep-open />
|
||||||
|
</x-menu>
|
||||||
|
|
||||||
|
<div style="position: fixed; right: 16px; bottom: 16px">
|
||||||
|
<x-fab-menu label="New">
|
||||||
|
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="create('files')" />
|
||||||
|
<x-fab-menu-item label="Paste text" icon="content_paste" wire:click="create('text')" />
|
||||||
|
</x-fab-menu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
BLADE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORT_MENU = "document.querySelector('[role=\"menu\"][aria-label=\"Sort\"]')";
|
||||||
|
|
||||||
|
const FAB_MENU = "document.querySelector('[role=\"menu\"][aria-label=\"New\"]')";
|
||||||
|
|
||||||
|
const NEW_FAB = 'button[aria-label="New"]';
|
||||||
|
|
||||||
|
function menuMorphProbe()
|
||||||
|
{
|
||||||
|
Livewire::component('menu-morph-probe', MenuMorphProbe::class);
|
||||||
|
|
||||||
|
Route::middleware('web')->get('/menu-morph-probe', fn () => Blade::render(<<<'BLADE'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<x-theme-script />
|
||||||
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
|
@livewireStyles
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface">
|
||||||
|
<livewire:menu-morph-probe />
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
BLADE));
|
||||||
|
|
||||||
|
return visit('/menu-morph-probe')->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
}
|
||||||
|
|
||||||
function showcase(string $section = 'buttons')
|
function showcase(string $section = 'buttons')
|
||||||
{
|
{
|
||||||
return visit("/material/{$section}")->waitForEvent('networkidle')
|
return visit("/material/{$section}")->waitForEvent('networkidle')
|
||||||
@@ -94,7 +218,7 @@ it('moves a connected group\'s choice with the arrow keys', function () {
|
|||||||
|
|
||||||
$page->keys(':focus', 'ArrowLeft')
|
$page->keys(':focus', 'ArrowLeft')
|
||||||
->assertScript($checked('dark'))
|
->assertScript($checked('dark'))
|
||||||
->assertScript("getComputedStyle(document.querySelector('#buttons input[value=\"dark\"]').parentElement).borderTopLeftRadius === '9999px'");
|
->assertScript("(el => getComputedStyle(el).borderTopLeftRadius === (el.offsetHeight / 2) + 'px')(document.querySelector('#buttons input[value=\"dark\"]').parentElement)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rounds a split button\'s trailing half while its menu is open', function () {
|
it('rounds a split button\'s trailing half while its menu is open', function () {
|
||||||
@@ -103,7 +227,16 @@ it('rounds a split button\'s trailing half while its menu is open', function ()
|
|||||||
showcase()
|
showcase()
|
||||||
->click('[data-split="trailing"] >> nth=0')
|
->click('[data-split="trailing"] >> nth=0')
|
||||||
->assertScript("{$trailing}.getAttribute('aria-expanded') === 'true'")
|
->assertScript("{$trailing}.getAttribute('aria-expanded') === 'true'")
|
||||||
->assertScript("getComputedStyle({$trailing}).borderTopLeftRadius === '9999px'");
|
->assertScript("getComputedStyle({$trailing}).borderTopLeftRadius === ({$trailing}.offsetHeight / 2) + 'px'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a connected segment\'s small inner corners, which a 9999px outer corner would scale away', function () {
|
||||||
|
// When a box's radii add up to more than a side, CSS shrinks every radius by the same
|
||||||
|
// factor: a full corner written as 9999px drew the 8px inner corners square. Every radius
|
||||||
|
// in a connected group or a split button stays within half its height, so none is scaled.
|
||||||
|
showcase()
|
||||||
|
->assertScript("[...document.querySelectorAll('[data-button-group=\"connected\"] > *, [data-split]')].every((el) => { const cs = getComputedStyle(el); const half = el.offsetHeight / 2 + 0.5; return el.offsetHeight === 0 || ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius'].every((corner) => parseFloat(cs[corner]) <= half); })")
|
||||||
|
->assertScript("(el => getComputedStyle(el).borderTopRightRadius === '8px')(document.querySelector('#buttons input[name=\"showcase-theme\"][value=\"light\"]').parentElement)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('turns the FAB into a close button while its menu is open', function () {
|
it('turns the FAB into a close button while its menu is open', function () {
|
||||||
@@ -124,3 +257,210 @@ it('animates the loading indicator in the browser', function () {
|
|||||||
|
|
||||||
showcase()->assertScript("{$clock} > 0.1");
|
showcase()->assertScript("{$clock} > 0.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A script giving the rects of a menu button (`control`) and of the menu it opens (`menu`). */
|
||||||
|
function menuAgainst(string $test, string $label): string
|
||||||
|
{
|
||||||
|
return "(() => { const control = document.querySelector('[data-test=\"{$test}\"]').getBoundingClientRect(); const menu = document.querySelector('[role=\"menu\"][aria-label=\"{$label}\"]').getBoundingClientRect(); return { control, menu }; })()";
|
||||||
|
}
|
||||||
|
|
||||||
|
it('hangs a menu on its menu button, even when the button is fixed to a corner of the window', function () {
|
||||||
|
Livewire::component('menu-anchor-probe', MenuAnchorProbe::class);
|
||||||
|
|
||||||
|
Route::middleware('web')->get('/menu-anchor-probe', fn () => Blade::render(<<<'BLADE'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<x-theme-script />
|
||||||
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
|
@livewireStyles
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface">
|
||||||
|
<livewire:menu-anchor-probe />
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
BLADE));
|
||||||
|
|
||||||
|
$placed = menuAgainst('fab', 'Create');
|
||||||
|
$above = "(({ control, menu }) => getComputedStyle(document.querySelector('[data-test=\"fab\"]')).position === 'fixed' && menu.bottom <= control.top && control.top - menu.bottom <= 16 && Math.abs(menu.right - control.right) <= 16 && menu.left >= 0 && menu.top >= 0)({$placed})";
|
||||||
|
|
||||||
|
$page = visit('/menu-anchor-probe')->resize(400, 800)->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
|
||||||
|
$page->click('@fab')
|
||||||
|
->assertAttribute('@fab', 'aria-expanded', 'true')
|
||||||
|
->assertScript($above);
|
||||||
|
|
||||||
|
$page->keys(':focus', 'Escape')->assertAttribute('@fab', 'aria-expanded', 'false');
|
||||||
|
|
||||||
|
// A Livewire render names the anchor afresh, on the wrapper again. Opened from the keyboard: a
|
||||||
|
// press this soon after the menu closed is taken for the light-dismiss press and ignored.
|
||||||
|
$page->script('window.eval("Livewire.first().touch()")');
|
||||||
|
|
||||||
|
$page->assertSeeIn('#renders', '1')
|
||||||
|
->script("document.querySelector('[data-test=\"fab\"]').focus()");
|
||||||
|
|
||||||
|
$page->keys(':focus', 'ArrowDown')
|
||||||
|
->assertAttribute('@fab', 'aria-expanded', 'true')
|
||||||
|
->assertScript($above);
|
||||||
|
|
||||||
|
// A button that also anchors its own tooltip keeps it, and the menu hangs under the button.
|
||||||
|
$page->resize(1024, 800)
|
||||||
|
->click('@more')
|
||||||
|
->assertAttribute('@more', 'aria-expanded', 'true')
|
||||||
|
->assertScript("(() => { const names = getComputedStyle(document.querySelector('[data-test=\"more\"]')).getPropertyValue('anchor-name'); return names.includes('--material-button-') && names.includes('--material-menu-'); })()")
|
||||||
|
->assertScript('(({ control, menu }) => menu.top >= control.bottom && menu.top - control.bottom <= 16 && Math.abs(menu.left - control.left) <= 16)('.menuAgainst('more', 'Share actions').')');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paints a group\'s hint and a menu item\'s icon in the colour their classes name', function () {
|
||||||
|
Route::middleware('web')->get('/colour-class-probe', fn () => Blade::render(<<<'BLADE'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<x-theme-script />
|
||||||
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface">
|
||||||
|
<span id="error-ink" class="text-error">Reference</span>
|
||||||
|
<div id="terrain">
|
||||||
|
<x-group name="terrain" hint="No elevation data here" hint-class="text-error" :options="[['id' => 'flat', 'name' => 'Flat']]" />
|
||||||
|
</div>
|
||||||
|
<x-menu-item id="run" label="Running plan" icon="directions_run" icon-class="text-error" />
|
||||||
|
<x-menu-item id="chosen" label="Cycling plan" icon="directions_bike" icon-class="text-error" :selected="true" />
|
||||||
|
<x-menu-item id="off" label="Swimming plan" icon="pool" icon-class="text-error" disabled />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
BLADE));
|
||||||
|
|
||||||
|
$ink = fn (string $element): string => "getComputedStyle({$element}).color";
|
||||||
|
$error = $ink("document.querySelector('#error-ink')");
|
||||||
|
|
||||||
|
visit('/colour-class-probe')->waitForEvent('networkidle')
|
||||||
|
->assertScript($ink("document.querySelector('#terrain p')")." === {$error}")
|
||||||
|
->assertScript($ink("document.querySelector('#run svg')")." === {$error}")
|
||||||
|
->assertScript($ink("document.querySelector('#chosen svg')")." === {$error}")
|
||||||
|
->assertScript($ink("document.querySelector('#off svg')")." !== {$error}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a menu open while the component around it renders, and closes it cleanly after', function () {
|
||||||
|
$page = menuMorphProbe()->assertNoJavaScriptErrors();
|
||||||
|
|
||||||
|
$page->click('@sort')
|
||||||
|
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||||||
|
|
||||||
|
$page->script('window.eval("Livewire.first().touch()")');
|
||||||
|
|
||||||
|
$page->assertSeeIn('#renders', '1')
|
||||||
|
->assertScript(SORT_MENU.".matches(':popover-open')")
|
||||||
|
->assertAttribute('@sort', 'aria-expanded', 'true')
|
||||||
|
->assertScript("document.querySelector('[data-test=\"sort\"]').getAttribute('aria-controls') === ".SORT_MENU.'.id')
|
||||||
|
->assertScript(focused("textContent.trim().startsWith('Newest')"));
|
||||||
|
|
||||||
|
$page->keys(':focus', 'Escape')
|
||||||
|
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||||||
|
->assertScript('! '.SORT_MENU.".matches(':popover-open')")
|
||||||
|
->assertScript(focused("dataset.test === 'sort'"));
|
||||||
|
|
||||||
|
// Opened from the keyboard: a press this soon after the menu closed is taken for the
|
||||||
|
// light-dismiss press and ignored.
|
||||||
|
$page->keys('@sort', 'ArrowDown')
|
||||||
|
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||||||
|
|
||||||
|
$page->script('window.eval("Livewire.first().touch()")');
|
||||||
|
|
||||||
|
$page->assertSeeIn('#renders', '2')
|
||||||
|
->click('#outside')
|
||||||
|
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||||||
|
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes a menu on a second press of its menu button, and after the component renders', function () {
|
||||||
|
$page = menuMorphProbe();
|
||||||
|
|
||||||
|
// The press closes the menu before its click reaches the button: the guard against that click
|
||||||
|
// opening it again once waited for the queued toggle event, which comes after the click.
|
||||||
|
$page->click('@sort')
|
||||||
|
->assertAttribute('@sort', 'aria-expanded', 'true')
|
||||||
|
->click('@sort')
|
||||||
|
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||||||
|
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
|
||||||
|
|
||||||
|
$page->keys('@sort', 'ArrowDown')
|
||||||
|
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||||||
|
|
||||||
|
$page->script('window.eval("Livewire.first().touch()")');
|
||||||
|
|
||||||
|
// Past the reopen guard of the close above, so the second press is on its own.
|
||||||
|
$page->assertSeeIn('#renders', '1')
|
||||||
|
->wait(0.3);
|
||||||
|
|
||||||
|
$page->click('@sort')
|
||||||
|
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||||||
|
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a menu open while a keep-open item\'s action runs', function () {
|
||||||
|
$page = menuMorphProbe();
|
||||||
|
|
||||||
|
$page->click('@sort')
|
||||||
|
->click('[role="menuitemcheckbox"]:has-text("Largest")')
|
||||||
|
->assertAttribute('[role="menuitemcheckbox"]:has-text("Largest")', 'aria-checked', 'true')
|
||||||
|
->assertScript(SORT_MENU.".matches(':popover-open')")
|
||||||
|
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||||||
|
|
||||||
|
$page->click('[role="menuitemcheckbox"]:has-text("Newest")')
|
||||||
|
->assertAttribute('[role="menuitemcheckbox"]:has-text("Newest")', 'aria-checked', 'true')
|
||||||
|
->assertScript(SORT_MENU.".matches(':popover-open')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a FAB menu open while the component around it renders, and closes it cleanly after', function () {
|
||||||
|
$page = menuMorphProbe();
|
||||||
|
|
||||||
|
$page->click(NEW_FAB)
|
||||||
|
->assertAttribute(NEW_FAB, 'aria-expanded', 'true');
|
||||||
|
|
||||||
|
$page->script('window.eval("Livewire.first().touch()")');
|
||||||
|
|
||||||
|
$page->assertSeeIn('#renders', '1')
|
||||||
|
->assertScript(FAB_MENU.".matches(':popover-open')")
|
||||||
|
->assertAttribute(NEW_FAB, 'aria-expanded', 'true')
|
||||||
|
->assertScript("document.querySelector('".NEW_FAB."').getAttribute('aria-controls') === ".FAB_MENU.'.id');
|
||||||
|
|
||||||
|
$page->keys(':focus', 'Escape')
|
||||||
|
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
|
||||||
|
->assertScript(focused("getAttribute('aria-label') === 'New'"));
|
||||||
|
|
||||||
|
$page->keys(NEW_FAB, 'ArrowDown')
|
||||||
|
->assertAttribute(NEW_FAB, 'aria-expanded', 'true');
|
||||||
|
|
||||||
|
$page->script('window.eval("Livewire.first().touch()")');
|
||||||
|
|
||||||
|
// Past the reopen guard of the Escape above, so the second press is on its own.
|
||||||
|
$page->assertSeeIn('#renders', '2')
|
||||||
|
->wait(0.3);
|
||||||
|
|
||||||
|
$page->click(NEW_FAB)
|
||||||
|
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
|
||||||
|
->assertScript('! '.FAB_MENU.".matches(':popover-open')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes a FAB menu when an item\'s action runs, and opens and closes it cleanly after', function () {
|
||||||
|
$page = menuMorphProbe();
|
||||||
|
|
||||||
|
$page->click(NEW_FAB)
|
||||||
|
->click('[role="menuitem"]:has-text("Upload files")')
|
||||||
|
->assertSeeIn('#created', 'files')
|
||||||
|
->assertAttribute(NEW_FAB, 'aria-expanded', 'false');
|
||||||
|
|
||||||
|
$page->script("document.querySelector('".NEW_FAB."').focus()");
|
||||||
|
|
||||||
|
$page->keys(NEW_FAB, 'ArrowDown')
|
||||||
|
->assertAttribute(NEW_FAB, 'aria-expanded', 'true')
|
||||||
|
->assertScript(focused("textContent.trim() === 'Upload files'"));
|
||||||
|
|
||||||
|
$page->keys(':focus', 'Escape')
|
||||||
|
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
|
||||||
|
->assertScript('! '.FAB_MENU.".matches(':popover-open')")
|
||||||
|
->assertScript(focused("getAttribute('aria-label') === 'New'"));
|
||||||
|
});
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ it('turns section tabs into a picker on a phone', function () {
|
|||||||
->assertScript("getComputedStyle(document.querySelector('[data-section-nav] nav')).display === 'none'")
|
->assertScript("getComputedStyle(document.querySelector('[data-section-nav] nav')).display === 'none'")
|
||||||
->click('[data-section-picker] button')
|
->click('[data-section-picker] button')
|
||||||
->assertScript("document.querySelector('[data-section-picker] [popover]').matches(':popover-open')")
|
->assertScript("document.querySelector('[data-section-picker] [popover]').matches(':popover-open')")
|
||||||
->assertAttribute('[data-section-picker] [role="menuitemcheckbox"][href="#profile"]', 'aria-checked', 'true')
|
// A menu of places: the current section is the page, not a checked choice.
|
||||||
->assertAttribute('[data-section-picker] [role="menuitemcheckbox"][href="#security"]', 'aria-checked', 'false');
|
->assertAttribute('[data-section-picker] [role="menuitem"][href="#profile"]', 'aria-current', 'page')
|
||||||
|
->assertScript("! document.querySelector('[data-section-picker] [role=\"menuitem\"][href=\"#security\"]').hasAttribute('aria-current')");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use Livewire\Livewire;
|
|||||||
*/
|
*/
|
||||||
class CarouselMorphProbe extends Component
|
class CarouselMorphProbe extends Component
|
||||||
{
|
{
|
||||||
public int $count = 3;
|
public int $count = 6;
|
||||||
|
|
||||||
public function add(): void
|
public function add(): void
|
||||||
{
|
{
|
||||||
@@ -219,10 +219,19 @@ it('measures itself again after a Livewire morph adds an item', function () {
|
|||||||
|
|
||||||
$page = visit('/carousel-morph-probe')->waitForEvent('networkidle')
|
$page = visit('/carousel-morph-probe')->waitForEvent('networkidle')
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertScript($masked, 3)
|
->assertScript($masked, 6)
|
||||||
->assertAttribute('[data-material-carousel-item] >> nth=2', 'aria-label', '3 of 3');
|
->assertAttribute('[data-material-carousel-item] >> nth=5', 'aria-label', '6 of 6');
|
||||||
|
|
||||||
$page->click('Add')
|
$page->click('Add')
|
||||||
->assertScript($masked, 4)
|
->assertScript($masked, 7)
|
||||||
->assertAttribute('[data-material-carousel-item] >> nth=3', 'aria-label', '4 of 4');
|
->assertAttribute('[data-material-carousel-item] >> nth=6', 'aria-label', '7 of 7');
|
||||||
|
|
||||||
|
// Scrolled once the morph has settled, so only the row's own scroll listener can re-mask the
|
||||||
|
// items: a row the morph swapped for a copy scrolls with its items' masks left as they were.
|
||||||
|
$page->script(onCarousel(0, "await pause(300); scroller.style.scrollSnapType = 'none'; scroller.scrollTo({ left: 2 * (size + gap), behavior: 'instant' })", 'body'));
|
||||||
|
|
||||||
|
$page->assertScript(onCarousel(0, <<<'JS'
|
||||||
|
await pause(50)
|
||||||
|
return inset(0) > 0.5 && inset(2) < 0.5
|
||||||
|
JS, 'body'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,48 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Livewire component that renders again while a rich tooltip is open, from the tooltip's own
|
||||||
|
* action.
|
||||||
|
*/
|
||||||
|
class RichTooltipMorphProbe extends Component
|
||||||
|
{
|
||||||
|
public int $renders = 0;
|
||||||
|
|
||||||
|
public function touch(): void
|
||||||
|
{
|
||||||
|
$this->renders++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return <<<'BLADE'
|
||||||
|
<div class="p-4">
|
||||||
|
<p id="outside">renders: <span id="renders">{{ $renders }}</span></p>
|
||||||
|
|
||||||
|
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time." persistent>
|
||||||
|
<x-button label="Details" data-test="details" />
|
||||||
|
<x-slot:actions><x-button label="Refresh" wire:click="touch" data-test="refresh" /></x-slot:actions>
|
||||||
|
</x-rich-tooltip>
|
||||||
|
|
||||||
|
<div style="margin-top: 200px">
|
||||||
|
<x-rich-tooltip text="Shown on hover.">
|
||||||
|
<x-button label="Hint" data-test="hint" />
|
||||||
|
</x-rich-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
BLADE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
|
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
|
||||||
|
|
||||||
|
const TOAST = "document.querySelector('[data-toast]')";
|
||||||
|
|
||||||
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/communication')->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'");
|
||||||
@@ -39,6 +80,65 @@ it('runs a toast\'s action and dismisses it', function () {
|
|||||||
->assertScript(SNACKBAR.' === null');
|
->assertScript(SNACKBAR.' === null');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not let a toast dismissed early cut the next one short', function () {
|
||||||
|
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
|
||||||
|
$page->script("window.eval(\"materialToast('Share deleted', { timeout: 800, action: { label: 'Undo' } }); materialToast('Link copied', { timeout: 2500 })\")");
|
||||||
|
|
||||||
|
// A click with no hover or focus before it, as a screen reader activates a button: nothing has
|
||||||
|
// paused the first toast's timer.
|
||||||
|
$page->script("document.querySelector('[data-toast] button[aria-label=\"Dismiss\"]').click()");
|
||||||
|
|
||||||
|
$page->assertScript(TOAST."?.textContent.includes('Link copied')")
|
||||||
|
->wait(1.2);
|
||||||
|
|
||||||
|
$page->assertScript(TOAST."?.textContent.includes('Link copied')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a sticky toast aside while a passing one shows, brings back the newest, and lets it go once dismissed', function () {
|
||||||
|
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
|
||||||
|
// As an application dispatches it; built in the page's realm (see above).
|
||||||
|
$page->script("window.eval(\"window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: 'A new version is ready', sticky: true, timeout: 300, action: { label: 'Reload', event: 'material:test-reload' } } }))\")");
|
||||||
|
|
||||||
|
$page->assertScript(TOAST."?.textContent.includes('A new version is ready')")
|
||||||
|
->wait(0.6);
|
||||||
|
|
||||||
|
$page->assertScript(TOAST."?.textContent.includes('A new version is ready')");
|
||||||
|
|
||||||
|
$page->script("window.eval(\"materialToast('Settings saved', { type: 'success', timeout: 900 }); materialToast('Version 2 is ready', { sticky: true })\")");
|
||||||
|
|
||||||
|
$page->assertScript(TOAST."?.textContent.includes('Settings saved')")
|
||||||
|
->wait(1.3);
|
||||||
|
|
||||||
|
$page->assertScript(TOAST."?.textContent.includes('Version 2 is ready')");
|
||||||
|
|
||||||
|
$page->click('[data-toast] button[aria-label="Dismiss"]')
|
||||||
|
->assertScript(TOAST.' === null');
|
||||||
|
|
||||||
|
// Dismissed rather than left to time out: the pointer still rests where the snackbar appears,
|
||||||
|
// and hovering pauses it.
|
||||||
|
$page->script("window.eval(\"materialToast('Link copied', { timeout: 0 })\")");
|
||||||
|
|
||||||
|
$page->assertScript(TOAST."?.textContent.includes('Link copied')")
|
||||||
|
->click('[data-toast] button[aria-label="Dismiss"]')
|
||||||
|
->assertScript(TOAST.' === null');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches a toast action\'s window event alongside its handler, and closes', function () {
|
||||||
|
$page = visit('/material/communication')->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
|
||||||
|
$page->script("window.eval(\"window.reloads = 0; window.handled = 0; window.addEventListener('material:test-reload', () => window.reloads++); materialToast('A new version is ready', { sticky: true, action: { label: 'Reload', event: 'material:test-reload', handler: () => window.handled++ } })\")");
|
||||||
|
|
||||||
|
$page->click('[data-toast-action]')
|
||||||
|
->assertScript(TOAST.' === null')
|
||||||
|
->assertScript("window.eval('window.reloads') === 1")
|
||||||
|
->assertScript("window.eval('window.handled') === 1");
|
||||||
|
});
|
||||||
|
|
||||||
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]')";
|
||||||
|
|
||||||
@@ -47,3 +147,46 @@ it('opens a persistent rich tooltip on press', function () {
|
|||||||
->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')");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a rich tooltip open while its action renders the component, and opens it again after', function () {
|
||||||
|
Livewire::component('rich-tooltip-morph-probe', RichTooltipMorphProbe::class);
|
||||||
|
|
||||||
|
Route::middleware('web')->get('/rich-tooltip-morph-probe', fn () => Blade::render(<<<'BLADE'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<x-theme-script />
|
||||||
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
|
@livewireStyles
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface">
|
||||||
|
<livewire:rich-tooltip-morph-probe />
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
BLADE));
|
||||||
|
|
||||||
|
$persistent = "document.querySelector('[role=\"dialog\"][popover]')";
|
||||||
|
$transient = "document.querySelector('[role=\"tooltip\"][popover]')";
|
||||||
|
|
||||||
|
$page = visit('/rich-tooltip-morph-probe')->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
|
||||||
|
$page->click('@details')
|
||||||
|
->assertScript("{$persistent}.matches(':popover-open')")
|
||||||
|
->click('@refresh')
|
||||||
|
->assertSeeIn('#renders', '1')
|
||||||
|
->assertScript("{$persistent}.matches(':popover-open')");
|
||||||
|
|
||||||
|
$page->click('#outside')
|
||||||
|
->assertScript("! {$persistent}.matches(':popover-open')");
|
||||||
|
|
||||||
|
$page->click('@details')
|
||||||
|
->assertScript("{$persistent}.matches(':popover-open')");
|
||||||
|
|
||||||
|
$page->click('#outside')
|
||||||
|
->hover('@hint')
|
||||||
|
->assertScript("{$transient}.matches(':popover-open')")
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|||||||
@@ -41,6 +41,73 @@ class OverlayProbe extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class CollapseProbe extends Component
|
||||||
|
{
|
||||||
|
public bool $fineTuning = false;
|
||||||
|
|
||||||
|
public int $renders = 0;
|
||||||
|
|
||||||
|
public function touch(): void
|
||||||
|
{
|
||||||
|
$this->renders++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expand(): void
|
||||||
|
{
|
||||||
|
$this->fineTuning = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collapse(): void
|
||||||
|
{
|
||||||
|
$this->fineTuning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return <<<'BLADE'
|
||||||
|
<div class="space-y-4 p-4">
|
||||||
|
<p>fine tuning: <span id="fine-tuning">{{ var_export($fineTuning, true) }}</span></p>
|
||||||
|
<p>renders: <span id="renders">{{ $renders }}</span></p>
|
||||||
|
|
||||||
|
<x-button label="Re-render" wire:click="touch" />
|
||||||
|
<x-button label="Open from the server" wire:click="expand" />
|
||||||
|
<x-button label="Close from the server" wire:click="collapse" />
|
||||||
|
|
||||||
|
<x-collapse id="fine-tuning-collapse" title="Fine-tuning" wire:model="fineTuning">Zones and paces.</x-collapse>
|
||||||
|
|
||||||
|
<div x-data="{ advanced: false }">
|
||||||
|
<p>advanced: <span id="advanced" x-text="advanced"></span></p>
|
||||||
|
<x-button label="Close from Alpine" x-on:click="advanced = false" />
|
||||||
|
<x-collapse id="advanced-collapse" title="Advanced" x-model="advanced">Everything else.</x-collapse>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
BLADE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseProbe()
|
||||||
|
{
|
||||||
|
Livewire::component('collapse-probe', CollapseProbe::class);
|
||||||
|
|
||||||
|
Route::middleware('web')->get('/collapse-probe', fn () => Blade::render(<<<'BLADE'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<x-theme-script />
|
||||||
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
|
@livewireStyles
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface">
|
||||||
|
<livewire:collapse-probe />
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
BLADE));
|
||||||
|
|
||||||
|
return visit('/collapse-probe')->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
}
|
||||||
|
|
||||||
function overlayProbe()
|
function overlayProbe()
|
||||||
{
|
{
|
||||||
Livewire::component('overlay-probe', OverlayProbe::class);
|
Livewire::component('overlay-probe', OverlayProbe::class);
|
||||||
@@ -155,3 +222,40 @@ it('opens a row\'s opener from a press anywhere on the row, but not from its own
|
|||||||
$page->click('#containment [data-card][data-list-row] button:has-text("Copy link")')
|
$page->click('#containment [data-card][data-list-row] button:has-text("Copy link")')
|
||||||
->assertScript('window.__opened === 1');
|
->assertScript('window.__opened === 1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('binds a collapse to a Livewire property both ways', function () {
|
||||||
|
$collapse = "document.querySelector('#fine-tuning-collapse')";
|
||||||
|
|
||||||
|
$page = collapseProbe()
|
||||||
|
->assertScript("{$collapse}.open === false");
|
||||||
|
|
||||||
|
$page->click('#fine-tuning-collapse summary')
|
||||||
|
->assertScript("{$collapse}.open === true")
|
||||||
|
->click('button:has-text("Re-render")')
|
||||||
|
->assertSeeIn('#renders', '1')
|
||||||
|
->assertSeeIn('#fine-tuning', 'true')
|
||||||
|
->assertScript("{$collapse}.open === true");
|
||||||
|
|
||||||
|
$page->click('button:has-text("Close from the server")')
|
||||||
|
->assertSeeIn('#fine-tuning', 'false')
|
||||||
|
->assertScript("{$collapse}.open === false");
|
||||||
|
|
||||||
|
$page->click('button:has-text("Open from the server")')
|
||||||
|
->assertSeeIn('#fine-tuning', 'true')
|
||||||
|
->assertScript("{$collapse}.open === true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds a collapse to an Alpine property both ways', function () {
|
||||||
|
$collapse = "document.querySelector('#advanced-collapse')";
|
||||||
|
|
||||||
|
$page = collapseProbe()
|
||||||
|
->assertScript("{$collapse}.open === false");
|
||||||
|
|
||||||
|
$page->click('#advanced-collapse summary')
|
||||||
|
->assertScript("{$collapse}.open === true")
|
||||||
|
->assertSeeIn('#advanced', 'true');
|
||||||
|
|
||||||
|
$page->click('button:has-text("Close from Alpine")')
|
||||||
|
->assertSeeIn('#advanced', 'false')
|
||||||
|
->assertScript("{$collapse}.open === false");
|
||||||
|
});
|
||||||
|
|||||||
@@ -69,6 +69,102 @@ function dateProbe(string $locale = 'en')
|
|||||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class DateFormatProbe extends Component
|
||||||
|
{
|
||||||
|
public ?string $sunday = '2026-09-13';
|
||||||
|
|
||||||
|
public ?string $iso = '2026-09-13';
|
||||||
|
|
||||||
|
public ?string $dotted = '2026-09-13';
|
||||||
|
|
||||||
|
/** @var array{start: ?string, end: ?string} */
|
||||||
|
public array $span = ['start' => '2026-09-13', 'end' => '2026-09-15'];
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return <<<'BLADE'
|
||||||
|
<div class="grid max-w-md gap-6 p-4">
|
||||||
|
<p>sunday: <span id="sunday">{{ $sunday }}</span></p>
|
||||||
|
<p>iso: <span id="iso">{{ $iso }}</span></p>
|
||||||
|
<p>dotted: <span id="dotted">{{ $dotted }}</span></p>
|
||||||
|
<p>span: <span id="span">{{ json_encode($span) }}</span></p>
|
||||||
|
|
||||||
|
<x-datepicker id="sunday-field" label="Sunday first" wire:model.live="sunday" week-start="0" />
|
||||||
|
<x-datepicker id="iso-field" label="ISO" wire:model.live="iso" format="yyyy-MM-dd" />
|
||||||
|
<x-datepicker id="dotted-field" label="Dotted" mode="input" wire:model.live="dotted" format="dd.MM.yyyy" />
|
||||||
|
<x-datepicker id="span-field" label="Span" range mode="modal" wire:model.live="span" format="dd/MM/yyyy" week-start="6" />
|
||||||
|
</div>
|
||||||
|
BLADE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateFormatProbe(string $locale = 'en')
|
||||||
|
{
|
||||||
|
Livewire::component('date-format-probe', DateFormatProbe::class);
|
||||||
|
|
||||||
|
Route::middleware('web')->get('/date-format-probe/{locale}', function (string $locale) {
|
||||||
|
app()->setLocale($locale);
|
||||||
|
|
||||||
|
return Blade::render(<<<'BLADE'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<x-theme-script />
|
||||||
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
|
@livewireStyles
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface">
|
||||||
|
<livewire:date-format-probe />
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
BLADE);
|
||||||
|
});
|
||||||
|
|
||||||
|
return visit("/date-format-probe/{$locale}")->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScopedDateProbe extends Component
|
||||||
|
{
|
||||||
|
public ?string $early = '2026-09-13';
|
||||||
|
|
||||||
|
public ?string $late = '2026-09-13';
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return <<<'BLADE'
|
||||||
|
<div class="grid max-w-md gap-6 p-4" x-data="{ step: 1 }">
|
||||||
|
<x-datepicker id="early-field" label="Early" wire:model.live="early" min="2026-09-10" week-start="0" />
|
||||||
|
<x-datepicker id="late-field" label="Late" wire:model.live="late" week-start="1" />
|
||||||
|
</div>
|
||||||
|
BLADE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopedDateProbe()
|
||||||
|
{
|
||||||
|
Livewire::component('scoped-date-probe', ScopedDateProbe::class);
|
||||||
|
|
||||||
|
Route::middleware('web')->get('/scoped-date-probe', fn () => Blade::render(<<<'BLADE'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<x-theme-script />
|
||||||
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
|
@livewireStyles
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface">
|
||||||
|
<livewire:scoped-date-probe />
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
BLADE));
|
||||||
|
|
||||||
|
return visit('/scoped-date-probe')->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
}
|
||||||
|
|
||||||
/** A picker's day cell, by its ISO date. */
|
/** A picker's day cell, by its ISO date. */
|
||||||
function day(string $picker, string $date): string
|
function day(string $picker, string $date): string
|
||||||
{
|
{
|
||||||
@@ -311,3 +407,98 @@ it('empties a date, or both ends of a range, with its clear button', function ()
|
|||||||
->assertSeeIn('#trip', '{"start":null,"end":null}')
|
->assertSeeIn('#trip', '{"start":null,"end":null}')
|
||||||
->assertValue('#trip-field', '');
|
->assertValue('#trip-field', '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('starts the week on the day week-start names, whatever the locale says', function () {
|
||||||
|
$page = dateFormatProbe('de')
|
||||||
|
->assertValue('#sunday-field', '13.09.2026')
|
||||||
|
->click('[aria-controls="sunday-field-picker"][data-datepicker-toggle]')
|
||||||
|
->assertScript("document.querySelector('#sunday-field-picker thead th').getAttribute('abbr') === 'Sonntag'")
|
||||||
|
->assertScript("document.querySelector('#sunday-field-picker tbody td').dataset.value === '2026-08-30'")
|
||||||
|
->assertScript(focusedDay('2026-09-13'));
|
||||||
|
|
||||||
|
$page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-19'));
|
||||||
|
$page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-13'));
|
||||||
|
|
||||||
|
$page->keys(':focus', 'Enter')
|
||||||
|
->assertSeeIn('#sunday', '2026-09-13')
|
||||||
|
->assertValue('#sunday-field', '13.09.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows and reads a year-first format and still binds Y-m-d', function () {
|
||||||
|
$page = dateFormatProbe()
|
||||||
|
->assertValue('#iso-field', '2026-09-13')
|
||||||
|
->assertAttribute('#iso-field', 'placeholder', 'YYYY-MM-DD')
|
||||||
|
->type('#iso-field', '2026-10-01')
|
||||||
|
->assertSeeIn('#iso', '2026-10-01');
|
||||||
|
|
||||||
|
$page->type('#iso-field', '10/02/2026')
|
||||||
|
->keys('#iso-field', 'Enter')
|
||||||
|
->assertSee('Date does not match expected pattern: YYYY-MM-DD')
|
||||||
|
->assertSeeIn('#iso', '2026-10-01');
|
||||||
|
|
||||||
|
$page->click('[aria-controls="iso-field-picker"][data-datepicker-toggle]')
|
||||||
|
->assertScript(focusedDay('2026-10-01'));
|
||||||
|
|
||||||
|
$page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-10-02'));
|
||||||
|
|
||||||
|
$page->keys(':focus', 'Enter')
|
||||||
|
->assertSeeIn('#iso', '2026-10-02')
|
||||||
|
->assertValue('#iso-field', '2026-10-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the dialog\'s text field in the given format, not the locale\'s', function () {
|
||||||
|
$dialog = "document.querySelector('#dotted-field-picker')";
|
||||||
|
|
||||||
|
$page = dateFormatProbe()
|
||||||
|
->assertValue('#dotted-field', '13.09.2026')
|
||||||
|
->click('#dotted-field')
|
||||||
|
->assertScript("{$dialog}.matches(':modal')")
|
||||||
|
->assertScript("document.activeElement.id === 'dotted-field-entry'")
|
||||||
|
->assertValue('#dotted-field-entry', '13.09.2026')
|
||||||
|
->assertAttribute('#dotted-field-entry', 'placeholder', 'DD.MM.YYYY');
|
||||||
|
|
||||||
|
$page->type('#dotted-field-entry', '2026-10-01')
|
||||||
|
->keys('#dotted-field-entry', 'Enter')
|
||||||
|
->assertSeeIn('#dotted-field-entry-support', 'Date does not match expected pattern: DD.MM.YYYY')
|
||||||
|
->assertScript("{$dialog}.open");
|
||||||
|
|
||||||
|
$page->type('#dotted-field-entry', '1.10.2026')
|
||||||
|
->assertSeeIn('#dotted-field-picker [data-datepicker-headline]', 'Oct 1, 2026')
|
||||||
|
->keys('#dotted-field-entry', 'Enter')
|
||||||
|
->assertSeeIn('#dotted', '2026-10-01')
|
||||||
|
->assertScript("! {$dialog}.open")
|
||||||
|
->assertValue('#dotted-field', '01.10.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lays out and shows a range in the given first day and format', function () {
|
||||||
|
$dialog = "document.querySelector('#span-field-picker')";
|
||||||
|
|
||||||
|
$page = dateFormatProbe()->assertValue('#span-field', '13/09/2026 – 15/09/2026');
|
||||||
|
|
||||||
|
$page->script("document.querySelector('#span-field').focus()");
|
||||||
|
|
||||||
|
$page->keys('#span-field', 'Enter')
|
||||||
|
->assertScript("{$dialog}.matches(':modal')")
|
||||||
|
->assertScript("document.querySelector('#span-field-picker thead th').getAttribute('abbr') === 'Saturday'")
|
||||||
|
->assertScript(focusedDay('2026-09-13'));
|
||||||
|
|
||||||
|
$page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-12'));
|
||||||
|
$page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-18'));
|
||||||
|
|
||||||
|
$page->click(day('span-field', '2026-09-20'))
|
||||||
|
->click(day('span-field', '2026-09-24'))
|
||||||
|
->click('#span-field-picker [data-datepicker-confirm]')
|
||||||
|
->assertSeeIn('#span', '{"start":"2026-09-20","end":"2026-09-24"}')
|
||||||
|
->assertValue('#span-field', '20/09/2026 – 24/09/2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps each picker\'s own settings inside a page\'s outer x-data scope', function () {
|
||||||
|
// Settings assigned in init() without being declared would land on the outermost scope,
|
||||||
|
// where the last picker's null min and Monday start would overwrite the first's.
|
||||||
|
$page = scopedDateProbe()
|
||||||
|
->click('[aria-controls="early-field-picker"][data-datepicker-toggle]')
|
||||||
|
->assertScript(focusedDay('2026-09-13'));
|
||||||
|
|
||||||
|
$page->assertAttribute(day('early-field', '2026-09-09'), 'aria-disabled', 'true')
|
||||||
|
->assertScript('! (\'min\' in Alpine.$data(document.querySelector(\'[x-data="{ step: 1 }"]\')))');
|
||||||
|
});
|
||||||
|
|||||||
@@ -176,3 +176,31 @@ it('skips to the content', function () {
|
|||||||
->assertScript("location.hash === '#content'")
|
->assertScript("location.hash === '#content'")
|
||||||
->assertScript("document.activeElement === document.getElementById('content')");
|
->assertScript("document.activeElement === document.getElementById('content')");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('moves an app bar\'s content under a safe area an application sets', function () {
|
||||||
|
$row = "Math.round(document.querySelector('[data-app-bar] [data-app-bar-row]').getBoundingClientRect().top)";
|
||||||
|
|
||||||
|
$page = navigationReady(visit('/material'));
|
||||||
|
$top = (int) $page->script($row);
|
||||||
|
|
||||||
|
$page->script("document.documentElement.style.setProperty('--material-safe-top', '47px')");
|
||||||
|
|
||||||
|
$page->assertScript("{$row} === ".($top + 47))
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lifts the snackbar and the page above something docked on the phone\'s bar', function () {
|
||||||
|
$snackbarBottom = "Math.round(parseFloat(getComputedStyle(document.querySelector('[x-data=\"materialSnackbar\"]')).bottom))";
|
||||||
|
$contentPadding = "Math.round(parseFloat(getComputedStyle(document.getElementById('content')).paddingBottom))";
|
||||||
|
|
||||||
|
$page = shellPage(400, 860);
|
||||||
|
$snackbar = (int) $page->script($snackbarBottom);
|
||||||
|
$content = (int) $page->script($contentPadding);
|
||||||
|
|
||||||
|
expect($content)->toBe(64)->and($snackbar)->toBe(80);
|
||||||
|
|
||||||
|
$page->script("document.documentElement.style.setProperty('--material-bottom-extra', '40px')");
|
||||||
|
|
||||||
|
$page->assertScript("{$snackbarBottom} === 120")
|
||||||
|
->assertScript("{$contentPadding} === 104");
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||||
|
|
||||||
|
use function Orchestra\Testbench\workbench_path;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The theme <html> shows. A bare `html` selector would be read as text to search for.
|
* The theme <html> shows. A bare `html` selector would be read as text to search for.
|
||||||
*/
|
*/
|
||||||
@@ -56,3 +62,94 @@ it('repaints a section that sets its own theme', function () {
|
|||||||
->assertScript(theme('data-theme', 'light'))
|
->assertScript(theme('data-theme', 'light'))
|
||||||
->assertScript("{$swatch(0)} !== {$swatch(1)}");
|
->assertScript("{$swatch(0)} !== {$swatch(1)}");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page has exactly one theme-color meta without a media query, and it shows the given colour.
|
||||||
|
*/
|
||||||
|
function themeColorIs(string $hex): string
|
||||||
|
{
|
||||||
|
return "(() => { const metas = document.head.querySelectorAll('meta[name=theme-color]:not([media])'); return metas.length === 1 && metas[0].getAttribute('content') === '{$hex}'; })()";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The surface colour role, as <html> resolves it.
|
||||||
|
*/
|
||||||
|
function pageSurfaceIs(string $hex): string
|
||||||
|
{
|
||||||
|
return "getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-surface').trim() === '{$hex}'";
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeReady(mixed $page): mixed
|
||||||
|
{
|
||||||
|
return $page->waitForEvent('networkidle')
|
||||||
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||||
|
}
|
||||||
|
|
||||||
|
it('adds a theme-color meta in the painted surface, and follows the theme and the colour profile', function () {
|
||||||
|
config([
|
||||||
|
'livewire-material.theme.meta' => true,
|
||||||
|
'livewire-material.scheme' => workbench_path('resources/css/material-scheme.json'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profiles = Scheme::profiles();
|
||||||
|
|
||||||
|
$page = themeReady(visit('/material/colour')->inLightMode())
|
||||||
|
->assertScript(theme('data-scheme', 'baseline'))
|
||||||
|
->assertScript(themeColorIs($profiles['baseline']['light']['surface']))
|
||||||
|
->assertScript(pageSurfaceIs($profiles['baseline']['light']['surface']));
|
||||||
|
|
||||||
|
$page->click('[data-theme-option="dark"]')
|
||||||
|
->assertScript(theme('data-theme', 'dark'))
|
||||||
|
->assertScript(themeColorIs($profiles['baseline']['dark']['surface']))
|
||||||
|
->assertScript(pageSurfaceIs($profiles['baseline']['dark']['surface']));
|
||||||
|
|
||||||
|
$page->click('#colour [data-scheme-option="rose"]')
|
||||||
|
->assertScript(theme('data-scheme', 'rose'))
|
||||||
|
->assertScript(themeColorIs($profiles['rose']['dark']['surface']))
|
||||||
|
->assertScript(pageSurfaceIs($profiles['rose']['dark']['surface']));
|
||||||
|
|
||||||
|
$page->click('[data-theme-option="light"]')
|
||||||
|
->assertScript(themeColorIs($profiles['rose']['light']['surface']))
|
||||||
|
->assertScript(pageSurfaceIs($profiles['rose']['light']['surface']))
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repaints the page\'s own theme-color meta, and the next page\'s after wire:navigate', function () {
|
||||||
|
config(['livewire-material.theme.meta' => true]);
|
||||||
|
|
||||||
|
Route::middleware('web')->get('/theme-color-probe/{page}', fn (string $page) => Blade::render(<<<'BLADE'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta name="theme-color" media="print" content="#ffffff" />
|
||||||
|
<x-theme-script />
|
||||||
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
|
@livewireStyles
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface">
|
||||||
|
<p id="page">This is page {{ $page }}.</p>
|
||||||
|
<a id="next" href="/theme-color-probe/two" wire:navigate>Next</a>
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
BLADE, ['page' => $page]));
|
||||||
|
|
||||||
|
$scheme = Scheme::load();
|
||||||
|
|
||||||
|
$page = themeReady(visit('/theme-color-probe/one')->inLightMode())
|
||||||
|
->assertScript(themeColorIs($scheme['light']['surface']))
|
||||||
|
->assertScript("document.head.querySelector('meta[media]').getAttribute('content') === '#ffffff'");
|
||||||
|
|
||||||
|
$page->script("window.eval(\"Alpine.store('theme').set('dark'); window.samePage = true\")");
|
||||||
|
|
||||||
|
$page->assertScript(themeColorIs($scheme['dark']['surface']));
|
||||||
|
|
||||||
|
$page->click('#next')
|
||||||
|
->assertSeeIn('#page', 'This is page two.')
|
||||||
|
->assertScript("window.eval('window.samePage') === true")
|
||||||
|
->assertScript(theme('data-theme', 'dark'))
|
||||||
|
->assertScript(themeColorIs($scheme['dark']['surface']))
|
||||||
|
->assertScript("document.head.querySelector('meta[media]').getAttribute('content') === '#ffffff'")
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
function shellDestinations(): array
|
function shellDestinations(): array
|
||||||
{
|
{
|
||||||
@@ -42,6 +44,16 @@ it('puts every destination in the rail and only those marked for the bar in the
|
|||||||
->and(substr_count($html, 'aria-current="page"'))->toBe(2);
|
->and(substr_count($html, 'aria-current="page"'))->toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('speaks a destination\'s badge in its own words when it has them', function () {
|
||||||
|
$html = (string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => [
|
||||||
|
['title' => 'Get started', 'icon' => 'rocket_launch', 'url' => '/start', 'badge' => '0/3', 'badgeLabel' => '0 of 3 done'],
|
||||||
|
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'badge' => 4],
|
||||||
|
]]);
|
||||||
|
|
||||||
|
expect($html)->toContain('0 of 3 done')
|
||||||
|
->and(substr_count($html, '0 of 3 done'))->toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('marks the destination at the current URL when none says it is active', function () {
|
it('marks the destination at the current URL when none says it is active', function () {
|
||||||
Route::get('/shell-probe/inbox', fn () => Blade::render('<x-app-shell :destinations="$destinations" />', ['destinations' => [
|
Route::get('/shell-probe/inbox', fn () => Blade::render('<x-app-shell :destinations="$destinations" />', ['destinations' => [
|
||||||
['title' => 'Inbox', 'icon' => 'inbox', 'url' => url('/shell-probe/inbox')],
|
['title' => 'Inbox', 'icon' => 'inbox', 'url' => url('/shell-probe/inbox')],
|
||||||
@@ -54,14 +66,46 @@ it('marks the destination at the current URL when none says it is active', funct
|
|||||||
->and(substr_count($html, 'aria-current="page"'))->toBe(2);
|
->and(substr_count($html, 'aria-current="page"'))->toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the destination at the page\'s URL current while a Livewire component on it updates', function () {
|
||||||
|
Livewire::component('app-shell-probe', new class extends Component
|
||||||
|
{
|
||||||
|
public string $page = '';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->page = url()->current();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return '<div><x-app-shell :destinations="[[\'title\' => \'Inbox\', \'icon\' => \'inbox\', \'url\' => $page], [\'title\' => \'Sent\', \'icon\' => \'send\', \'url\' => \'/sent\']]" /></div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$probe = Livewire::test('app-shell-probe');
|
||||||
|
|
||||||
|
expect(substr_count($probe->html(), 'aria-current="page"'))->toBe(2)
|
||||||
|
->and(substr_count($probe->call('$refresh')->html(), 'aria-current="page"'))->toBe(2)
|
||||||
|
->and($probe->html())->toMatch('/href="[^"]*\/livewire-unit-test-endpoint\/[^"]*"[^>]*aria-current="page"/');
|
||||||
|
});
|
||||||
|
|
||||||
it('lifts the snackbar above the bar only when there is a bar', function () {
|
it('lifts the snackbar above the bar only when there is a bar', function () {
|
||||||
expect((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
|
expect((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
|
||||||
->toContain('max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]')
|
->toContain('max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]')
|
||||||
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]]))
|
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]]))
|
||||||
->not->toContain('data-app-shell-bar')
|
->not->toContain('data-app-shell-bar')
|
||||||
->not->toContain('--material-bottom-bar:');
|
->not->toContain('--material-bottom-bar:');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads the safe area and anything docked on the bar through variables an application can set', function () {
|
||||||
|
$html = (string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]);
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('+var(--material-bottom-extra,0px))]')
|
||||||
|
->toContain('focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)]')
|
||||||
|
->not->toMatch('/(?<!,)env\(safe-area-inset/');
|
||||||
|
});
|
||||||
|
|
||||||
it('places each slot once', function () {
|
it('places each slot once', function () {
|
||||||
$html = (string) $this->blade(<<<'BLADE'
|
$html = (string) $this->blade(<<<'BLADE'
|
||||||
<x-app-shell :destinations="$destinations">
|
<x-app-shell :destinations="$destinations">
|
||||||
|
|||||||
@@ -30,3 +30,63 @@ it('draws a status label in a container or an outline', function () {
|
|||||||
->toContain('border border-outline-variant text-on-surface-variant')
|
->toContain('border border-outline-variant text-on-surface-variant')
|
||||||
->not->toContain('bg-error');
|
->not->toContain('bg-error');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders its slot as HTML, and is a dot while the slot holds nothing but comments', function () {
|
||||||
|
$html = (string) $this->blade('<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('h-6 gap-1 rounded-corner-sm')
|
||||||
|
->toContain('<svg')
|
||||||
|
->toContain(' Pro</span>')
|
||||||
|
->not->toContain('<svg')
|
||||||
|
->and((string) $this->blade('<x-badge value="<b>4</b>" />'))->toContain('<b>4</b>')
|
||||||
|
->and((string) $this->blade("<x-badge tonal>\n <!-- nothing yet -->\n</x-badge>"))->toContain('size-1.5 rounded-corner-full')
|
||||||
|
->and((string) $this->blade('<x-badge max="99">120</x-badge>'))->toContain('>99+</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws neutral ink on every variant', function () {
|
||||||
|
expect((string) $this->blade('<x-badge color="neutral" />'))->toContain('size-1.5 rounded-corner-full bg-on-surface-variant text-surface')
|
||||||
|
->and((string) $this->blade('<x-badge value="7" color="neutral" />'))->toContain('tabular-nums bg-on-surface-variant text-surface')
|
||||||
|
->and((string) $this->blade('<x-badge value="Draft" tone="neutral" tonal />'))->toContain('type-label-md bg-surface-container-high text-on-surface-variant')
|
||||||
|
->and((string) $this->blade('<x-badge value="Draft" color="neutral" outline />'))->toContain('type-label-md border border-outline-variant text-on-surface-variant');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a plain badge to the caller\'s colour classes', function (string $badge, string $shape) {
|
||||||
|
$html = (string) $this->blade($badge);
|
||||||
|
|
||||||
|
preg_match('/class="([^"]*)"/', $html, $class);
|
||||||
|
|
||||||
|
expect($class[1])
|
||||||
|
->toContain($shape)
|
||||||
|
->toEndWith('bg-tertiary-container text-on-tertiary-container')
|
||||||
|
->and(preg_replace('/bg-tertiary-container text-on-tertiary-container$/', '', $class[1]))->not->toMatch('/(^|\s)(bg|text|border)-/');
|
||||||
|
})->with([
|
||||||
|
'dot' => ['<x-badge color="plain" class="bg-tertiary-container text-on-tertiary-container" />', 'size-1.5 rounded-corner-full'],
|
||||||
|
'count' => ['<x-badge value="3" color="plain" class="bg-tertiary-container text-on-tertiary-container" />', 'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums'],
|
||||||
|
'tonal' => ['<x-badge value="Run" color="plain" tonal class="bg-tertiary-container text-on-tertiary-container" />', 'h-6 gap-1 rounded-corner-sm px-2 type-label-md'],
|
||||||
|
'outline' => ['<x-badge value="Run" color="plain" outline class="bg-tertiary-container text-on-tertiary-container" />', 'h-6 gap-1 rounded-corner-sm px-2 type-label-md border'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('keeps its default colours, and falls back to error on a colour it does not know', function () {
|
||||||
|
expect((string) $this->blade('<x-badge value="4" />'))
|
||||||
|
->toContain('class="inline-flex shrink-0 items-center justify-center whitespace-nowrap h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums bg-error text-on-error"')
|
||||||
|
->and((string) $this->blade('<x-badge value="Active" tonal />'))
|
||||||
|
->toContain('class="inline-flex shrink-0 items-center justify-center whitespace-nowrap h-6 gap-1 rounded-corner-sm px-2 type-label-md bg-error-container text-on-error-container"')
|
||||||
|
->and((string) $this->blade('<x-badge value="Pro" outline color="success" />'))
|
||||||
|
->toContain('class="inline-flex shrink-0 items-center justify-center whitespace-nowrap h-6 gap-1 rounded-corner-sm px-2 type-label-md border border-outline-variant text-on-surface-variant"')
|
||||||
|
->and((string) $this->blade('<x-badge value="3" color="sport-run" />'))->toContain('bg-error text-on-error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a solid status label in the colour itself, spoken like any label', function () {
|
||||||
|
$html = (string) $this->blade('<x-badge value="Built in" solid color="primary" />');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('bg-primary text-on-primary')
|
||||||
|
->toContain('h-6 gap-1 rounded-corner-sm px-2 type-label-md')
|
||||||
|
->not->toContain('aria-hidden')
|
||||||
|
->toContain('>Built in<')
|
||||||
|
->and((string) $this->blade('<x-badge value="Draft" solid color="neutral" />'))
|
||||||
|
->toContain('bg-on-surface-variant text-surface')
|
||||||
|
->and((string) $this->blade('<x-badge solid />'))
|
||||||
|
->toContain('size-1.5');
|
||||||
|
});
|
||||||
|
|||||||
@@ -83,3 +83,17 @@ it('binds to a Livewire property and shows its validation message', function ()
|
|||||||
->assertSee('Pick light.')
|
->assertSee('Pick light.')
|
||||||
->assertDontSee('How it looks');
|
->assertDontSee('How it looks');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds hint-class to the hint, which a validation message still replaces', function () {
|
||||||
|
$options = [['id' => 'flat', 'name' => 'Flat'], ['id' => 'hilly', 'name' => 'Hilly']];
|
||||||
|
|
||||||
|
expect((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options]))
|
||||||
|
->toContain('<p class="mt-1 type-body-sm [:where(&)]:text-on-surface-variant text-warning">No elevation data here</p>')
|
||||||
|
->and((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" :$options />', ['options' => $options]))
|
||||||
|
->toContain('<p class="mt-1 type-body-sm text-on-surface-variant">No elevation data here</p>');
|
||||||
|
|
||||||
|
expect((string) $this->withViewErrors(['terrain' => 'Pick a terrain.'])->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options]))
|
||||||
|
->toContain('<p class="mt-1 type-body-sm text-error">Pick a terrain.</p>')
|
||||||
|
->not->toContain('No elevation data here')
|
||||||
|
->not->toContain('text-warning');
|
||||||
|
});
|
||||||
|
|||||||
@@ -156,3 +156,10 @@ it('submits a form when asked', function () {
|
|||||||
$this->blade('<x-button label="Save" type="submit" />')
|
$this->blade('<x-button label="Save" type="submit" />')
|
||||||
->assertSee('type="submit"', false);
|
->assertSee('type="submit"', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps aria-pressed off a selected link, which is not a toggle', function () {
|
||||||
|
expect((string) $this->blade('<x-button label="Plans" link="/plans" :selected="true" />'))
|
||||||
|
->not->toContain('aria-pressed')
|
||||||
|
->and((string) $this->blade('<x-button label="Bold" :selected="true" />'))
|
||||||
|
->toContain('aria-pressed="true"');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
it('discloses on the native details element, kept open through a morph', function () {
|
it('discloses on the native details element, kept open through a morph', function () {
|
||||||
$html = (string) $this->blade('<x-collapse title="How long do links last?" icon="schedule" open variant="filled">Until the expiry.</x-collapse>');
|
$html = (string) $this->blade('<x-collapse title="How long do links last?" icon="schedule" open variant="filled">Until the expiry.</x-collapse>');
|
||||||
|
|
||||||
@@ -12,3 +15,50 @@ it('discloses on the native details element, kept open through a morph', functio
|
|||||||
->toContain('Until the expiry.')
|
->toContain('Until the expiry.')
|
||||||
->toContain('group-open/collapse:rotate-180');
|
->toContain('group-open/collapse:rotate-180');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('has no Alpine on it without a binding', function () {
|
||||||
|
$html = (string) $this->blade('<x-collapse title="Advanced" open>Body</x-collapse>');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toMatch('/<details\s+wire:ignore.self\s+open\s+class="group\/collapse/')
|
||||||
|
->not->toContain('x-data')
|
||||||
|
->not->toContain('x-modelable')
|
||||||
|
->not->toContain('x-on:toggle')
|
||||||
|
->and((string) $this->blade('<x-collapse title="Advanced">Body</x-collapse>'))
|
||||||
|
->toMatch('/<details\s+wire:ignore.self\s+class="group\/collapse/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds its open state through x-model, drawing the open prop until Alpine starts', function () {
|
||||||
|
$html = (string) $this->blade('<x-collapse title="Advanced" x-model="advanced" open>Body</x-collapse>');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toMatch('/x-data="{ collapseOpen:\s*true\s*}"/')
|
||||||
|
->toContain('x-modelable="collapseOpen"')
|
||||||
|
->toContain('x-model="advanced"')
|
||||||
|
->toContain('x-effect="$el.open = collapseOpen"')
|
||||||
|
->toContain('x-on:toggle="collapseOpen = $el.open"')
|
||||||
|
->toMatch('/\sopen\s/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entangles its open state with a Livewire property and renders open as the property is', function (bool $fineTuning) {
|
||||||
|
Livewire::component('collapse-probe', new class extends Component
|
||||||
|
{
|
||||||
|
public bool $fineTuning = false;
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return '<div><x-collapse title="Fine-tuning" wire:model.live="fineTuning" :open="! $fineTuning">Body</x-collapse></div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$html = Livewire::test('collapse-probe', ['fineTuning' => $fineTuning])->html();
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain(".entangle('fineTuning').live")
|
||||||
|
->not->toContain('x-modelable')
|
||||||
|
->not->toContain('wire:model');
|
||||||
|
|
||||||
|
preg_match('/<details[^>]*>/', $html, $tag);
|
||||||
|
|
||||||
|
expect(preg_match('/\sopen\s/', $tag[0]) === 1)->toBe($fineTuning);
|
||||||
|
})->with(['open' => true, 'closed' => false]);
|
||||||
|
|||||||
@@ -72,6 +72,27 @@ it('hands min, max and the application locale to the picker as Y-m-d', function
|
|||||||
->toMatchArray(['min' => null, 'max' => null]);
|
->toMatchArray(['min' => null, 'max' => null]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hands a chosen first day of the week and format to the picker, and ignores values that are neither', function () {
|
||||||
|
app()->setLocale('de');
|
||||||
|
|
||||||
|
$html = (string) $this->blade('<x-datepicker label="Date" week-start="0" format="yyyy-MM-dd" value="2026-09-13" />');
|
||||||
|
|
||||||
|
expect(datepickerConfig($html))->toMatchArray(['weekStart' => 0, 'format' => 'yyyy-MM-dd', 'locale' => 'de'])
|
||||||
|
->and($html)->toContain('value="2026-09-13"')
|
||||||
|
->and(datepickerConfig((string) $this->blade('<x-datepicker label="Date" :week-start="6" format="MM/dd/yyyy" />')))
|
||||||
|
->toMatchArray(['weekStart' => 6, 'format' => 'MM/dd/yyyy'])
|
||||||
|
->and((string) $this->blade('<x-datepicker label="Trip" range format="dd/MM/yyyy" :value="[\'start\' => \'2026-09-13\', \'end\' => \'2026-09-20\']" />'))
|
||||||
|
->toContain('value="13/09/2026 – 20/09/2026"');
|
||||||
|
|
||||||
|
foreach (['week-start="7"', 'week-start="-1"', 'week-start="monday"', 'week-start', 'format="d.M.yy"', 'format="dd.MM/yyyy"', 'format="dd.dd.yyyy"', 'format="yyyy-mm-dd"'] as $attribute) {
|
||||||
|
expect(datepickerConfig((string) $this->blade("<x-datepicker label=\"Date\" {$attribute} />")))
|
||||||
|
->toMatchArray(['weekStart' => null, 'format' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(datepickerConfig((string) $this->blade('<x-datepicker label="Date" />')))->toMatchArray(['weekStart' => null, 'format' => null])
|
||||||
|
->and((string) $this->blade('<x-datepicker label="Date" format="dd/MM/y" value="2026-09-13" />'))->toContain('value="13.09.2026"');
|
||||||
|
});
|
||||||
|
|
||||||
it('shows the value in the locale\'s numeric format before Alpine starts', function () {
|
it('shows the value in the locale\'s numeric format before Alpine starts', function () {
|
||||||
expect((string) $this->blade('<x-datepicker label="Date" value="2026-09-13" name="expires" />'))
|
expect((string) $this->blade('<x-datepicker label="Date" value="2026-09-13" name="expires" />'))
|
||||||
->toContain('value="09/13/2026"')
|
->toContain('value="09/13/2026"')
|
||||||
|
|||||||
@@ -15,3 +15,34 @@ it('sets an icon on an Expressive shape above its words and action', function ()
|
|||||||
->toContain('Upload files')
|
->toContain('Upload files')
|
||||||
->and(substr_count($html, '<svg'))->toBe(2);
|
->and(substr_count($html, '<svg'))->toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('draws an illustration in place of the shape and icon', function () {
|
||||||
|
$html = (string) $this->blade(<<<'BLADE'
|
||||||
|
<x-empty-state icon="upload_file" title="No routes yet">
|
||||||
|
<x-slot:illustration class="text-primary"><svg class="size-32" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="4" /></svg></x-slot:illustration>
|
||||||
|
</x-empty-state>
|
||||||
|
BLADE);
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('<div class="text-primary"><svg class="size-32" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="4" /></svg></div>')
|
||||||
|
->not->toContain('text-secondary-container')
|
||||||
|
->not->toContain('text-on-secondary-container')
|
||||||
|
->toContain('No routes yet')
|
||||||
|
->and(substr_count($html, '<svg'))->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the shape and icon while the illustration slot holds nothing but comments', function () {
|
||||||
|
$html = (string) $this->blade(<<<'BLADE'
|
||||||
|
<x-empty-state title="No shares yet">
|
||||||
|
<x-slot:illustration>
|
||||||
|
<!-- artwork to come -->
|
||||||
|
</x-slot:illustration>
|
||||||
|
</x-empty-state>
|
||||||
|
BLADE);
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('text-secondary-container')
|
||||||
|
->toContain('text-on-secondary-container')
|
||||||
|
->not->toContain('artwork to come')
|
||||||
|
->and(substr_count($html, '<svg'))->toBe(2);
|
||||||
|
});
|
||||||
|
|||||||
@@ -53,6 +53,20 @@ it('makes a selectable item a menuitemcheckbox', function () {
|
|||||||
->toContain('aria-checked="false"');
|
->toContain('aria-checked="false"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks the page an item leads to, and carries a badge', function () {
|
||||||
|
$html = (string) $this->blade('<x-menu-item label="Support" icon="support" link="/admin/support" current badge="3" />');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('role="menuitem"')
|
||||||
|
->toContain('aria-current="page"')
|
||||||
|
->not->toContain('aria-checked')
|
||||||
|
->toContain('bg-secondary-container text-on-secondary-container')
|
||||||
|
->toContain('>3<')
|
||||||
|
->and((string) $this->blade('<x-menu-item label="Users" link="/admin/users" />'))
|
||||||
|
->not->toContain('aria-current')
|
||||||
|
->not->toContain('secondary-container');
|
||||||
|
});
|
||||||
|
|
||||||
it('links an item, and keeps a disabled one out of reach', function () {
|
it('links an item, and keeps a disabled one out of reach', function () {
|
||||||
$this->blade('<x-menu-item label="Settings" link="/settings" />')
|
$this->blade('<x-menu-item label="Settings" link="/settings" />')
|
||||||
->assertSee('href="/settings"', false)
|
->assertSee('href="/settings"', false)
|
||||||
@@ -69,3 +83,18 @@ it('separates and labels groups', function () {
|
|||||||
$this->blade('<x-menu-group label="Sort by"><x-menu-item label="Newest" /></x-menu-group>')
|
$this->blade('<x-menu-group label="Sort by"><x-menu-item label="Newest" /></x-menu-group>')
|
||||||
->assertSee('role="group" aria-label="Sort by"', false);
|
->assertSee('role="group" aria-label="Sort by"', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds icon-class to the leading icon, over its own colour but not over disabled', function () {
|
||||||
|
$leading = fn (string $html): string => preg_match('/<svg[^>]*class="([^"]*)"/', $html, $icon) ? $icon[1] : '';
|
||||||
|
|
||||||
|
expect($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" icon-right="chevron_right" />')))
|
||||||
|
->toBe('shrink-0 size-5 [:where(&)]:text-on-surface-variant text-sport-run')
|
||||||
|
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" :selected="true" />')))
|
||||||
|
->toBe('shrink-0 size-5 [:where(&)]:text-on-tertiary-container text-sport-run')
|
||||||
|
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" disabled />')))
|
||||||
|
->toBe('shrink-0 size-5 text-sport-run text-on-surface/38!')
|
||||||
|
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" />')))
|
||||||
|
->toBe('shrink-0 size-5 text-on-surface-variant')
|
||||||
|
->and((string) $this->blade('<x-menu-item label="Next" icon-right="chevron_right" icon-class="text-sport-run" />'))
|
||||||
|
->not->toContain('text-sport-run');
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
/** The child component in each row of `KeyedRowsProbe`. */
|
||||||
|
class KeyedRowChildProbe extends Component
|
||||||
|
{
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return '<p data-row-child>Row</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list whose rows each hold `$markup`, then a child component. Livewire keys a child from the
|
||||||
|
* loop around it, and takes a `wire:key` written in any template rendered in the row for the row's
|
||||||
|
* key: a component that wrote its own that way would key every row's child alike.
|
||||||
|
*/
|
||||||
|
class KeyedRowsProbe extends Component
|
||||||
|
{
|
||||||
|
public static string $markup = '';
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return '<div>@foreach ([1, 2] as $row)<div wire:key="row-{{ $row }}">'.static::$markup.'<livewire:keyed-row-child-probe /></div>@endforeach</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keys what a morph must patch in place, without keying the child components after it', function (string $markup, string $key) {
|
||||||
|
Livewire::component('keyed-row-child-probe', KeyedRowChildProbe::class);
|
||||||
|
Livewire::component('keyed-rows-probe', KeyedRowsProbe::class);
|
||||||
|
|
||||||
|
KeyedRowsProbe::$markup = $markup;
|
||||||
|
|
||||||
|
$html = Livewire::test('keyed-rows-probe')->html();
|
||||||
|
|
||||||
|
preg_match_all('/<p\b[^>]*\bwire:key="([^"]+)"[^>]*>Row<\/p>/', $html, $children);
|
||||||
|
|
||||||
|
expect(substr_count($html, "wire:key=\"{$key}\""))->toBe(2)
|
||||||
|
->and($children[1])->toHaveCount(2)
|
||||||
|
->and(array_unique($children[1]))->toHaveCount(2);
|
||||||
|
})->with([
|
||||||
|
'menu' => ['<x-menu label="Row actions"><x-slot:trigger><button>More</button></x-slot:trigger><x-menu-item label="Delete" /></x-menu>', 'material-menu'],
|
||||||
|
'FAB menu' => ['<x-fab-menu label="New"><x-fab-menu-item label="Upload" /></x-fab-menu>', 'material-fab-menu'],
|
||||||
|
'rich tooltip' => ['<x-rich-tooltip text="Details"><button>i</button></x-rich-tooltip>', 'material-rich-tooltip'],
|
||||||
|
'carousel' => ['<x-carousel label="Photos"><x-carousel-item><div class="size-full"></div></x-carousel-item></x-carousel>', 'material-carousel'],
|
||||||
|
]);
|
||||||
@@ -55,6 +55,15 @@ it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the
|
|||||||
->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>');
|
->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('leaves a pane open on Escape unless it is asked to close then too', function () {
|
||||||
|
expect((string) $this->blade('<x-drawer pane>Body</x-drawer>'))
|
||||||
|
->toContain('x-on:keydown.window.escape="if (open && ! wide) close()"')
|
||||||
|
->and((string) $this->blade('<x-drawer pane pane-close-on-escape>Body</x-drawer>'))
|
||||||
|
->toContain('x-on:keydown.window.escape="if (open) close()"')
|
||||||
|
->and((string) $this->blade('<x-drawer pane pane-close-on-escape :close-on-escape="false">Body</x-drawer>'))
|
||||||
|
->not->toContain('keydown.window.escape');
|
||||||
|
});
|
||||||
|
|
||||||
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"')
|
||||||
|
|||||||
@@ -67,7 +67,11 @@ it('draws section navigation as secondary tabs and a picker', function () {
|
|||||||
->toContain('sm:flex')
|
->toContain('sm:flex')
|
||||||
->toMatch('/href="\/settings\/security"\s+data-tab\s+aria-current="page"\s+wire:navigate/')
|
->toMatch('/href="\/settings\/security"\s+data-tab\s+aria-current="page"\s+wire:navigate/')
|
||||||
->not->toMatch('/href="\/settings\/profile"\s+data-tab\s+aria-current/')
|
->not->toMatch('/href="\/settings\/profile"\s+data-tab\s+aria-current/')
|
||||||
->toContain('role="menuitemcheckbox"');
|
// The picker is a menu of places: the current one is the page, not a checked choice,
|
||||||
|
// and a section's badge shows there as well as on its tab.
|
||||||
|
->not->toContain('role="menuitemcheckbox"')
|
||||||
|
->toMatch('/role="menuitem"[^>]*aria-current="page"[^>]*href="\/settings\/security"/')
|
||||||
|
->toMatch('/data-section-picker.*Security.*>\s*1\s*<.*<nav/s');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks the section whose url is the request\'s, and wraps many sections onto a grid', function () {
|
it('marks the section whose url is the request\'s, and wraps many sections onto a grid', function () {
|
||||||
@@ -80,3 +84,29 @@ it('marks the section whose url is the request\'s, and wraps many sections onto
|
|||||||
->toMatch('/data-tab\s+aria-current="page"\s*>\s*<span data-tab-content>\s*<span class="truncate">S3/')
|
->toMatch('/data-tab\s+aria-current="page"\s*>\s*<span data-tab-content>\s*<span class="truncate">S3/')
|
||||||
->not->toContain('wire:navigate');
|
->not->toContain('wire:navigate');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the page\'s section current while a Livewire component on it updates', function () {
|
||||||
|
Livewire::component('section-nav-probe', new class extends Component
|
||||||
|
{
|
||||||
|
public string $page = '';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->page = url()->current();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return '<div><x-section-nav :items="[[\'title\' => \'Here\', \'url\' => $page], [\'title\' => \'Elsewhere\', \'url\' => \'/elsewhere\']]" no-wire-navigate /></div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$current = '/href="[^"]*\/livewire-unit-test-endpoint\/[^"]*"\s+data-tab\s+aria-current="page"/';
|
||||||
|
|
||||||
|
$probe = Livewire::test('section-nav-probe');
|
||||||
|
|
||||||
|
expect($probe->html())->toMatch($current)
|
||||||
|
->and($probe->call('$refresh')->html())->toMatch($current)
|
||||||
|
// Once as the tab, once as the picker's item: one section, drawn for both widths.
|
||||||
|
->and(substr_count($probe->html(), 'aria-current="page"'))->toBe(2);
|
||||||
|
});
|
||||||
|
|||||||
@@ -67,3 +67,63 @@ it('names the active colour profile for <html data-scheme>, and none for a singl
|
|||||||
File::delete($path);
|
File::delete($path);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves the theme-color meta alone by default', function () {
|
||||||
|
$this->blade('<x-theme-script />')
|
||||||
|
->assertDontSee('theme-color', false)
|
||||||
|
->assertDontSee('"meta"', false)
|
||||||
|
->assertDontSee('MutationObserver', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paints the theme-color meta in the resolved theme\'s surface when asked', function () {
|
||||||
|
config(['livewire-material.theme.meta' => true]);
|
||||||
|
|
||||||
|
$scheme = Scheme::load();
|
||||||
|
|
||||||
|
$this->blade('<x-theme-script />')
|
||||||
|
->assertSee('"meta":{"light":"'.$scheme['light']['surface'].'","dark":"'.$scheme['dark']['surface'].'","profiles":{}}', false)
|
||||||
|
->assertSee('document.head.querySelectorAll(\'meta[name="theme-color"]:not([media])\')', false)
|
||||||
|
->assertSee("new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme'] });", false)
|
||||||
|
->assertSee("document.addEventListener('livewire:navigated', paintThemeColor);", false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the theme-color meta every profile\'s surfaces, the active one\'s first', function () {
|
||||||
|
$path = sys_get_temp_dir().'/theme-script-meta-'.uniqid().'.json';
|
||||||
|
File::put($path, json_encode([
|
||||||
|
'default' => 'indigo',
|
||||||
|
'profiles' => [
|
||||||
|
'indigo' => ['label' => 'Indigo', 'light' => ['surface' => '#fbf8ff'], 'dark' => ['surface' => '#12131a']],
|
||||||
|
'teal' => ['label' => 'Teal', 'light' => ['surface' => '#f4fbf8'], 'dark' => ['surface' => '#0e1513']],
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
config(['livewire-material.scheme' => $path, 'livewire-material.theme.meta' => true]);
|
||||||
|
Scheme::resolveProfileUsing(fn (): string => 'teal');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->blade('<x-theme-script />')
|
||||||
|
->assertSee('"meta":{"light":"#f4fbf8","dark":"#0e1513","profiles":{"indigo":{"light":"#fbf8ff","dark":"#12131a"},"teal":{"light":"#f4fbf8","dark":"#0e1513"}}}', false);
|
||||||
|
} finally {
|
||||||
|
Scheme::resolveProfileUsing(null);
|
||||||
|
File::delete($path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paints the meta without profiles and without a deprecation on PHP 8.5', function () {
|
||||||
|
config(['livewire-material.theme.meta' => true, 'livewire-material.profiles' => []]);
|
||||||
|
|
||||||
|
$deprecations = [];
|
||||||
|
set_error_handler(function (int $level, string $message) use (&$deprecations): bool {
|
||||||
|
$deprecations[] = $message;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, E_DEPRECATED | E_USER_DEPRECATED);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$html = (string) $this->blade('<x-theme-script />');
|
||||||
|
} finally {
|
||||||
|
restore_error_handler();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($html)->toContain('"meta":{')
|
||||||
|
->and($deprecations)->toBe([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,3 +13,11 @@ it('hosts the snackbar queue, kept across wire:navigate', function () {
|
|||||||
it('can sit at the start', function () {
|
it('can sit at the start', function () {
|
||||||
expect((string) $this->blade('<x-toast position="bottom-start" />'))->toContain('justify-start');
|
expect((string) $this->blade('<x-toast position="bottom-start" />'))->toContain('justify-start');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks the snackbar and its action for tests and styling', function () {
|
||||||
|
$html = (string) $this->blade('<x-toast />');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toMatch('/<div\s[^>]*\bdata-toast\b[^>]*aria-live="polite"/')
|
||||||
|
->toMatch('/<button\s[^>]*\bdata-toast-action\b[^>]*x-on:click="act\(\)"/');
|
||||||
|
});
|
||||||
|
|||||||
@@ -56,6 +56,48 @@ it('generates a different scheme per variant', function () {
|
|||||||
->and($vibrant['light']['primary'])->not->toBe($tonalSpot['light']['primary']);
|
->and($vibrant['light']['primary'])->not->toBe($tonalSpot['light']['primary']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('generates with the 2021 colour spec when asked, and records it', function () {
|
||||||
|
$this->artisan('material:scheme', [
|
||||||
|
'seed' => '#00bc7d',
|
||||||
|
'--variant' => 'vibrant',
|
||||||
|
'--success' => '#00d390',
|
||||||
|
'--warning' => '#fcb700',
|
||||||
|
'--info' => '#00bafe',
|
||||||
|
'--spec' => '2021',
|
||||||
|
'--output' => $this->stylesheet,
|
||||||
|
])->assertSuccessful();
|
||||||
|
|
||||||
|
$scheme = json_decode(File::get($this->data), true);
|
||||||
|
|
||||||
|
expect($scheme['spec'])->toBe('2021')
|
||||||
|
->and($scheme['dark'])->toMatchArray([
|
||||||
|
'surface' => '#0b1610',
|
||||||
|
'primary' => '#00e297',
|
||||||
|
'on-primary' => '#003822',
|
||||||
|
'success' => '#2ce19c',
|
||||||
|
'warning' => '#ffbb16',
|
||||||
|
'info' => '#80cfff',
|
||||||
|
])
|
||||||
|
->and($scheme['light'])->toMatchArray([
|
||||||
|
'surface' => '#f0fdf2',
|
||||||
|
'primary' => '#006c46',
|
||||||
|
'success' => '#006c48',
|
||||||
|
'warning' => '#7c5800',
|
||||||
|
'info' => '#00658c',
|
||||||
|
])
|
||||||
|
->and(File::get($this->stylesheet))
|
||||||
|
->toContain('(spec 2021)')
|
||||||
|
->toContain('php artisan material:scheme "#00bc7d" --variant=vibrant --spec=2021 --success="#00d390" --warning="#fcb700" --info="#00bafe"'."\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an unknown colour spec', function () {
|
||||||
|
$this->artisan('material:scheme', ['seed' => '#4f46e5', '--spec' => '2023', '--output' => $this->stylesheet])
|
||||||
|
->expectsOutputToContain('Unknown spec "2023". Use one of: 2021, 2025.')
|
||||||
|
->assertFailed();
|
||||||
|
|
||||||
|
expect(File::exists($this->stylesheet))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
it('refuses a seed that is not a colour', function () {
|
it('refuses a seed that is not a colour', function () {
|
||||||
$this->artisan('material:scheme', ['seed' => 'indigo', '--output' => $this->stylesheet])
|
$this->artisan('material:scheme', ['seed' => 'indigo', '--output' => $this->stylesheet])
|
||||||
->expectsOutputToContain('#rrggbb')
|
->expectsOutputToContain('#rrggbb')
|
||||||
@@ -118,6 +160,38 @@ it('generates every configured profile into one stylesheet keyed by data-scheme'
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('takes a profile\'s own spec and state colours, and the command\'s for a profile without them', function () {
|
||||||
|
config([
|
||||||
|
'livewire-material.profiles' => [
|
||||||
|
'expressive' => ['seed' => '#00bc7d', 'variant' => 'vibrant'],
|
||||||
|
'classic' => ['seed' => '#00bc7d', 'variant' => 'vibrant', 'spec' => 2021, 'success' => '#00d390', 'warning' => '#fcb700', 'info' => '#00bafe'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('material:scheme', ['--info' => '#00bafe', '--output' => $this->stylesheet])->assertSuccessful();
|
||||||
|
|
||||||
|
$profiles = json_decode(File::get($this->data), true)['profiles'];
|
||||||
|
|
||||||
|
expect($profiles['classic'])->spec->toBe('2021')
|
||||||
|
->and($profiles['classic']['dark'])->toMatchArray(['primary' => '#00e297', 'success' => '#2ce19c', 'warning' => '#ffbb16', 'info' => '#80cfff'])
|
||||||
|
->and($profiles['expressive'])->spec->toBe('2025')
|
||||||
|
->and($profiles['expressive']['dark']['primary'])->not->toBe('#00e297')
|
||||||
|
->and($profiles['expressive']['dark']['success'])->not->toBe('#2ce19c')
|
||||||
|
->and($profiles['expressive']['dark']['info'])->toBe('#80cfff')
|
||||||
|
->and(File::get($this->stylesheet))
|
||||||
|
->toContain('(spec 2025)')
|
||||||
|
->toMatch('/ \* classic\s+#00bc7d, vibrant, spec 2021\n/')
|
||||||
|
->toMatch('/ \* expressive\s+#00bc7d, vibrant\n/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the profile whose spec is unknown', function () {
|
||||||
|
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5', 'spec' => '2019']]]);
|
||||||
|
|
||||||
|
$this->artisan('material:scheme', ['--output' => $this->stylesheet])
|
||||||
|
->expectsOutputToContain('Profile "indigo": Unknown spec "2019"')
|
||||||
|
->assertFailed();
|
||||||
|
});
|
||||||
|
|
||||||
it('names the profile a generator error belongs to', function () {
|
it('names the profile a generator error belongs to', function () {
|
||||||
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5'], 'broken' => ['seed' => 'teal']]]);
|
config(['livewire-material.profiles' => ['indigo' => ['seed' => '#4f46e5'], 'broken' => ['seed' => 'teal']]]);
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,27 @@ it('leaves the choice of theme to the head script, never to a media query', func
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads every safe-area inset through a variable that can replace it', function () {
|
||||||
|
$files = collect([packageCss(), __DIR__.'/../../resources/views', __DIR__.'/../../resources/js'])
|
||||||
|
->flatMap(fn (string $path): array => File::allFiles($path));
|
||||||
|
|
||||||
|
$insets = 0;
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
preg_match_all('/env\(safe-area-inset-(top|bottom|left|right)\)/', $file->getContents(), $matches, PREG_OFFSET_CAPTURE);
|
||||||
|
|
||||||
|
foreach ($matches[1] as [$side, $offset]) {
|
||||||
|
$insets++;
|
||||||
|
|
||||||
|
expect(substr($file->getContents(), 0, $offset - strlen('env(safe-area-inset-')))
|
||||||
|
->toMatch("/var\\(--material-safe-{$side},\\s?$/", "{$file->getRelativePathname()} reads safe-area-inset-{$side} directly");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($insets)->toBeGreaterThan(10)
|
||||||
|
->and(File::get(packageCss('components/app-bar.css')))->toContain('padding-top: var(--material-safe-top, env(safe-area-inset-top));');
|
||||||
|
});
|
||||||
|
|
||||||
it('makes every motion token instant under reduced motion', function () {
|
it('makes every motion token instant under reduced motion', function () {
|
||||||
$motion = File::get(packageCss('tokens/motion.css'));
|
$motion = File::get(packageCss('tokens/motion.css'));
|
||||||
$reduced = Str::of($motion)->after('prefers-reduced-motion: reduce')->toString();
|
$reduced = Str::of($motion)->after('prefers-reduced-motion: reduce')->toString();
|
||||||
|
|||||||
Reference in New Issue
Block a user