"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 (
{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}
);
})}
);
}