160 lines
3.8 KiB
TypeScript
160 lines
3.8 KiB
TypeScript
// 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<HTMLDivElement>(null)
|
|
const popoverRef = useRef<HTMLDivElement>(null)
|
|
|
|
const [open, setOpen] = useState(false)
|
|
const [pos, setPos] = useState<Pos | null>(null)
|
|
|
|
const setOpenState = (next: boolean) => {
|
|
setOpen((prev) => (prev === next ? prev : next))
|
|
}
|
|
|
|
useEffect(() => {
|
|
onOpenChange?.(open)
|
|
}, [open, onOpenChange])
|
|
|
|
const closeTimeoutRef = useRef<number | null>(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 (
|
|
<>
|
|
<div
|
|
ref={triggerRef}
|
|
className="inline-flex"
|
|
onMouseEnter={handleEnter}
|
|
onMouseLeave={handleLeave}
|
|
>
|
|
{children}
|
|
</div>
|
|
|
|
{open &&
|
|
createPortal(
|
|
<div
|
|
ref={popoverRef}
|
|
className="fixed z-[120]"
|
|
style={{
|
|
left: pos?.left ?? -9999,
|
|
top: pos?.top ?? -9999,
|
|
visibility: pos ? 'visible' : 'hidden',
|
|
}}
|
|
onMouseEnter={handleEnter}
|
|
onMouseLeave={handleLeave}
|
|
>
|
|
<Card
|
|
className="shadow-lg ring-1 ring-black/10 dark:ring-white/10 max-w-[calc(100vw-16px)]"
|
|
noBodyPadding
|
|
>
|
|
{renderContent()}
|
|
</Card>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</>
|
|
)
|
|
} |