Keep the theme-color meta in step with the theme
An app installed as a PWA, and a mobile browser, colour their bar from <meta name="theme-color">, which the package left alone: the bar kept the server's one colour whatever theme the visitor chose. With the new `theme.meta` option (off by default), <x-theme-script> sets the content of every theme-color meta without a `media` attribute to the resolved theme's surface, from the scheme data and for the profile in <html data-scheme>, adding one to <head> when the page has none. A MutationObserver on <html> follows every later change of data-theme or data-scheme: $store.theme's set() and toggle(), an OS change while `system`, a profile preview, an application's own script. wire:navigate's head merge puts the next page's server-rendered meta in place, so it is painted again as the page is swapped in and on livewire:navigated. Turned off, the script is byte-for-byte what it was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
This commit is contained in:
co-authored by
Claude Opus 5
parent
3a7aa96ec7
commit
bcf0d4f4e3
@@ -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`).
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ 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.
|
||||||
|
|
||||||
## Toasts
|
## Toasts
|
||||||
|
|
||||||
@@ -187,7 +189,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>`
|
||||||
|
|
||||||
|
|||||||
@@ -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,17 @@
|
|||||||
'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'] = [
|
||||||
|
...$surfaces($schemeProfiles[$settings['scheme']] ?? \NoNameWeb\LivewireMaterial\Support\Scheme::load()),
|
||||||
|
'profiles' => (object) collect($schemeProfiles)->map($surfaces)->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -94,6 +116,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 +161,10 @@
|
|||||||
root.setAttribute(attribute[0], attribute[1]);
|
root.setAttribute(attribute[0], attribute[1]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@if (isset($settings['meta']))
|
||||||
|
|
||||||
|
paintThemeColor();
|
||||||
|
@endif
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})(@json($settings));
|
})(@json($settings));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -67,3 +67,43 @@ 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user