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
This commit is contained in:
@@ -8,7 +8,10 @@
|
||||
(`$store.theme.previewScheme`); storing it — and telling `Scheme::resolveProfileUsing()` — is
|
||||
the application's. The dots are the only colours not drawn from tokens: they show other
|
||||
profiles than the page's, so they are custom properties set inline from the scheme file's
|
||||
checked hexes, light or dark with the page. Without profiles it renders nothing.
|
||||
checked hexes, light or dark with the page. The inline pair is the standard contrast level,
|
||||
which is what the server can know; Alpine swaps in the level on screen
|
||||
(`$store.theme.resolvedContrast`) so a dot never promises a colour the page would not paint.
|
||||
Without profiles it renders nothing.
|
||||
|
||||
Props: `label`, `hint`, `name` (needed with `x-model`), `profiles` (default: every generated
|
||||
profile). A validation message for the bound property replaces the hint. --}}
|
||||
@@ -26,10 +29,32 @@
|
||||
$name ??= $model ?: 'scheme';
|
||||
$errorKey = $model ?: (filled($attributes->get('name')) ? (string) $attributes->get('name') : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
$roles = ['primary', 'secondary', 'tertiary'];
|
||||
|
||||
// profile => level => theme => role => hex, for the dots. Every level is filled — a profile
|
||||
// the caller passed in by hand, or a scheme file without the levels, repeats its standard
|
||||
// colours — so the binding below never asks whether a level is there.
|
||||
$swatches = [];
|
||||
|
||||
foreach ($profiles === [] ? [] : \NoNameWeb\LivewireMaterial\Support\Scheme::LEVELS as $level) {
|
||||
$generated = $level === 'standard' ? [] : \NoNameWeb\LivewireMaterial\Support\Scheme::profiles(contrast: $level);
|
||||
|
||||
foreach ($profiles as $profile => $scheme) {
|
||||
foreach (['light', 'dark'] as $theme) {
|
||||
foreach ($roles as $role) {
|
||||
$swatches[$profile][$level][$theme][$role] = ($generated[$profile] ?? $scheme)[$theme][$role];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
@if ($profiles !== [])
|
||||
<fieldset x-data data-scheme-picker {{ $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except('name')->class('min-w-0') }}>
|
||||
<fieldset
|
||||
x-data="{ swatches: @js($swatches) }"
|
||||
data-scheme-picker
|
||||
{{ $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except('name')->class('min-w-0') }}
|
||||
>
|
||||
@if (filled($label))
|
||||
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
|
||||
@endif
|
||||
@@ -50,9 +75,13 @@
|
||||
/>
|
||||
|
||||
<span class="flex shrink-0 -space-x-1.5" aria-hidden="true">
|
||||
@foreach (['primary', 'secondary', 'tertiary'] as $role)
|
||||
@foreach ($roles as $role)
|
||||
<span
|
||||
style="--swatch-light: {{ $scheme['light'][$role] }}; --swatch-dark: {{ $scheme['dark'][$role] }}"
|
||||
x-bind:style="{
|
||||
'--swatch-light': swatches['{{ $profile }}'][$store.theme.resolvedContrast].light['{{ $role }}'],
|
||||
'--swatch-dark': swatches['{{ $profile }}'][$store.theme.resolvedContrast].dark['{{ $role }}'],
|
||||
}"
|
||||
class="size-5 rounded-corner-full bg-(--swatch-light) ring-2 ring-surface-container-low in-has-checked:ring-secondary-container dark:bg-(--swatch-dark)"
|
||||
></span>
|
||||
@endforeach
|
||||
|
||||
@@ -11,6 +11,17 @@
|
||||
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.
|
||||
@@ -27,9 +38,10 @@
|
||||
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
|
||||
<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.
|
||||
|
||||
@@ -43,27 +55,44 @@
|
||||
@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, and every profile's for a preview.
|
||||
// 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']];
|
||||
$schemeProfiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles();
|
||||
|
||||
// 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'] = [
|
||||
// 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(),
|
||||
...$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
|
||||
@@ -72,8 +101,11 @@
|
||||
(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;
|
||||
|
||||
@@ -85,6 +117,12 @@
|
||||
railChosen = true;
|
||||
}
|
||||
|
||||
var storedContrast = localStorage.getItem(settings.contrast.key);
|
||||
|
||||
if (validContrast(storedContrast)) {
|
||||
contrast = storedContrast;
|
||||
}
|
||||
|
||||
var stored = localStorage.getItem(settings.key);
|
||||
|
||||
if (valid(stored)) {
|
||||
@@ -111,12 +149,30 @@
|
||||
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);
|
||||
|
||||
@@ -126,19 +182,23 @@
|
||||
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 colour = (Object.prototype.hasOwnProperty.call(settings.meta.profiles, scheme) ? settings.meta.profiles[scheme] : settings.meta)[theme];
|
||||
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) {
|
||||
@@ -157,13 +217,13 @@
|
||||
};
|
||||
|
||||
paintThemeColor();
|
||||
new MutationObserver(paintThemeColor).observe(root, { attributes: true, attributeFilter: ['data-theme', 'data-scheme'] });
|
||||
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-rail', 'data-rail-auto', 'data-rail-key'].map(function (name) {
|
||||
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)];
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
"Dark theme", pressed while dark.
|
||||
- `cycle`: an icon button that steps light → dark → system, showing the choice it is on.
|
||||
- `picker`: M3's segmented buttons for the three choices, for a settings page.
|
||||
- `contrast`: the same row for M3's three contrast levels — standard, medium (3:1) and high
|
||||
(7:1), the three the scheme is generated in. The row marks the level in force
|
||||
(`resolvedContrast`), so the operating system's setting shows while the choice is
|
||||
`system`, and choosing one is an explicit choice from then on.
|
||||
|
||||
The theme is the visitor's, known only in the browser, so the parts that depend on it wait for
|
||||
Alpine (`x-cloak`) rather than render a guess. `class` lands on the button or the group. --}}
|
||||
@@ -16,10 +20,37 @@
|
||||
])
|
||||
|
||||
@php
|
||||
$mode = in_array($mode, ['toggle', 'cycle', 'picker'], true) ? $mode : 'toggle';
|
||||
$mode = in_array($mode, ['toggle', 'cycle', 'picker', 'contrast'], true) ? $mode : 'toggle';
|
||||
@endphp
|
||||
|
||||
@if ($mode === 'picker')
|
||||
@if ($mode === 'contrast')
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="{{ __('Contrast') }}"
|
||||
x-data
|
||||
data-theme-toggle="contrast"
|
||||
{{ $attributes->class(['inline-flex h-10 rounded-corner-full border border-outline']) }}
|
||||
>
|
||||
@foreach (['standard' => ['Standard', 'contrast'], 'medium' => ['Medium', 'contrast_circle'], 'high' => ['High', 'contrast_square']] as $level => [$text, $icon])
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked="false"
|
||||
x-bind:aria-checked="($store.theme.resolvedContrast === '{{ $level }}').toString()"
|
||||
x-bind:tabindex="$store.theme.resolvedContrast === '{{ $level }}' ? 0 : -1"
|
||||
x-on:click="$store.theme.setContrast('{{ $level }}')"
|
||||
x-on:keydown.arrow-right.prevent="$el.nextElementSibling?.click(); $el.nextElementSibling?.focus();"
|
||||
x-on:keydown.arrow-left.prevent="$el.previousElementSibling?.click(); $el.previousElementSibling?.focus();"
|
||||
data-contrast-option="{{ $level }}"
|
||||
class="state-layer focus-ring inline-flex min-w-0 flex-1 cursor-pointer items-center justify-center gap-2 border-outline px-3 type-label-lg text-on-surface not-first:border-s first:rounded-s-corner-full last:rounded-e-corner-full aria-checked:bg-secondary-container aria-checked:text-on-secondary-container"
|
||||
>
|
||||
<x-livewire-material::icon name="check" class="hidden size-4.5 in-aria-checked:block" />
|
||||
<x-livewire-material::icon :name="$icon" class="size-4.5 in-aria-checked:hidden" />
|
||||
{{ __($text) }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif ($mode === 'picker')
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="{{ __('Theme') }}"
|
||||
|
||||
Reference in New Issue
Block a user