1"use client";
2
3import React, { useState, useCallback, useRef } from "react";
4
5export interface ReactionItem {
6 emoji: string;
7 label: string;
8 count: number;
9 active?: boolean;
10}
11
12interface ReactionBarProps {
13 reactions?: ReactionItem[];
14 className?: string;
15}
16
17const defaultReactions: ReactionItem[] = [
18 { emoji: "π", label: "clap", count: 0 },
19 { emoji: "β€οΈ", label: "love", count: 0 },
20 { emoji: "π₯", label: "fire", count: 0 },
21 { emoji: "π‘", label: "insightful", count: 0 },
22 { emoji: "π", label: "celebrate", count: 0 },
23];
24
25export function ReactionBar({
26 reactions: initialReactions,
27 className = "",
28}: ReactionBarProps) {
29 const [reactions, setReactions] = useState<ReactionItem[]>(
30 initialReactions ?? defaultReactions
31 );
32 const [animatingKey, setAnimatingKey] = useState<string | null>(null);
33 const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
34
35 const handleClick = useCallback(
36 (index: number) => {
37 setReactions((prev) =>
38 prev.map((r, i) => {
39 if (i !== index) return r;
40 return {
41 ...r,
42 active: !r.active,
43 count: r.active ? r.count - 1 : r.count + 1,
44 };
45 })
46 );
47
48 const key = reactions[index].label;
49 setAnimatingKey(key);
50 if (timeoutRef.current) clearTimeout(timeoutRef.current);
51 timeoutRef.current = setTimeout(() => setAnimatingKey(null), 300);
52 },
53 [reactions]
54 );
55
56 return (
57 <div
58 className={["flex flex-wrap gap-2", className].filter(Boolean).join(" ")}
59 >
60 {reactions.map((reaction, index) => {
61 const isAnimating = animatingKey === reaction.label;
62 return (
63 <button
64 key={reaction.label}
65 type="button"
66 onClick={() => handleClick(index)}
67 aria-pressed={!!reaction.active}
68 aria-label={`${reaction.label}, ${reaction.count}`}
69 className={[
70 "inline-flex items-center gap-1.5 rounded-[11px] pl-2.5 pr-3 py-[7px] text-[13px] font-medium cursor-pointer",
71 "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",
72 reaction.active ? "text-white" : "text-neutral-600 hover:text-neutral-900",
73 ].join(" ")}
74 style={{
75 /* Off is a raised face; on is the dark face β pressed in and lit */
76 background: reaction.active ? "var(--face-dark)" : "var(--face-light)",
77 boxShadow: reaction.active ? "var(--face-dark-shadow)" : "var(--face-light-shadow)",
78 textShadow: reaction.active ? "0 -1px 0 rgba(0,0,0,0.4)" : "0 1px 0 rgba(255,255,255,0.9)",
79 transform: isAnimating ? "scale(1.08)" : "scale(1)",
80 transition:
81 "transform 250ms var(--ease-spring), color 200ms ease, box-shadow 200ms ease",
82 }}
83 >
84 <span aria-hidden className="text-[15px] leading-none">{reaction.emoji}</span>
85 <span
86 className="tabular-nums leading-none"
87 style={{
88 transform: isAnimating ? "scale(1.15)" : "scale(1)",
89 display: "inline-block",
90 transition:
91 "transform 250ms cubic-bezier(0.34, 1.4, 0.64, 1)",
92 }}
93 >
94 {reaction.count}
95 </span>
96 </button>
97 );
98 })}
99 </div>
100 );
101}