*/ 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'); });