nsfwapp/frontend/src/components/ui/HoverPopover.tsx
2026-04-07 07:20:08 +02:00

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
)}
</>
)
}