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
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
188 lines
7.8 KiB
PHP
188 lines
7.8 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}"]')
|
|
// Writes go through the page's own realm: Firefox runs this script in a sandbox whose
|
|
// assignments do not reach Alpine's reactive proxies.
|
|
const set = (value) => window.eval(`Alpine.\$data(document.querySelector('#progress [aria-label="{$label}"]')).progress = \${JSON.stringify(value)}`)
|
|
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 = [])
|
|
{
|
|
// networkidle alone can return before a repeated visit has even loaded in Firefox; the
|
|
// assertion retries until the page is complete and Alpine has started.
|
|
return visit('/material/progress', $options)->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
|
}
|
|
|
|
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()
|
|
set(80)
|
|
// The first frame that shows movement, not a timer: under load a timer fires late enough
|
|
// that the 330ms spring has nearly arrived, and a jump would look the same as a fast move.
|
|
// A jump's first moved frame is already at the target; a move's is on the way.
|
|
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve))
|
|
let during = reach()
|
|
for (let i = 0; i < 60 && during <= before + 0.01; i++) {
|
|
await frame()
|
|
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'
|
|
set(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
|
|
set(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));
|
|
});
|