tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m5s
tests / feature (8.5) (push) Successful in 1m5s
tests / browser (chrome, chromium) (push) Successful in 3m17s
tests / browser (firefox, firefox) (push) Successful in 4m1s
tests / browser (safari, webkit) (push) Failing after 5m38s
Standard, centered and range sliders on native range inputs, drawn as Compose draws them, in five sizes with ticks, value labels and inset icons. Completes Phase 6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
255 lines
11 KiB
PHP
255 lines
11 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')->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-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');
|
|
});
|