Files
livewire-material/tests/Browser/ProgressTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 e57063b0f4
tests / browser (chrome, chromium) (push) Successful in 2m8s
tests / browser (firefox, firefox) (push) Failing after 1m56s
tests / lint (push) Successful in 57s
tests / feature (8.4) (push) Successful in 1m5s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 2m52s
Add M3 Expressive progress indicators
<x-progress>: linear or circular, flat or wavy, determinate or
indeterminate, 4px or thick, in any colour role, following a server value
through Livewire morphs or an Alpine expression in the browser. The first
frame is server-rendered SVG; Compose Material 3's drawing and keyframes
are ported to an Alpine component that animates only while something
moves and the indicator is on screen.

The previous commit already imported progress.js and included its
showcase section without the files; this adds them.

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

175 lines
6.7 KiB
PHP

<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
/**
* A Livewire component whose value only the server changes, to watch an indicator through a morph.
*/
class ProgressMorphProbe extends Component
{
public int $done = 20;
public function advance(): void
{
$this->done = 80;
}
public function render(): string
{
return <<<'BLADE'
<div class="p-4">
<x-progress :value="$done" label="Server" />
<button type="button" wire:click="advance">Advance</button>
</div>
BLADE;
}
}
/**
* A script run against one indicator in the showcase, scrolled into view first: frames are only
* drawn while an indicator is on screen. `el` is its root; the script's value is the result.
* Pest retries a failing assertion, and gives each attempt a second: keep scripts well inside it.
*/
function onProgress(string $label, string $body): string
{
return <<<JS
(async () => {
const el = document.querySelector('#progress [aria-label="{$label}"]')
el.scrollIntoView({ block: 'center' })
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const active = () => el.querySelector('path[stroke="currentColor"]')
const reach = () => { const box = active().getBBox(); return (box.x + box.width) / el.offsetWidth }
await pause(150)
{$body}
})()
JS;
}
function progressShowcase(array $options = [])
{
return visit('/material', $options)->waitForEvent('networkidle');
}
it('draws a linear indicator to its value', function () {
progressShowcase()
->assertNoJavaScriptErrors()
->assertScript(onProgress('Linear', <<<'JS'
const box = active().getBBox()
return Math.abs(box.x - 2) < 0.5 && Math.abs(box.x + box.width - el.offsetWidth * 0.4) < 1
&& el.querySelectorAll('svg svg').length === 0
JS));
});
it('draws a circular arc to its value, from 12 o\'clock', function () {
progressShowcase()->assertScript(onProgress('Circular', <<<'JS'
const path = active()
const end = path.getPointAtLength(path.getTotalLength())
const start = path.getPointAtLength(0)
const turn = (point) => (Math.atan2(point.y - el.offsetHeight / 2, point.x - el.offsetWidth / 2) * 180 / Math.PI + 450) % 360 / 360
return Math.min(turn(start), 1 - turn(start)) < 0.005 && Math.abs(turn(end) - 0.65) < 0.005
JS));
});
it('draws a circular wave as far as its value', function () {
progressShowcase()->assertScript(onProgress('Circular wavy', <<<'JS'
const path = active()
const [dash] = path.getAttribute('stroke-dasharray').split(' ').map(Number)
return path.getAttribute('visibility') === null && Math.abs(dash / (Number(path.getAttribute('pathLength')) / 2) - 0.65) < 0.001
JS));
});
it('moves to a new bound value instead of jumping', function () {
progressShowcase()
->assertScript(onProgress('Bound', <<<'JS'
const before = reach()
Alpine.$data(el).progress = 80
await pause(80)
const during = reach()
return Math.abs(before - 0.3) < 0.01 && during > before + 0.01 && during < 0.79
JS))
->wait(1)
->assertScript(onProgress('Bound', "return Math.abs(reach() - 0.8) < 0.01 && el.getAttribute('aria-valuenow') === '80'"));
});
it('turns indeterminate when a bound value is null, and back', function () {
progressShowcase()->assertScript(onProgress('Bound', <<<'JS'
Alpine.$data(el).progress = null
await pause(100)
const indeterminate = !el.hasAttribute('aria-valuenow') && !el.hasAttribute('data-value')
const first = active().getAttribute('d')
await pause(200)
const moving = active().getAttribute('d') !== first
Alpine.$data(el).progress = 50
await pause(100)
return indeterminate && moving && el.getAttribute('aria-valuenow') === '50' && Math.abs(reach() - 0.5) < 0.01
JS));
});
it('follows a value the server changes through a Livewire morph', function () {
Livewire::component('progress-morph-probe', ProgressMorphProbe::class);
Route::middleware('web')->get('/progress-morph-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:progress-morph-probe />
@livewireScripts
</body>
</html>
BLADE));
$reach = "(() => { const el = document.querySelector('[aria-label=\"Server\"]'); const box = el.querySelector('path[stroke=\"currentColor\"]').getBBox(); return (box.x + box.width) / el.offsetWidth })()";
$page = visit('/progress-morph-probe')->waitForEvent('networkidle')
->assertNoJavaScriptErrors()
->assertScript("Math.abs({$reach} - 0.2) < 0.01");
$page->click('Advance')
->wait(1.5)
->assertAttribute('[aria-label="Server"]', 'aria-valuenow', '80')
->assertScript("Math.abs({$reach} - 0.8) < 0.01")
->assertScript("document.querySelectorAll('[aria-label=\"Server\"] svg svg').length === 0");
});
it('animates while indeterminate', function () {
progressShowcase()
->assertScript(onProgress('Linear, indeterminate', <<<'JS'
const first = active().getAttribute('d')
await pause(200)
return active().getAttribute('d') !== first
JS))
->assertScript(onProgress('Circular wavy, indeterminate', <<<'JS'
const group = el.querySelector('g')
const first = group.getAttribute('transform') + active().getAttribute('stroke-dasharray')
await pause(200)
return group.getAttribute('transform') + active().getAttribute('stroke-dasharray') !== first
JS));
});
it('holds still under reduced motion', function () {
progressShowcase(['reducedMotion' => 'reduce'])
->assertScript(onProgress('Wavy, indeterminate', <<<'JS'
const first = active().getAttribute('d')
await pause(300)
return active().getAttribute('d') === first
JS))
->assertScript(onProgress('Wavy', <<<'JS'
const first = active().getAttribute('d')
await pause(300)
return active().getAttribute('d') === first
JS))
->assertScript(onProgress('Bound', <<<'JS'
Alpine.$data(el).progress = 90
await pause(50)
return Math.abs(reach() - 0.9) < 0.01
JS));
});