Files
livewire-material/tests/Browser/SliderTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 281c21b0a4 Draw the slider without Tailwind
<x-slider> renders data-md-slider with its size, colour, value label,
orientation and centring as data-md-* attributes and its parts as
data-md-slider-*; every size, colour role, disabled state, the vertical
quarter turn, RTL mirroring and the no-script fallback move into
components/slider.css as custom properties per size and colour (plan
step 36). slider.js reads the props from the root and marks the
renamed handle, tick and icon hooks.

Browser tests pin the handle centred on the track at each size, Space
held with an arrow, and the vertical slider's drag along Y, keys and
value label beside the handle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 15:01:19 +02:00

334 lines
15 KiB
PHP

<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
/**
* A Livewire component bound live to a slider and a range, whose values the server can also set.
*/
class SliderProbe extends Component
{
public int $volume = 30;
/** @var array<int, int|string> */
public array $price = [20, 80];
public function maximise(): void
{
$this->volume = 90;
}
public function render(): string
{
return <<<'BLADE'
<div style="width: 420px; padding: 4rem 2rem">
<x-slider label="Volume" wire:model.live="volume" />
<p>volume: <span id="volume">{{ $volume }}</span></p>
<x-slider label="Price" wire:model.live="price" range />
<p>price: <span id="price">{{ implode(',', $price) }}</span></p>
<button type="button" wire:click="maximise">Maximise</button>
</div>
BLADE;
}
}
function sliderProbe()
{
Livewire::component('slider-probe', SliderProbe::class);
Route::middleware('web')->get('/slider-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:slider-probe />
@livewireScripts
</body>
</html>
BLADE));
return visit('/slider-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
function sliderShowcase()
{
return visit('/material/sliders')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
/**
* A script run against the first slider labelled `$label`, scrolled into view. In scope: `root`
* (the element with x-data), `inputs`, `width` (the track's), `left(selector)` (a drawn part's
* position), `segment(name)` (a track segment's {from, to}, or null while hidden), `near()`,
* `pause(ms)` and `pointer(type, fraction)`, which sends a mouse pointer event at that fraction of
* the track. Pest retries a failing assertion: scripts that change something go through
* `script()`, and the assertions after them only read.
*/
function onSlider(string $label, string $body): string
{
return <<<JS
(async () => {
const root = [...document.querySelectorAll('[data-md-slider-control]')]
.find((slider) => slider.parentElement.querySelector(':scope > label, :scope > span')?.textContent.trim() === '{$label}')
root.scrollIntoView({ block: 'center' })
const inputs = [...root.querySelectorAll('input[type="range"]')]
const width = root.clientWidth - 4
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const near = (a, b, tolerance = 1) => Math.abs(a - b) <= tolerance
const left = (selector) => Number.parseFloat(root.querySelector(selector).style.left)
const segment = (name) => {
const element = root.querySelector(`[data-md-slider-segment="\${name}"]`)
const from = Number.parseFloat(element.style.left)
return element.hidden ? null : { from, to: from + Number.parseFloat(element.style.width) }
}
const pointer = (type, fraction) => {
const box = root.getBoundingClientRect()
const target = type === 'pointerdown' ? root : window
target.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, clientX: box.left + 2 + width * fraction, clientY: box.top + box.height / 2, button: 0, pointerId: 1, pointerType: 'mouse' }))
}
{$body}
})()
JS;
}
it('moves with the keyboard, and the drawing follows', function () {
$page = sliderShowcase()->assertNoJavaScriptErrors();
$page->script(onSlider('Volume', 'inputs[0].focus()'));
$page->keys(':focus', ['ArrowRight', 'ArrowRight'])
->assertScript(onSlider('Volume', <<<'JS'
return inputs[0].value === '42'
&& near(left('[data-md-slider-handle="start"]'), width * 0.42)
&& near(segment('active').to, width * 0.42 - 8)
&& near(segment('end').from, width * 0.42 + 8)
&& root.querySelector('[data-md-slider-handle="start"]').hasAttribute('data-md-focused')
&& root.querySelector('[data-md-slider-value]').textContent === '42'
JS));
$page->keys(':focus', 'PageUp')
->assertScript(onSlider('Volume', "return inputs[0].value === '52' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.52)"));
$page->keys(':focus', 'End')
->assertScript(onSlider('Volume', "return inputs[0].value === '100' && segment('end') === null && ! root.querySelector('[data-md-slider-stop=\"end\"]').checkVisibility()"));
$page->keys(':focus', 'Home')
->assertScript(onSlider('Volume', "return inputs[0].value === '0' && segment('active') === null && near(segment('end').from, 8)"));
});
it('follows a pointer drag with a narrowed handle and its value label', function () {
$page = sliderShowcase();
$page->script(onSlider('Volume', "pointer('pointerdown', 0.4); pointer('pointermove', 0.75)"));
$page->assertScript(onSlider('Volume', <<<'JS'
await pause(450)
const thumb = root.querySelector('[data-md-slider-handle="start"]')
const label = thumb.querySelector('[data-md-slider-value]')
return inputs[0].value === '75'
&& label.textContent === '75'
&& getComputedStyle(label).opacity === '1'
&& thumb.hasAttribute('data-md-pressed')
&& thumb.firstElementChild.offsetWidth === 2
&& near(left('[data-md-slider-handle="start"]'), width * 0.75)
&& document.activeElement === inputs[0]
JS));
$page->script(onSlider('Volume', "window.__changed = 0; inputs[0].addEventListener('change', () => window.__changed++); pointer('pointerup', 0.75)"));
$page->assertScript(onSlider('Volume', <<<'JS'
await pause(450)
const thumb = root.querySelector('[data-md-slider-handle="start"]')
return inputs[0].value === '75'
&& window.__changed === 1
&& ! thumb.hasAttribute('data-md-pressed')
&& ! thumb.hasAttribute('data-md-focused')
&& thumb.firstElementChild.offsetWidth === 4
&& getComputedStyle(thumb.querySelector('[data-md-slider-value]')).opacity === '0'
JS));
});
it('keeps a range\'s handles from crossing, by pointer and by keyboard', function () {
$page = sliderShowcase();
// The start handle, from 20 dragged past the end at 60, stops on it.
$page->script(onSlider('Price', "pointer('pointerdown', 0.2); pointer('pointermove', 0.9); pointer('pointerup', 0.9)"));
$page->assertScript(onSlider('Price', <<<'JS'
return inputs[0].value === '60' && inputs[1].value === '60'
&& near(left('[data-md-slider-handle="start"]'), left('[data-md-slider-handle="end"]'))
&& segment('active') === null
JS));
$page->script(onSlider('Price', 'inputs[1].focus()'));
$page->keys(':focus', 'Home')
->assertScript(onSlider('Price', "return inputs[0].value === '60' && inputs[1].value === '60'"));
// Bound with x-model to an array: the model never holds crossed ends either.
$page->script(onSlider('Bound price', 'inputs[0].focus()'));
$page->keys(':focus', 'End')
->assertScript("document.querySelector('[data-bound=\"price\"]').textContent === '[300,300]'");
});
it('fills a centred slider from the middle', function () {
$page = sliderShowcase();
$page->assertScript(onSlider('Balance', <<<'JS'
const handle = left('[data-md-slider-handle="start"]')
return inputs[0].value === '15'
&& near(handle, width * 0.65)
&& near(segment('start').to, width / 2 - 6)
&& near(segment('active').from, width / 2)
&& near(segment('active').to, handle - 8)
&& near(segment('end').from, handle + 8)
JS));
$page->script(onSlider('Balance', 'inputs[0].focus()'));
$page->keys(':focus', 'Home')
->assertScript(onSlider('Balance', <<<'JS'
return inputs[0].value === '-50'
&& segment('start') === null
&& near(segment('active').from, 8)
&& near(segment('active').to, width / 2)
&& near(segment('end').from, width / 2 + 6)
JS));
});
it('follows a value x-model sets from outside', function () {
$page = sliderShowcase();
$page->script("[...document.querySelectorAll('#sliders button')].find((button) => button.textContent.trim() === 'Set 80').click()");
$page->assertScript(onSlider('Bound volume', "return inputs[0].value === '80' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.8)"));
});
it('sends live values to Livewire, is not moved back by a render mid-drag, and moves when the server sets a value', function () {
$page = sliderProbe();
$page->script(onSlider('Volume', 'inputs[0].focus()'));
$page->keys(':focus', ['ArrowRight', 'ArrowRight', 'ArrowRight'])
->assertSeeIn('#volume', '33');
// A drag that outruns a round trip: while the request for 50 is on its way, the handle moves
// on to 70, and the render for 50 must not pull it back.
$page->script(onSlider('Volume', <<<'JS'
window.__renders = 0
let moved = false
window.Livewire.hook('commit', ({ succeed }) => {
if (! moved) {
moved = true
queueMicrotask(() => pointer('pointermove', 0.7))
}
succeed(() => window.__renders++)
})
pointer('pointerdown', 0.5)
JS));
// Both round trips back: the handle is where it was dragged, and so is the property.
$page->assertScript(onSlider('Volume', <<<'JS'
return window.__renders >= 2 && inputs[0].value === '70' && near(left('[data-md-slider-handle="start"]'), width * 0.7)
JS))
->assertSeeIn('#volume', '70');
$page->script(onSlider('Volume', "pointer('pointerup', 0.7)"));
$page->click('button:has-text("Maximise")')
->assertSeeIn('#volume', '90')
->assertScript(onSlider('Volume', "return inputs[0].value === '90' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.9) && root.querySelector('[data-md-slider-value]').textContent === '90'"));
$page->script(onSlider('Price', 'inputs[1].focus()'));
$page->keys(':focus', 'ArrowLeft')
->assertSeeIn('#price', '20,79');
});
it('centres the handle on the track at every size', function () {
$page = sliderShowcase();
foreach (['Volume', 'Small (24px)', 'Medium (40px)', 'Extra large (96px)'] as $label) {
$page->assertScript(onSlider($label, <<<'JS'
const thumb = root.querySelector('[data-md-slider-thumb]').getBoundingClientRect()
const track = root.querySelector('[data-md-slider-track]').getBoundingClientRect()
const box = root.getBoundingClientRect()
return near(thumb.top + thumb.height / 2, track.top + track.height / 2)
&& near(track.top + track.height / 2, box.top + box.height / 2)
&& thumb.height === box.height - (box.height === 48 ? 4 : 0)
JS));
}
});
it('moves by the large interval with an arrow while Space is held', function () {
$page = sliderShowcase();
$page->script(onSlider('Volume', "inputs[0].focus(); inputs[0].dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true, cancelable: true }))"));
$page->keys(':focus', 'ArrowRight')
->assertScript(onSlider('Volume', "return inputs[0].value === '50' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.5)"));
$page->script(onSlider('Volume', "inputs[0].dispatchEvent(new KeyboardEvent('keyup', { key: ' ', code: 'Space', bubbles: true }))"));
$page->keys(':focus', 'ArrowRight')
->assertScript(onSlider('Volume', "return inputs[0].value === '51'"));
});
it('stands a slider up: dragged along Y, moved by Up, Down, Home and End, its label beside the handle', function () {
$page = sliderShowcase();
// Where the handle sits along a vertical slider, measured up from its bottom edge.
$vertical = <<<'JS'
const controls = [...document.querySelectorAll('[data-md-slider][data-md-orientation="vertical"] [data-md-slider-control]')]
const slider = controls.find((control) => control.parentElement.querySelector(':scope > label')?.textContent.trim() === 'Warmth')
slider.scrollIntoView({ block: 'center' })
const input = slider.querySelector('input[type="range"]')
const length = slider.clientHeight - 4
const box = () => slider.getBoundingClientRect()
const thumb = () => slider.querySelector('[data-md-slider-thumb]').getBoundingClientRect()
const up = () => box().bottom - 2 - (thumb().top + thumb().height / 2)
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const pointer = (type, fraction) => (type === 'pointerdown' ? slider : window).dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, clientX: box().left + box().width / 2, clientY: box().bottom - 2 - length * fraction, button: 0, pointerId: 1, pointerType: 'mouse' }))
JS;
$page->assertScript("(async () => { {$vertical}; return input.value === '6' && input.getAttribute('aria-orientation') === 'vertical' && box().height > box().width })()");
// Warmth has ten steps on ticks, so a drag to 30% of the way up lands on 3.
$page->script("(async () => { {$vertical}; pointer('pointerdown', 0.9); pointer('pointermove', 0.3) })()");
$page->assertScript("(async () => { {$vertical}; await pause(400);
const label = slider.querySelector('[data-md-slider-value]').getBoundingClientRect()
const handle = thumb()
return input.value === '3'
&& slider.querySelector('[data-md-slider-value]').textContent === '3'
// Beside the handle, not above it: level with it, and off to one side.
&& Math.abs((label.top + label.height / 2) - (handle.top + handle.height / 2)) < 2
&& (label.right <= handle.left || label.left >= handle.right)
})()");
$page->script("(async () => { {$vertical}; pointer('pointerup', 0.3); input.focus() })()");
$page->keys(':focus', 'ArrowUp')
->assertScript("(async () => { {$vertical}; await pause(400); return input.value === '4' })()");
$page->keys(':focus', 'ArrowDown')
->keys(':focus', 'ArrowDown')
->assertScript("(async () => { {$vertical}; return input.value === '2' })()");
$page->keys(':focus', 'End')
->assertScript("(async () => { {$vertical}; await pause(400); return input.value === '10' && up() > length - 2 })()");
$page->keys(':focus', 'Home')
->assertScript("(async () => { {$vertical}; await pause(400); return input.value === '0' && up() < 2 })()");
});