fix: emoji picker dropdown escapes dialog overflow clipping

Portal the dropdown into the nearest <dialog> element so it stays in
the browser's top layer, and temporarily set overflow:visible on the
dialog while the picker is open so it isn't clipped.
This commit is contained in:
xarmian
2026-04-10 20:06:19 +00:00
parent cabb552faf
commit 7432ffb1ec
@@ -10,13 +10,62 @@
let { value = $bindable(), placeholder = '⚡', size = 'sm' }: Props = $props();
let open = $state(false);
let triggerEl = $state<HTMLButtonElement>();
let dropdownX = $state(0);
let dropdownY = $state(0);
/**
* Portal into the nearest <dialog> (stays in top-layer, escapes overflow
* children like .dialog-body), or document.body if not inside a dialog.
*/
function portal(node: HTMLElement) {
const dialog = triggerEl?.closest('dialog');
const target = dialog || document.body;
target.appendChild(node);
// <dialog> has browser-default overflow:auto which clips children.
// Temporarily override while the dropdown is mounted.
if (dialog) {
dialog.style.overflow = 'visible';
}
return {
destroy() {
node.remove();
if (dialog) {
dialog.style.overflow = '';
}
}
};
}
function handleWindowClick(e: MouseEvent) {
if (open && !(e.target as HTMLElement)?.closest('.emoji-picker-button')) {
open = false;
if (open) {
const target = e.target as HTMLElement;
if (!target?.closest('.emoji-picker-button') && !target?.closest('.epb-dropdown')) {
open = false;
}
}
}
function toggleOpen() {
if (!open && triggerEl) {
const triggerRect = triggerEl.getBoundingClientRect();
const dialog = triggerEl.closest('dialog');
if (dialog) {
// dialog with transform creates a containing block —
// position: absolute is relative to it, so offset accordingly
const dialogRect = dialog.getBoundingClientRect();
dropdownX = triggerRect.left - dialogRect.left;
dropdownY = triggerRect.bottom - dialogRect.top + 4;
} else {
dropdownX = triggerRect.left;
dropdownY = triggerRect.bottom + 4;
}
}
open = !open;
}
function handleSelect(emoji: string) {
value = emoji;
open = false;
@@ -27,17 +76,18 @@
<div class="emoji-picker-button" class:size-md={size === 'md'}>
<button
bind:this={triggerEl}
type="button"
class="trigger"
class:open
class:empty={!value}
onclick={() => (open = !open)}
onclick={toggleOpen}
>
{value || placeholder}
</button>
{#if open}
<div class="dropdown">
<div class="epb-dropdown" use:portal style="position:absolute; z-index:99999; left:{dropdownX}px; top:{dropdownY}px;">
<EmojiPicker selected={value} onselect={handleSelect} />
</div>
{/if}
@@ -80,11 +130,4 @@
.trigger.empty {
color: var(--text-muted);
}
.dropdown {
position: absolute;
z-index: 50;
top: calc(100% + 4px);
left: 0;
}
</style>