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()">
whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
@@ -244,92 +241,21 @@
+ {{-- 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)
-
- @if ($searchable)
-
-
-
-
-
- @endif
-
- @if ($searchable)
-
- @else
- {{ $emptyText }}
- @endif
-
-
-
- {{-- 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. --}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- @if ($searchable && $allowCustom)
-
-
- Use
-
-
- @endif
- @if ($searchable)
-
- @endif
-
-
- @else
-
@if ($searchable)
@@ -393,6 +319,8 @@
@endif
+ @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();