// Copyright (c) 2026 PowerOn AG // All rights reserved. /** * FloatingPortal — renders floating UI on document.body with fixed positioning * relative to an anchor element. Escapes ancestor overflow clipping. */ import React, { useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import styles from './FloatingPortal.module.css'; export type FloatingPlacement = 'top' | 'bottom' | 'auto'; export interface FloatingPortalProps { open: boolean; anchorRef: React.RefObject; onClose?: () => void; placement?: FloatingPlacement; offset?: number; align?: 'start' | 'center' | 'end'; /** Keep children mounted while closed (avoids reload lag on reopen). */ keepMounted?: boolean; className?: string; children: React.ReactNode; } interface FloatingCoords { top: number; left: number; minWidth: number; } function _resolvePlacement( preferred: FloatingPlacement, anchorRect: DOMRect, popRect: { width: number; height: number }, offset: number, ): 'top' | 'bottom' { if (preferred === 'top' || preferred === 'bottom') return preferred; const spaceBelow = window.innerHeight - anchorRect.bottom - offset; const spaceAbove = anchorRect.top - offset; if (spaceBelow >= popRect.height) return 'bottom'; if (spaceAbove >= popRect.height) return 'top'; return spaceBelow >= spaceAbove ? 'bottom' : 'top'; } function _computeCoords( anchor: HTMLElement, popEl: HTMLElement, placement: FloatingPlacement, offset: number, align: 'start' | 'center' | 'end', ): FloatingCoords { const margin = 8; const anchorRect = anchor.getBoundingClientRect(); const popW = popEl.offsetWidth || 220; const popH = popEl.offsetHeight || 200; const resolved = _resolvePlacement(placement, anchorRect, { width: popW, height: popH }, offset); let top = resolved === 'bottom' ? anchorRect.bottom + offset : anchorRect.top - offset - popH; let left = anchorRect.left; if (align === 'center') { left = anchorRect.left + anchorRect.width / 2 - popW / 2; } else if (align === 'end') { left = anchorRect.right - popW; } if (left + popW > window.innerWidth - margin) { left = window.innerWidth - margin - popW; } if (left < margin) left = margin; if (top + popH > window.innerHeight - margin) { top = Math.max(margin, window.innerHeight - margin - popH); } if (top < margin) top = margin; return { top, left, minWidth: Math.max(anchorRect.width, 0), }; } export const FloatingPortal: React.FC = ({ open, anchorRef, onClose, placement = 'auto', offset = 4, align = 'start', keepMounted = false, className, children, }) => { const layerRef = useRef(null); const [coords, setCoords] = useState(null); useLayoutEffect(() => { if (!open && !keepMounted) { setCoords(null); return; } if (!open) return; const anchor = anchorRef.current; const layer = layerRef.current; if (!anchor || !layer) return; const _update = () => { const next = _computeCoords(anchor, layer, placement, offset, align); setCoords(next); }; _update(); const rafId = requestAnimationFrame(_update); window.addEventListener('resize', _update); window.addEventListener('scroll', _update, true); return () => { cancelAnimationFrame(rafId); window.removeEventListener('resize', _update); window.removeEventListener('scroll', _update, true); }; }, [open, keepMounted, anchorRef, placement, offset, align, children]); useLayoutEffect(() => { if (!open || !onClose) return; const _onPointerDown = (event: MouseEvent) => { const layer = layerRef.current; const anchor = anchorRef.current; const target = event.target as Node; if (layer?.contains(target) || anchor?.contains(target)) return; onClose(); }; document.addEventListener('mousedown', _onPointerDown); return () => document.removeEventListener('mousedown', _onPointerDown); }, [open, onClose, anchorRef]); if (!open && !keepMounted) return null; return createPortal(
{children}
, document.body, ); }; export default FloatingPortal;