116 lines
No EOL
3.6 KiB
TypeScript
116 lines
No EOL
3.6 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { createPortal } from "react-dom"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
interface TooltipProps {
|
|
children: React.ReactNode
|
|
content: string
|
|
className?: string
|
|
side?: 'top' | 'bottom' | 'left' | 'right'
|
|
asChild?: boolean
|
|
}
|
|
|
|
export function Tooltip({ children, content, className, side = 'top', asChild = false }: TooltipProps) {
|
|
const [isVisible, setIsVisible] = React.useState(false)
|
|
const [triggerRect, setTriggerRect] = React.useState<DOMRect | null>(null)
|
|
const triggerRef = React.useRef<HTMLDivElement>(null)
|
|
|
|
const updatePosition = () => {
|
|
if (triggerRef.current) {
|
|
setTriggerRect(triggerRef.current.getBoundingClientRect())
|
|
}
|
|
}
|
|
|
|
const handleMouseEnter = () => {
|
|
updatePosition()
|
|
setIsVisible(true)
|
|
}
|
|
|
|
const handleMouseLeave = () => {
|
|
setIsVisible(false)
|
|
}
|
|
|
|
const getTooltipStyle = (): React.CSSProperties => {
|
|
if (!triggerRect) return {}
|
|
|
|
const offset = 8
|
|
let left = 0
|
|
let top = 0
|
|
|
|
switch (side) {
|
|
case 'top':
|
|
left = triggerRect.left + triggerRect.width / 2
|
|
top = triggerRect.top - offset
|
|
break
|
|
case 'bottom':
|
|
left = triggerRect.left + triggerRect.width / 2
|
|
top = triggerRect.bottom + offset
|
|
break
|
|
case 'left':
|
|
left = triggerRect.left - offset
|
|
top = triggerRect.top + triggerRect.height / 2
|
|
break
|
|
case 'right':
|
|
left = triggerRect.right + offset
|
|
top = triggerRect.top + triggerRect.height / 2
|
|
break
|
|
}
|
|
|
|
// Position the tooltip relative to the computed anchor point
|
|
// For 'top', anchor is at the bottom-center of the tooltip (so translateY(-100%))
|
|
// For 'bottom', anchor is at the top-center (no vertical translation)
|
|
// For 'left'/'right', anchor centered vertically.
|
|
let transform = ''
|
|
if (side === 'top') transform = 'translate(-50%, -100%)'
|
|
else if (side === 'bottom') transform = 'translate(-50%, 0)'
|
|
else if (side === 'left') transform = 'translate(-100%, -50%)'
|
|
else transform = 'translate(0, -50%)'
|
|
|
|
return {
|
|
position: 'fixed',
|
|
left: `${left}px`,
|
|
top: `${top}px`,
|
|
transform,
|
|
zIndex: 9999
|
|
}
|
|
}
|
|
|
|
const arrowClasses = {
|
|
top: 'top-full left-1/2 transform -translate-x-1/2 border-t-gray-900 border-t-4 border-x-transparent border-x-4 border-b-0',
|
|
bottom: 'bottom-full left-1/2 transform -translate-x-1/2 border-b-gray-900 border-b-4 border-x-transparent border-x-4 border-t-0',
|
|
left: 'left-full top-1/2 transform -translate-y-1/2 border-l-gray-900 border-l-4 border-y-transparent border-y-4 border-r-0',
|
|
right: 'right-full top-1/2 transform -translate-y-1/2 border-r-gray-900 border-r-4 border-y-transparent border-y-4 border-l-0'
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={triggerRef}
|
|
className={asChild ? "block w-full" : "inline-block"}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
onFocus={handleMouseEnter}
|
|
onBlur={handleMouseLeave}
|
|
>
|
|
{children}
|
|
</div>
|
|
|
|
{isVisible && triggerRect && typeof window !== 'undefined' && createPortal(
|
|
<div
|
|
style={getTooltipStyle()}
|
|
className={cn(
|
|
"px-3 py-2 text-sm text-white bg-gray-900 rounded-lg shadow-xl whitespace-nowrap pointer-events-none relative",
|
|
"animate-in fade-in-0 zoom-in-95 duration-150",
|
|
className
|
|
)}
|
|
>
|
|
{content}
|
|
<div className={cn("absolute w-0 h-0", arrowClasses[side])} />
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</>
|
|
)
|
|
} |