import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Navigation } from 'lucide-react' import type { NavigationTarget } from '../Planner/placeNavigation' import { navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation' import { useTranslation } from '../../i18n' interface NavigationMenuProps { targets: NavigationTarget[] /** The element the menu is anchored to; it is measured, never moved. */ anchor: HTMLElement | null onClose: () => void /** Heading above the list. Omitted on desktop, where the trigger says it. */ title?: string } const GAP = 6 const EDGE = 8 /** * The map-app picker. * * Portalled rather than nested, because both call sites sit inside panels that * clip their overflow — a menu rendered in place would be cut off at the panel * edge. Being in the body means the position has to be computed, and computed * after the menu has a size: opening near the bottom of the window flips it * above the trigger instead of letting it run off the screen, and a menu wider * than the space to its right is pulled back inside. */ export function NavigationMenu({ targets, anchor, onClose, title }: NavigationMenuProps) { const { t } = useTranslation() const ref = useRef(null) const [pos, setPos] = useState<{ top: number; left: number } | null>(null) // Layout effect, not effect: the menu is placed before the browser paints, so // it never appears at the wrong spot for a frame and jumps. useLayoutEffect(() => { const el = ref.current if (!el || !anchor) return const place = () => { const a = anchor.getBoundingClientRect() const m = el.getBoundingClientRect() const below = window.innerHeight - a.bottom const flip = below < m.height + GAP + EDGE && a.top > below const top = flip ? Math.max(EDGE, a.top - m.height - GAP) : a.bottom + GAP const left = Math.min(Math.max(EDGE, a.left), window.innerWidth - m.width - EDGE) setPos({ top, left }) } place() window.addEventListener('resize', place) window.addEventListener('scroll', place, true) return () => { window.removeEventListener('resize', place) window.removeEventListener('scroll', place, true) } }, [anchor, targets.length]) useEffect(() => { const onDown = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node) && !anchor?.contains(e.target as Node)) onClose() } const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } document.addEventListener('mousedown', onDown) document.addEventListener('keydown', onKey) return () => { document.removeEventListener('mousedown', onDown) document.removeEventListener('keydown', onKey) } }, [anchor, onClose]) if (targets.length === 0) return null return createPortal( , document.body, ) }