Kez/kez-chat/web/src/lib/EmojiButton.svelte
Jason Tudisco 46cb58307c feat(kez-chat/web): emoji picker + emoji-only message style boost
Compose bar gets a 😀 button. Click → emoji-picker-element (the
canonical web component, ~140 KB) lazy-loads on first open and stays
cached after. Pick → inserts at cursor position, focus returns to
input. Click-outside closes the popover.

Bonus: emoji-only messages render iMessage-style — no bubble, larger
text. Uses Intl.Segmenter to count grapheme clusters so 👨‍👩‍👧 reads
as 1 not 5 code points.

  • 1 emoji  → text-5xl
  • 2-3      → text-4xl
  • 4-6      → text-3xl
  • 7+ or any letters/digits/punct → normal bubble

Bundle: emoji picker chunked separately via dynamic import (38 KB
gzipped). Initial Messages-page JS only nudged ~159→162 KB.

Native emoji input (macOS ⌃⌘Space, iOS keyboard, Android long-press)
still works — the picker is just for discoverability.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 23:19:59 -06:00

107 lines
3.1 KiB
Svelte

<script lang="ts">
// Emoji picker button — toggles a popover with the full Unicode set
// (search, skin tone, category nav) via emoji-picker-element. The
// picker is ~140 KB so we dynamic-import on first click; subsequent
// clicks are instant (module cached).
import { onDestroy } from "svelte";
interface Props {
/** Called with the picked emoji's character. */
onpick: (emoji: string) => void;
}
let { onpick }: Props = $props();
let open = $state(false);
let mountEl: HTMLDivElement | null = $state(null);
let pickerEl: HTMLElement | null = null;
let loading = $state(false);
let loadError = $state<string | null>(null);
async function togglePicker() {
if (open) {
close();
return;
}
open = true;
if (!pickerEl) {
loading = true;
try {
// Side-effect import — registers <emoji-picker> as a custom element.
await import("emoji-picker-element");
pickerEl = document.createElement("emoji-picker") as HTMLElement;
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);
}
document.addEventListener("click", onDocumentClick);
}
function close() {
open = false;
document.removeEventListener("click", onDocumentClick);
}
function onEmojiClick(ev: Event) {
const e = ev as CustomEvent<{ unicode: string }>;
onpick(e.detail.unicode);
close();
}
function onDocumentClick(ev: MouseEvent) {
// Close when the user clicks anywhere outside the popover or trigger.
if (mountEl && !mountEl.contains(ev.target as Node)) close();
}
onDestroy(() => {
pickerEl?.removeEventListener("emoji-click", onEmojiClick as EventListener);
document.removeEventListener("click", onDocumentClick);
});
</script>
<div class="relative" bind:this={mountEl}>
<button
type="button"
class="px-2 py-2 text-xl border border-gray-300 rounded hover:bg-gray-50 leading-none"
title="Insert emoji"
onclick={(e) => {
e.stopPropagation();
togglePicker();
}}
>
😀
</button>
{#if open}
<div
class="absolute bottom-full right-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. -->
</div>
{/if}
</div>
<style>
/* emoji-picker-element renders inside shadow DOM; only its outer size
is themeable here. Width comes from the component itself (~340px). */
:global(emoji-picker) {
height: 360px;
--background: #fff;
--border-color: #e5e7eb;
}
</style>