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

This commit is contained in:
Andras Bacsai authored and GitHub committed 2026-08-16 13:44:30 +02:00
1 parent 4c22919d38
commit 9729e765b4
6 files changed
+140 -136

No files matched your search

@@ -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"