Files
livewire-material/tests/Feature/Components/SliderTest.php
T
Andreas Reinhold / reiniandClaude Fable 5.1 790ef93c6a Stand a slider up with orientation="vertical"
M3 Expressive's configuration table lists a vertical orientation the library
had no prop for (plan step 24, audit docs/audits/m3-alignment/inputs.md
§ Missing). The drawing is the horizontal one, laid out as long as the slider
is tall inside a size container and turned a quarter anticlockwise, so the
geometry, the sizes and the tokens are untouched; the value label and the inset
icon are turned back so they read across. The keyboard stays the native range's
— Up and Right raise, Down and Left lower — and the inputs say
aria-orientation. A vertical slider takes its length from its wrapper, so the
header, the SKILL.md entry and the showcase all say to give it a height. M3
keeps range sliders horizontal, so `range` wins over `orientation`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:31:07 +02:00

238 lines
11 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 items-center')
->toMatch('/data-value-label[^>]*class="[^"]*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('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-orientation="vertical"')
->toContain('aria-orientation="vertical"')
// The slider is as wide as a horizontal one is tall, and as long as its wrapper.
->toContain('w-12')
->toContain('touch-pan-x h-48 min-h-0 flex-auto [container-type:size]')
->toContain('h-[100cqw] w-[calc(100cqh-0.25rem)] -translate-1/2 -rotate-90')
// The value label is turned back, so it lands beside the handle and reads across.
->toMatch('/data-value-label[^>]*class="[^"]*rotate-90/')
->not->toContain('origin-bottom')
->not->toContain('rtl:-scale-x-100');
// The sizes are the horizontal ones, across instead of along.
expect((string) $this->blade('<x-slider orientation="vertical" size="xl" />'))->toContain('w-27')
->and((string) $this->blade('<x-slider orientation="vertical" value-label="always" />'))->toContain('ms-12');
});
it('keeps a range slider horizontal, as M3 asks', function () {
expect((string) $this->blade('<x-slider label="Price" range orientation="vertical" />'))
->not->toContain('data-orientation')
->not->toContain('aria-orientation')
->toContain('h-12');
});
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'],
]);