*/
public array $price = [20, 80];
public function maximise(): void
{
$this->volume = 90;
}
public function render(): string
{
return <<<'BLADE'
volume: {{ $volume }}
price: {{ implode(',', $price) }}
BLADE;
}
}
function sliderProbe()
{
Livewire::component('slider-probe', SliderProbe::class);
Route::middleware('web')->get('/slider-probe', fn () => Blade::render(<<<'BLADE'
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
@livewireScripts
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 << {
const root = [...document.querySelectorAll('[data-slider]')]
.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-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-handle="start"]'), width * 0.42)
&& near(segment('active').to, width * 0.42 - 8)
&& near(segment('end').from, width * 0.42 + 8)
&& root.querySelector('[data-handle="start"]').hasAttribute('data-focused')
&& root.querySelector('[data-value-label]').textContent === '42'
JS));
$page->keys(':focus', 'PageUp')
->assertScript(onSlider('Volume', "return inputs[0].value === '52' && near(left('[data-handle=\"start\"]'), width * 0.52)"));
$page->keys(':focus', 'End')
->assertScript(onSlider('Volume', "return inputs[0].value === '100' && segment('end') === null && ! root.querySelector('[data-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-handle="start"]')
const label = thumb.querySelector('[data-value-label]')
return inputs[0].value === '75'
&& label.textContent === '75'
&& getComputedStyle(label).opacity === '1'
&& thumb.hasAttribute('data-pressed')
&& thumb.firstElementChild.offsetWidth === 2
&& near(left('[data-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-handle="start"]')
return inputs[0].value === '75'
&& window.__changed === 1
&& ! thumb.hasAttribute('data-pressed')
&& ! thumb.hasAttribute('data-focused')
&& thumb.firstElementChild.offsetWidth === 4
&& getComputedStyle(thumb.querySelector('[data-value-label]')).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-handle="start"]'), left('[data-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-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-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-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-handle=\"start\"]'), width * 0.9) && root.querySelector('[data-value-label]').textContent === '90'"));
$page->script(onSlider('Price', 'inputs[1].focus()'));
$page->keys(':focus', 'ArrowLeft')
->assertSeeIn('#price', '20,79');
});