The searchable choice put its `anchor-name` on a `<div>` around its field, and menu.css sizes the popover list with `anchor-size(width)`. The wrapper fills its container, but from `medium` the field inside it stops at 40rem, so in a wider pane the list ran past the field's end across the whole container: 1168px under a 640px field. Found while giving the choice `full`, which only hid it. The field itself carries the anchor now and the wrapper is gone, so the list is as wide as the field, bounded or `full`, and still hangs under the field's supporting text as before. A browser test opens a bounded and a `full` searchable choice in a 1200px column and compares each list's width and start with its field; it fails without the change in Chrome, Firefox and Safari. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
419 lines
20 KiB
PHP
419 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>
|
|
<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('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>
|
|
<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 = visit('/wide-choices-probe')->resize(1280, 800)->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
|
|
|
|
$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` (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('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'");
|
|
|
|
// 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('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'");
|
|
});
|