Files
livewire-material/tests/Browser/SliderTest.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 d15312023b Move browser-test probes off Tailwind utility classes
Plan step 38 (last batch), plan step 42's target for tests/: every
Tailwind utility class in a tests/Browser/*.php probe's Blade string
or Livewire component render() (~60 class attributes across 14 files)
becomes an inline style built from --md-sys-color-*/--md-sys-shape-*/
--md-sys-measurement-* tokens or a literal px value for an arbitrary
demo size — PickingTest's clip box, every probe's <body
class="bg-surface">, the grid/stack/row wrapper divs, the carousel
item's coloured filler. No test's assertions, selectors or expected
text change; these are layout containers around the components under
test, not anything a test reads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 05:34:20 +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 style="background-color: var(--md-sys-color-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 })()");
});