HoverCard component (new): - Rich preview cards on hover with framer-motion animation - Viewport-aware positioning, portal rendering, open/close delays - UserHoverContent: Discord-style user preview (avatar, bio, stats, follow) - TrackHoverContent: Spotify-style track preview (cover, stats, play) Audio player — Spotify-like 3-column layout: - grid-cols-3 layout: track info | controls | volume+queue - Progress bar moved to top edge (minimal variant) - Glassmorphism (bg-background/95 backdrop-blur-md) - Prominent centered play button (h-10 w-10 rounded-full, active:scale-95) - Title marquee animation for long track names - Reduced padding for tighter premium feel Scrollbar styling: - Migrated hardcoded rgba() to semantic tokens via color-mix(in oklch) - Added transition on thumb hover for smooth visual feedback ContextMenu integration: - TrackListRow wrapped with ContextMenu (play, like, more actions) - Dynamic items based on available callbacks Co-authored-by: Cursor <cursoragent@cursor.com>
268 lines
7.3 KiB
TypeScript
268 lines
7.3 KiB
TypeScript
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,
|
|
)}
|
|
</>
|
|
);
|
|
}
|