"use client"; /** * EmojiPicker โ€” a tiny, dependency-free emoji popover for text inputs. * * Renders just a trigger button; the panel is portaled to with fixed * positioning and a transparent full-screen backdrop that swallows the * dismiss click. That keeps it OUTSIDE any surrounding drawer/sheet DOM, so * opening it can't resize or reposition the host (e.g. the vaul comments * drawer), and the dismiss click can't leak through and close that drawer. * * It stays open after a pick so several emojis can be added in a row; the * caller decides what to do with each via `onSelect`. */ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Smile } from "lucide-react"; import { cn } from "@/lib/utils"; // A curated, broadly-useful set โ€” enough to feel like a picker without // shipping a full emoji database. Grouped for easy scanning. const EMOJI_GROUPS: { label: string; emojis: string[] }[] = [ { label: "Smileys", emojis: [ "๐Ÿ˜€", "๐Ÿ˜ƒ", "๐Ÿ˜„", "๐Ÿ˜", "๐Ÿ˜…", "๐Ÿ˜‚", "๐Ÿ™‚", "๐Ÿ˜Š", "๐Ÿ˜‡", "๐Ÿ˜", "๐Ÿฅฐ", "๐Ÿ˜˜", "๐Ÿ˜‰", "๐Ÿ˜Œ", "๐Ÿ˜Ž", "๐Ÿค—", "๐Ÿค”", "๐Ÿ˜ด", "๐Ÿ˜ข", "๐Ÿ˜ญ", "๐Ÿ˜ฎ", "๐Ÿ˜ฒ", "๐Ÿฅฒ", "๐Ÿ˜ฌ", ], }, { label: "Gestures & people", emojis: [ "๐Ÿ™", "๐Ÿ‘", "๐Ÿ‘Ž", "๐Ÿ‘", "๐Ÿ™Œ", "๐Ÿค", "โœŒ๏ธ", "๐Ÿคž", "๐Ÿ’ช", "๐Ÿซถ", "๐Ÿ‘‹", "๐Ÿ™‡", "๐Ÿง˜", "๐Ÿ•‰๏ธ", "โ˜ฎ๏ธ", "๐Ÿคฒ", ], }, { label: "Hearts", emojis: [ "โค๏ธ", "๐Ÿงก", "๐Ÿ’›", "๐Ÿ’š", "๐Ÿ’™", "๐Ÿ’œ", "๐Ÿค", "๐ŸคŽ", "๐Ÿ–ค", "๐Ÿ’–", "๐Ÿ’•", "๐Ÿ’—", "๐Ÿ’“", "๐Ÿ’ž", "๐Ÿ’”", "โฃ๏ธ", ], }, { label: "Nature & spirit", emojis: [ "๐ŸŒธ", "๐ŸŒบ", "๐ŸŒน", "๐Ÿชท", "๐ŸŒป", "๐ŸŒผ", "๐Ÿƒ", "๐ŸŒฟ", "๐Ÿช”", "๐Ÿ•ฏ๏ธ", "๐Ÿ”ฅ", "โœจ", "๐ŸŒŸ", "โญ", "โ˜€๏ธ", "๐ŸŒˆ", ], }, { label: "Symbols", emojis: [ "๐ŸŽ‰", "๐ŸŽŠ", "๐Ÿฅณ", "๐Ÿ’ฏ", "โœ…", "โ—", "โ“", "๐Ÿ’ฌ", "๐Ÿ“ฟ", "๐Ÿ›•", "๐Ÿฎ", "๐Ÿฆš", "๐Ÿชˆ", "๐ŸŽถ", "๐Ÿ“–", "๐Ÿ™๐Ÿฝ", ], }, ]; const PANEL_WIDTH = 300; export function EmojiPicker({ onSelect, className, }: { onSelect: (emoji: string) => void; className?: string; }) { const [open, setOpen] = useState(false); const [pos, setPos] = useState<{ bottom: number; left: number } | null>(null); const btnRef = useRef(null); const panelRef = useRef(null); // Dismiss on any outside pointer-down or Escape. Deferred a tick so the // opening click doesn't immediately re-close it; capture phase so it fires // wherever the target lives. No close-on-scroll here โ€” the panel scrolls // internally, so a scroll inside it must NOT dismiss. useEffect(() => { if (!open) return; function onDown(e: PointerEvent) { const t = e.target as Node; if (panelRef.current?.contains(t) || btnRef.current?.contains(t)) return; setOpen(false); } function onKey(e: KeyboardEvent) { if (e.key === "Escape") setOpen(false); } const id = window.setTimeout(() => { document.addEventListener("pointerdown", onDown, true); document.addEventListener("keydown", onKey); }, 0); return () => { window.clearTimeout(id); document.removeEventListener("pointerdown", onDown, true); document.removeEventListener("keydown", onKey); }; }, [open]); function toggle() { if (open) { setOpen(false); return; } const r = btnRef.current?.getBoundingClientRect(); if (r) { // Open ABOVE the trigger (the composer sits at the bottom of the sheet), // clamped so the 300px panel stays on-screen horizontally. const left = Math.max( 8, Math.min(r.left, window.innerWidth - PANEL_WIDTH - 8), ); setPos({ bottom: window.innerHeight - r.top + 6, left }); } setOpen(true); } return ( <> {open && pos && typeof document !== "undefined" ? createPortal(
e.stopPropagation()} onTouchMove={(e) => e.stopPropagation()} > {EMOJI_GROUPS.map((g) => (

{g.label}

{g.emojis.map((e, i) => ( ))}
))}
, document.body, ) : null} ); }