fix(ui): float listbox panels over modals and scroll containers

Position every listbox panel with position: fixed so overflow containers
such as modal bodies no longer clip it or add a scrollbar. The portal
prop now only teleports the panel to the body.

Closes #11529

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-24 00:35:06 +02:00
co-authored by Claude Opus 5.5
parent 2ea1afb811
commit 33b369243b
2 changed files with 65 additions and 84 deletions
@@ -189,13 +189,10 @@
toggle() {
this.open = !this.open;
this.positioned = false;
if (this.open && @js($portal)) {
this.$nextTick(() => requestAnimationFrame(() => this.positionPanel()));
}
},
positionPanel(panel = null) {
const trigger = this.$refs.trigger;
panel ??= document.getElementById(@js($panelId));
panel ??= this.$refs.panel;
if (!trigger || !panel) return;
const gap = 4;
@@ -229,7 +226,7 @@
{{ $attributes->whereStartsWith('x-model') }}
{{ $attributes->whereStartsWith('x-effect') }}
@if ($preserveValue) wire:ignore @endif
@click.outside="document.getElementById(@js($panelId))?.contains($event.target) || (open = false)" @keydown.escape="open = false" @resize.window="open && positionPanel()"
@click.outside="$refs.panel?.contains($event.target) || (open = false)" @keydown.escape="open = false" @resize.window="open && positionPanel()"
@scroll.window.capture="open && positionPanel()">
<button x-ref="trigger" id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="toggle()"
@disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
@@ -244,92 +241,21 @@
<path stroke-linecap="round" stroke-linejoin="round" d="m8 9 4-4 4 4m0 6-4 4-4-4" />
</svg>
</button>
{{-- Fixed positioning lets the panel escape overflow containers such as modal bodies and tables.
portal also moves it to <body>, for an ancestor with a transform or filter. --}}
@if ($portal)
<template x-teleport="body">
<div id="{{ $panelId }}" class="listbox-panel{{ $searchable ? ' searchable-listbox-panel' : '' }}"
style="position: fixed; z-index: 9999; visibility: hidden" x-show="open"
x-cloak :style="{ visibility: positioned ? 'visible' : 'hidden', ...panelPosition }"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]"
x-effect="if (open) requestAnimationFrame(() => positionPanel($el))" role="listbox"
@if ($multiple) aria-multiselectable="true" @endif>
@if ($searchable)
<div class="searchable-listbox-search">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-3 size-3 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-ref="search" type="search" x-model="query" autocomplete="off"
placeholder="{{ $searchPlaceholder }}" class="searchable-listbox-search-input"
@keydown.enter.prevent="chooseOnlyMatch()" @keydown.escape.stop="open = false" />
</div>
<div class="searchable-listbox-options">
@endif
<div x-show="visibleOptions.length === 0{{ $searchable && $allowCustom ? ' && !customOption' : '' }}"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
@if ($searchable)
<span x-text="options.length === 0 ? @js($emptyText) : @js($searchEmptyText)"></span>
@else
{{ $emptyText }}
@endif
</div>
<template x-for="option in visibleOptions" :key="String(option.value)">
<div>
{{-- Non-selectable group header (options with header: true). Each header is the
only child of its x-for wrapper, so `first:` can't target it — the divider
is an unconditional top border, which reads as a separator between groups. --}}
<template x-if="option.header">
<div class="mt-1 border-t border-neutral-100 px-3 pt-2 pb-1 text-[10px] font-semibold tracking-wide text-neutral-400 uppercase dark:border-white/[0.06] dark:text-fg-faint"
x-text="option.label"></div>
</template>
<template x-if="!option.header">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
:aria-selected="isSelected(option)" @click="choose(option)">
<span class="flex min-w-0 items-center gap-2 text-left">
<span x-show="option.dot" class="size-1.5 shrink-0 rounded-full" :class="dotClass(option.dot)"></span>
<span class="flex min-w-0 flex-col items-start">
<span class="flex min-w-0 items-baseline gap-2">
<span class="truncate" x-text="option.label"></span>
<span x-show="option.state" x-text="option.state"
class="shrink-0 text-xs text-neutral-500 dark:text-fg-dim"></span>
</span>
<span x-show="option.description" x-text="option.description"
class="text-xs whitespace-normal text-neutral-500 dark:text-fg-dim"></span>
</span>
</span>
<svg x-show="isSelected(option)" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
class="ml-auto size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</button>
</template>
</div>
</template>
@if ($searchable && $allowCustom)
<template x-if="customOption">
<button type="button" class="listbox-option" role="option" aria-selected="false"
@click="choose(customOption)">
<span class="truncate">Use <span class="font-mono" x-text="customOption.label"></span></span>
</button>
</template>
@endif
@if ($searchable)
</div>
@endif
</div>
</template>
@else
<div x-ref="panel" class="listbox-panel{{ $searchable ? ' searchable-listbox-panel' : '' }}" x-show="open" x-cloak
@endif
<div x-ref="panel" id="{{ $panelId }}" class="listbox-panel{{ $searchable ? ' searchable-listbox-panel' : '' }}"
style="position: fixed; z-index: 9999; visibility: hidden" x-show="open"
x-cloak :style="{ visibility: positioned ? 'visible' : 'hidden', ...panelPosition }"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]" role="listbox"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]"
x-effect="if (open) requestAnimationFrame(() => positionPanel($el))" role="listbox"
@if ($multiple) aria-multiselectable="true" @endif>
@if ($searchable)
<div class="searchable-listbox-search">
@@ -393,6 +319,8 @@
</div>
@endif
</div>
@if ($portal)
</template>
@endif
</div>
@if ($errorMessage)
@@ -97,6 +97,59 @@ it('keeps the PR suffix listbox visible outside the volumes table while scrollin
->screenshot(filename: 'application-persistent-storage-pr-suffix-listbox');
});
it('floats the backup target listbox over the new backup modal instead of scrolling it', function () {
foreach (range(1, 6) as $index) {
LocalPersistentVolume::create([
'uuid' => (string) new Cuid2,
'name' => $this->application->uuid.'-data-'.$index,
'mount_path' => '/data/'.$index,
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
}
loginAndSkipBoarding();
$url = applicationConfigurationUrl(
$this->stack['project'],
$this->stack['environment'],
$this->application
).'/backups';
$page = visit($url);
$page->assertSee('Storage backups')
->click('button:visible:has-text("Add")')
->assertSee('New scheduled backup')
->assertScript(<<<'JS'
() => {
document.querySelector('#targetKey-trigger').click();
return true;
}
JS)
->wait(0.3)
->assertScript(<<<'JS'
() => {
const panel = document.querySelector('#targetKey-panel');
const modalBody = panel.closest('.application-settings-section-body');
const panelRect = panel.getBoundingClientRect();
return modalBody.scrollHeight <= modalBody.clientHeight
&& panelRect.bottom > modalBody.getBoundingClientRect().bottom
&& panelRect.bottom <= window.innerHeight
&& document.elementFromPoint(panelRect.left + 10, panelRect.bottom - 10)?.closest('#targetKey-panel') === panel;
}
JS)
->screenshot(filename: 'application-backup-target-listbox');
selectListboxOption($page, 'targetKey', 'Volume: '.$this->application->uuid.'-data-6');
$page->assertScript(<<<'JS'
() => document.querySelector('#targetKey-trigger').textContent.includes('-data-6')
JS)
->assertNoJavaScriptErrors();
});
it('saves application name description and ports from the general form', function () {
loginAndSkipBoarding();