Plan step 37: all.css replaces the two interim import lists with one entry for an application that wants everything — the foundation, every layout stylesheet and every component stylesheet, grouped under the same block comments components.css used, plus a Layout block. It also directly imports the three files nothing imported by name before (layout/spacing.css, layout/visibility.css, components/selection.css), so every file under components/ and layout/ is now one @import away. Every test that read a block of components.css or layout.css now reads the matching block of all.css through one shared helper (allCssBlock(), in tests/Pest.php so it loads for any test run) instead of repeating the same substr() search in each file. StylesheetsTest.php's shape, Tailwind-free and breakpoint checks, previously run twice (once for the foundation, once for the layout tree), now run once over the whole tree all.css reaches, since every component and layout stylesheet is plain CSS after step 36; a new test asserts all.css imports everything under components/ and layout/ exactly once. The Workbench imports all.css in place of layout.css and components.css. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
272 lines
14 KiB
PHP
272 lines
14 KiB
PHP
<?php
|
|
|
|
use Illuminate\Support\MessageBag;
|
|
use Illuminate\Support\ViewErrorBag;
|
|
use Livewire\Component;
|
|
use Livewire\Livewire;
|
|
|
|
it('is a labelled native range input under a drawing only the script touches', function () {
|
|
$html = (string) $this->blade('<x-slider label="Volume" name="volume" value="40" :min="0" :max="200" :step="5" id="volume" />');
|
|
|
|
expect($html)
|
|
->toContain('<label for="volume" id="volume-label"')
|
|
->toContain('type="range"')
|
|
->toContain('id="volume"')
|
|
->toContain('name="volume"')
|
|
->toContain('min="0"')
|
|
->toContain('max="200"')
|
|
->toContain('step="5"')
|
|
->toContain('value="40"')
|
|
->toContain('x-data="materialSlider"')
|
|
->toContain('x-on:pointerdown="press($event)"')
|
|
->toContain('data-md-slider-control')
|
|
->toContain('<div data-md-slider-drawing wire:ignore aria-hidden="true">')
|
|
->toContain('data-md-slider-input="start"')
|
|
->not->toContain('class=')
|
|
->not->toContain('role="group"');
|
|
});
|
|
|
|
it('draws the first frame on the server: the track split around the handle, and the stop', function () {
|
|
$html = (string) $this->blade('<x-slider value="40" />');
|
|
|
|
expect($html)
|
|
->toMatch('/data-md-slider-segment="active"\s+style="left: calc\(0% \+ 0px\); width: calc\(40% - 8px\); border-radius: 8px 2px 2px 8px"/')
|
|
->toMatch('/data-md-slider-segment="end"\s+style="left: calc\(40% \+ 8px\); width: calc\(60% - 8px\); border-radius: 2px 8px 8px 2px"/')
|
|
->toMatch('/data-md-slider-segment="start"\s+hidden/')
|
|
->toContain('style="left: calc(100% - 8px)"')
|
|
->toContain('<span data-md-slider-handle="start" style="left: calc(40% + 0px)">')
|
|
->toContain('<span data-md-slider-value>40</span>')
|
|
->toMatch('/<div\s+data-md-slider\s+data-md-size="xs"\s+data-md-color="primary"\s+data-md-value-label="drag"/');
|
|
});
|
|
|
|
it('draws M3\'s 44x48 value indicator and puts the stop on the inactive track', function () {
|
|
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
|
|
|
|
expect((string) $this->blade('<x-slider value="40" />'))->toMatch('/data-md-slider-stop="end"\s+style="left: calc\(100% - 8px\)"/')
|
|
->and($css)
|
|
->toMatch('/\[data-md-slider-value\] \{[^}]*min-width: var\(--md-sys-measurement-space600\);[^}]*height: 44px;[^}]*background-color: var\(--md-sys-color-inverse-surface\);[^}]*transform-origin: bottom;/')
|
|
->toMatch('/:is\(\[data-md-slider-tick\], \[data-md-slider-stop\]\) \{[^}]*background-color: var\(--slider-on-inactive\);/')
|
|
->toContain("@import './icon.css';")
|
|
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/slider.css';");
|
|
});
|
|
|
|
it('snaps and clamps the value to the step grid', function (string $template, string $value) {
|
|
expect((string) $this->blade($template))->toContain("value=\"{$value}\"");
|
|
})->with([
|
|
'between steps' => ['<x-slider value="23" :step="5" />', '25'],
|
|
'below the minimum' => ['<x-slider :value="-20" />', '0'],
|
|
'past the last step' => ['<x-slider value="10" :max="10" :step="3" />', '9'],
|
|
'a fraction' => ['<x-slider value="0.34" :max="1" step="0.1" />', '0.3'],
|
|
'continuous' => ['<x-slider value="12.345" step="any" />', '12.345'],
|
|
'centred without a value' => ['<x-slider :min="-50" :max="50" centered />', '0'],
|
|
]);
|
|
|
|
it('binds wire:model and x-model to the input as numbers, and names it after the property', function () {
|
|
expect((string) $this->blade('<x-slider wire:model.live.debounce.250ms="volume" />'))
|
|
->toContain('wire:model.number.live.debounce.250ms="volume"')
|
|
->toContain('name="volume"');
|
|
|
|
expect((string) $this->blade('<x-slider x-model="volume" name="volume" x-on:change="saved = true" />'))
|
|
->toContain('x-model.number="volume"')
|
|
->toContain('x-on:change="saved = true"');
|
|
|
|
expect((string) $this->blade('<x-slider x-model.number="volume" />'))->toContain('x-model.number="volume"');
|
|
});
|
|
|
|
it('gives a range two inputs bound to the ends of an array, in a named group', function () {
|
|
$html = (string) $this->blade('<x-slider label="Price" wire:model="price" range :value="[80, 20]" id="price" />');
|
|
|
|
expect($html)
|
|
->toContain('role="group"')
|
|
->toContain('aria-labelledby="price-label"')
|
|
->toContain('wire:model.number="price.0"')
|
|
->toContain('wire:model.number="price.1"')
|
|
->toContain('aria-label="Range start"')
|
|
->toContain('aria-label="Range end"')
|
|
->toContain('id="price-end"')
|
|
->toContain('data-md-slider-handle="end"')
|
|
->toContain('data-md-slider-input="end"')
|
|
->not->toContain('<label for=');
|
|
|
|
expect(substr_count($html, 'name="price[]"'))->toBe(2)
|
|
// Out of order, the ends are sorted, as Compose sorts them.
|
|
->and(strpos($html, 'value="20"'))->toBeLessThan(strpos($html, 'value="80"'));
|
|
|
|
expect((string) $this->blade('<x-slider x-model="price" range />'))
|
|
->toContain('x-model.number="price[0]"')
|
|
->toContain('x-model.number="price[1]"');
|
|
});
|
|
|
|
it('draws the value of the bound Livewire property', function () {
|
|
$component = new class extends Component
|
|
{
|
|
public int $volume = 70;
|
|
|
|
/** @var array<int, int> */
|
|
public array $price = [100, 400];
|
|
|
|
public function render(): string
|
|
{
|
|
return <<<'BLADE'
|
|
<div>
|
|
<x-slider wire:model.live="volume" />
|
|
<x-slider wire:model="price" range :max="500" />
|
|
</div>
|
|
BLADE;
|
|
}
|
|
};
|
|
|
|
Livewire::test($component)
|
|
->assertSeeHtml('value="70"')
|
|
->assertSeeHtml('style="left: calc(70% + 0px)"')
|
|
->assertSeeHtml('value="100"')
|
|
->assertSeeHtml('value="400"');
|
|
});
|
|
|
|
it('replaces the hint with the validation message and marks the input invalid', function () {
|
|
$errors = (new ViewErrorBag)->put('default', new MessageBag(['volume' => ['Too loud.'], 'price.1' => ['Too expensive.']]));
|
|
|
|
$this->withViewErrors([]);
|
|
view()->share('errors', $errors);
|
|
|
|
expect((string) $this->blade('<x-slider name="volume" hint="Quietly" id="volume" />'))
|
|
->toContain('Too loud.')
|
|
->toContain('<div id="volume-hint" data-md-slider-errors>')
|
|
->toContain('aria-invalid="true"')
|
|
->toContain('aria-describedby="volume-hint"')
|
|
->not->toContain('Quietly');
|
|
|
|
expect((string) $this->blade('<x-slider name="price" range hint="Per night" />'))
|
|
->toContain('Too expensive.')
|
|
->not->toContain('Per night');
|
|
|
|
expect((string) $this->blade('<x-slider name="balance" hint="Centre is even" />'))
|
|
->toMatch('/data-md-slider-hint>Centre is even<\/p>/')
|
|
->not->toContain('aria-invalid');
|
|
});
|
|
|
|
it('sizes the track and the handle by Expressive\'s tokens', function (string $size, string $track, string $handle, string $across) {
|
|
expect((string) $this->blade('<x-slider :size="$size" />', ['size' => $size]))
|
|
->toContain("data-md-size=\"{$size}\"");
|
|
|
|
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
|
|
$block = $size === 'xs'
|
|
? '/\[data-md-slider\] \{[^}]*/'
|
|
: '/\[data-md-slider\]\[data-md-size=\''.$size.'\'\] \{[^}]*/';
|
|
|
|
preg_match($block, $css, $rule);
|
|
|
|
foreach (['--slider-across' => $across, '--slider-track' => $track, '--slider-handle' => $handle] as $property => $value) {
|
|
if ($size === 'sm' && $property !== '--slider-track') {
|
|
// sm keeps xs's handle and height.
|
|
continue;
|
|
}
|
|
|
|
expect($rule[0])->toContain("{$property}: {$value};");
|
|
}
|
|
|
|
// A focused handle is 6px shorter, so its ring stays clear of the label.
|
|
expect($css)->toContain('height: calc(var(--slider-handle) - 6px);');
|
|
})->with([
|
|
'xs' => ['xs', '16px', '44px', '48px'],
|
|
'sm' => ['sm', '24px', '44px', '48px'],
|
|
'md' => ['md', '40px', '52px', '52px'],
|
|
'lg' => ['lg', '56px', '68px', '68px'],
|
|
'xl' => ['xl', '96px', '108px', '108px'],
|
|
]);
|
|
|
|
it('insets an icon in the track from md, but not on a small, range or centred slider', function () {
|
|
expect((string) $this->blade('<x-slider size="md" icon="volume_up" value="60" />'))
|
|
->toMatch('/data-md-slider-icon\s+data-md-active/')
|
|
->toContain('--md-icon-size: 24px');
|
|
|
|
expect((string) $this->blade('<x-slider size="xl" icon="volume_up" value="2" />'))
|
|
->toMatch('/data-md-slider-icon\s+style=/')
|
|
->toContain('--md-icon-size: 32px');
|
|
|
|
foreach (['<x-slider size="sm" icon="volume_up" />', '<x-slider size="lg" icon="volume_up" range />', '<x-slider size="lg" icon="volume_up" centered />'] as $template) {
|
|
expect((string) $this->blade($template))->not->toContain('data-md-slider-icon');
|
|
}
|
|
});
|
|
|
|
it('fills a centred slider from the middle', function () {
|
|
expect((string) $this->blade('<x-slider :min="-50" :max="50" value="25" centered />'))
|
|
->toContain('data-md-centered')
|
|
->toMatch('/data-md-slider-segment="start"\s+style="left: calc\(0% \+ 0px\); width: calc\(50% - 6px\)/')
|
|
->toMatch('/data-md-slider-segment="active"\s+style="left: calc\(50% \+ 0px\); width: calc\(25% - 8px\)/')
|
|
->toMatch('/data-md-slider-segment="end"\s+style="left: calc\(75% \+ 8px\)/');
|
|
});
|
|
|
|
it('marks every step when asked, and no more than 200 of them', function () {
|
|
$html = (string) $this->blade('<x-slider ticks :max="10" value="5" />');
|
|
|
|
expect(substr_count($html, 'data-md-slider-tick="'))->toBe(11)
|
|
->and($html)->toContain('data-md-slider-tick="0.5"')
|
|
->and($html)->toMatch('/data-md-slider-tick="0.2"\s+data-md-active/')
|
|
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css'))
|
|
->toMatch('/\[data-md-slider-tick\]\[data-md-active\] \{\s*background-color: var\(--slider-on-active\);/');
|
|
|
|
expect((string) $this->blade('<x-slider ticks :max="1000" />'))->not->toContain('data-md-slider-tick=')
|
|
->and((string) $this->blade('<x-slider ticks step="any" />'))->not->toContain('data-md-slider-tick=')
|
|
->and((string) $this->blade('<x-slider :max="10" />'))->not->toContain('data-md-slider-tick=');
|
|
});
|
|
|
|
it('shows the value label on drag, always, or never', function () {
|
|
expect((string) $this->blade('<x-slider />'))->toContain('data-md-value-label="drag"')
|
|
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css'))
|
|
->toMatch("/\\[data-md-slider\\]\\[data-md-value-label='drag'\\] \\[data-md-slider-value\\] \\{\\s*opacity: 0;\\s*scale: 0.75;/")
|
|
->toMatch("/\\[data-md-slider\\]\\[data-md-value-label='always'\\] \\[data-md-slider-control\\] \\{\\s*margin-top: var\\(--md-sys-measurement-space600\\);/");
|
|
|
|
expect((string) $this->blade('<x-slider value-label="always" value="30" />'))
|
|
->toContain('data-md-value-label="always"')
|
|
->toContain('<span data-md-slider-value>30</span>');
|
|
|
|
expect((string) $this->blade('<x-slider value-label="never" />'))->not->toContain('data-md-slider-value');
|
|
});
|
|
|
|
it('stands a slider up on request, turning the drawing a quarter', function () {
|
|
$html = (string) $this->blade('<x-slider label="Volume" orientation="vertical" value="40" class="h-64" />');
|
|
|
|
expect($html)
|
|
->toContain('data-md-orientation="vertical"')
|
|
->toContain('aria-orientation="vertical"')
|
|
->toContain('class="h-64"');
|
|
|
|
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
|
|
|
|
expect($css)
|
|
// The slider is as wide as a horizontal one is tall, and as long as its wrapper.
|
|
->toMatch("/\\[data-md-slider\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-control\\] \\{[^}]*width: var\\(--slider-across\\);[^}]*height: 192px;[^}]*container-type: size;/")
|
|
->toMatch("/\\[data-md-slider\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-drawing\\] \\{[^}]*width: calc\\(100cqh - var\\(--md-sys-measurement-space50\\)\\);[^}]*height: 100cqw;[^}]*rotate: -90deg;/")
|
|
// The value label is turned back, so it lands beside the handle and reads across.
|
|
->toMatch("/\\[data-md-slider\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-value\\] \\{[^}]*rotate: 90deg;/")
|
|
->toMatch("/\\[data-md-slider\\]\\[data-md-value-label='always'\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-control\\] \\{[^}]*margin-inline-start: var\\(--md-sys-measurement-space600\\);/");
|
|
});
|
|
|
|
it('keeps a range slider horizontal, as M3 asks', function () {
|
|
expect((string) $this->blade('<x-slider label="Price" range orientation="vertical" />'))
|
|
->not->toContain('data-md-orientation')
|
|
->not->toContain('aria-orientation');
|
|
});
|
|
|
|
it('draws in the colour and its container, and greys out when disabled', function (string $color, string $active, string $inactive) {
|
|
expect((string) $this->blade('<x-slider :color="$color" value="50" disabled />', ['color' => $color]))
|
|
->toContain('data-md-color="'.($color === 'pink' ? 'primary' : $color).'"')
|
|
->toMatch('/<input[^>]*\sdisabled[\s>]/');
|
|
|
|
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
|
|
|
|
if ($color === 'primary' || $color === 'pink') {
|
|
expect($css)->toMatch("/\\[data-md-slider\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
|
|
} else {
|
|
expect($css)->toMatch("/\\[data-md-slider\\]\\[data-md-color='{$color}'\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
|
|
}
|
|
|
|
expect($css)->toMatch('/\[data-md-slider\]:has\(\[data-md-slider-input\]:disabled\) \{[^}]*--slider-active: var\(--slider-disabled-active\);[^}]*--slider-inactive: var\(--slider-disabled-inactive\);/');
|
|
})->with([
|
|
'primary' => ['primary', 'primary', 'secondary-container'],
|
|
'tertiary' => ['tertiary', 'tertiary', 'tertiary-container'],
|
|
'error' => ['error', 'error', 'error-container'],
|
|
'unknown' => ['pink', 'primary', 'secondary-container'],
|
|
]);
|