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
238 lines
11 KiB
PHP
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'],
|
|
]);
|