ui-nyla/src/components/UiComponents/FloatingPortal/FloatingPortal.tsx
ValueOn AG d579df1c92
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 52s
panel ui
2026-06-11 16:43:53 +02:00

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;