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, )} ); }