An over-engineering audit of the whole tree, applied in five reviewed batches. Behaviour stays the same except where UPGRADE.md says otherwise. PHP: the showcase and error-page stylesheets are prebuilt into resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import (first occurrence kept, the order an application's build gives), instead of Stylesheets::bundle() inlining imports on every request; only the import walk DesignGuard needs stays. SchemeStylesheet::withProfiles() replaces three copies of the scheme-plus-profiles loop, material:scheme leaves spec and contrast checks to the node script that already made them, and the error page's scheme cache, the hashed view namespace, the translations path with no lang/ folder and DesignGuard's 1.x-name hints are gone. JS: the androidx shape port progress.js and both bin scripts each carried lives once in resources/js/shapes.js (the generated SVGs are unchanged); util.js holds ringIndex(), ms(), reopenGuard() and remember(), which were written out several times; listeners are released through AbortController; tooltip.js's hoverPopover() serves the rich tooltip too. CSS: every rule for an element inside the navigation rail queries `--md-navigation-rail-value` instead of repeating the seven collapsed conditions under five media branches; badge, alert, progress, slider and button read one non-inheriting colour-role table (components/color.css); the dialog chrome, the submenu's popover chrome, the chip's state layer and touch target, and the visually-hidden inputs use the shared rules they copied; foundation/tokens.css is folded into foundation.css. Views: Support\Field and Support\Link replace the error-key, bound-value and link-attribute blocks copied into the fields and link components; the timepicker period group, the menu filter and the showcase head are partials; the datepicker's steppers and entry fields are loops; component docblocks no longer restate SKILL.md. Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces four per-group files, DesignGuardTest and the layout-component tests use datasets, browser tests share one ready() helper, CSS parsing lives in ComponentStylesheet alone. docs/audits and the finding IDs citing it are removed, as are pestphp/pest-plugin-laravel, the unused composer scripts and check:font; the lint job runs in the feature job, which now installs node packages so the prebuilt-stylesheet staleness test runs in CI. Feature suite 1177 passed, Chrome browser suite 299 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
332 lines
15 KiB
PHP
332 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 ready(visit('/slider-probe'));
|
|
}
|
|
|
|
function sliderShowcase()
|
|
{
|
|
return ready(visit('/material/sliders'));
|
|
}
|
|
|
|
/**
|
|
* 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 })()");
|
|
});
|