Files
livewire-material/tests/Browser/PickingTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 e7885ce1eb
tests / feature (8.4) (push) Successful in 1m49s
tests / feature (8.5) (push) Successful in 1m51s
tests / browser (chrome, chromium) (push) Successful in 7m34s
tests / browser (firefox, firefox) (push) Failing after 14m19s
tests / browser (safari, webkit) (push) Failing after 13m16s
Keep the search view closed when its focus comes back late
On a slow machine Escape could close the full-screen search and have it
open again for good. A close hands focus back to the input — Escape does,
and so does the view's focus trap as it lets go — and `focused()` told
that return from someone coming to search by a 250ms wall-clock window.
Both returns run on frames, and a runner painting a few frames a second
took longer than that, so the returning focus opened the view again. A
`returning` flag now covers the close until the hand-back has actually
run, on the same frame, and the constant is gone.

`hold()` times the full-screen layout from the exit's own duration token,
the one Alpine's x-transition holds `display` for, instead of waiting a
frame or two for the exit's transitions to appear: `getAnimations()` is
empty both before an engine creates them and after they end, and a loaded
engine can leave a second between frames.

The browser tests were racing the same slow machine, reproduced in a
Linux container like the runner, with its Playwright Firefox and
WebKitGTK and two cores kept busy:

- The browser plugin retries every script and action with a one-second
  attempt until the budget runs out. An in-page sleep longer than that
  only ever passed on the last attempt, which is where the 47-50s tests
  came from, and a script that clicks was run again against a page that
  had moved on. `onceInPage()` runs such a script once however often it
  is retried; the long sleeps are plain retried conditions now.
- Under load WebKitGTK paints no frame while a tight setTimeout loop
  runs, so a sample loop saw the start value and then nothing.
  `caughtMidExit()` samples on animation frames, for 2.5s.
- "No animations running" is also true before an opening transition
  exists, so a close could be sampled from a scrim at 4% opacity.
  `settled()` waits two frames before it asks.

The workflow no longer uploads failure screenshots: Gitea's artifact
service timed out on every attempt, two minutes per red run, and the job
logs are readable without it.

Feature 1159 passed. Browser 299 passed on Chrome, Firefox and WebKit on
macOS, and on Firefox and WebKitGTK in the Linux container under load,
twice each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:49:17 +02:00

422 lines
20 KiB
PHP

<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
class PickProbe extends Component
{
/** @var list<int> */
public array $days = [2];
public ?string $zone = 'Europe/Zurich';
public string $query = '';
public string $iconQuery = '';
public function render(): string
{
return <<<'BLADE'
<div style="display: grid; max-width: 448px; gap: var(--md-sys-measurement-space300); padding: var(--md-sys-measurement-space200);">
<p>days: <span id="days">{{ json_encode($days) }}</span></p>
<p>zone: <span id="zone">{{ $zone }}</span></p>
<p>query: <span id="query">{{ $query }}</span></p>
<x-choices label="Days" wire:model.live="days" :options="[['id' => 1, 'name' => 'Mon'], ['id' => 2, 'name' => 'Tue'], ['id' => 3, 'name' => 'Wed']]" />
<div id="clip" style="height: 96px; overflow: hidden; border-radius: var(--md-sys-shape-corner-md); background-color: var(--md-sys-color-surface-container); padding: var(--md-sys-measurement-space100);">
<x-choices id="zone-field" label="Time zone" searchable wire:model.live="zone" :options="[['id' => 'Europe/Berlin', 'name' => 'Berlin'], ['id' => 'Europe/Zurich', 'name' => 'Zurich'], ['id' => 'UTC', 'name' => 'UTC', 'disabled' => true]]" />
</div>
<x-search id="find" wire:model.live="query" placeholder="Search files">
@foreach (array_filter(['holiday.zip', 'contract.pdf', 'review.mp4'], fn ($file) => $query === '' || str_contains($file, $query)) as $file)
<button type="button" wire:key="result-{{ $file }}" style="display: block; width: 100%; padding: var(--md-sys-measurement-space150) var(--md-sys-measurement-space200); text-align: start;">{{ $file }}</button>
@endforeach
<x-slot:empty>No files match.</x-slot:empty>
</x-search>
<div id="toolbar" style="display: flex; align-items: center; gap: 8px">
<span>Files</span>
<x-search id="find-icon" trigger="icon" label="Find files" wire:model.live="iconQuery">
@foreach (array_filter(['holiday.zip', 'contract.pdf'], fn ($file) => str_contains($file, $iconQuery)) as $file)
<button type="button" wire:key="icon-result-{{ $file }}">{{ $file }}</button>
@endforeach
<x-slot:suggestions>
<button type="button">Recent: holiday.zip</button>
<button type="button">Recent: review.mp4</button>
<button type="button">Recent: notes.txt</button>
</x-slot:suggestions>
</x-search>
</div>
</div>
BLADE;
}
}
function pickProbe()
{
Livewire::component('pick-probe', PickProbe::class);
Route::middleware('web')->get('/pick-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:pick-probe />
@livewireScripts
</body>
</html>
BLADE));
return ready(visit('/pick-probe'));
}
it('keeps the values of filter chips as the property\'s own type', function () {
pickProbe()
->assertAttribute('button[aria-pressed="true"]', 'aria-pressed', 'true')
->click('button:has-text("Mon")')
->assertSeeIn('#days', '[2,1]')
->click('button:has-text("Tue")')
->assertSeeIn('#days', '[1]')
->assertScript("[...document.querySelectorAll('[aria-pressed=\"true\"]')].map((chip) => chip.textContent.trim()).join() === 'Mon'");
});
it('filters a searchable choice as it is typed and chooses from the keyboard', function () {
$list = "document.querySelector('#zone-field-list')";
$page = pickProbe()
->assertValue('#zone-field', 'Zurich')
->click('#zone-field')
->assertScript("{$list}.matches(':popover-open')")
->assertAttribute('#zone-field', 'aria-expanded', 'true');
$page->type('#zone-field', 'ber')
->assertScript("{$list}.querySelectorAll('[role=\"option\"]').length === 1");
$page->keys('#zone-field', 'Enter')
->assertSeeIn('#zone', 'Europe/Berlin')
->assertValue('#zone-field', 'Berlin')
->assertScript("! {$list}.matches(':popover-open')");
});
it('puts a searchable choice back on Escape and never chooses a disabled option', function () {
$page = pickProbe()->click('#zone-field')->type('#zone-field', 'ber');
$page->keys('#zone-field', 'Escape')
->assertValue('#zone-field', 'Zurich')
->assertSeeIn('#zone', 'Europe/Zurich');
$page->click('#zone-field')->type('#zone-field', 'UTC')->keys('#zone-field', 'Enter')
->assertSeeIn('#zone', 'Europe/Zurich');
});
it('opens a searchable choice\'s list above a container that clips', function () {
pickProbe()
->click('#zone-field')
->assertScript(<<<'JS'
(() => {
const list = document.querySelector('#zone-field-list');
const clip = document.querySelector('#clip').getBoundingClientRect();
const box = list.getBoundingClientRect();
const probe = document.elementFromPoint(box.left + box.width / 2, box.bottom - 8);
return box.bottom > clip.bottom && list.contains(probe);
})()
JS);
});
it('hangs a searchable choice\'s list under its field and as wide, bounded at 40rem or full', function () {
Route::middleware('web')->get('/wide-choices-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);">
<div style="display: grid; width: 1200px; gap: var(--md-sys-measurement-space300); padding: var(--md-sys-measurement-space200);">
<x-choices id="bounded-zone" label="Time zone" searchable :options="[['id' => 'Europe/Berlin', 'name' => 'Berlin'], ['id' => 'Europe/Zurich', 'name' => 'Zurich']]" />
<x-choices id="full-zone" label="Time zone" searchable full :options="[['id' => 'Europe/Berlin', 'name' => 'Berlin'], ['id' => 'Europe/Zurich', 'name' => 'Zurich']]" />
<div style="height: 320px"></div>
</div>
@livewireScripts
</body>
</html>
BLADE));
// [the field's width, the list's width, the list's start against the field's].
$placed = fn (string $id): string => "(() => {
const field = document.querySelector('[data-md-field]:has(#{$id})').getBoundingClientRect();
const list = document.getElementById('{$id}-list').getBoundingClientRect();
return [Math.round(field.width), Math.round(list.width), Math.round(list.left - field.left)];
})()";
$page = ready(visit('/wide-choices-probe')->resize(1280, 800), livewire: false);
$page->click('#bounded-zone')->assertScript("document.getElementById('bounded-zone-list').matches(':popover-open')");
expect($page->script($placed('bounded-zone')))->toBe([640, 640, 0]);
$page->keys('#bounded-zone', 'Escape')->assertScript("! document.getElementById('bounded-zone-list').matches(':popover-open')");
$page->click('#full-zone')->assertScript("document.getElementById('full-zone-list').matches(':popover-open')");
expect($page->script($placed('full-zone')))->toBe([1168, 1168, 0]);
});
it('opens the search view with the results Livewire renders for the query', function () {
$view = "document.querySelector('#find-view')";
// The combobox is the wrapper around the input, which carries `aria-expanded`.
$page = pickProbe()
->click('#find')
->assertScript("getComputedStyle({$view}).display !== 'none'")
->assertAttribute('[role="combobox"]:has(> #find)', 'aria-expanded', 'true');
$page->type('#find', 'con')
->assertSeeIn('#query', 'con')
->assertScript("[...{$view}.querySelectorAll('button')].map((button) => button.textContent.trim()).join() === 'contract.pdf'");
$page->keys('#find', 'ArrowDown')
->assertScript("document.activeElement.textContent.trim() === 'contract.pdf'");
$page->keys(':focus', 'Escape')
->assertScript("getComputedStyle({$view}).display === 'none'")
->assertScript("document.activeElement.id === 'find'")
->assertAttribute('[role="combobox"]:has(> #find)', 'aria-expanded', 'false');
});
it('says so when nothing matches, and closes on a press outside or a chosen result', function () {
$view = "document.querySelector('#find-view')";
$page = pickProbe()
->click('#find')
->type('#find', 'zzz')
->assertSeeIn('#find-view', 'No files match.');
// The docked view opens over a scrim that covers the rest of the page, so a
// press outside the view lands on the scrim.
$page->click('[data-md-search]:has(#find) [data-md-search-scrim]')
->assertScript("getComputedStyle({$view}).display === 'none'");
$page->click('#find')
->clear('#find')
->assertScript("{$view}.querySelectorAll('button').length === 3")
->click('#find-view button:has-text("review.mp4")')
->assertScript("getComputedStyle({$view}).display === 'none'");
});
/**
* Presses the open docked search's scrim and samples, in the page and in the same round trip,
* whether `$expression` (a number) was ever caught strictly between `$from` and `$to` while the
* element was still displayed: the exit ran, rather than the element vanishing at once.
*/
function searchCloseSample(string $element, string $expression): string
{
return caughtMidExit(
<<<'JS'
const root = document.querySelector('[data-md-search]:has(#find)')
root.querySelector('[data-md-search-scrim]').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
JS,
<<<JS
(() => {
const element = root.querySelector('{$element}')
const value = {$expression}
return getComputedStyle(element).display !== 'none' && value > 0.02 && value < 0.98
})()
JS,
);
}
it('fades the docked search\'s scrim out on close, rather than making it vanish', function () {
$root = "document.querySelector('[data-md-search]:has(#find)')";
$page = pickProbe()
->click('#find')
// Displayed as well as opaque: until the first frame of its entry the scrim is still
// `display: none` at the open state's full opacity. And settled, not still fading in: an
// engine reads a transition's end value until its next refresh tick, so an opacity of 1
// alone does not say the entry has run — and a close sampled from there has nothing left
// to fade.
->assertScript("(({ display, opacity }) => display !== 'none' && opacity === '1')(getComputedStyle({$root}.querySelector('[data-md-search-scrim]')))")
->assertScript(settled("{$root}.querySelector('[data-md-search-scrim]')"));
slowMotion($page);
expect($page->script(searchCloseSample('[data-md-search-scrim]', 'parseFloat(getComputedStyle(element).opacity)')))->toBeTrue();
$page->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-scrim]')).display === 'none'");
});
it('keeps the docked search above the page while its view closes', function () {
$view = "document.querySelector('#find-view')";
$page = pickProbe()
->click('#find')
->assertScript("getComputedStyle({$view}).display !== 'none' && getComputedStyle({$view}).opacity === '1'")
// Settled, not still fading in: see the scrim's fade above.
->assertScript(settled($view));
slowMotion($page);
// The view caught part-way through its fade only counts while the root is still at z-index 50.
expect($page->script(searchCloseSample('[data-md-search-view]', "getComputedStyle(root).zIndex === '50' ? parseFloat(getComputedStyle(element).opacity) : 0")))->toBeTrue();
$page->assertScript("getComputedStyle({$view}).display === 'none' && getComputedStyle(document.querySelector('[data-md-search]:has(#find)')).zIndex !== '50'");
});
it('takes the whole screen on a compact window, with a back arrow', function () {
$page = pickProbe()->resize(400, 800);
$page->click('#find')
->assertScript("document.querySelector('[data-md-search]').hasAttribute('data-md-full-screen')")
->assertScript("(() => { const box = document.querySelector('#find-view').getBoundingClientRect(); return box.top === 0 && box.width === 400; })()")
->click('[data-md-search]:has(#find) [data-md-search-back]')
->assertScript("! document.querySelector('[data-md-search]').hasAttribute('data-md-open')");
});
it('says how many results there are, politely, as they change', function () {
$status = "document.querySelector('[data-md-search]:has(#find) [data-md-search-status]')";
$page = pickProbe()
->assertScript("{$status}.getAttribute('aria-live') === 'polite' && {$status}.getAttribute('aria-atomic') === 'true'")
->click('#find')
->assertScript("{$status}.textContent === '3 results'");
$page->type('#find', 'con')
->assertSeeIn('#query', 'con')
->assertScript("{$status}.textContent === '1 result'");
$page->type('#find', 'zzz')
->assertSeeIn('#query', 'zzz')
->assertScript("{$status}.textContent === 'No results'");
// Closed from a result (Escape in the field itself would first clear the query), the region
// falls silent.
$page->type('#find', 'hol')
->assertSeeIn('#query', 'hol')
->assertScript("{$status}.textContent === '1 result'");
$page->keys('#find', 'ArrowDown')
->assertScript("document.activeElement.textContent.trim() === 'holiday.zip'");
$page->keys(':focus', 'Escape')
->assertScript("{$status}.textContent === ''");
});
it('expands the search icon into the full-screen view, and hands focus back to the icon on close', function () {
$root = "document.querySelector('[data-md-search]:has(#find-icon)')";
$trigger = "{$root}.querySelector('[data-md-search-trigger]')";
$page = pickProbe()
->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-bar]')).display === 'none'")
->assertScript("{$root}.getBoundingClientRect().width === 48 && {$trigger}.getAttribute('aria-expanded') === 'false'");
$page->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]')
->assertScript("{$root}.hasAttribute('data-md-open') && {$root}.hasAttribute('data-md-full-screen')")
->assertScript("document.activeElement.id === 'find-icon'")
->assertScript("(() => { const view = {$root}.querySelector('[data-md-search-view]').getBoundingClientRect(); return view.top === 0 && view.left === 0 && view.width === innerWidth; })()")
// The toolbar does not shift under the expanded search.
->assertScript("{$root}.getBoundingClientRect().width === 48");
$page->keys('#find-icon', 'Escape')
->assertScript("! {$root}.hasAttribute('data-md-open')")
->assertScript("document.activeElement === {$trigger}")
->assertScript("getComputedStyle({$trigger}).display !== 'none'");
});
/**
* Closes a full-screen search from its back arrow and samples, in the page, for a moment in its
* exit where the layout is still full screen — the root marked, the header bar fixed at the top —
* with both the bar and the view part-way through their fade, rather than the bar gone back to
* its resting form (or to nothing, behind the icon) on the first frame. Call it after
* slowMotion(): a loaded runner paints only a handful of frames a second, and a real 350ms exit
* can pass between two of them with nothing in between to catch.
*/
function fullScreenCloseSample(string $input): string
{
return caughtMidExit(
<<<JS
const root = document.querySelector('[data-md-search]:has(#{$input})')
root.querySelector('[data-md-search-back]').click()
JS,
<<<'JS'
(() => {
const bar = getComputedStyle(root.querySelector('[data-md-search-bar]'))
const view = getComputedStyle(root.querySelector('[data-md-search-view]'))
const fading = (style) => style.display !== 'none' && parseFloat(style.opacity) > 0.02 && parseFloat(style.opacity) < 0.98
return ! root.hasAttribute('data-md-open') && root.hasAttribute('data-md-full-screen') && bar.position === 'fixed' && fading(bar) && fading(view)
})()
JS,
);
}
it('closes the full-screen view and its bar together, back into the search icon', function () {
$root = "document.querySelector('[data-md-search]:has(#find-icon)')";
$trigger = "{$root}.querySelector('[data-md-search-trigger]')";
$page = pickProbe()
->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]')
->assertScript("{$root}.hasAttribute('data-md-full-screen')")
// Its entry run: Firefox reads a transition's end value until its next refresh tick, so an
// opacity of 1 alone does not say the view has finished fading in.
->assertScript(settled($root, subtree: true));
slowMotion($page);
expect($page->script(fullScreenCloseSample('find-icon')))->toBeTrue();
$page->assertScript("! {$root}.hasAttribute('data-md-full-screen')")
->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-bar]')).display === 'none'")
->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-view]')).display === 'none'")
->assertScript("document.activeElement === {$trigger}")
// The view does not open again when the trap's return of focus lands on the icon.
->assertScript("! {$root}.hasAttribute('data-md-open')");
});
it('closes a compact window\'s full-screen view and its bar together, back into the bar', function () {
$root = "document.querySelector('[data-md-search]:has(#find)')";
$page = pickProbe()->resize(400, 800);
$page->click('#find')
->assertScript("{$root}.hasAttribute('data-md-full-screen')")
->assertScript(settled($root, subtree: true));
slowMotion($page);
expect($page->script(fullScreenCloseSample('find')))->toBeTrue();
$page->assertScript("! {$root}.hasAttribute('data-md-full-screen')")
->assertScript("(({ position, display, opacity }) => position === 'relative' && display !== 'none' && opacity === '1')(getComputedStyle({$root}.querySelector('[data-md-search-bar]')))")
->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-view]')).display === 'none'")
->assertScript("document.activeElement.id === 'find'")
->assertScript("! {$root}.hasAttribute('data-md-open')");
});
it('shows the suggestions until the first key, then the results, and counts whichever is on screen', function () {
$root = "document.querySelector('[data-md-search]:has(#find-icon)')";
$shown = fn (string $list): string => "{$root}.querySelector('[data-md-search-{$list}]').checkVisibility()";
$page = pickProbe()
->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]')
->assertScript("({$shown('suggestions')}) && ! ({$shown('results')})")
->assertScript("{$root}.querySelector('[data-md-search-status]').textContent === '3 suggestions'");
$page->type('#find-icon', 'h')
->assertScript("! ({$shown('suggestions')}) && ({$shown('results')})")
->assertScript("{$root}.querySelector('[data-md-search-status]').textContent === '1 result'");
});