Files
livewire-material/resources/views/components/theme-script.blade.php
T
Andreas Reinhold / reini 18023166bb Merge branch 'worktree-agent-a9537eabbf38e0608'
# Conflicts:
#	resources/views/components/scheme-picker.blade.php
#	resources/views/components/theme-script.blade.php
#	resources/views/showcase/sections/colour.blade.php
#	tests/Feature/Components/ThemeScriptTest.php
2026-09-14 05:43:37 +02:00

244 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{-- The theme and the navigation rail's width, decided before the first paint. Include it in
<head>, ahead of @vite.
It reads the visitor's choice from localStorage (`livewire-material.theme.storage_key`):
`light`, `dark` or `system`, falling back to `theme.default`. `system` follows the
operating system, now and whenever it changes. The result is written to
<html data-theme>, which is the only thing the stylesheet keys on; the choice itself is
<html data-theme-choice>, where `$store.theme` picks it up.
A value under one of `theme.legacy_keys` an earlier toggle's key, which may be
JSON-encoded (maryUI's `"dark"`) is adopted once and removed. Nothing is written for a
visitor who never chose, so changing `theme.default` later reaches them too.
M3's contrast level rides the same rails (`livewire-material.theme.contrast`):
`standard`, `medium`, `high` or `system`, kept under `contrast.storage_key`. `system` asks
the operating system, now and whenever it changes, and takes `more` as high. The result is
<html data-contrast>, which the generated stylesheet keys its medium and high blocks on —
standard writes no attribute, because the plain blocks are already it. The choice is
<html data-contrast-choice>, where `$store.theme` picks it up.
`livewire-material.motion.scheme` is M3's motion scheme: `standard` is written to
<html data-motion> (and swaps the spatial springs for the restrained set), `expressive`,
the default, writes nothing.
With colour profiles (`livewire-material.profiles`, generated by `material:scheme`), the
active one `Scheme::profile()`, which asks the application's resolver — is written to
<html data-scheme>, which the generated stylesheet keys each profile on.
The rail rides along for the theme's reason: <html data-rail> is `expanded` or `collapsed`
(`livewire-material.rail.storage_key`, falling back to `rail.default`), and a collapsible
rail's width is CSS keyed on it (the `rail-collapsed:` variant). Set any later, a collapsed
rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
changes it. <html data-rail-auto> rides with it and says nothing was stored — the value is only
`rail.default`, not a choice — so `<x-app-shell>`'s adaptive rail can start collapsed in the
expanded class (8401199) and expanded from large, as M3 asks, while still obeying a visitor
who has chosen. `$store.rail` drops it the first time they do.
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) and at the level in <html data-contrast>. A
MutationObserver on <html> keeps it in step with whatever changes `data-theme`,
`data-scheme` or `data-contrast` afterwards: `$store.theme.set()` and `toggle()`,
`setContrast()`, an OS change while `system`, a profile preview, the application's own script. A layout's own
theme-color meta belongs before this script: one written after it is only painted on
DOMContentLoaded, beside the one added here. Off by default, and then none of it is emitted.
wire:navigate swaps the body, merges the head without running this again, and gives <html>
the next page's attributes — which the server rendered without any of these, so Livewire
removes them. They are put back as the new page is swapped in (`onSwap`, in the same task,
before anything paints), so this only has to run on a full load. The head merge also puts the
next page's server-rendered theme-color meta in place of the painted one, so it is painted
again there, and once more on `livewire:navigated`. --}}
@php
$theme = config('livewire-material.theme');
$rail = config('livewire-material.rail');
$contrast = $theme['contrast'] ?? [];
$levels = \NoNameWeb\LivewireMaterial\Support\Scheme::LEVELS;
$settings = [
'scheme' => \NoNameWeb\LivewireMaterial\Support\Scheme::profile(),
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
'key' => $theme['storage_key'] ?? 'material-theme',
'legacy' => array_values($theme['legacy_keys'] ?? []),
'contrast' => [
'default' => in_array($contrast['default'] ?? null, [...$levels, 'system'], true) ? $contrast['default'] : 'system',
'key' => $contrast['storage_key'] ?? 'material-contrast',
],
'motion' => (config('livewire-material.motion.scheme') === 'standard') ? 'standard' : null,
'rail' => [
'default' => ($rail['default'] ?? null) === 'collapsed' ? 'collapsed' : 'expanded',
'key' => $rail['storage_key'] ?? 'material-rail',
],
];
// Only the surfaces the meta can show: the active scheme's, every profile's for a preview,
// and each of those at M3's other two contrast levels. `Scheme::load()` resolves the active
// profile itself, so it answers for a single scheme and for profiles alike.
if ((bool) ($theme['meta'] ?? false)) {
$surfaces = fn (array $scheme): array => ['light' => $scheme['light']['surface'], 'dark' => $scheme['dark']['surface']];
// One read of the scheme file per level rather than one per profile and level.
$profilesAt = collect($levels)->mapWithKeys(fn (string $level): array => [$level => \NoNameWeb\LivewireMaterial\Support\Scheme::profiles(contrast: $level)]);
$otherLevels = fn (callable $of): object => (object) collect($levels)->skip(1)->mapWithKeys(fn (string $level): array => [$level => $of($level)])->all();
$settings['meta'] = [
...$surfaces(\NoNameWeb\LivewireMaterial\Support\Scheme::load()),
'contrast' => $otherLevels(fn (string $level): array => $surfaces(\NoNameWeb\LivewireMaterial\Support\Scheme::load(contrast: $level))),
'profiles' => (object) collect($profilesAt['standard'])
->map(fn (array $scheme, string $name): array => [
...$surfaces($scheme),
'contrast' => $otherLevels(fn (string $level): array => $surfaces($profilesAt[$level][$name])),
])
->all(),
];
}
@endphp
<script>
(function (settings) {
var root = document.documentElement;
var media = window.matchMedia('(prefers-color-scheme: dark)');
var contrastMedia = window.matchMedia('(prefers-contrast: more)');
var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; };
var validContrast = function (value) { return value === 'standard' || value === 'medium' || value === 'high' || value === 'system'; };
var choice = settings.default;
var contrast = settings.contrast.default;
var rail = settings.rail.default;
var railChosen = false;
try {
var storedRail = localStorage.getItem(settings.rail.key);
if (storedRail === 'collapsed' || storedRail === 'expanded') {
rail = storedRail;
railChosen = true;
}
var storedContrast = localStorage.getItem(settings.contrast.key);
if (validContrast(storedContrast)) {
contrast = storedContrast;
}
var stored = localStorage.getItem(settings.key);
if (valid(stored)) {
choice = stored;
} else {
settings.legacy.forEach(function (key) {
var legacy = (localStorage.getItem(key) || '').replace(/"/g, '');
if (valid(legacy)) {
choice = legacy;
localStorage.setItem(settings.key, legacy);
}
localStorage.removeItem(key);
});
}
} catch (e) {
// Private windows and blocked site data throw on access; the default stands.
}
var apply = function () {
var current = root.getAttribute('data-theme-choice');
root.setAttribute('data-theme', current === 'system' ? (media.matches ? 'dark' : 'light') : current);
};
// Standard is the stylesheet's plain blocks, so it is the absence of the attribute.
var applyContrast = function () {
var current = root.getAttribute('data-contrast-choice');
var level = current === 'system' ? (contrastMedia.matches ? 'high' : 'standard') : current;
if (level === 'medium' || level === 'high') {
root.setAttribute('data-contrast', level);
} else {
root.removeAttribute('data-contrast');
}
};
if (settings.scheme) {
root.setAttribute('data-scheme', settings.scheme);
}
if (settings.motion) {
root.setAttribute('data-motion', settings.motion);
}
root.setAttribute('data-theme-key', settings.key);
root.setAttribute('data-theme-choice', choice);
root.setAttribute('data-contrast-key', settings.contrast.key);
root.setAttribute('data-contrast-choice', contrast);
root.setAttribute('data-rail-key', settings.rail.key);
root.setAttribute('data-rail', rail);
if (railChosen) {
root.removeAttribute('data-rail-auto');
} else {
root.setAttribute('data-rail-auto', '');
}
apply();
applyContrast();
media.addEventListener('change', apply);
contrastMedia.addEventListener('change', applyContrast);
@if (isset($settings['meta']))
var paintThemeColor = function () {
var theme = root.getAttribute('data-theme');
var scheme = root.getAttribute('data-scheme');
var level = root.getAttribute('data-contrast');
if ((theme !== 'light' && theme !== 'dark') || !document.head) {
return;
}
var surfaces = Object.prototype.hasOwnProperty.call(settings.meta.profiles, scheme) ? settings.meta.profiles[scheme] : settings.meta;
var colour = (Object.prototype.hasOwnProperty.call(surfaces.contrast, level) ? surfaces.contrast[level] : surfaces)[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', 'data-contrast'] });
document.addEventListener('DOMContentLoaded', paintThemeColor);
document.addEventListener('livewire:navigated', paintThemeColor);
@endif
document.addEventListener('livewire:navigating', function (event) {
var kept = ['data-scheme', 'data-theme', 'data-theme-choice', 'data-theme-key', 'data-contrast', 'data-contrast-choice', 'data-contrast-key', 'data-motion', 'data-rail', 'data-rail-auto', 'data-rail-key'].map(function (name) {
return [name, root.getAttribute(name)];
});
event.detail.onSwap(function () {
kept.forEach(function (attribute) {
if (attribute[1] !== null) {
root.setAttribute(attribute[0], attribute[1]);
}
});
@if (isset($settings['meta']))
paintThemeColor();
@endif
});
});
})(@json($settings));
</script>