Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 52s
168 lines
4.6 KiB
TypeScript
168 lines
4.6 KiB
TypeScript
// 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<HTMLElement | null>;
|
|
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<FloatingPortalProps> = ({
|
|
open,
|
|
anchorRef,
|
|
onClose,
|
|
placement = 'auto',
|
|
offset = 4,
|
|
align = 'start',
|
|
keepMounted = false,
|
|
className,
|
|
children,
|
|
}) => {
|
|
const layerRef = useRef<HTMLDivElement>(null);
|
|
const [coords, setCoords] = useState<FloatingCoords | null>(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(
|
|
<div
|
|
ref={layerRef}
|
|
className={[styles.layer, className].filter(Boolean).join(' ')}
|
|
style={!open ? {
|
|
top: -9999,
|
|
left: -9999,
|
|
visibility: 'hidden',
|
|
pointerEvents: 'none',
|
|
} : coords ? {
|
|
top: coords.top,
|
|
left: coords.left,
|
|
minWidth: coords.minWidth,
|
|
visibility: 'visible',
|
|
} : {
|
|
top: -9999,
|
|
left: -9999,
|
|
visibility: 'hidden',
|
|
}}
|
|
>
|
|
{children}
|
|
</div>,
|
|
document.body,
|
|
);
|
|
};
|
|
|
|
export default FloatingPortal;
|