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
334 lines
15 KiB
PHP
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 })()");
|
|
});
|