diff --git a/config/livewire-material.php b/config/livewire-material.php index 3f88ca67..29bbdab1 100644 --- a/config/livewire-material.php +++ b/config/livewire-material.php @@ -35,6 +35,12 @@ return [ | wire:navigate included. A theme-color meta with a `media` attribute is | left alone. | + | 'contrast' is M3's contrast level, the same three the scheme is generated + | in: 'standard', 'medium' (3:1) or 'high' (7:1), or 'system' to follow the + | operating system's own contrast setting until the visitor chooses. The + | head script writes it to before the first paint — + | standard, having the plain blocks, writes no attribute. + | */ 'theme' => [ @@ -42,6 +48,27 @@ return [ 'storage_key' => 'material-theme', 'legacy_keys' => [], 'meta' => false, + 'contrast' => [ + 'default' => 'system', + 'storage_key' => 'material-contrast', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Motion + |-------------------------------------------------------------------------- + | + | M3's two motion schemes. 'expressive' (the default) is the bouncy one the + | library draws with; 'standard' is the restrained set, "minimal bounce, + | for utilitarian products" — the head script writes it to + | before the first paint, and the spatial springs swap. + | The effects springs are the same in both. + | + */ + + 'motion' => [ + 'scheme' => 'expressive', ], /* diff --git a/resources/js/theme.js b/resources/js/theme.js index f81dadb8..e7c82d44 100644 --- a/resources/js/theme.js +++ b/resources/js/theme.js @@ -8,6 +8,11 @@ * `value` is an accessor, so binding a control with `x-model="$store.theme.value"` goes * through the same write as `set()` and `toggle()`: the attributes, then localStorage. * + * M3's contrast level is the same pair: `contrast` is the choice (standard, medium, high or + * system) and `resolvedContrast` the level on screen, which is `standard` wherever + * carries no data-contrast — the stylesheet's plain blocks are that level. `setContrast()` + * writes both, through the same attribute the head script wrote before the first paint. + * * `scheme` is the colour profile on screen (, which the server chose), and * `previewScheme(name)` shows another one on this page without storing anything — the * application saves a choice itself, and the next full load draws what it saved. @@ -15,13 +20,18 @@ document.addEventListener('alpine:init', () => { const root = document.documentElement const media = window.matchMedia('(prefers-color-scheme: dark)') + const contrastMedia = window.matchMedia('(prefers-contrast: more)') const choices = ['light', 'dark', 'system'] + const levels = ['standard', 'medium', 'high', 'system'] const resolve = (choice) => (choice === 'system' ? (media.matches ? 'dark' : 'light') : choice) + const resolveContrast = (choice) => (choice === 'system' ? (contrastMedia.matches ? 'high' : 'standard') : choice) window.Alpine.store('theme', { choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system', resolved: root.dataset.theme === 'dark' ? 'dark' : 'light', + contrast: levels.includes(root.dataset.contrastChoice) ? root.dataset.contrastChoice : 'system', + resolvedContrast: root.dataset.contrast === 'medium' || root.dataset.contrast === 'high' ? root.dataset.contrast : 'standard', scheme: root.dataset.scheme || null, get value() { @@ -54,6 +64,29 @@ document.addEventListener('alpine:init', () => { this.set(this.resolved === 'dark' ? 'light' : 'dark') }, + setContrast(choice) { + if (!levels.includes(choice)) { + return + } + + this.contrast = choice + this.resolvedContrast = resolveContrast(choice) + + root.dataset.contrastChoice = choice + + if (this.resolvedContrast === 'standard') { + delete root.dataset.contrast + } else { + root.dataset.contrast = this.resolvedContrast + } + + try { + localStorage.setItem(root.dataset.contrastKey || 'material-contrast', choice) + } catch { + // Blocked storage: the page still switches, it just will not remember. + } + }, + previewScheme(name) { if (typeof name !== 'string' || !/^[a-z0-9-]+$/.test(name)) { return @@ -73,4 +106,12 @@ document.addEventListener('alpine:init', () => { theme.resolved = resolve('system') } }) + + contrastMedia.addEventListener('change', () => { + const theme = window.Alpine.store('theme') + + if (theme.contrast === 'system') { + theme.resolvedContrast = resolveContrast('system') + } + }) }) diff --git a/resources/views/components/theme-script.blade.php b/resources/views/components/theme-script.blade.php index 41fa9ecc..a145a120 100644 --- a/resources/views/components/theme-script.blade.php +++ b/resources/views/components/theme-script.blade.php @@ -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 + , 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 + , where `$store.theme` picks it up. + + `livewire-material.motion.scheme` is M3's motion scheme: `standard` is written to + (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 , which the generated stylesheet keys each profile on. @@ -24,9 +35,10 @@ With `theme.meta` on, the browser's own chrome follows too: the `content` of every without a `media` attribute — one is added to
when there is none — is the resolved theme's `surface`, from the scheme file (`Scheme`), for the profile in - (else the active one). A MutationObserver on 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 + (else the active one) and at the level in . A + MutationObserver on 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. @@ -40,27 +52,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 @@ -69,8 +98,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; try { @@ -80,6 +112,12 @@ rail = storedRail; } + var storedContrast = localStorage.getItem(settings.contrast.key); + + if (validContrast(storedContrast)) { + contrast = storedContrast; + } + var stored = localStorage.getItem(settings.key); if (valid(stored)) { @@ -106,28 +144,50 @@ 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); 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) { @@ -146,13 +206,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-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-key'].map(function (name) { return [name, root.getAttribute(name)]; }); diff --git a/tests/Feature/Components/ThemeScriptTest.php b/tests/Feature/Components/ThemeScriptTest.php index a982fc31..bff50a34 100644 --- a/tests/Feature/Components/ThemeScriptTest.php +++ b/tests/Feature/Components/ThemeScriptTest.php @@ -5,7 +5,36 @@ use NoNameWeb\LivewireMaterial\Support\Scheme; it('follows the operating system until the visitor chooses, by default', function () { $this->blade('