Files
livewire-material/tests/Browser/SliderTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 889f8a8aa1
tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
Put the showcase in the app shell, with a page per section
The showcase is now an overview and one page per section behind a
grouped navigation rail, moved between with wire:navigate, instead of
one long page under a scrolling top bar. The switch exposed a WebKit
bug in the menu: inside a focusable region, WebKit moves focus out of
the closing popover before its toggle event, so Escape no longer
returned focus to the trigger. The menu now reads it on beforetoggle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:30:26 +02:00

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