import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { HoverCardProps, HoverCardPosition } from './types';
/**
* Calculate the position of the hover card relative to the trigger element.
* Flips to the opposite side when there isn't enough viewport space.
*/
function calculateCardPosition(
triggerRect: DOMRect,
cardRect: DOMRect,
preferred: HoverCardPosition,
gap = 12,
): { top: number; left: number; actual: HoverCardPosition } {
const viewport = {
width: window.innerWidth,
height: window.innerHeight,
};
let actual = preferred;
// Check if preferred position has enough space, otherwise flip
switch (preferred) {
case 'top':
if (triggerRect.top - cardRect.height - gap < 0) actual = 'bottom';
break;
case 'bottom':
if (triggerRect.bottom + cardRect.height + gap > viewport.height) actual = 'top';
break;
case 'left':
if (triggerRect.left - cardRect.width - gap < 0) actual = 'right';
break;
case 'right':
if (triggerRect.right + cardRect.width + gap > viewport.width) actual = 'left';
break;
}
let top = 0;
let left = 0;
const padding = 8;
switch (actual) {
case 'top':
top = triggerRect.top - cardRect.height - gap;
left = triggerRect.left + triggerRect.width / 2 - cardRect.width / 2;
break;
case 'bottom':
top = triggerRect.bottom + gap;
left = triggerRect.left + triggerRect.width / 2 - cardRect.width / 2;
break;
case 'left':
top = triggerRect.top + triggerRect.height / 2 - cardRect.height / 2;
left = triggerRect.left - cardRect.width - gap;
break;
case 'right':
top = triggerRect.top + triggerRect.height / 2 - cardRect.height / 2;
left = triggerRect.right + gap;
break;
}
// Clamp within viewport
left = Math.max(padding, Math.min(left, viewport.width - cardRect.width - padding));
top = Math.max(padding, Math.min(top, viewport.height - cardRect.height - padding));
return { top, left, actual };
}
/**
* Get the initial y-offset for the entrance animation based on position.
*/
function getInitialY(position: HoverCardPosition): number {
switch (position) {
case 'top':
return 4;
case 'bottom':
return -4;
default:
return 0;
}
}
/**
* HoverCard - Discord-style rich preview card on hover
*
* Shows a rich content card when hovering over a trigger element.
* Uses a portal for rendering and framer-motion for smooth animations.
*
* @example
* ```tsx
* }
* >
* @johndoe
*
* ```
*/
export function HoverCard({
content,
children,
position = 'bottom',
openDelay = 400,
closeDelay = 200,
disabled = false,
className,
maxWidth,
}: HoverCardProps) {
const [visible, setVisible] = useState(false);
const [cardStyle, setCardStyle] = useState({});
const [actualPosition, setActualPosition] = useState(position);
const triggerRef = useRef(null);
const cardRef = useRef(null);
const openTimerRef = useRef | null>(null);
const closeTimerRef = useRef | null>(null);
const clearTimers = useCallback(() => {
if (openTimerRef.current) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const open = useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
if (openTimerRef.current) return;
openTimerRef.current = setTimeout(() => {
setVisible(true);
openTimerRef.current = null;
}, openDelay);
}, [openDelay]);
const close = useCallback(() => {
if (openTimerRef.current) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
if (closeTimerRef.current) return;
closeTimerRef.current = setTimeout(() => {
setVisible(false);
closeTimerRef.current = null;
}, closeDelay);
}, [closeDelay]);
// Recalculate position when visible
useEffect(() => {
if (!visible || !triggerRef.current) return;
const updatePosition = () => {
if (!triggerRef.current || !cardRef.current) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
const cardRect = cardRef.current.getBoundingClientRect();
const result = calculateCardPosition(triggerRect, cardRect, position);
setActualPosition(result.actual);
setCardStyle({
top: result.top,
left: result.left,
});
};
// Position on next frame after mount
requestAnimationFrame(updatePosition);
}, [visible, position]);
// Close on scroll or resize
useEffect(() => {
if (!visible) return;
const handleDismiss = () => {
clearTimers();
setVisible(false);
};
window.addEventListener('scroll', handleDismiss, true);
window.addEventListener('resize', handleDismiss);
return () => {
window.removeEventListener('scroll', handleDismiss, true);
window.removeEventListener('resize', handleDismiss);
};
}, [visible, clearTimers]);
// Close on click outside
useEffect(() => {
if (!visible) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (
triggerRef.current &&
!triggerRef.current.contains(target) &&
cardRef.current &&
!cardRef.current.contains(target)
) {
clearTimers();
setVisible(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [visible, clearTimers]);
// Cleanup timers on unmount
useEffect(() => {
return clearTimers;
}, [clearTimers]);
if (disabled) {
return <>{children}>;
}
return (
<>
{children}
{createPortal(
{visible && (
{content}
)}
,
document.body,
)}
>
);
}