From 33b369243b5e384719abce4b226b2c3e65f40843 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:35:06 +0200 Subject: [PATCH] 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 --- .../views/components/forms/listbox.blade.php | 96 +++---------------- .../Browser/ApplicationConfigurationTest.php | 53 ++++++++++ 2 files changed, 65 insertions(+), 84 deletions(-) diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php index 06b68b3dbe..018e1bb5ba 100644 --- a/resources/views/components/forms/listbox.blade.php +++ b/resources/views/components/forms/listbox.blade.php @@ -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()"> + {{-- Fixed positioning lets the panel escape overflow containers such as modal bodies and tables. + portal also moves it to , for an ancestor with a transform or filter. --}} @if ($portal) - @else - + @if ($portal) + @endif @if ($errorMessage) diff --git a/tests/v4/Browser/ApplicationConfigurationTest.php b/tests/v4/Browser/ApplicationConfigurationTest.php index 6348a60c9c..946904fce6 100644 --- a/tests/v4/Browser/ApplicationConfigurationTest.php +++ b/tests/v4/Browser/ApplicationConfigurationTest.php @@ -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();