mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-18 14:23:15 +00:00
fix(ui): prevent floating dropdown Alpine scope errors (#11311)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
|
||||
@@ -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>');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user