"use client";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "motion/react";
import { cn } from "@/lib/utils";
interface PopoverProps {
children: React.ReactNode;
content: React.ReactNode;
side?: "top" | "bottom" | "left" | "right";
className?: string;
trigger?: "hover" | "click";
variant?: "v1" | "v2" | "v3";
}
export function Popover({
children,
content,
side = "top",
className,
trigger = "hover",
variant = "v1",
}: PopoverProps) {
const [show, setShow] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0, width: 0, height: 0 });
useEffect(() => {
setMounted(true);
}, []);
const updatePosition = useCallback(() => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const newCoords = {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
};
setCoords((prev) => {
if (
prev.top === newCoords.top &&
prev.left === newCoords.left &&
prev.width === newCoords.width &&
prev.height === newCoords.height
) {
return prev;
}
return newCoords;
});
}, []);
useEffect(() => {
if (show) {
updatePosition();
let frameId: number;
const handleEvent = () => {
cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(updatePosition);
};
window.addEventListener("scroll", handleEvent, true);
window.addEventListener("resize", handleEvent);
return () => {
cancelAnimationFrame(frameId);
window.removeEventListener("scroll", handleEvent, true);
window.removeEventListener("resize", handleEvent);
};
}
}, [show, updatePosition]);
const open = () => setShow(true);
const close = () => setShow(false);
const toggle = () => setShow(!show);
const triggerProps =
trigger === "hover"
? { onMouseEnter: open, onMouseLeave: close }
: { onClick: toggle };
const getPopoverStyle = (): React.CSSProperties => {
const gap = 8;
const baseStyle: React.CSSProperties = {
position: "fixed",
zIndex: 9999,
pointerEvents: "none",
};
switch (side) {
case "top":
return {
...baseStyle,
top: coords.top - gap,
left: coords.left + coords.width / 2,
transform: "translate(-50%, -100%)",
};
case "bottom":
return {
...baseStyle,
top: coords.top + coords.height + gap,
left: coords.left + coords.width / 2,
transform: "translateX(-50%)",
};
case "left":
return {
...baseStyle,
top: coords.top + coords.height / 2,
left: coords.left - gap,
transform: "translate(-100%, -50%)",
};
case "right":
return {
...baseStyle,
top: coords.top + coords.height / 2,
left: coords.left + coords.width + gap,
transform: "translateY(-50%)",
};
default:
return baseStyle;
}
};
const getArrowStyle = (side: string): string => {
switch (side) {
case "top":
return cn(
"top-full left-1/2 -translate-x-1/2 border-t-current",
variant === "v1" && "text-border",
variant === "v2" && "text-white/10",
variant === "v3" && "text-foreground",
);
case "bottom":
return cn(
"bottom-full left-1/2 -translate-x-1/2 border-b-current",
variant === "v1" && "text-border",
variant === "v2" && "text-white/10",
variant === "v3" && "text-foreground",
);
case "left":
return cn(
"left-full top-1/2 -translate-y-1/2 border-l-current",
variant === "v1" && "text-border",
variant === "v2" && "text-white/10",
variant === "v3" && "text-foreground",
);
case "right":
return cn(
"right-full top-1/2 -translate-y-1/2 border-r-current",
variant === "v1" && "text-border",
variant === "v2" && "text-white/10",
variant === "v3" && "text-foreground",
);
default:
return "";
}
};
return (
<>
<div ref={triggerRef} className="inline-flex" {...triggerProps}>
{children}
</div>
{mounted &&
createPortal(
<AnimatePresence>
{show && (
<motion.div
initial={{
opacity: 0,
scale: 0.95,
y: side === "top" ? 5 : side === "bottom" ? -5 : 0,
}}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{
opacity: 0,
scale: 0.95,
y: side === "top" ? 5 : side === "bottom" ? -5 : 0,
}}
transition={{ duration: 0.1, ease: "easeOut" }}
style={getPopoverStyle()}
className={cn(
"px-2.5 py-1.5 backdrop-blur-md shadow-xl whitespace-nowrap",
variant === "v1" &&
"rounded-xl border border-border bg-background/80",
variant === "v2" &&
"rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl shadow-[0_0_20px_rgba(0,0,0,0.2)]",
variant === "v3" &&
"rounded-none border-2 border-foreground bg-background shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,1)]",
className,
)}
>
<div
className={cn(
"text-xs font-semibold flex items-center gap-2",
variant === "v1" && "text-foreground/90",
variant === "v2" && "text-white/90",
variant === "v3" &&
"text-foreground font-bold uppercase tracking-wider",
)}
>
{content}
</div>
<div
className={cn(
"absolute border-[6px] border-transparent",
getArrowStyle(side),
variant === "v3" && "hidden",
)}
/>
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</>
);
}