97 lines
3.8 KiB
TypeScript
97 lines
3.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import { EMOJIS, FREQUENT, searchEmojis, type EmojiCategory } from "@/lib/emoji";
|
|
import { Search } from "lucide-react";
|
|
|
|
const CATS: EmojiCategory[] = ["Smileys", "Gestures", "Activity", "Animals", "Food", "Travel", "Objects", "Symbols"];
|
|
|
|
export function EmojiPicker({
|
|
onPick,
|
|
onClose,
|
|
align = "left",
|
|
}: {
|
|
onPick: (emoji: string) => void;
|
|
onClose: () => void;
|
|
align?: "left" | "right";
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [q, setQ] = useState("");
|
|
const [pos, setPos] = useState<{ v: "up" | "down"; h: "left" | "right" }>({ v: "up", h: align });
|
|
|
|
// auto-place: flip up/down + left/right based on available space (tooltip-style)
|
|
useLayoutEffect(() => {
|
|
const wrap = (ref.current?.offsetParent as HTMLElement | null) ?? ref.current;
|
|
const r = wrap?.getBoundingClientRect();
|
|
if (!r) return;
|
|
const H = 300, W = 300;
|
|
const v = r.top < H + 12 && window.innerHeight - r.bottom > r.top ? "down" : "up";
|
|
const h = window.innerWidth - r.left < W ? "right" : "left";
|
|
setPos({ v, h });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); };
|
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } };
|
|
const t = setTimeout(() => document.addEventListener("mousedown", onDoc), 0);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => { clearTimeout(t); document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
|
|
}, [onClose]);
|
|
|
|
const results = useMemo(() => (q ? searchEmojis(q, 64) : null), [q]);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={`absolute z-40 w-[300px] rounded-control border border-border bg-elevated p-2 shadow-pop animate-slide-up ${pos.v === "up" ? "bottom-9" : "top-9"} ${pos.h === "right" ? "right-0" : "left-0"}`}
|
|
>
|
|
<div className="mb-2 flex items-center gap-2 rounded-control border border-border bg-panel px-2">
|
|
<Search size={14} className="text-muted" />
|
|
<input
|
|
autoFocus
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
placeholder="Search emoji…"
|
|
className="w-full bg-transparent py-1.5 text-[13px] outline-none placeholder:text-dim"
|
|
/>
|
|
</div>
|
|
<div className="max-h-[240px] overflow-y-auto pr-0.5">
|
|
{results ? (
|
|
<Grid list={results.map((e) => e.e)} onPick={onPick} />
|
|
) : (
|
|
<>
|
|
<CatLabel>Frequently used</CatLabel>
|
|
<Grid list={FREQUENT} onPick={onPick} />
|
|
{CATS.map((c) => (
|
|
<div key={c}>
|
|
<CatLabel>{c}</CatLabel>
|
|
<Grid list={EMOJIS.filter((e) => e.c === c).map((e) => e.e)} onPick={onPick} />
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
{results && results.length === 0 && <div className="py-6 text-center text-[12.5px] text-muted">No emoji found</div>}
|
|
</div>
|
|
<div className="mt-1 border-t border-border px-1 pt-1 text-[10.5px] text-dim">
|
|
Tip: type <code className="text-accent">:tick:</code> in a message for ✅
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CatLabel({ children }: { children: React.ReactNode }) {
|
|
return <div className="px-1 pb-0.5 pt-1.5 text-[10px] font-bold uppercase tracking-wide text-dim">{children}</div>;
|
|
}
|
|
|
|
function Grid({ list, onPick }: { list: string[]; onPick: (e: string) => void }) {
|
|
return (
|
|
<div className="grid grid-cols-8 gap-0.5">
|
|
{list.map((e, i) => (
|
|
<button key={e + i} onClick={() => onPick(e)} className="grid h-8 w-8 place-items-center rounded-md text-lg hover:bg-hover" title={e}>
|
|
{e}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|