fix(ui): prevent floating dropdown Alpine scope errors (#11311)

This commit is contained in:
Andras Bacsai
2026-08-16 13:44:30 +02:00
committed by GitHub
parent 4c22919d38
commit 9729e765b4
6 changed files with 140 additions and 136 deletions
@@ -0,0 +1,69 @@
<script>
window.floatingDropdown = function floatingDropdown(config, state = {}) {
state.open = false;
state.positioned = false;
state.toggle = function toggle() {
this.open = !this.open;
this.positioned = false;
if (this.open && config.portal) {
this.$nextTick(() => this.schedulePosition());
}
};
state.close = function close() {
this.open = false;
this.positioned = false;
};
state.schedulePosition = function schedulePosition(panel = null) {
window.requestAnimationFrame(() => this.positionPanel(panel));
};
state.positionPanel = function positionPanel(panel = null) {
const trigger = this.$refs.trigger;
panel ??= document.getElementById(config.panelId);
if (!trigger || !panel) {
return;
}
const gap = 4;
const edge = 12;
const availableWidth = window.innerWidth - (edge * 2);
const availableHeight = window.innerHeight - (edge * 2);
const triggerRect = trigger.getBoundingClientRect();
const desiredWidth = config.matchTriggerWidth
? Math.max(triggerRect.width, panel.offsetWidth)
: panel.offsetWidth;
const panelWidth = Math.min(desiredWidth, availableWidth);
const panelHeight = Math.min(panel.scrollHeight, config.maxHeight ?? availableHeight);
const fitsBelow = window.innerHeight - triggerRect.bottom - gap >= panelHeight;
const top = fitsBelow
? triggerRect.bottom + gap
: Math.max(edge, triggerRect.top - gap - panelHeight);
const alignedLeft = config.align === 'right'
? triggerRect.right - panelWidth
: triggerRect.left;
const left = window.innerWidth < 768
? (window.innerWidth - panelWidth) / 2
: Math.min(Math.max(edge, alignedLeft), window.innerWidth - panelWidth - edge);
panel.style.top = `${top}px`;
panel.style.left = `${left}px`;
panel.style.width = `${panelWidth}px`;
panel.style.maxWidth = `${availableWidth}px`;
if (config.matchTriggerWidth) {
panel.style.minWidth = `${triggerRect.width}px`;
} else {
panel.style.maxHeight = `${availableHeight}px`;
}
this.positioned = true;
};
return state;
};
</script>
@@ -43,9 +43,13 @@
@endif
</div>
@endif
<div class="relative min-w-0" x-data="{
open: false,
positioned: false,
<div class="relative min-w-0" x-data="floatingDropdown({
panelId: @js($panelId),
portal: @js($portal),
align: 'left',
matchTriggerWidth: true,
maxHeight: 256,
}, {
saving: false,
options: @js(array_values($options)),
value: @if (!$wire) @js($value) @elseif ($live && ! $onChange) @entangle($id).live @else @entangle($id) @endif,
@@ -75,49 +79,11 @@
}
@endif
},
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));
if (!trigger || !panel) return;
const gap = 4;
const edge = 12;
const triggerRect = trigger.getBoundingClientRect();
const panelWidth = Math.min(
Math.max(triggerRect.width, panel.offsetWidth),
window.innerWidth - (edge * 2),
);
const panelHeight = Math.min(panel.scrollHeight, 256);
const fitsBelow = window.innerHeight - triggerRect.bottom - gap >= panelHeight;
const top = fitsBelow
? triggerRect.bottom + gap
: Math.max(edge, triggerRect.top - gap - panelHeight);
const left = window.innerWidth < 768
? (window.innerWidth - panelWidth) / 2
: Math.min(
Math.max(edge, triggerRect.left),
window.innerWidth - panelWidth - edge,
);
panel.style.top = `${top}px`;
panel.style.left = `${left}px`;
panel.style.width = `${panelWidth}px`;
panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`;
panel.style.minWidth = `${triggerRect.width}px`;
this.positioned = true;
}
}" x-modelable="value" :class="{ 'pointer-events-none opacity-70': saving }"
})" x-modelable="value" :class="{ 'pointer-events-none opacity-70': saving }"
{{ $attributes->whereStartsWith('x-model') }}
{{ $attributes->whereStartsWith('x-effect') }}
@if ($preserveValue) wire:ignore @endif
@click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()">
@click.outside="open = false" @keydown.escape="open = false" @resize.window="open && schedulePosition()">
<button x-ref="trigger" id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="toggle()"
@disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
:aria-expanded="open" @if ($tooltip) :title="current" @endif>
@@ -128,35 +94,33 @@
</svg>
</button>
@if ($portal)
<template x-teleport="body">
<div id="{{ $panelId }}" class="listbox-panel"
style="position: fixed; z-index: 9999; visibility: hidden" x-show="open"
x-cloak :style="{ visibility: positioned ? 'visible' : 'hidden' }"
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">
<div x-show="options.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
</div>
<template x-for="option in options" :key="String(option.value)">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
<span class="truncate" x-text="option.label"></span>
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
class="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 id="{{ $panelId }}" class="listbox-panel"
style="position: fixed; z-index: 9999; visibility: hidden" x-show="open"
x-cloak :style="{ visibility: positioned ? 'visible' : 'hidden' }"
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) schedulePosition($el)" role="listbox">
<div x-show="options.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
</div>
</template>
<template x-for="option in options" :key="String(option.value)">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
<span class="truncate" x-text="option.label"></span>
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
class="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>
@else
<div x-ref="panel" class="listbox-panel" x-show="open" x-cloak
x-transition:enter="transition ease-out duration-100"
@@ -6,63 +6,19 @@
@php($panelId = 'table-dropdown-'.uniqid())
<div class="relative" x-data="{
open: false,
positioned: false,
toggle() {
this.open = !this.open;
this.positioned = false;
if (this.open) {
this.$nextTick(() => requestAnimationFrame(() => this.positionPanel()));
}
},
close() {
this.open = false;
this.positioned = false;
},
positionPanel() {
const trigger = this.$refs.trigger;
const panel = document.getElementById(@js($panelId));
if (!trigger || !panel) return;
const gap = 4;
const edge = 12;
const triggerRect = trigger.getBoundingClientRect();
const panelWidth = Math.min(panel.offsetWidth, window.innerWidth - (edge * 2));
const panelHeight = Math.min(panel.scrollHeight, window.innerHeight - (edge * 2));
const fitsBelow = window.innerHeight - triggerRect.bottom - gap >= panelHeight;
const top = fitsBelow
? triggerRect.bottom + gap
: Math.max(edge, triggerRect.top - gap - panelHeight);
const left = window.innerWidth < 768
? (window.innerWidth - panelWidth) / 2
: Math.min(
Math.max(edge, triggerRect.right - panelWidth),
window.innerWidth - panelWidth - edge,
);
panel.style.top = `${top}px`;
panel.style.left = `${left}px`;
panel.style.width = `${panelWidth}px`;
panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`;
panel.style.maxHeight = `${window.innerHeight - (edge * 2)}px`;
this.positioned = true;
}
}" @click.outside="close()" @keydown.escape.window="close()"
@resize.window="open && positionPanel()" @scroll.window="open && positionPanel()">
<div class="relative"
x-data="floatingDropdown({ panelId: @js($panelId), portal: true, align: 'right', matchTriggerWidth: false })"
@click.outside="close()" @keydown.escape.window="close()"
@resize.window="open && schedulePosition()" @scroll.window="open && schedulePosition()">
<div x-ref="trigger" @click="toggle()">
{{ $trigger }}
</div>
<template x-teleport="body">
<div id="{{ $panelId }}" x-show="open" x-cloak
class="listbox-panel floating-dropdown-panel z-[9999]! {{ $panelClass }}" style="position: fixed; visibility: hidden"
:style="{ visibility: positioned ? 'visible' : 'hidden' }"
x-effect="if (open) requestAnimationFrame(() => positionPanel())" role="{{ $role }}"
@if ($multiselectable) aria-multiselectable="true" @endif>
{{ $slot }}
</div>
</template>
<div id="{{ $panelId }}" x-show="open" x-cloak
class="listbox-panel floating-dropdown-panel z-[9999]! {{ $panelClass }}" style="position: fixed; visibility: hidden"
:style="{ visibility: positioned ? 'visible' : 'hidden' }"
x-effect="if (open) schedulePosition($el)" role="{{ $role }}"
@if ($multiselectable) aria-multiselectable="true" @endif>
{{ $slot }}
</div>
</div>
+1
View File
@@ -70,6 +70,7 @@
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/svg+xml" />
@endenv
<meta name="csrf-token" content="{{ csrf_token() }}">
@include('components.floating-dropdown-script')
@vite(['resources/js/app.js', 'resources/css/app.css'])
<script>
// Update theme-color meta tag (non-critical, can run async)
@@ -43,11 +43,14 @@ test('portaled listboxes center in the mobile viewport', function () {
$html = Blade::render('<x-forms.listbox id="region" :options="[]" :wire="false" />');
expect($html)
->toContain('window.innerWidth < 768')
->toContain('(window.innerWidth - panelWidth) / 2')
->toContain('panel.style.width = `${panelWidth}px`')
->toContain('panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`')
->toContain('floatingDropdown(')
->not->toContain('x-teleport="body"')
->toContain("align: 'left'")
->toContain('matchTriggerWidth: true')
->toContain('x-show="open"')
->toContain('schedulePosition($el)')
->toContain("visibility: positioned ? 'visible' : 'hidden'")
->not->toContain('positionPanel(panel = null)')
->not->toContain('x-show="open && positioned"');
});
+17 -6
View File
@@ -22,15 +22,14 @@ it('renders the standard table toolbar controls', function () {
->toContain('table-toolbar')
->toContain('table-search')
->toContain('wire:model.live="search"')
->toContain('x-teleport="body"')
->toContain('positionPanel')
->not->toContain('x-teleport="body"')
->toContain('floatingDropdown(')
->toContain('position: fixed')
->toContain('floating-dropdown-panel')
->toContain('window.innerWidth < 768')
->toContain('(window.innerWidth - panelWidth) / 2')
->toContain('panel.style.width = `${panelWidth}px`')
->toContain('panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`')
->toContain('x-show="open"')
->toContain('schedulePosition($el)')
->toContain("visibility: positioned ? 'visible' : 'hidden'")
->not->toContain('positionPanel() {')
->not->toContain('x-show="open && positioned"')
->toContain('aria-multiselectable="true"')
->toContain('Reset filters')
@@ -38,6 +37,18 @@ it('renders the standard table toolbar controls', function () {
->toContain('Sort');
});
it('centralizes floating dropdown positioning in an Alpine data provider', function () {
$layout = file_get_contents(resource_path('views/layouts/base.blade.php'));
$provider = file_get_contents(resource_path('views/components/floating-dropdown-script.blade.php'));
expect($layout)->toContain("@include('components.floating-dropdown-script')")
->and($provider)
->toContain('window.floatingDropdown = function floatingDropdown')
->toContain('window.requestAnimationFrame')
->toContain('positionPanel(panel = null)')
->toContain('window.innerWidth < 768');
});
it('renders the standard table loading overlay', function () {
$html = Blade::render('<div class="relative"><x-table.loading target="applyFilters" text="Loading records..." /></div>');