Hang a searchable choice's list as wide as its field

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>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 07:06:25 +02:00
co-authored by Claude Opus 5
parent 7aa6dc7d33
commit 16c52d46e9
3 changed files with 80 additions and 36 deletions
+4
View File
@@ -87,6 +87,10 @@
card. `<x-choices>`' chips have no bound and take `full` without a change, so a call site can card. `<x-choices>`' chips have no bound and take `full` without a change, so a call site can
switch `searchable` on and off. A width rule an application wrote around one of them for this switch `searchable` on and off. A width rule an application wrote around one of them for this
can go. can go.
- **A searchable `<x-choices>`' list is as wide as its field.** It was anchored to a wrapper
around the field, which fills its container, so from `medium`, where the field stops at 40rem,
the list ran on past the field's end across the whole container. The field itself is the anchor
now, and the wrapper `<div>` is gone; the list keeps hanging under the field's supporting text.
## From 2.0.0 to 2.1.0 ## From 2.0.0 to 2.1.0
+35 -36
View File
@@ -11,7 +11,8 @@
and End go to the ends of the open list (with it closed they are the field's own and move the and End go to the ends of the open list (with it closed they are the field's own and move the
caret), Enter chooses, Escape puts the field back — WAI-ARIA's combobox keyboard, since M3 caret), Enter chooses, Escape puts the field back — WAI-ARIA's combobox keyboard, since M3
has no combobox. One value only. The list is a popover in the top has no combobox. One value only. The list is a popover in the top
layer, placed by CSS anchor positioning, so a card's clipping never cuts it off. `icon`, layer, placed by CSS anchor positioning on the field itself, so a card's clipping never cuts
it off and it is as wide as the field, 40rem bound and all. `icon`,
`variant` and `placeholder` are the field's, and `full` takes the 40rem bound off the field `variant` and `placeholder` are the field's, and `full` takes the 40rem bound off the field
as it does off every field. Chips have no such bound, so they take `full` and change nothing. as it does off every field. Chips have no such bound, so they take `full` and change nothing.
@@ -135,42 +136,40 @@
}" }"
@if ($model === null) x-modelable="value" @endif @if ($model === null) x-modelable="value" @endif
> >
<div style="anchor-name: {{ $anchor }}"> <x-livewire-material::field :$id :$label :$hint :$messages :$icon :$variant :$full style="anchor-name: {{ $anchor }}">
<x-livewire-material::field :$id :$label :$hint :$messages :$icon :$variant :$full> <input
<input id="{{ $id }}"
id="{{ $id }}" type="text"
type="text" role="combobox"
role="combobox" autocomplete="off"
autocomplete="off" aria-autocomplete="list"
aria-autocomplete="list" aria-controls="{{ $id }}-list"
aria-controls="{{ $id }}-list" aria-expanded="false"
aria-expanded="false" x-bind:aria-expanded="open.toString()"
x-bind:aria-expanded="open.toString()" x-bind:aria-activedescendant="open && filtered[active] ? '{{ $id }}-option-' + active : null"
x-bind:aria-activedescendant="open && filtered[active] ? '{{ $id }}-option-' + active : null" @if ($messages !== []) aria-invalid="true" @endif
@if ($messages !== []) aria-invalid="true" @endif @if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif placeholder="{{ filled($placeholder) ? $placeholder : ' ' }}"
placeholder="{{ filled($placeholder) ? $placeholder : ' ' }}" data-md-field-control
data-md-field-control x-model="query"
x-model="query" x-on:focus="show(); $nextTick(() => $el.select())"
x-on:focus="show(); $nextTick(() => $el.select())" x-on:pointerdown="selecting = document.activeElement !== $el"
x-on:pointerdown="selecting = document.activeElement !== $el" x-on:click="if (selecting) { $el.select(); selecting = false; } if (! open) show();"
x-on:click="if (selecting) { $el.select(); selecting = false; } if (! open) show();" x-on:input="open = true; active = 0"
x-on:input="open = true; active = 0" x-on:keydown.arrow-down.prevent="move(1)"
x-on:keydown.arrow-down.prevent="move(1)" x-on:keydown.arrow-up.prevent="move(-1)"
x-on:keydown.arrow-up.prevent="move(-1)" x-on:keydown.home="jump($event, false)"
x-on:keydown.home="jump($event, false)" x-on:keydown.end="jump($event, true)"
x-on:keydown.end="jump($event, true)" x-on:keydown.enter.prevent="choose(filtered[active])"
x-on:keydown.enter.prevent="choose(filtered[active])" x-on:keydown.escape="if (open) $event.preventDefault(); close()"
x-on:keydown.escape="if (open) $event.preventDefault(); close()" x-on:keydown.tab="close()"
x-on:keydown.tab="close()" x-on:blur="close()"
x-on:blur="close()" />
/>
<x-slot:trailing> <x-slot:trailing>
<x-livewire-material::icon name="arrow_drop_down" data-md-field-trailing data-md-field-arrow /> <x-livewire-material::icon name="arrow_drop_down" data-md-field-trailing data-md-field-arrow />
</x-slot:trailing> </x-slot:trailing>
</x-livewire-material::field> </x-livewire-material::field>
</div>
<ul <ul
id="{{ $id }}-list" id="{{ $id }}-list"
+41
View File
@@ -133,6 +133,47 @@ it('opens a searchable choice\'s list above a container that clips', function ()
JS); 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 () { it('opens the search view with the results Livewire renders for the query', function () {
$view = "document.querySelector('#find-view')"; $view = "document.querySelector('#find-view')";