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:
co-authored by
Claude Sonnet 5
parent
74cbcd2f44
commit
bc284b2b33
@@ -81,3 +81,4 @@
|
||||
@import './components/section-nav.css';
|
||||
@import './components/account-menu.css';
|
||||
@import './components/theme-toggle.css';
|
||||
@import './components/scheme-picker.css';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -64,7 +64,7 @@ it('draws each profile in light and dark, and the default without the attribute'
|
||||
it('previews a profile from the picker and the showcase menu, and keeps it through wire:navigate', function () {
|
||||
$page = profilesReady(visit('/material/colour')->inLightMode());
|
||||
|
||||
$page->click('#colour [data-scheme-option="rose"]')
|
||||
$page->click('#colour [data-md-scheme-picker-option="rose"]')
|
||||
->assertScript("document.documentElement.getAttribute('data-scheme') === 'rose'")
|
||||
->assertScript(pagePrimary()." === '{$this->profiles['rose']['light']['primary']}'")
|
||||
->assertScript("window.eval(\"Alpine.store('theme').scheme\") === 'rose'");
|
||||
|
||||
@@ -103,7 +103,7 @@ it('adds a theme-color meta in the painted surface, and follows the theme and th
|
||||
->assertScript(themeColorIs($profiles['baseline']['dark']['surface']))
|
||||
->assertScript(pageSurfaceIs($profiles['baseline']['dark']['surface']));
|
||||
|
||||
$page->click('#colour [data-scheme-option="rose"]')
|
||||
$page->click('#colour [data-md-scheme-picker-option="rose"]')
|
||||
->assertScript(theme('data-scheme', 'rose'))
|
||||
->assertScript(themeColorIs($profiles['rose']['dark']['surface']))
|
||||
->assertScript(pageSurfaceIs($profiles['rose']['dark']['surface']));
|
||||
@@ -114,6 +114,53 @@ it('adds a theme-color meta in the painted surface, and follows the theme and th
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
it('picks the high contrast level when the operating system prefers it, until the visitor chooses', function () {
|
||||
visit('/material', ['contrast' => 'more'])
|
||||
->assertScript(theme('data-contrast', 'high'))
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
// Standard is the absence of the attribute (theme-script.blade.php): the stylesheet's plain
|
||||
// blocks already are that level.
|
||||
visit('/material', ['contrast' => 'no-preference'])
|
||||
->assertScript("document.documentElement.hasAttribute('data-contrast') === false");
|
||||
});
|
||||
|
||||
it('repaints the contrast level at once, and it survives a wire:navigate', function () {
|
||||
Route::middleware('web')->get('/contrast-probe/{page}', fn (string $page) => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body>
|
||||
<x-theme-toggle mode="contrast" />
|
||||
<p id="page">This is page {{ $page }}.</p>
|
||||
<a id="next" href="/contrast-probe/two" wire:navigate>Next</a>
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE, ['page' => $page]));
|
||||
|
||||
// on-surface-variant is one of the roles a contrast level moves most (showcase/sections/colour.blade.php).
|
||||
$ink = "getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-on-surface-variant').trim()";
|
||||
|
||||
$page = themeReady(visit('/contrast-probe/one'));
|
||||
$standard = $page->script($ink);
|
||||
|
||||
$page->click('label:has(input[name="material-contrast"][value="high"])')
|
||||
->assertScript(theme('data-contrast', 'high'))
|
||||
->assertScript("{$ink} !== '{$standard}'")
|
||||
->assertScript("localStorage.getItem('material-contrast') === 'high'");
|
||||
|
||||
$page->click('#next')
|
||||
->assertSeeIn('#page', 'This is page two.')
|
||||
->assertScript(theme('data-contrast', 'high'))
|
||||
->assertScript("{$ink} !== '{$standard}'")
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
it('repaints the page\'s own theme-color meta, and the next page\'s after wire:navigate', function () {
|
||||
config(['livewire-material.theme.meta' => true]);
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ dataset('navigation components', [
|
||||
'section-nav',
|
||||
'account-menu',
|
||||
'theme-toggle',
|
||||
'scheme-picker',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->path = sys_get_temp_dir().'/scheme-picker-'.uniqid().'.json';
|
||||
@@ -22,10 +23,10 @@ it('draws a radio per generated profile, bound, named and previewing on change',
|
||||
$html = (string) $this->blade('<x-scheme-picker label="Colour profile" hint="Applies after saving" wire:model="colorProfile" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Colour profile</legend>')
|
||||
->toContain('<legend data-md-scheme-picker-legend class="md-type-label-lg md-ink-variant">Colour profile</legend>')
|
||||
->toContain('Applies after saving')
|
||||
->toContain('data-scheme-option="indigo"')
|
||||
->toContain('data-scheme-option="teal"')
|
||||
->toContain('data-md-scheme-picker-option="indigo"')
|
||||
->toContain('data-md-scheme-picker-option="teal"')
|
||||
->toMatch('/<input\s+wire:model="colorProfile"\s+type="radio"\s+name="colorProfile"\s+value="teal"/')
|
||||
->toContain('x-on:change="$store.theme.previewScheme($event.target.value)"')
|
||||
->toContain('>Teal</span>')
|
||||
@@ -35,11 +36,48 @@ it('draws a radio per generated profile, bound, named and previewing on change',
|
||||
it('puts its other attributes on the group and the binding on the radios', function () {
|
||||
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" data-test="color-profile" class="mt-4" />');
|
||||
|
||||
expect($html)->toMatch('/<fieldset\s+x-data="\{ swatches: [^"]+\}"\s+data-scheme-picker\s+class="min-w-0 mt-4" data-test="color-profile"\s*>/')
|
||||
expect($html)->toMatch('/<fieldset\s+x-data="\{ swatches: [^"]+\}"\s+data-md-scheme-picker\s+data-test="color-profile" class="mt-4"\s*>/')
|
||||
->and(substr_count($html, 'wire:model="colorProfile"'))->toBe(2)
|
||||
->and(substr_count($html, 'data-test="color-profile"'))->toBe(1);
|
||||
});
|
||||
|
||||
it('lays the swatch cards out 2 up below medium and 4 up from it', function () {
|
||||
$css = ComponentStylesheet::read('scheme-picker');
|
||||
|
||||
expect($css->declarations('[data-md-scheme-picker-options]'))
|
||||
->toBe([
|
||||
'display' => 'grid',
|
||||
'grid-template-columns' => 'repeat(2, 1fr)',
|
||||
'gap' => 'var(--md-sys-measurement-space100)',
|
||||
])
|
||||
->and($css->declarations('[data-md-scheme-picker-options]', ['@media (width >= 600px)']))
|
||||
->toBe(['grid-template-columns' => 'repeat(4, 1fr)']);
|
||||
});
|
||||
|
||||
it('marks the chosen card through the radio inside, read with :has()', function () {
|
||||
$css = ComponentStylesheet::read('scheme-picker');
|
||||
|
||||
expect($css->declarations('[data-md-scheme-picker-option]:has(:checked)'))
|
||||
->toBe([
|
||||
'border-color' => 'transparent',
|
||||
'background-color' => 'var(--md-sys-color-secondary-container)',
|
||||
'color' => 'var(--md-sys-color-on-secondary-container)',
|
||||
])
|
||||
->and($css->declarations('[data-md-scheme-picker-option]:has(:checked) [data-md-scheme-picker-swatch]'))
|
||||
->toBe(['box-shadow' => '0 0 0 2px var(--md-sys-color-secondary-container)'])
|
||||
->and($css->declarations('[data-md-scheme-picker-option]:has(:checked) [data-md-scheme-picker-check]'))
|
||||
->toBe(['display' => 'block']);
|
||||
});
|
||||
|
||||
it('swaps a swatch to its dark colour under [data-theme=dark], never by class', function () {
|
||||
$css = ComponentStylesheet::read('scheme-picker');
|
||||
|
||||
expect($css->declarations('[data-md-scheme-picker-swatch]'))
|
||||
->toHaveKey('background-color', 'var(--swatch-light)')
|
||||
->and($css->declarations("[data-theme='dark'] [data-md-scheme-picker-swatch]"))
|
||||
->toBe(['background-color' => 'var(--swatch-dark)']);
|
||||
});
|
||||
|
||||
it('swaps the dots to the contrast level on screen', function () {
|
||||
File::put($this->path, json_encode([
|
||||
'default' => 'indigo',
|
||||
@@ -74,7 +112,9 @@ it('keeps its inline styles to the scheme file\'s checked colours', function ()
|
||||
|
||||
$html = (string) $this->blade('<x-scheme-picker wire:model="colorProfile" />');
|
||||
|
||||
preg_match_all('/\sstyle="([^"]*)"/', $html, $styles);
|
||||
// Only the swatches' own style: the check icon's --md-icon-size is a size prop, not a colour
|
||||
// the scheme file supplied.
|
||||
preg_match_all('/data-md-scheme-picker-swatch\s+style="([^"]*)"/', $html, $styles);
|
||||
|
||||
expect($styles[1])->not->toBeEmpty()
|
||||
->each->toMatch('/^--swatch-light: #[0-9a-fA-F]{6}; --swatch-dark: #[0-9a-fA-F]{6}$/')
|
||||
|
||||
Reference in New Issue
Block a user