import React, { useRef, useState, useEffect, useLayoutEffect, useCallback, ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { usePortalContainer } from './portalContainer'; export type Placement = | 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end'; interface PositionedPortalProps { open: boolean; anchor: React.RefObject; children: ReactNode; placement?: Placement; offset?: number; onClickOutside?: () => void; onEscapeKey?: () => void; matchWidth?: boolean; zIndex?: number; } interface Position { top: number; left: number; width?: number; } const calculatePosition = ( anchorRect: DOMRect, contentSize: { width: number; height: number }, placement: Placement, offset: number, matchWidth: boolean ): Position => { // anchorRect from getBoundingClientRect() is viewport-relative, // which is exactly what we need for position: fixed. const vpWidth = window.innerWidth; const vpHeight = window.innerHeight; let position: Position = { top: 0, left: 0 }; // Calculate initial position based on placement (viewport-relative for fixed positioning) switch (placement) { case 'top': position = { top: anchorRect.top - contentSize.height - offset, left: anchorRect.left + anchorRect.width / 2 - contentSize.width / 2, }; break; case 'top-start': position = { top: anchorRect.top - contentSize.height - offset, left: anchorRect.left, }; break; case 'top-end': position = { top: anchorRect.top - contentSize.height - offset, left: anchorRect.right - contentSize.width, }; break; case 'bottom': position = { top: anchorRect.bottom + offset, left: anchorRect.left + anchorRect.width / 2 - contentSize.width / 2, }; break; case 'bottom-start': position = { top: anchorRect.bottom + offset, left: anchorRect.left, }; break; case 'bottom-end': position = { top: anchorRect.bottom + offset, left: anchorRect.right - contentSize.width, }; break; case 'left': position = { top: anchorRect.top + anchorRect.height / 2 - contentSize.height / 2, left: anchorRect.left - contentSize.width - offset, }; break; case 'left-start': position = { top: anchorRect.top, left: anchorRect.left - contentSize.width - offset, }; break; case 'left-end': position = { top: anchorRect.bottom - contentSize.height, left: anchorRect.left - contentSize.width - offset, }; break; case 'right': position = { top: anchorRect.top + anchorRect.height / 2 - contentSize.height / 2, left: anchorRect.right + offset, }; break; case 'right-start': position = { top: anchorRect.top, left: anchorRect.right + offset, }; break; case 'right-end': position = { top: anchorRect.bottom - contentSize.height, left: anchorRect.right + offset, }; break; } // Match anchor width if requested if (matchWidth) { position.width = anchorRect.width; } const padding = 8; // Flip placement if it overflows (must run BEFORE clamping) const isAbove = placement.startsWith('top'); const isBelow = placement.startsWith('bottom'); const isLeft = placement.startsWith('left'); const isRight = placement.startsWith('right'); if (isBelow && position.top + contentSize.height > vpHeight - padding) { const aboveTop = anchorRect.top - contentSize.height - offset; if (aboveTop >= padding) { position.top = aboveTop; } } else if (isAbove && position.top < padding) { const belowTop = anchorRect.bottom + offset; if (belowTop + contentSize.height <= vpHeight - padding) { position.top = belowTop; } } if (isRight && position.left + contentSize.width > vpWidth - padding) { const leftPos = anchorRect.left - contentSize.width - offset; if (leftPos >= padding) { position.left = leftPos; } } else if (isLeft && position.left < padding) { const rightPos = anchorRect.right + offset; if (rightPos + contentSize.width <= vpWidth - padding) { position.left = rightPos; } } // Clamp to viewport bounds as final safety net position.left = Math.max(padding, Math.min(position.left, vpWidth - contentSize.width - padding)); position.top = Math.max(padding, Math.min(position.top, vpHeight - contentSize.height - padding)); return position; }; export const PositionedPortal: React.FC = ({ open, anchor, children, placement = 'bottom-start', offset = 4, onClickOutside, onEscapeKey, matchWidth = false, zIndex = 1000, }) => { const contentRef = useRef(null); const [position, setPosition] = useState({ top: 0, left: 0 }); const [isPositioned, setIsPositioned] = useState(false); // Acquire the shared theme-aware portal container on mount, release on // unmount. The container mirrors the active Unistyles theme class and // sets color-scheme so that portal content inherits the correct theme. const portalContainer = usePortalContainer(); // Calculate position const updatePosition = useCallback(() => { if (!contentRef.current || !anchor.current) return; const anchorRect = anchor.current.getBoundingClientRect(); // Use scrollWidth/scrollHeight for intrinsic content size — // getBoundingClientRect() can be wrong when the element is at position 0,0 // because the container div has no explicit width and may stretch. const contentWidth = contentRef.current.scrollWidth; const contentHeight = contentRef.current.scrollHeight; const newPosition = calculatePosition( anchorRect, { width: contentWidth, height: contentHeight }, placement, offset, matchWidth ); setPosition(newPosition); setIsPositioned(true); }, [anchor, placement, offset, matchWidth]); // Position after DOM is ready useLayoutEffect(() => { if (open) { const rafId = requestAnimationFrame(() => { updatePosition(); }); return () => cancelAnimationFrame(rafId); } else { setIsPositioned(false); } }, [open, updatePosition]); // Update position on scroll/resize useEffect(() => { if (!open) return; updatePosition(); const handleUpdate = () => updatePosition(); window.addEventListener('resize', handleUpdate); window.addEventListener('scroll', handleUpdate, true); return () => { window.removeEventListener('resize', handleUpdate); window.removeEventListener('scroll', handleUpdate, true); }; }, [open, updatePosition]); // Handle escape key useEffect(() => { if (!open || !onEscapeKey) return; const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { onEscapeKey(); } }; document.addEventListener('keydown', handleEscape); return () => document.removeEventListener('keydown', handleEscape); }, [open, onEscapeKey]); // Handle click outside useEffect(() => { if (!open || !onClickOutside) return; const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node; if ( contentRef.current && !contentRef.current.contains(target) && anchor.current && !anchor.current.contains(target) ) { onClickOutside(); } }; document.addEventListener('mousedown', handleClickOutside, true); return () => document.removeEventListener('mousedown', handleClickOutside, true); }, [open, onClickOutside, anchor]); if (!open) return null; const content = (
{children}
); return createPortal(content, portalContainer); };