The md handle is 52px, not the 44 the MDC tokens gave it; the value indicator is M3's 44x48 label container, not Flutter's 32px pill; and a stop indicator, a tick and the inset icon now take the "on" colour of the part of the track they sit on, which M3's nine colour roles spell out and the library had inverted. Space held with an arrow moves by the same large interval as PageUp, M3's "Space & Arrows" row. Plan step 20, findings IN-04, IN-05, IN-06 and IN-24. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
210 lines
9.8 KiB
PHP
210 lines
9.8 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-slider-drawing wire:ignore aria-hidden="true"')
|
|
->toContain('noscript:appearance-auto')
|
|
->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-segment="active"\s+class="[^"]*bg-primary[^"]*"\s+style="left: calc\(0% \+ 0px\); width: calc\(40% - 8px\); border-radius: 8px 2px 2px 8px"/')
|
|
->toMatch('/data-segment="end"\s+class="[^"]*bg-secondary-container[^"]*"\s+style="left: calc\(40% \+ 8px\); width: calc\(60% - 8px\); border-radius: 2px 8px 8px 2px"/')
|
|
->toMatch('/data-segment="start"\s+hidden/')
|
|
->toContain('style="left: calc(100% - 8px)"')
|
|
->toContain('<span data-handle="start" class="group/thumb absolute inset-y-0 w-0" style="left: calc(40% + 0px)">')
|
|
->toMatch('/data-value-label\s+class="[^"]*opacity-0[^"]*"\s*>40<\/span>/');
|
|
});
|
|
|
|
it('draws M3\'s 44x48 value indicator and puts the stop on the inactive track', function () {
|
|
expect((string) $this->blade('<x-slider value="40" />'))
|
|
->toContain('flex h-11 min-w-12 origin-bottom')
|
|
->toMatch('/data-stop="end"[^>]*class="[^"]*bg-on-secondary-container/');
|
|
});
|
|
|
|
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-handle="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('text-error')
|
|
->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" />'))
|
|
->toContain('Centre is even')
|
|
->not->toContain('aria-invalid');
|
|
});
|
|
|
|
it('sizes the track and the handle by Expressive\'s tokens', function (string $size, string $track, string $handle, string $body) {
|
|
expect((string) $this->blade('<x-slider :size="$size" />', ['size' => $size]))
|
|
->toContain("data-size=\"{$size}\"")
|
|
->toContain("-translate-y-1/2 {$track}\"")
|
|
->toContain($handle)
|
|
->toMatch("/group\\/slider [^\"]* {$body} /");
|
|
})->with([
|
|
'xs' => ['xs', 'h-4', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'],
|
|
'sm' => ['sm', 'h-6', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'],
|
|
'md' => ['md', 'h-10', 'h-13 group-data-focused/thumb:h-11.5', 'h-13'],
|
|
'lg' => ['lg', 'h-14', 'h-17 group-data-focused/thumb:h-15.5', 'h-17'],
|
|
'xl' => ['xl', 'h-24', 'h-27 group-data-focused/thumb:h-25.5', 'h-27'],
|
|
]);
|
|
|
|
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" />'))
|
|
->toContain('data-track-icon')
|
|
->toMatch('/data-track-icon\s+data-active/')
|
|
->toContain('text-on-secondary-container data-active:text-on-primary');
|
|
|
|
expect((string) $this->blade('<x-slider size="xl" icon="volume_up" value="2" />'))
|
|
->toMatch('/data-track-icon\s+class/')
|
|
->toContain('size-8');
|
|
|
|
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-track-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-centered')
|
|
->toMatch('/data-segment="start"\s+class="[^"]*"\s+style="left: calc\(0% \+ 0px\); width: calc\(50% - 6px\)/')
|
|
->toMatch('/data-segment="active"\s+class="[^"]*"\s+style="left: calc\(50% \+ 0px\); width: calc\(25% - 8px\)/')
|
|
->toMatch('/data-segment="end"\s+class="[^"]*"\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-tick="'))->toBe(11)
|
|
->and($html)->toContain('data-tick="0.5"')
|
|
->and($html)->toContain('bg-on-secondary-container data-active:bg-on-primary');
|
|
|
|
expect((string) $this->blade('<x-slider ticks :max="1000" />'))->not->toContain('data-tick=')
|
|
->and((string) $this->blade('<x-slider ticks step="any" />'))->not->toContain('data-tick=')
|
|
->and((string) $this->blade('<x-slider :max="10" />'))->not->toContain('data-tick=');
|
|
});
|
|
|
|
it('shows the value label on drag, always, or never', function () {
|
|
expect((string) $this->blade('<x-slider />'))->toContain('opacity-0 scale-75 group-data-pressed/thumb:opacity-100');
|
|
|
|
expect((string) $this->blade('<x-slider value-label="always" value="30" />'))
|
|
->toContain('data-value-label')
|
|
->toContain('mt-12')
|
|
->not->toContain('opacity-0 scale-75');
|
|
|
|
expect((string) $this->blade('<x-slider value-label="never" />'))->not->toContain('data-value-label');
|
|
});
|
|
|
|
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("absolute inset-y-0 {$active} group-has-disabled/slider:bg-on-surface/38")
|
|
->toContain("absolute inset-y-0 {$inactive} group-has-disabled/slider:bg-on-surface/12")
|
|
->toMatch('/<input[^>]*\sdisabled[\s>]/');
|
|
})->with([
|
|
'primary' => ['primary', 'bg-primary', 'bg-secondary-container'],
|
|
'tertiary' => ['tertiary', 'bg-tertiary', 'bg-tertiary-container'],
|
|
'error' => ['error', 'bg-error', 'bg-error-container'],
|
|
'unknown' => ['pink', 'bg-primary', 'bg-secondary-container'],
|
|
]);
|