Files
livewire-material/resources/views/components/scheme-picker.blade.php
T
Andreas Reinhold / reiniandClaude Fable 5.1 57d9e0aa6f Offer the contrast level where the theme is chosen
<x-theme-toggle mode="contrast"> is the picker's row for M3's three levels, marking
the one in force so the operating system's setting shows while the choice is system.
The scheme picker's dots follow the level on screen instead of always promising the
standard colours, and the showcase's colour section puts the three levels side by
side — data-scheme beside data-contrast, the way a profile's level blocks key.
Plan: docs/plans/material-3-alignment.md, step 7 (core C2).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:38:31 +02:00

109 lines
5.7 KiB
PHP

{{-- A choice of colour profile: one swatch per profile `material:scheme` generated from
`livewire-material.profiles`, each its name and its primary, secondary and tertiary colour.
<x-scheme-picker label="Colour profile" wire:model="colorProfile" hint="Applies to every page after saving" />
Native radios under the swatches, so `wire:model` and `x-model` bind as on any input and the
arrow keys move the choice. Choosing one shows it on the page at once
(`$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. 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. --}}
@props([
'label' => null,
'hint' => null,
'name' => null,
'profiles' => null,
])
@php
$profiles ??= \NoNameWeb\LivewireMaterial\Support\Scheme::profiles();
$model = $attributes->whereStartsWith('wire:model')->first();
$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 (\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="{ 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
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
@foreach ($profiles as $profile => $scheme)
<label
data-scheme-option="{{ $profile }}"
class="state-layer flex min-w-0 cursor-pointer select-none flex-col items-start gap-2 rounded-corner-lg border border-outline-variant bg-surface-container-low p-3 text-on-surface transition-[background-color,border-color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary"
>
<input
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
type="radio"
name="{{ $name }}"
value="{{ $profile }}"
x-on:change="$store.theme.previewScheme($event.target.value)"
class="peer sr-only"
/>
<span class="flex shrink-0 -space-x-1.5" aria-hidden="true">
@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
</span>
<span class="w-full truncate pe-6 type-label-lg">{{ __($scheme['label']) }}</span>
{{-- In the corner, so a long name keeps its room. The wrapper carries the position. --}}
<span class="pointer-events-none absolute end-2 top-2 hidden peer-checked:block">
<x-livewire-material::icon name="check" class="size-5" />
</span>
</label>
@endforeach
</div>
@if ($messages !== [])
@foreach ($messages as $message)
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
@endforeach
@elseif (filled($hint))
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
@endif
</fieldset>
@endif