|
|
"use client"; |
|
|
|
|
|
import React, { useState, useRef, useEffect } from "react"; |
|
|
import { Info } from "lucide-react"; |
|
|
import { cn } from "@/lib/utils/cn"; |
|
|
|
|
|
interface InfoButtonProps { |
|
|
content: string | React.ReactNode; |
|
|
title?: string; |
|
|
position?: "top" | "bottom" | "left" | "right"; |
|
|
className?: string; |
|
|
|
|
|
variant?: "default" | "subtle"; |
|
|
} |
|
|
|
|
|
export const InfoButton: React.FC<InfoButtonProps> = ({ |
|
|
content, |
|
|
title, |
|
|
position = "top", |
|
|
className = "", |
|
|
variant = "subtle", |
|
|
}) => { |
|
|
const [isOpen, setIsOpen] = useState(false); |
|
|
const buttonRef = useRef<HTMLButtonElement>(null); |
|
|
const tooltipRef = useRef<HTMLDivElement>(null); |
|
|
|
|
|
useEffect(() => { |
|
|
const handleClickOutside = (event: MouseEvent) => { |
|
|
if ( |
|
|
tooltipRef.current && |
|
|
buttonRef.current && |
|
|
!tooltipRef.current.contains(event.target as Node) && |
|
|
!buttonRef.current.contains(event.target as Node) |
|
|
) { |
|
|
setIsOpen(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
if (isOpen) { |
|
|
document.addEventListener("mousedown", handleClickOutside); |
|
|
} |
|
|
|
|
|
return () => { |
|
|
document.removeEventListener("mousedown", handleClickOutside); |
|
|
}; |
|
|
}, [isOpen]); |
|
|
|
|
|
const positionClasses = { |
|
|
top: "bottom-full left-1/2 -translate-x-1/2 mb-2", |
|
|
bottom: "top-full left-1/2 -translate-x-1/2 mt-2", |
|
|
left: "right-full top-1/2 -translate-y-1/2 mr-2", |
|
|
right: "left-full top-1/2 -translate-y-1/2 ml-2", |
|
|
}; |
|
|
|
|
|
const isBottom = position === "bottom"; |
|
|
const isTop = position === "top"; |
|
|
|
|
|
return ( |
|
|
<span className={cn("relative inline-flex", className)}> |
|
|
<button |
|
|
ref={buttonRef} |
|
|
type="button" |
|
|
onClick={(e) => { |
|
|
e.preventDefault(); |
|
|
e.stopPropagation(); |
|
|
setIsOpen(!isOpen); |
|
|
}} |
|
|
className={cn( |
|
|
"inline-flex items-center justify-center rounded-full transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-400/50 focus:ring-offset-1", |
|
|
variant === "subtle" |
|
|
? "w-4 h-4 text-gray-400 hover:text-gray-600 hover:bg-gray-100" |
|
|
: "w-5 h-5 bg-gray-100 text-gray-500 hover:bg-gray-200 hover:text-gray-700" |
|
|
)} |
|
|
aria-label="Show information" |
|
|
> |
|
|
<Info className="w-3 h-3" strokeWidth={2.25} /> |
|
|
</button> |
|
|
|
|
|
{isOpen && ( |
|
|
<div |
|
|
ref={tooltipRef} |
|
|
className={cn( |
|
|
"absolute z-[100] w-72 sm:w-80 max-w-[calc(100vw-2rem)]", |
|
|
positionClasses[position] |
|
|
)} |
|
|
> |
|
|
<div |
|
|
className={cn( |
|
|
"relative rounded-xl border border-gray-200/90 bg-white/95 shadow-lg backdrop-blur-sm", |
|
|
"info-tooltip-enter" |
|
|
)} |
|
|
> |
|
|
<div className="max-h-[70vh] overflow-y-auto rounded-xl p-4"> |
|
|
{title && ( |
|
|
<h3 className="text-sm font-semibold text-gray-900 mb-2 pr-6"> |
|
|
{title} |
|
|
</h3> |
|
|
)} |
|
|
<div className="text-sm leading-relaxed text-gray-600"> |
|
|
{typeof content === "string" ? ( |
|
|
<p className="whitespace-pre-line">{content}</p> |
|
|
) : ( |
|
|
content |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
{/* Arrow */} |
|
|
<div |
|
|
className={cn( |
|
|
"absolute w-2 h-2 rotate-45 border border-gray-200/90 bg-white/95", |
|
|
isBottom && "top-0 left-1/2 -translate-x-1/2 -translate-y-px border-t-transparent border-l-transparent", |
|
|
isTop && "bottom-0 left-1/2 -translate-x-1/2 translate-y-px border-b-transparent border-r-transparent", |
|
|
position === "left" && "right-0 top-1/2 -translate-y-1/2 translate-x-px border-r-transparent border-b-transparent", |
|
|
position === "right" && "left-0 top-1/2 -translate-y-1/2 -translate-x-px border-l-transparent border-t-transparent" |
|
|
)} |
|
|
/> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</span> |
|
|
); |
|
|
} |
|
|
|