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>
107 lines
3.1 KiB
Svelte
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>
|