diff --git a/resources/css/components.css b/resources/css/components.css index c1fa3021..ecf823c8 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -81,3 +81,4 @@ @import './components/section-nav.css'; @import './components/account-menu.css'; @import './components/theme-toggle.css'; +@import './components/scheme-picker.css'; diff --git a/resources/css/components/scheme-picker.css b/resources/css/components/scheme-picker.css new file mode 100644 index 00000000..593a965e --- /dev/null +++ b/resources/css/components/scheme-picker.css @@ -0,0 +1,145 @@ +/* + * : 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); + } +} diff --git a/resources/views/components/scheme-picker.blade.php b/resources/views/components/scheme-picker.blade.php index 81d403f1..110692d0 100644 --- a/resources/views/components/scheme-picker.blade.php +++ b/resources/views/components/scheme-picker.blade.php @@ -52,46 +52,42 @@ @if ($profiles !== [])
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)) - {{ $label }} + {{ $label }} @endif -
+
@foreach ($profiles as $profile => $scheme) -
@endif diff --git a/tests/Browser/ColourProfilesTest.php b/tests/Browser/ColourProfilesTest.php index c2262b34..2f85fd3a 100644 --- a/tests/Browser/ColourProfilesTest.php +++ b/tests/Browser/ColourProfilesTest.php @@ -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'"); diff --git a/tests/Browser/ThemeTest.php b/tests/Browser/ThemeTest.php index 2f6745c2..c0f387ce 100644 --- a/tests/Browser/ThemeTest.php +++ b/tests/Browser/ThemeTest.php @@ -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' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + +

This is page {{ $page }}.

+ + @livewireScripts + + + 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]); diff --git a/tests/Feature/Components/NavigationStylesheetsTest.php b/tests/Feature/Components/NavigationStylesheetsTest.php index 0df9c26f..000bd972 100644 --- a/tests/Feature/Components/NavigationStylesheetsTest.php +++ b/tests/Feature/Components/NavigationStylesheetsTest.php @@ -35,6 +35,7 @@ dataset('navigation components', [ 'section-nav', 'account-menu', 'theme-toggle', + 'scheme-picker', ]); /** diff --git a/tests/Feature/Components/SchemePickerTest.php b/tests/Feature/Components/SchemePickerTest.php index 3f548709..c06827c0 100644 --- a/tests/Feature/Components/SchemePickerTest.php +++ b/tests/Feature/Components/SchemePickerTest.php @@ -1,6 +1,7 @@ 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(''); expect($html) - ->toContain('Colour profile') + ->toContain('Colour profile') ->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('/toContain('x-on:change="$store.theme.previewScheme($event.target.value)"') ->toContain('>Teal') @@ -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(''); - expect($html)->toMatch('//') + expect($html)->toMatch('//') ->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(''); - 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}$/')