fix(kez-chat/web): emoji picker pops up as overlay instead of squeezing compose

Bug: I was appending the <emoji-picker> custom element to the OUTER
wrapper div (which is part of the flex compose row), so when the picker
opened it became a flex sibling of the button and pushed the text
input + Send button down to a tiny strip.

Fix: append into the absolute-positioned popover container (new
popoverEl ref) instead of the wrapper. The popover is taken out of
flow so the compose row stays put and the picker floats above it.

Also:
- Outer wrapper gets shrink-0 so it doesn't expand even if the picker
  somehow leaks.
- Click-outside check now looks at both wrapEl AND popoverEl (since
  the picker is no longer a descendant of the wrapper).
- Popover anchors bottom-full left-0 — picker grows up and to the
  right of the 😀 button.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-27 00:03:38 -06:00
parent d789e872b1
commit ca5290dc0f

View File

@ -13,7 +13,10 @@
let { onpick }: Props = $props();
let open = $state(false);
let mountEl: HTMLDivElement | null = $state(null);
/** Outer wrapper — captures the document-click bounds (must include the button). */
let wrapEl: HTMLDivElement | null = $state(null);
/** Floating popover container — where the <emoji-picker> custom element mounts. */
let popoverEl: HTMLDivElement | null = $state(null);
let pickerEl: HTMLElement | null = null;
let loading = $state(false);
let loadError = $state<string | null>(null);
@ -33,15 +36,21 @@
pickerEl.addEventListener("emoji-click", onEmojiClick as EventListener);
// Match the dark-on-light scheme of the rest of the app.
pickerEl.classList.add("light");
if (mountEl) mountEl.appendChild(pickerEl);
} catch (e) {
loadError = (e as Error).message;
} finally {
loading = false;
}
} else if (mountEl && !mountEl.contains(pickerEl)) {
mountEl.appendChild(pickerEl);
}
// Mount into the floating popover div on every open — the popover
// is conditionally rendered, so it's a fresh DOM node each time.
// Wait a tick so the {#if open} block actually mounts before we
// try to appendChild into it.
queueMicrotask(() => {
if (pickerEl && popoverEl && !popoverEl.contains(pickerEl)) {
popoverEl.appendChild(pickerEl);
}
});
document.addEventListener("click", onDocumentClick);
}
@ -58,7 +67,10 @@
function onDocumentClick(ev: MouseEvent) {
// Close when the user clicks anywhere outside the popover or trigger.
if (mountEl && !mountEl.contains(ev.target as Node)) close();
const t = ev.target as Node;
const inside =
(wrapEl && wrapEl.contains(t)) || (popoverEl && popoverEl.contains(t));
if (!inside) close();
}
onDestroy(() => {
@ -67,7 +79,10 @@
});
</script>
<div class="relative" bind:this={mountEl}>
<!-- `relative` here is purely a positioning anchor for the absolute
popover — the popover itself is taken out of flow so it doesn't
squeeze the rest of the compose row. -->
<div class="relative shrink-0" bind:this={wrapEl}>
<button
type="button"
class="px-2 py-2 text-xl border border-gray-300 rounded hover:bg-gray-50 leading-none"
@ -82,15 +97,16 @@
{#if open}
<div
class="absolute bottom-full right-0 mb-2 z-50 shadow-lg rounded overflow-hidden bg-white"
bind:this={popoverEl}
class="absolute bottom-full left-0 mb-2 z-50 shadow-lg rounded overflow-hidden bg-white"
>
{#if loading}
<div class="p-4 text-xs text-gray-500">Loading…</div>
{:else if loadError}
<div class="p-4 text-xs text-red-700">Failed: {loadError}</div>
{/if}
<!-- The actual <emoji-picker> custom element is appended here
imperatively by togglePicker() so we can control its lifecycle. -->
<!-- The <emoji-picker> custom element is appended into this div
imperatively by togglePicker() so we control its lifecycle. -->
</div>
{/if}
</div>