path = sys_get_temp_dir().'/scheme-picker-'.uniqid().'.json'; File::put($this->path, json_encode([ 'default' => 'indigo', 'profiles' => [ 'indigo' => ['label' => 'Indigo', 'light' => ['primary' => '#4f46e5', 'secondary' => '#5b5d72', 'tertiary' => '#77536d'], 'dark' => ['primary' => '#c0c1ff', 'secondary' => '#c4c5dd', 'tertiary' => '#e6bad7']], 'teal' => ['label' => 'Teal', 'light' => ['primary' => '#00897b', 'secondary' => '#4a635f', 'tertiary' => '#456179'], 'dark' => ['primary' => '#80cbc4', 'secondary' => '#b1ccc6', 'tertiary' => '#adcae5']], ], ])); config(['livewire-material.scheme' => $this->path]); }); afterEach(function () { File::delete($this->path); }); it('draws a radio per generated profile, bound, named and previewing on change', function () { $html = (string) $this->blade(''); expect($html) ->toContain('Colour profile') ->toContain('Applies after saving') ->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') ->toContain('style="--swatch-light: #00897b; --swatch-dark: #80cbc4"'); }); it('puts its other attributes on the group and the binding on the radios', function () { $html = (string) $this->blade(''); 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', 'profiles' => [ 'indigo' => [ 'label' => 'Indigo', 'light' => ['primary' => '#4f46e5', 'secondary' => '#5b5d72', 'tertiary' => '#77536d'], 'dark' => ['primary' => '#c0c1ff', 'secondary' => '#c4c5dd', 'tertiary' => '#e6bad7'], 'contrast' => ['high' => ['light' => ['primary' => '#241bb0'], 'dark' => ['primary' => '#eeeaff']]], ], ], ])); $html = (string) $this->blade(''); expect($html) // Standard is the inline pair, so a dot is painted before Alpine runs. ->toContain('style="--swatch-light: #4f46e5; --swatch-dark: #c0c1ff"') ->toContain('\u0022standard\u0022:{\u0022light\u0022:{\u0022primary\u0022:\u0022#4f46e5\u0022') ->toContain('\u0022high\u0022:{\u0022light\u0022:{\u0022primary\u0022:\u0022#241bb0\u0022') // A level the file does not carry repeats the standard colours rather than none. ->toContain('\u0022medium\u0022:{\u0022light\u0022:{\u0022primary\u0022:\u0022#4f46e5\u0022') ->toContain('swatches[\'indigo\'][$store.theme.resolvedContrast].light[\'primary\']') ->toContain('swatches[\'indigo\'][$store.theme.resolvedContrast].dark[\'tertiary\']'); }); it('keeps its inline styles to the scheme file\'s checked colours', function () { File::put($this->path, json_encode([ 'default' => 'indigo', 'profiles' => ['indigo' => ['label' => 'Indigo', 'light' => ['primary' => 'red; background: url(x)'], 'dark' => []]], ])); $html = (string) $this->blade(''); // 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}$/') // The colours Alpine swaps in are the same filtered ones, never the file's own text. ->and($html)->not->toContain('url('); }); it('shows a validation message for the bound property instead of the hint', function () { $this->withViewErrors(['colorProfile' => ['Choose one of the colour profiles.']]) ->blade('') ->assertSee('Choose one of the colour profiles.') ->assertDontSee('Applies after saving'); }); it('renders nothing for a single scheme', function () { File::put($this->path, json_encode(['light' => ['primary' => '#123456'], 'dark' => []])); expect(trim((string) $this->blade('')))->toBe(''); });