Files
livewire-material/tests/Browser/PickingTest.php
T
surtic86andClaude Opus 5 ebdc2ef2e1
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
Press once whatever a second press would open over or undo
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>
2026-09-18 16:15:25 +02:00

482 lines
22 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 () {
$page = pickProbe()
->assertAttribute('button[aria-pressed="true"]', 'aria-pressed', 'true');
// A filter chip toggles on every press: pressOnce(), tests/Pest.php.
pressOnce($page)->click('button:has-text("Mon")');
$page->assertSeeIn('#days', '[2,1]');
pressOnce($page)->click('button:has-text("Tue")');
$page->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');
// Opens the searchable choice's list over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#zone-field');
$page->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();
// Opens the searchable choice's list over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#zone-field');
$page->type('#zone-field', 'ber');
$page->keys('#zone-field', 'Escape')
->assertValue('#zone-field', 'Zurich')
->assertSeeIn('#zone', 'Europe/Zurich');
pressOnce($page)->click('#zone-field');
$page->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 () {
$page = pickProbe();
// Opens the searchable choice's list over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#zone-field');
$page->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);
// Opens the searchable choice's list over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#bounded-zone');
$page->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')");
pressOnce($page)->click('#full-zone');
$page->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();
// Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#find');
$page->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();
// Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#find');
$page->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, which closes the view: pressOnce(), tests/Pest.php.
pressOnce($page)->click('[data-md-search]:has(#find) [data-md-search-scrim]');
$page->assertScript("getComputedStyle({$view}).display === 'none'");
pressOnce($page)->click('#find');
$page->clear('#find')
->assertScript("{$view}.querySelectorAll('button').length === 3");
// Selecting a result closes the view: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#find-view button:has-text("review.mp4")');
$page->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();
// Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#find');
$page
// 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();
// Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#find');
$page->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);
// Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#find');
$page->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; })()");
// The back arrow closes the search: pressOnce(), tests/Pest.php.
pressOnce($page)->click('[data-md-search]:has(#find) [data-md-search-back]');
$page->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'");
// Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#find');
$page->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'");
// Opens the search view over the trigger that expands it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]');
$page->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();
// Opens the search view over the trigger that expands it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]');
$page->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);
// Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#find');
$page->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();
// Opens the search view over the trigger that expands it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]');
$page->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'");
});