"use client"; import { SmilePlusIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, type PointerEvent, useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; // Stays false until mounted so the server render and the first client render match. function useSafeReducedMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } export interface ReactionOption { emoji: string; label: string; } export interface Reaction { emoji: string; count: number; mine?: boolean; } export interface ReactionPickerProps { reactions?: Reaction[]; options?: ReactionOption[]; onChange?: (reactions: Reaction[]) => void; holdDelay?: number; className?: string; } export const defaultReactionOptions: ReactionOption[] = [ { emoji: "👍", label: "Like" }, { emoji: "❤️", label: "Love" }, { emoji: "😂", label: "Funny" }, { emoji: "🎉", label: "Celebrate" }, { emoji: "😮", label: "Wow" }, { emoji: "🙏", label: "Thanks" }, ]; const TRIGGER = 32; const SLOT = 36; const PAD = 4; const TRAY_H = 44; const GAP = 8; const BLUR_ROOM = 24; const spring = { type: "spring", stiffness: 520, damping: 34 } as const; function Count({ value, reduce }: { value: number; reduce: boolean | null }) { return ( {value} ); } export function ReactionPicker({ reactions: initial = [], options = defaultReactionOptions, onChange, holdDelay = 280, className, }: ReactionPickerProps) { const reduce = useSafeReducedMotion(); const filterId = `goo-${useId().replace(/:/g, "")}`; const trayId = useId(); const [reactions, setReactions] = useState(initial); const [open, setOpen] = useState(false); const [hovered, setHovered] = useState(null); const [focusIndex, setFocusIndex] = useState(0); const holdTimer = useRef>(undefined); const holding = useRef(false); const wasOpen = useRef(false); const triggerRef = useRef(null); const emojiRefs = useRef<(HTMLButtonElement | null)[]>([]); const trayW = options.length * SLOT + PAD * 2; useEffect(() => () => clearTimeout(holdTimer.current), []); const toggle = (emoji: string) => { setReactions((rs) => { const existing = rs.find((r) => r.emoji === emoji); let next: Reaction[]; if (!existing) next = [...rs, { emoji, count: 1, mine: true }]; else if (existing.mine) { next = rs .map((r) => (r.emoji === emoji ? { ...r, count: r.count - 1, mine: false } : r)) .filter((r) => r.count > 0); } else next = rs.map((r) => (r.emoji === emoji ? { ...r, count: r.count + 1, mine: true } : r)); onChange?.(next); return next; }); }; const pick = (emoji: string) => { const mine = reactions.find((r) => r.emoji === emoji)?.mine; if (!mine) toggle(emoji); close(true); }; const close = (refocus = false) => { setOpen(false); setHovered(null); if (refocus) triggerRef.current?.focus({ preventScroll: true }); }; const emojiAt = (x: number, y: number) => { const i = emojiRefs.current.findIndex((el) => { if (!el) return false; const r = el.getBoundingClientRect(); return x >= r.left - 2 && x <= r.right + 2 && y >= r.top - 14 && y <= r.bottom + 14; }); return i < 0 ? null : i; }; const onPointerDown = (e: PointerEvent) => { if (e.button !== 0) return; e.currentTarget.setPointerCapture(e.pointerId); wasOpen.current = open; holding.current = false; clearTimeout(holdTimer.current); holdTimer.current = setTimeout(() => { holding.current = true; setOpen(true); }, holdDelay); }; const onPointerMove = (e: PointerEvent) => { if (holding.current) setHovered(emojiAt(e.clientX, e.clientY)); }; const onPointerUp = (e: PointerEvent) => { clearTimeout(holdTimer.current); if (holding.current) { holding.current = false; const i = emojiAt(e.clientX, e.clientY); if (i !== null) pick(options[i].emoji); return; } setOpen(!wasOpen.current); }; const onTriggerKey = (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " " || e.key === "ArrowUp") { e.preventDefault(); setOpen(true); setFocusIndex(0); requestAnimationFrame(() => emojiRefs.current[0]?.focus()); } }; const onTrayKey = (e: KeyboardEvent) => { let i = focusIndex; if (e.key === "ArrowRight") i = (focusIndex + 1) % options.length; else if (e.key === "ArrowLeft") i = (focusIndex - 1 + options.length) % options.length; else if (e.key === "Home") i = 0; else if (e.key === "End") i = options.length - 1; else if (e.key === "Escape") { e.preventDefault(); close(true); return; } else return; e.preventDefault(); setFocusIndex(i); emojiRefs.current[i]?.focus(); }; const trayLeft = -PAD; const trayTop = -(TRAY_H + GAP); const originX = TRIGGER / 2 - trayLeft; return (
{open && ( )}
{reduce && ( )} {open && ( { if (!e.currentTarget.contains(e.relatedTarget as Node) && e.relatedTarget !== triggerRef.current) close(); }} onKeyDown={onTrayKey} role="toolbar" style={{ left: trayLeft, top: trayTop, width: trayW, height: TRAY_H, paddingInline: PAD }} > {options.map((o, i) => { const mine = reactions.find((r) => r.emoji === o.emoji)?.mine; const isHot = hovered === i; return ( pick(o.emoji)} onFocus={() => setFocusIndex(i)} onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(i)} onPointerLeave={(e) => e.pointerType === "mouse" && setHovered((h) => (h === i ? null : h))} ref={(el) => { emojiRefs.current[i] = el; }} style={{ width: SLOT, height: SLOT }} tabIndex={i === focusIndex ? 0 : -1} transition={ reduce ? { duration: 0.12 } : { ...spring, delay: isHot || hovered !== null ? 0 : 0.05 + i * 0.025, } } type="button" > {o.emoji} {mine && } {isHot && ( {o.label} )} ); })} )}
{reactions.map((r) => { const label = options.find((o) => o.emoji === r.emoji)?.label ?? r.emoji; return ( toggle(r.emoji)} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 600, damping: 36 }} type="button" > {r.emoji} ); })}
); }