veza/apps/web/src/components/ui/hover-card/HoverCard.tsx

269 lines
7.3 KiB
TypeScript
Raw Normal View History

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
* <HoverCard
* content={<UserHoverContent name="John" username="johndoe" />}
* >
* <span className="text-primary cursor-pointer">@johndoe</span>
* </HoverCard>
* ```
*/
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<React.CSSProperties>({});
const [actualPosition, setActualPosition] = useState<HoverCardPosition>(position);
const triggerRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<>
<div
ref={triggerRef}
className="inline-block"
onMouseEnter={open}
onMouseLeave={close}
>
{children}
</div>
{createPortal(
<AnimatePresence>
{visible && (
<motion.div
ref={cardRef}
role="dialog"
className={cn(
'fixed z-50 p-4 max-w-xs',
'bg-popover border border-border rounded-xl shadow-lg backdrop-blur-md',
className,
)}
style={{
...cardStyle,
...(maxWidth != null && { maxWidth: `${maxWidth}px` }),
}}
initial={{ opacity: 0, scale: 0.96, y: getInitialY(actualPosition) }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
onMouseEnter={open}
onMouseLeave={close}
>
{content}
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</>
);
}