mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
co-authored by
Claude Opus 5.5
parent
2ea1afb811
commit
33b369243b
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user