import { useCallback, useEffect, useRef, useState } from 'react'; import type { MutableRefObject, CSSProperties } from 'react'; import { clamp } from '../utils/math'; import { DEFAULT_VIEWPORT_MARGIN } from '../constants/ui'; type PositionStrategy = 'fixed' | 'absolute' | (string & {}); interface FloatingMenuMetrics { strategy: PositionStrategy; top: number; left: number; minWidth?: number; width?: number; } type FloatingMenuStyle = (CSSProperties & { '--floating-min-width'?: string }) | null; const resolveViewportWidth = () => window.innerWidth || document.documentElement.clientWidth || 0; const computeWidth = (anchorWidth: number, minWidth: number, matchAnchorWidth: boolean) => { if (matchAnchorWidth) { return Math.max(anchorWidth, minWidth); } return Math.max(minWidth || 0, anchorWidth || 0); }; const formatStyle = (metrics: FloatingMenuMetrics | null): FloatingMenuStyle => { if (!metrics) { return null; } const style: FloatingMenuStyle = { position: metrics.strategy === 'absolute' ? 'absolute' : 'fixed', top: metrics.top, left: metrics.left, }; if (Number.isFinite(metrics.minWidth)) { style['--floating-min-width'] = `${Math.max(metrics.minWidth ?? 0, 0)}px`; } if (metrics.width) { style.width = metrics.width; } return style; }; interface UseFloatingMenuOptions { anchorRef?: MutableRefObject; offset?: number; minWidth?: number; matchAnchorWidth?: boolean; align?: 'start' | 'center' | 'end' | (string & {}); viewportMargin?: number; onOpenChange?: (isOpen: boolean) => void; positionStrategy?: PositionStrategy; } const useFloatingMenu = ({ anchorRef, offset = 6, minWidth = 0, matchAnchorWidth = false, align = 'start', viewportMargin = DEFAULT_VIEWPORT_MARGIN, onOpenChange, positionStrategy = 'fixed', }: UseFloatingMenuOptions = {}) => { const menuRef = useRef(null); const [menuMetrics, setMenuMetrics] = useState(null); const [isOpen, setIsOpen] = useState(false); const onOpenChangeRef = useRef(onOpenChange); useEffect(() => { onOpenChangeRef.current = onOpenChange; }, [onOpenChange]); const updatePosition = useCallback(() => { const anchor = anchorRef?.current; if (!anchor) { return false; } const rect = anchor.getBoundingClientRect(); const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth); const menu = menuRef.current; const measuredWidth = menu?.offsetWidth ?? desiredWidth; const widthForAlignment = matchAnchorWidth ? desiredWidth : Math.max(desiredWidth, measuredWidth); if (positionStrategy === 'absolute') { const anchor = anchorRef?.current; if (!anchor) { return false; } const offsetParent = (menu && menu.offsetParent) || anchor.offsetParent || anchor.parentElement; if (!offsetParent) { // Fall back to fixed positioning if we cannot resolve a relative parent. setMenuMetrics({ strategy: 'fixed', top: rect.bottom + offset, left: rect.left, minWidth: desiredWidth, width: matchAnchorWidth ? desiredWidth : undefined, }); return true; } let left; if (align === 'end') { left = anchor.offsetLeft + anchor.offsetWidth - widthForAlignment; } else if (align === 'center') { left = anchor.offsetLeft + anchor.offsetWidth / 2 - widthForAlignment / 2; } else { left = anchor.offsetLeft; } const top = anchor.offsetTop + anchor.offsetHeight + offset; setMenuMetrics({ strategy: 'absolute', top, left, minWidth: desiredWidth, width: matchAnchorWidth ? desiredWidth : undefined, }); return true; } const viewportWidth = resolveViewportWidth(); const viewportHeight = window.innerHeight || 0; const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN; const menuHeight = menu?.offsetHeight ?? 0; let left; if (align === 'end') { left = rect.right - widthForAlignment; } else if (align === 'center') { left = rect.left + rect.width / 2 - widthForAlignment / 2; } else { left = rect.left; } const maxLeft = viewportWidth > 0 ? viewportWidth - widthForAlignment - safeMargin : left; const clampedLeft = viewportWidth > 0 ? clamp(left, safeMargin, Math.max(maxLeft, safeMargin)) : left; let top = rect.bottom + offset; if (viewportHeight > 0 && menuHeight > 0) { const projectedBottom = top + menuHeight + safeMargin; if (projectedBottom > viewportHeight) { const upwardTop = rect.top - offset - menuHeight; top = Math.max(upwardTop, safeMargin); } } setMenuMetrics({ strategy: 'fixed', top, left: clampedLeft, minWidth: desiredWidth, width: matchAnchorWidth ? desiredWidth : undefined, }); return true; }, [ anchorRef, align, matchAnchorWidth, minWidth, offset, positionStrategy, viewportMargin, ]); const close = useCallback(() => { setIsOpen((prev) => { if (!prev) { return prev; } onOpenChangeRef.current?.(false); return false; }); }, []); const open = useCallback(() => { setIsOpen((prev) => { if (prev) { return prev; } const positioned = updatePosition(); if (!positioned) { onOpenChangeRef.current?.(false); return prev; } onOpenChangeRef.current?.(true); return true; }); }, [updatePosition]); const toggle = useCallback(() => { setIsOpen((prev) => { if (prev) { onOpenChangeRef.current?.(false); return false; } const positioned = updatePosition(); if (!positioned) { onOpenChangeRef.current?.(false); return prev; } onOpenChangeRef.current?.(true); return true; }); }, [updatePosition]); useEffect(() => { if (!isOpen) { return undefined; } let ignoreFocusEvents = true; const raf = window.requestAnimationFrame; const rafId = raf ? raf(() => { ignoreFocusEvents = false; }) : null; if (!anchorRef?.current) { close(); return undefined; } const handlePointer = (event) => { if (event.type === 'focusin' && ignoreFocusEvents) { return; } const target = event.target; // If the target is no longer in the document, it means it was unmounted // (e.g. due to a re-render caused by the open action). // In this case, we should ignore the event. if (target instanceof Node && !document.contains(target)) { return; } const menu = menuRef.current; const anchor = anchorRef?.current; if ((anchor && anchor.contains(target)) || (menu && menu.contains(target))) { return; } close(); }; const handleKeyDown = (event) => { if (event.key === 'Escape') { close(); } }; // Delay adding listeners to avoid capturing the event that opened the menu const timer = setTimeout(() => { document.addEventListener('mousedown', handlePointer); document.addEventListener('touchstart', handlePointer, { passive: true }); document.addEventListener('focusin', handlePointer); document.addEventListener('keydown', handleKeyDown); }, 0); return () => { clearTimeout(timer); if (rafId != null) { window.cancelAnimationFrame(rafId); } document.removeEventListener('mousedown', handlePointer); document.removeEventListener('touchstart', handlePointer); document.removeEventListener('focusin', handlePointer); document.removeEventListener('keydown', handleKeyDown); }; }, [anchorRef, close, isOpen]); useEffect(() => { if (!isOpen) { return undefined; } const handleRelayout = () => { const positioned = updatePosition(); if (!positioned) { close(); } }; handleRelayout(); window.addEventListener('resize', handleRelayout); window.addEventListener('scroll', handleRelayout, true); return () => { window.removeEventListener('resize', handleRelayout); window.removeEventListener('scroll', handleRelayout, true); }; }, [close, isOpen, updatePosition]); return { isOpen, open, close, toggle, menuRef, menuStyle: formatStyle(menuMetrics), updatePosition, }; }; export default useFloatingMenu;