Skip to content

Commit 4216bb4

Browse files
committed
merge: Popover — trigger click/hover, auto-placement, arrow, click-outside+ESC dismiss, usePopover hook
2 parents 983c870 + 9978496 commit 4216bb4

1 file changed

Lines changed: 165 additions & 0 deletions

File tree

web/app/components/Popover.tsx

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
'use client'
2+
3+
import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react'
4+
5+
// ── Types ──────────────────────────────────────────────────────────────────
6+
7+
export type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right' | 'auto'
8+
export type PopoverTrigger = 'click' | 'hover'
9+
10+
export type PopoverProps = {
11+
content: ReactNode
12+
placement?: PopoverPlacement
13+
trigger?: PopoverTrigger
14+
defaultOpen?: boolean
15+
disabled?: boolean
16+
maxWidth?: number
17+
showArrow?: boolean
18+
onOpen?: () => void
19+
onClose?: () => void
20+
children: ReactNode
21+
className?: string
22+
}
23+
24+
// ── Placement helpers ──────────────────────────────────────────────────────
25+
26+
type Side = 'top' | 'bottom' | 'left' | 'right'
27+
28+
function autoSide(r: DOMRect): Side {
29+
if (r.top > 120) return 'top'
30+
if (window.innerHeight - r.bottom > 120) return 'bottom'
31+
if (r.left > 160) return 'left'
32+
return 'right'
33+
}
34+
35+
const GAP = 8
36+
37+
type CSSPos = { top?: string; bottom?: string; left?: string; right?: string; transform: string }
38+
39+
function popStyle(side: Side): CSSPos {
40+
switch (side) {
41+
case 'top': return { bottom: `calc(100% + ${GAP}px)`, left: '50%', transform: 'translateX(-50%)' }
42+
case 'bottom': return { top: `calc(100% + ${GAP}px)`, left: '50%', transform: 'translateX(-50%)' }
43+
case 'left': return { right: `calc(100% + ${GAP}px)`, top: '50%', transform: 'translateY(-50%)' }
44+
case 'right': return { left: `calc(100% + ${GAP}px)`, top: '50%', transform: 'translateY(-50%)' }
45+
}
46+
}
47+
48+
function arrowStyle(side: Side): React.CSSProperties {
49+
const S = 5; const bg = 'var(--color-panel)'
50+
const base: React.CSSProperties = { position: 'absolute', width: 0, height: 0 }
51+
switch (side) {
52+
case 'top': return { ...base, top: '100%', left: '50%', transform: 'translateX(-50%)', borderLeft: `${S}px solid transparent`, borderRight: `${S}px solid transparent`, borderTop: `${S}px solid ${bg}` }
53+
case 'bottom': return { ...base, bottom: '100%', left: '50%', transform: 'translateX(-50%)', borderLeft: `${S}px solid transparent`, borderRight: `${S}px solid transparent`, borderBottom: `${S}px solid ${bg}` }
54+
case 'left': return { ...base, left: '100%', top: '50%', transform: 'translateY(-50%)', borderTop: `${S}px solid transparent`, borderBottom: `${S}px solid transparent`, borderLeft: `${S}px solid ${bg}` }
55+
case 'right': return { ...base, right: '100%', top: '50%', transform: 'translateY(-50%)', borderTop: `${S}px solid transparent`, borderBottom: `${S}px solid transparent`, borderRight: `${S}px solid ${bg}` }
56+
}
57+
}
58+
59+
// ── Popover ────────────────────────────────────────────────────────────────
60+
61+
export function Popover({
62+
content, placement = 'auto', trigger = 'click', defaultOpen = false,
63+
disabled, maxWidth = 280, showArrow = true, onOpen, onClose, children, className,
64+
}: PopoverProps) {
65+
const [open, setOpen] = useState(defaultOpen)
66+
const [side, setSide] = useState<Side>('bottom')
67+
const wrapRef = useRef<HTMLDivElement>(null)
68+
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
69+
70+
// Click-outside dismiss
71+
useEffect(() => {
72+
if (!open || trigger !== 'click') return
73+
const handler = (e: MouseEvent) => {
74+
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
75+
setOpen(false); onClose?.()
76+
}
77+
}
78+
document.addEventListener('mousedown', handler)
79+
return () => document.removeEventListener('mousedown', handler)
80+
}, [open, trigger, onClose])
81+
82+
// ESC dismiss
83+
useEffect(() => {
84+
if (!open) return
85+
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { setOpen(false); onClose?.() } }
86+
document.addEventListener('keydown', handler)
87+
return () => document.removeEventListener('keydown', handler)
88+
}, [open, onClose])
89+
90+
const computeSide = useCallback(() => {
91+
if (!wrapRef.current) return 'bottom' as Side
92+
const r = wrapRef.current.getBoundingClientRect()
93+
return placement === 'auto' ? autoSide(r) : placement as Side
94+
}, [placement])
95+
96+
const show = useCallback(() => {
97+
if (disabled) return
98+
setSide(computeSide())
99+
setOpen(true); onOpen?.()
100+
}, [disabled, computeSide, onOpen])
101+
102+
const hide = useCallback(() => { setOpen(false); onClose?.() }, [onClose])
103+
104+
// Hover trigger handlers
105+
const onMouseEnter = trigger === 'hover' ? () => {
106+
if (hoverTimer.current) clearTimeout(hoverTimer.current)
107+
show()
108+
} : undefined
109+
110+
const onMouseLeave = trigger === 'hover' ? () => {
111+
hoverTimer.current = setTimeout(hide, 120)
112+
} : undefined
113+
114+
const onClick = trigger === 'click' ? () => {
115+
if (open) hide(); else show()
116+
} : undefined
117+
118+
const pos = popStyle(side)
119+
120+
return (
121+
<div ref={wrapRef} className={`relative inline-flex ${className ?? ''}`}
122+
onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
123+
124+
{/* Trigger */}
125+
<div onClick={onClick} style={{ cursor: trigger === 'click' ? 'pointer' : 'default' }}>
126+
{children}
127+
</div>
128+
129+
{/* Popover panel */}
130+
{open && (
131+
<div role="dialog"
132+
onMouseEnter={trigger === 'hover' ? () => { if (hoverTimer.current) clearTimeout(hoverTimer.current) } : undefined}
133+
onMouseLeave={trigger === 'hover' ? () => { hoverTimer.current = setTimeout(hide, 120) } : undefined}
134+
style={{
135+
position: 'absolute',
136+
zIndex: 9000,
137+
maxWidth,
138+
...pos,
139+
background: 'var(--color-panel)',
140+
border: '1px solid var(--color-border)',
141+
borderRadius: 10,
142+
boxShadow: '0 8px 24px rgba(0,0,0,0.45)',
143+
animation: 'pop-in 0.14s ease',
144+
}}>
145+
{showArrow && <span style={arrowStyle(side)} />}
146+
{content}
147+
</div>
148+
)}
149+
150+
<style>{`
151+
@keyframes pop-in {
152+
from { opacity: 0; transform: ${pos.transform} scale(0.94); }
153+
to { opacity: 1; transform: ${pos.transform} scale(1); }
154+
}
155+
`}</style>
156+
</div>
157+
)
158+
}
159+
160+
// ── usePopover — hook per controllo esterno ────────────────────────────────
161+
162+
export function usePopover() {
163+
const [open, setOpen] = useState(false)
164+
return { open, show: () => setOpen(true), hide: () => setOpen(false), toggle: () => setOpen(v => !v) }
165+
}

0 commit comments

Comments
 (0)