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
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
7084aac788
commit
57d9e0aa6f
@@ -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 (\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,10 +75,14 @@
|
||||
/>
|
||||
|
||||
<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] }}"
|
||||
class="size-5 rounded-full bg-(--swatch-light) ring-2 ring-surface-container-low in-has-checked:ring-secondary-container dark:bg-(--swatch-dark)"
|
||||
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>
|
||||
|
||||
@@ -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') }}"
|
||||
|
||||
@@ -11,12 +11,21 @@
|
||||
'Ink and lines' => ['bg-body', 'bg-meta', 'bg-quiet', 'bg-structure', 'bg-chrome', 'bg-divider'],
|
||||
];
|
||||
|
||||
// The roles a contrast level moves most: the page keeps its surface, the ink and the
|
||||
// outlines carry the level.
|
||||
$levels = ['standard' => 'Standard', 'medium' => 'Medium · 3:1', 'high' => 'High · 7:1'];
|
||||
$levelRoles = ['bg-primary', 'bg-on-primary', 'bg-primary-container', 'bg-on-primary-container', 'bg-on-surface-variant', 'bg-outline', 'bg-error', 'bg-success', 'bg-warning', 'bg-info'];
|
||||
$profile = \NoNameWeb\LivewireMaterial\Support\Scheme::profile();
|
||||
|
||||
$examples = [
|
||||
'Colour profiles' => <<<'BLADE'
|
||||
<div x-data="{ profile: $store.theme.scheme }" class="w-full max-w-3xl">
|
||||
<x-scheme-picker label="Colour profile" name="profile" x-model="profile" hint="Previews on this page; an application stores the choice and names it with Scheme::resolveProfileUsing()." />
|
||||
</div>
|
||||
BLADE,
|
||||
'Contrast level' => <<<'BLADE'
|
||||
<x-theme-toggle mode="contrast" />
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
@@ -34,10 +43,43 @@
|
||||
profiles the picker draws nothing.
|
||||
</p>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
Every scheme is generated at M3's three contrast levels — standard, medium (3:1) and high (7:1) — and the
|
||||
head script writes the visitor's to <code><html data-contrast></code>, following the operating system
|
||||
until they choose. The page keeps its surface; the ink, the outlines and the containers carry the level.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
<x-showcase::example :$title :$code />
|
||||
@endforeach
|
||||
|
||||
<div class="grid gap-4 large:grid-cols-3">
|
||||
@foreach ($levels as $level => $title)
|
||||
{{-- data-scheme beside data-contrast: a profile's level blocks key on both attributes
|
||||
together, as they do on <html>. --}}
|
||||
<div
|
||||
@if ($profile !== null) data-scheme="{{ $profile }}" @endif
|
||||
@if ($level !== 'standard') data-contrast="{{ $level }}" @endif
|
||||
class="space-y-3"
|
||||
>
|
||||
<h3 class="type-title-sm text-on-surface-variant">{{ $title }}</h3>
|
||||
|
||||
@foreach (['light', 'dark'] as $theme)
|
||||
<div data-theme="{{ $theme }}" class="space-y-2 rounded-corner-lg bg-surface p-3 text-on-surface">
|
||||
<h4 class="type-label-md capitalize text-on-surface-variant">{{ $theme }}</h4>
|
||||
|
||||
@foreach ($levelRoles as $role)
|
||||
<div class="flex items-center gap-2">
|
||||
<span @class(['size-6 shrink-0 rounded-corner-xs border border-outline-variant', $role])></span>
|
||||
<span class="min-w-0 truncate type-body-sm">{{ \Illuminate\Support\Str::after($role, 'bg-') }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
@foreach (['light', 'dark'] as $theme)
|
||||
<div data-theme="{{ $theme }}" class="space-y-6 rounded-corner-lg bg-surface p-4 text-on-surface">
|
||||
|
||||
Reference in New Issue
Block a user