Files

190 lines
6.6 KiB
TypeScript

"use client";
/**
* EmojiPicker — a tiny, dependency-free emoji popover for text inputs.
*
* Renders just a trigger button; the panel is portaled to <body> 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<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(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 (
<>
<button
ref={btnRef}
type="button"
onClick={toggle}
aria-label="Add emoji"
aria-haspopup="dialog"
aria-expanded={open}
className={cn(
"size-10 shrink-0 inline-flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
className,
)}
>
<Smile className="size-5" />
</button>
{open && pos && typeof document !== "undefined"
? createPortal(
<div
ref={panelRef}
role="dialog"
aria-label="Emoji picker"
style={{
position: "fixed",
bottom: pos.bottom,
left: pos.left,
width: PANEL_WIDTH,
maxWidth: "calc(100vw - 1rem)",
}}
// pointer-events-auto: vaul makes the drawer modal, setting
// body { pointer-events: none } while open — a body-portaled
// panel inherits it and would be click-through without this.
className="pointer-events-auto z-[101] flex max-h-80 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-xl animate-in fade-in-0 zoom-in-95"
>
<div
// min-h-0 lets this flex child shrink below its content so it
// actually scrolls; stopPropagation keeps the wheel/touch from
// reaching the host drawer (vaul) and being hijacked.
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2"
style={{ WebkitOverflowScrolling: "touch", touchAction: "pan-y" }}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
>
{EMOJI_GROUPS.map((g) => (
<div key={g.label} className="mb-1.5">
<p className="px-1 pb-0.5 pt-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{g.label}
</p>
<div className="grid grid-cols-8 gap-0.5">
{g.emojis.map((e, i) => (
<button
key={`${e}-${i}`}
type="button"
onClick={() => onSelect(e)}
aria-label={`Emoji ${e}`}
className="flex size-8 items-center justify-center rounded-md text-xl leading-none hover:bg-muted"
>
{e}
</button>
))}
</div>
</div>
))}
</div>
</div>,
document.body,
)
: null}
</>
);
}