Files
livewire-material/tests/Browser/PickingTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 db6023bf80 Close a full-screen search back into its bar or icon, bar and view together
`fullScreen` followed `open`, so the moment a full-screen search closed,
`data-md-full-screen` went with it: the fixed header bar went back to its
resting pill — or to `display: none`, behind the search icon — and the
view, still fading out under Alpine's hold, dropped to the docked layout
for its exit. In every engine the bar vanished on the first frame while
the view went on fading somewhere else.

search.js: a close from full screen now `hold()`s the layout — `leaving`
keeps `fullScreen`, and with it `data-md-full-screen`, the back arrow and
the fixed bar, for the view's own closing duration, read from its
computed style a frame on (zero under reduced motion); reopening lets a
pending end go by. search.css fades the header bar out with the view on
the view's spring and keeps the root above the page while it leaves,
and the icon trigger's bar stays displayed until the layout settles. The
focus trap now binds to `open && fullScreen`, so it still lets go at the
close and its return of focus lands inside RETURN_GUARD_MS rather than
reopening the view at the end of the exit.

PickingTest samples both exits in the page — the icon trigger, and the
bar trigger on a compact window — for a moment where the root is still
full screen with a fixed bar and both bar and view part-way through their
fade, then checks the settled layout and focus back on the icon or the
field; both fail on the previous code in Chrome, Firefox and Safari. The
tests wait for the entry's own transitions first: Firefox reads a
transition's end value until its next refresh tick.

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

365 lines
17 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>
<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 visit('/pick-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
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('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` (inputs.md IN-01).
$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 (inputs.md IN-09), 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 <<<JS
(async () => {
const root = document.querySelector('[data-md-search]:has(#find)')
root.querySelector('[data-md-search-scrim]').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
for (let i = 0; i < 80; i++) {
const element = root.querySelector('{$element}')
const value = {$expression}
if (getComputedStyle(element).display !== 'none' && value > 0.02 && value < 0.98) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
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.
->assertScript("(({ display, opacity }) => display !== 'none' && opacity === '1')(getComputedStyle({$root}.querySelector('[data-md-search-scrim]')))");
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('closes the docked search view back into its bar, rather than making it vanish', function () {
$view = "document.querySelector('#find-view')";
$page = pickProbe()
->click('#find')
->assertScript("getComputedStyle({$view}).display !== 'none' && getComputedStyle({$view}).opacity === '1'");
expect($page->script(searchCloseSample('[data-md-search-view]', 'parseFloat(getComputedStyle(element).opacity)')))->toBeTrue();
$page->assertScript("getComputedStyle({$view}).display === 'none'");
});
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.
*/
function fullScreenCloseSample(string $input): string
{
return <<<JS
(async () => {
const root = document.querySelector('[data-md-search]:has(#{$input})')
root.querySelector('[data-md-search-back]').click()
for (let i = 0; i < 80; i++) {
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
if (! root.hasAttribute('data-md-open') && root.hasAttribute('data-md-full-screen') && bar.position === 'fixed' && fading(bar) && fading(view)) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
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("{$root}.getAnimations({ subtree: true }).length === 0");
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("{$root}.getAnimations({ subtree: true }).length === 0");
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'");
});