Draw the scheme picker without Tailwind

Plan step 36 (navigation group, third batch, last component):
<x-scheme-picker>'s class lists move into resources/css/components/
scheme-picker.css, keyed on data-md-scheme-picker (its legend and
options grid, 2 columns below medium and 4 from it) and
data-md-scheme-picker-option, whose radio inside is read with :has()
for the chosen and the focus states — the input, not the label, is
the real control and carries the ring group.css's own segments keep
the same refinement for. `dark:bg-(--swatch-dark)` becomes
[data-theme='dark'] on the swatch (theme.css's own dark custom
variant reduces to a plain descendant selector here, since the
swatch never carries data-theme itself); the profile's name keeps its
label-large type as a text class on the view, since it is the
caller's own prose.

Hooks renamed: data-scheme-picker to data-md-scheme-picker,
data-scheme-option to data-md-scheme-picker-option, updated in
SchemePickerTest.php and tests/Browser/{ThemeTest,ColourProfilesTest}.php.

Browser tests added (docs/plans/material-3-browser-tests.md): the
contrast switch repaints at once and survives a wire:navigate, and
prefers-contrast: more picks high (Playwright's contrast context
option, the same shape as the existing reducedMotion tests).

This is the last navigation-group component off Tailwind. Every
package component is now drawn without Tailwind; tailwind.css keeps
only tokens/theme.css and tokens/utilities.css, which the showcase
still needs until step 38.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 00:52:12 +02:00
co-authored by Claude Sonnet 5
parent 74cbcd2f44
commit bc284b2b33
7 changed files with 253 additions and 23 deletions
+1
View File
@@ -81,3 +81,4 @@
@import './components/section-nav.css';
@import './components/account-menu.css';
@import './components/theme-toggle.css';
@import './components/scheme-picker.css';
+145
View File
@@ -0,0 +1,145 @@
/*
* <x-scheme-picker>: a choice of colour profile as a grid of swatch cards, native radios under
* them so `wire:model`/`x-model` bind and the arrow keys move the choice.
*
* [data-md-scheme-picker] fieldset
* [data-md-scheme-picker-legend] optional; the fixed label-large/on-surface-variant text
* classes carry its type and ink
* [data-md-scheme-picker-options] 2 columns below medium, 4 from it
* [data-md-scheme-picker-option] the card; md-state-layer for hover and press, a hidden
* radio inside read with :has() for focus and choice
* [data-md-scheme-picker-swatches] the overlapping dots
* [data-md-scheme-picker-swatch] one role's colour, light by default, the dark theme's
* custom property from [data-theme='dark']
* [data-md-scheme-picker-label] the profile's name, truncated
* [data-md-scheme-picker-check] the chosen mark, shown with the radio
* [data-md-scheme-picker-hint] the hint or, in error, the validation message; ink and
* type are the fixed text classes on the view
*
* The chosen card takes secondary-container over a transparent edge, on the fast effects spring;
* its focus ring reads off the radio inside with `:has()`, the one thing `md-focus-ring` cannot draw
* since the real control is the input, not the label — the same refinement group.css keeps for its
* own segments. `dark:` is `[data-theme='dark']` per an ancestor or the element itself
* (tokens/theme.css's own `dark` custom variant, `&:where([data-theme='dark'], [data-theme='dark']
* *)`, reduces to a plain descendant selector here since the swatch itself never carries
* `data-theme`).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-scheme-picker] {
min-inline-size: 0;
}
[data-md-scheme-picker-legend] {
margin-block-end: var(--md-sys-measurement-space100);
}
[data-md-scheme-picker-options] {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--md-sys-measurement-space100);
@media (width >= 600px) {
grid-template-columns: repeat(4, 1fr);
}
}
[data-md-scheme-picker-option] {
display: flex;
min-inline-size: 0;
flex-direction: column;
align-items: flex-start;
gap: var(--md-sys-measurement-space100);
cursor: pointer;
-webkit-user-select: none;
user-select: none;
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-corner-lg);
padding: 12px;
background-color: var(--md-sys-color-surface-container-low);
color: var(--md-sys-color-on-surface);
transition-property: background-color, border-color;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
&:has(:checked) {
border-color: transparent;
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
/* Focus lands on the input inside, not the label, so it is read with :has() — the one
difference md-state-layer's own :focus-visible rule cannot draw (group.css's segments
keep the same refinement, for the same reason). */
&:has(:focus-visible) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
&::before {
opacity: var(--md-sys-state-focus-state-layer-opacity);
}
}
& > input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border-width: 0;
}
}
[data-md-scheme-picker-swatches] {
display: flex;
flex-shrink: 0;
}
[data-md-scheme-picker-swatches] > * + * {
margin-inline-start: -6px;
}
[data-md-scheme-picker-swatch] {
inline-size: 20px;
block-size: 20px;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--swatch-light);
box-shadow: 0 0 0 2px var(--md-sys-color-surface-container-low);
}
[data-theme='dark'] [data-md-scheme-picker-swatch] {
background-color: var(--swatch-dark);
}
[data-md-scheme-picker-option]:has(:checked) [data-md-scheme-picker-swatch] {
box-shadow: 0 0 0 2px var(--md-sys-color-secondary-container);
}
[data-md-scheme-picker-label] {
inline-size: 100%;
padding-inline-end: var(--md-sys-measurement-space300);
}
[data-md-scheme-picker-check] {
position: absolute;
pointer-events: none;
inset-inline-end: var(--md-sys-measurement-space100);
top: var(--md-sys-measurement-space100);
display: none;
}
[data-md-scheme-picker-option]:has(:checked) [data-md-scheme-picker-check] {
display: block;
}
[data-md-scheme-picker-hint] {
margin-block-start: var(--md-sys-measurement-space50);
}
}
@@ -52,46 +52,42 @@
@if ($profiles !== [])
<fieldset
x-data="{ swatches: @js($swatches) }"
data-scheme-picker
{{ $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except('name')->class('min-w-0') }}
data-md-scheme-picker
{{ $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except('name') }}
>
@if (filled($label))
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
<legend data-md-scheme-picker-legend class="md-type-label-lg md-ink-variant">{{ $label }}</legend>
@endif
<div class="grid grid-cols-2 gap-2 medium:grid-cols-4">
<div data-md-scheme-picker-options>
@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"
>
<label data-md-scheme-picker-option="{{ $profile }}" class="md-state-layer">
<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">
<span data-md-scheme-picker-swatches aria-hidden="true">
@foreach ($roles as $role)
<span
data-md-scheme-picker-swatch
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>
<span data-md-scheme-picker-label class="md-truncate md-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 data-md-scheme-picker-check>
<x-livewire-material::icon name="check" :size="20" />
</span>
</label>
@endforeach
@@ -99,10 +95,10 @@
@if ($messages !== [])
@foreach ($messages as $message)
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
<p data-md-scheme-picker-hint class="md-type-body-sm md-ink-error">{{ $message }}</p>
@endforeach
@elseif (filled($hint))
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
<p data-md-scheme-picker-hint class="md-type-body-sm md-ink-variant">{{ $hint }}</p>
@endif
</fieldset>
@endif