tests / feature (8.4) (push) Successful in 1m50s
tests / feature (8.5) (push) Successful in 1m54s
tests / browser (chrome, chromium) (push) Successful in 7m52s
tests / browser (firefox, firefox) (push) Successful in 11m56s
tests / browser (safari, webkit) (push) Successful in 12m35s
The browser plugin runs every call on a page again when its first attempt takes over a second, and on the runner a press can. For most presses that costs nothing. For two kinds it breaks the test: a press that opens a dialog, a sheet, a full-screen view or a modal rail over its own trigger, whose second press can never land, and a press that changes state a second one would change again — a menu trigger, a toggle, a chip, a range picker's day, a paging key, a Save. The bottom sheet with preset heights timed out on WebKit that way after the date picker had on Firefox. Every such press in the browser suite now goes through pressOnce(), 253 of them across sixteen files, not only the ones the runner happened to catch; focus moves inside an open view, Escape, links and plain "set" actions keep the retry, which is harmless for them. Two samples that were still racing the machine: - The standard side sheet's exit is caught half-way with its motion stretched, as every other mid-exit sample is, and fullSpeed() takes the stretch off again before the test times a reopen against the real exit. Under load on Linux WebKit it had failed two runs in five; it passes ten in ten. - The switch-and-checkbox row measures its widths once, so it now waits for the resize to land and the brand face to load before it does. Browser 300 passed on Firefox and WebKitGTK in a Linux container held to two busy cores, and on Chrome, Firefox and WebKit on macOS. Feature 1159 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
301 lines
14 KiB
PHP
301 lines
14 KiB
PHP
<?php
|
|
|
|
use Illuminate\Support\Facades\Blade;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Livewire\Component;
|
|
use Livewire\Livewire;
|
|
|
|
class ChipProbe extends Component
|
|
{
|
|
/** @var list<string> */
|
|
public array $kinds = ['photos'];
|
|
|
|
/** @var list<string> */
|
|
public array $tags = ['php', 'js', 'css'];
|
|
|
|
public function onlyVideo(): void
|
|
{
|
|
$this->kinds = ['video'];
|
|
}
|
|
|
|
public function removeTag(string $tag): void
|
|
{
|
|
$this->tags = array_values(array_diff($this->tags, [$tag]));
|
|
}
|
|
|
|
public function render(): string
|
|
{
|
|
return <<<'BLADE'
|
|
<div style="display: flex; flex-direction: column; gap: var(--md-sys-measurement-space200); padding: var(--md-sys-measurement-space200);">
|
|
<p>kinds: <span id="kinds">{{ implode(',', $kinds) }}</span></p>
|
|
|
|
<x-chip-set label="Kinds">
|
|
@foreach (['photos' => 'Photos', 'documents' => 'Documents', 'video' => 'Video'] as $value => $name)
|
|
<x-chip type="filter" :label="$name" :value="$value" wire:model.live="kinds" wire:key="kind-{{ $value }}" />
|
|
@endforeach
|
|
</x-chip-set>
|
|
|
|
<x-button label="Only video" wire:click="onlyVideo" />
|
|
|
|
<p>tags: <span id="tags">{{ implode(',', $tags) }}</span></p>
|
|
|
|
<x-chip-set label="Tags">
|
|
@foreach ($tags as $tag)
|
|
<x-chip type="input" :label="$tag" removable wire:remove="removeTag('{{ $tag }}')" wire:key="tag-{{ $tag }}" />
|
|
@endforeach
|
|
</x-chip-set>
|
|
</div>
|
|
BLADE;
|
|
}
|
|
}
|
|
|
|
function chipProbe()
|
|
{
|
|
Livewire::component('chip-probe', ChipProbe::class);
|
|
|
|
Route::middleware('web')->get('/chip-probe', fn () => Blade::render(<<<'BLADE'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<link rel="icon" href="data:,">
|
|
<x-theme-script />
|
|
@vite(config('livewire-material.showcase.vite'))
|
|
@livewireStyles
|
|
</head>
|
|
<body style="background-color: var(--md-sys-color-surface);">
|
|
<livewire:chip-probe />
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
return ready(visit('/chip-probe'));
|
|
}
|
|
|
|
function chipShowcase()
|
|
{
|
|
return ready(visit('/material/chips'));
|
|
}
|
|
|
|
/**
|
|
* The script for a showcase filter chip's checkbox, found by its value.
|
|
*/
|
|
function filterInput(string $value): string
|
|
{
|
|
return "document.querySelector('#chips input[type=\"checkbox\"][value=\"{$value}\"]')";
|
|
}
|
|
|
|
it('toggles a filter chip with a click and with Space, and grows its check in', function () {
|
|
$check = fn (string $value): string => filterInput($value).".parentElement.querySelector('[data-md-chip-check]').parentElement.getBoundingClientRect().width";
|
|
|
|
$page = chipShowcase()->assertNoJavaScriptErrors();
|
|
|
|
// A filter chip toggles on every press: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('#chips label:has-text("Documents")');
|
|
|
|
$page->assertScript(filterInput('documents').'.checked')
|
|
->assertScript("Math.round({$check('documents')}) === 18")
|
|
->assertScript('getComputedStyle('.filterInput('documents').".parentElement).backgroundColor !== 'rgba(0, 0, 0, 0)'")
|
|
->assertScript("window.eval(\"Alpine.\$data(document.querySelector('#chips input[value=documents]')).kinds.join(',')\") === 'photos,documents'");
|
|
|
|
// A key press first, so the browser is in keyboard modality; focused directly, not tabbed to,
|
|
// because WebKit leaves form controls out of the Tab order unless full keyboard access is on.
|
|
$page->keys('#content', 'Tab');
|
|
$page->script(filterInput('archives').'.focus()');
|
|
|
|
// Space toggles a filter chip on every press: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->keys(':focus', 'Space');
|
|
|
|
$page->assertScript(filterInput('archives').'.checked')
|
|
->assertScript("Math.round({$check('archives')}) === 18");
|
|
|
|
pressOnce($page)->keys(':focus', 'Space');
|
|
|
|
$page->assertScript('! '.filterInput('archives').'.checked')
|
|
->assertScript("Math.round({$check('archives')}) === 0");
|
|
});
|
|
|
|
it('binds filter chips to a Livewire array, and a server change reaches them after a morph', function () {
|
|
$input = fn (string $value): string => "document.querySelector('input[value=\"{$value}\"]')";
|
|
|
|
$page = chipProbe()->assertNoJavaScriptErrors();
|
|
|
|
$page->assertScript("{$input('photos')}.checked && ! {$input('documents')}.checked");
|
|
|
|
// A filter chip toggles on every press: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('label:has-text("Documents")');
|
|
|
|
$page->assertSeeIn('#kinds', 'photos,documents');
|
|
|
|
$page->click('button:has-text("Only video")')
|
|
->assertSeeIn('#kinds', 'video')
|
|
->assertScript("! {$input('photos')}.checked && ! {$input('documents')}.checked && {$input('video')}.checked")
|
|
->assertScript("getComputedStyle({$input('video')}.parentElement).backgroundColor !== 'rgba(0, 0, 0, 0)'")
|
|
->assertScript("getComputedStyle({$input('photos')}.parentElement).backgroundColor === 'rgba(0, 0, 0, 0)'");
|
|
});
|
|
|
|
it('removes a Livewire input chip with its button and with Delete, focusing the chip after it', function () {
|
|
$page = chipProbe();
|
|
|
|
// The remove button vanishes once its chip is gone: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('button[aria-label="Remove js"]');
|
|
|
|
$page->assertSeeIn('#tags', 'php,css')
|
|
->assertScript("document.querySelector('button[aria-label=\"Remove js\"]') === null");
|
|
|
|
$page->script("document.querySelector('button[aria-label=\"Remove php\"]').focus()");
|
|
|
|
// Removes the focused chip, whose button then vanishes: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->keys(':focus', 'Delete');
|
|
|
|
$page->assertScript("document.querySelector('#tags').textContent === 'css'")
|
|
->assertScript("document.querySelector('button[aria-label=\"Remove php\"]') === null")
|
|
->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove css'");
|
|
});
|
|
|
|
it('removes an Alpine input chip with Backspace, focusing the chip before it', function () {
|
|
$remove = fn (string $who): string => "document.querySelector('#chips button[aria-label=\"Remove {$who}@example.com\"]')";
|
|
|
|
$page = chipShowcase();
|
|
|
|
// Named in the browser: the chips come from x-for, so the server never knew their labels.
|
|
$page->assertScript("{$remove('ben')} !== null");
|
|
$page->script("{$remove('ben')}.focus()");
|
|
|
|
// Removes the focused chip, whose button then vanishes: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->keys(':focus', 'Backspace');
|
|
|
|
$page->assertScript("{$remove('ben')} === null")
|
|
->assertScript("{$remove('anna')} !== null && {$remove('chiara')} !== null")
|
|
->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove anna@example.com'");
|
|
});
|
|
|
|
it('scrolls a chip set sideways, fading the edge it can still scroll towards', function () {
|
|
// The scrolling row, inside the component that also holds its scroll buttons.
|
|
$row = "document.querySelector('#chips [data-md-chip-set-scroller] > [data-md-chip-set-row]')";
|
|
|
|
$page = chipShowcase();
|
|
|
|
$page->assertScript("{$row}.scrollWidth > {$row}.clientWidth")
|
|
->assertScript("{$row}.hasAttribute('data-md-scroll-end') && ! {$row}.hasAttribute('data-md-scroll-start')")
|
|
->assertScript("getComputedStyle({$row}).flexWrap === 'nowrap'");
|
|
|
|
$page->script("{$row}.querySelector('input[value=\"unopened\"]').focus()");
|
|
|
|
$page->assertScript("Math.abs({$row}.scrollLeft) > 0")
|
|
->assertScript("{$row}.hasAttribute('data-md-scroll-start')")
|
|
->assertScript("getComputedStyle({$row}).getPropertyValue('--chip-fade-start').trim() === '24px'");
|
|
});
|
|
|
|
it('draws the chip 32px tall and catches presses over 48px, the chip and its remove button alike', function () {
|
|
$page = chipProbe();
|
|
|
|
$page->assertScript("document.querySelector('[data-md-chip=\"input\"]').getBoundingClientRect().height === 32")
|
|
// The remove button is an 18px icon whose target reaches 15px past it on every side.
|
|
->assertScript("(() => {
|
|
const button = document.querySelector('button[aria-label=\"Remove css\"]');
|
|
const box = button.getBoundingClientRect();
|
|
const after = getComputedStyle(button, '::after');
|
|
|
|
return box.width === 18 && box.height === 18 && after.position === 'absolute' && after.top === '-15px' && after.left === '-15px';
|
|
})()");
|
|
|
|
// A press 22px out from the icon's centre, over the chip's 48px strip, still removes the chip.
|
|
$page->script("(() => {
|
|
const button = document.querySelector('button[aria-label=\"Remove css\"]');
|
|
const box = button.getBoundingClientRect();
|
|
|
|
document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2 + 22).click();
|
|
})()");
|
|
|
|
$page->assertSeeIn('#tags', 'php,js')
|
|
->assertScript("document.querySelector('button[aria-label=\"Remove css\"]') === null");
|
|
});
|
|
|
|
it('draws a selected filter chip without its outline, and puts an unselected one\'s label 16px in', function () {
|
|
$input = fn (string $value): string => "document.querySelector('input[value=\"{$value}\"]')";
|
|
|
|
$page = chipProbe();
|
|
|
|
$page->assertScript("getComputedStyle({$input('photos')}.parentElement).borderTopColor === 'rgba(0, 0, 0, 0)'")
|
|
->assertScript("getComputedStyle({$input('documents')}.parentElement).borderTopWidth === '1px'")
|
|
->assertScript("getComputedStyle({$input('documents')}.parentElement).borderTopColor !== 'rgba(0, 0, 0, 0)'")
|
|
// 1px border, 7px padding, the empty check's 8px margin: Compose's 16px before the label.
|
|
->assertScript("Math.round({$input('documents')}.parentElement.querySelector('[data-md-chip-label]').getBoundingClientRect().left - {$input('documents')}.parentElement.getBoundingClientRect().left) === 16");
|
|
});
|
|
|
|
function chipScrollProbe(string $dir)
|
|
{
|
|
Route::middleware('web')->get('/chip-scroll-probe', fn () => Blade::render(<<<'BLADE'
|
|
<!DOCTYPE html>
|
|
<html dir="{{ request('dir') }}">
|
|
<head>
|
|
<link rel="icon" href="data:,">
|
|
<x-theme-script />
|
|
@vite(config('livewire-material.showcase.vite'))
|
|
@livewireStyles
|
|
</head>
|
|
<body>
|
|
<div style="width: 320px; padding: 24px">
|
|
<x-chip-set aria-label="Sort" scroll>
|
|
@foreach (['Newest', 'Oldest', 'Largest', 'Smallest', 'Shared', 'Starred', 'Unopened', 'Expired'] as $sort)
|
|
<x-chip type="filter" :label="$sort" :value="$sort" name="sort[]" />
|
|
@endforeach
|
|
</x-chip-set>
|
|
</div>
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
return ready(visit("/chip-scroll-probe?dir={$dir}"), livewire: false);
|
|
}
|
|
|
|
it('shows a scroll button only on the edge the row can still scroll towards, in either direction', function (string $dir) {
|
|
$row = "document.querySelector('[data-md-chip-set-row]')";
|
|
$shown = fn (string $edge): string => "getComputedStyle(document.querySelector('[data-md-chip-scroll=\"{$edge}\"]')).display === 'grid'";
|
|
|
|
$page = chipScrollProbe($dir)
|
|
->assertScript("{$row}.scrollWidth > {$row}.clientWidth")
|
|
->assertScript("! ({$shown('start')}) && ({$shown('end')})");
|
|
|
|
// A paging key a retry would page twice: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('[data-md-chip-scroll="end"]');
|
|
|
|
$page->wait(0.8)
|
|
->assertScript("Math.abs({$row}.scrollLeft) > 0")
|
|
->assertScript("({$shown('start')})");
|
|
|
|
$page->script("{$row}.scrollTo({ left: ({$row}.scrollWidth) * (getComputedStyle({$row}).direction === 'rtl' ? -1 : 1), behavior: 'instant' })");
|
|
|
|
$page->wait(0.2)
|
|
->assertScript("({$shown('start')}) && ! ({$shown('end')})")
|
|
// The end button sits over the end edge: on the right in LTR, on the left in RTL.
|
|
->assertScript("(() => { const start = document.querySelector('[data-md-chip-scroll=\"start\"]').getBoundingClientRect(); const box = {$row}.parentElement.getBoundingClientRect(); return '{$dir}' === 'rtl' ? Math.abs(start.right - box.right) < 1 : Math.abs(start.left - box.left) < 1; })()");
|
|
})->with(['ltr', 'rtl']);
|
|
|
|
it('walks a chip set with the arrow keys as one tab stop, and scrolls the focused chip clear of the buttons', function () {
|
|
$inputs = "[...document.querySelectorAll('[data-md-chip-set-row] input')]";
|
|
|
|
$page = chipScrollProbe('ltr')
|
|
->assertScript("{$inputs}.filter((input) => input.tabIndex === 0).length === 1");
|
|
|
|
$page->script("{$inputs}[0].focus()");
|
|
|
|
$page->keys(':focus', 'ArrowRight')
|
|
->assertScript("document.activeElement === {$inputs}[1]")
|
|
->assertScript("{$inputs}.filter((input) => input.tabIndex === 0).map((input) => input.value).join() === 'Oldest'");
|
|
|
|
$page->keys(':focus', 'End')
|
|
->assertScript("document.activeElement === {$inputs}.at(-1)")
|
|
->wait(0.8)
|
|
// The focused chip stands clear of the start button and its fade.
|
|
->assertScript("(() => { const chip = document.activeElement.closest('[data-md-chip]').getBoundingClientRect(); const button = document.querySelector('[data-md-chip-scroll=\"start\"]').getBoundingClientRect(); return getComputedStyle(document.querySelector('[data-md-chip-scroll=\"start\"]')).display === 'grid' && chip.left >= button.right; })()");
|
|
|
|
$page->keys(':focus', 'Home')
|
|
->assertScript("document.activeElement === {$inputs}[0]");
|
|
|
|
$page->keys(':focus', 'ArrowLeft')
|
|
->assertScript("document.activeElement === {$inputs}.at(-1)");
|
|
});
|