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:
co-authored by
Claude Opus 5
parent
7aa6dc7d33
commit
16c52d46e9
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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')";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user