// frontend\src\components\ui\HoverPopover.tsx 'use client' import { type PropsWithChildren, type ReactNode, useEffect, useLayoutEffect, useRef, useState, } from 'react' import { createPortal } from 'react-dom' import Card from './Card' type Pos = { left: number; top: number } type HoverPopoverAPI = { close: () => void } type HoverPopoverProps = PropsWithChildren<{ content: ReactNode | ((open: boolean, api: HoverPopoverAPI) => ReactNode) onOpenChange?: (open: boolean) => void }> export default function HoverPopover({ children, content, onOpenChange }: HoverPopoverProps) { const triggerRef = useRef(null) const popoverRef = useRef(null) const [open, setOpen] = useState(false) const [pos, setPos] = useState(null) const setOpenState = (next: boolean) => { setOpen((prev) => (prev === next ? prev : next)) } useEffect(() => { onOpenChange?.(open) }, [open, onOpenChange]) const closeTimeoutRef = useRef(null) const clearCloseTimeout = () => { if (closeTimeoutRef.current !== null) { window.clearTimeout(closeTimeoutRef.current) closeTimeoutRef.current = null } } const scheduleClose = () => { clearCloseTimeout() closeTimeoutRef.current = window.setTimeout(() => { setOpenState(false) closeTimeoutRef.current = null }, 150) } const handleEnter = () => { clearCloseTimeout() setOpenState(true) } const handleLeave = () => { scheduleClose() } const close = () => { clearCloseTimeout() setOpenState(false) } const computePos = () => { const trigger = triggerRef.current const pop = popoverRef.current if (!trigger || !pop) return const gap = 8 const pad = 8 const r = trigger.getBoundingClientRect() const pr = pop.getBoundingClientRect() let top = r.bottom + gap const bottomOverflow = top + pr.height > window.innerHeight - pad if (bottomOverflow) { const above = r.top - pr.height - gap if (above >= pad) top = above else top = Math.max(pad, window.innerHeight - pr.height - pad) } let left = r.left if (left + pr.width > window.innerWidth - pad) { left = window.innerWidth - pr.width - pad } left = Math.max(pad, left) setPos({ left, top }) } useLayoutEffect(() => { if (!open) return const id = requestAnimationFrame(() => computePos()) return () => cancelAnimationFrame(id) }, [open]) useEffect(() => { if (!open) return const onMove = () => requestAnimationFrame(() => computePos()) window.addEventListener('resize', onMove) window.addEventListener('scroll', onMove, true) return () => { window.removeEventListener('resize', onMove) window.removeEventListener('scroll', onMove, true) } }, [open]) useEffect(() => { return () => clearCloseTimeout() }, []) const renderContent = () => typeof content === 'function' ? (content as any)(open, { close }) : content return ( <>
{children}
{open && createPortal(
{renderContent()}
, document.body )} ) }