190 lines
6.6 KiB
TypeScript
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.
|
|
export 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}
|
|
</>
|
|
);
|
|
}
|