(null)
// Initial guess; corrected (before paint) by the layout effect once we can measure the menu.
const [pos, setPos] = useState<{ top: number; left: number }>(() => ({
top: anchor.bottom + 6,
left: Math.max(MARGIN, Math.min(anchor.left, window.innerWidth - 240 - MARGIN)),
}))
// Position viewport-aware: flip above the anchor if it would overflow the bottom
// (the trigger often sits right above the composer), and clamp into the viewport.
useLayoutEffect(() => {
const el = ref.current
if (!el) return
const { height, width } = el.getBoundingClientRect()
const vh = window.innerHeight
const vw = window.innerWidth
let top = anchor.bottom + 6
if (height > 0 && top + height > vh - MARGIN) {
const flipped = anchor.top - height - 6
top = flipped >= MARGIN ? flipped : Math.max(MARGIN, vh - height - MARGIN)
}
let left = anchor.left
if (width > 0) left = Math.max(MARGIN, Math.min(left, vw - width - MARGIN))
setPos({ top, left })
}, [anchor])
useEffect(() => {
const onDown = (e: MouseEvent) => {
const target = e.target as Node | null
if (!target) return
// Ignore clicks inside the menu itself, AND clicks inside the trigger
// element that opened it — the trigger's own onClick handler will toggle.
if (ref.current?.contains(target)) return
if (triggerEl && triggerEl.contains(target)) return
onClose()
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
const onViewportMove = () => onClose()
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
window.addEventListener('scroll', onViewportMove, true)
window.addEventListener('resize', onViewportMove)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onViewportMove, true)
window.removeEventListener('resize', onViewportMove)
}
}, [onClose, triggerEl])
return createPortal(
{items.map((item) => (
))}
,
document.body,
)
}