111 lines
2.9 KiB
TypeScript
111 lines
2.9 KiB
TypeScript
import React, { useLayoutEffect, useEffect, useRef, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
|
|
const MENU_GAP = 4;
|
|
const VIEWPORT_PAD = 8;
|
|
|
|
function useAnchoredMenuPosition(
|
|
open: boolean,
|
|
anchorRef: React.RefObject<HTMLElement | null>,
|
|
menuRef: React.RefObject<HTMLElement | null>
|
|
) {
|
|
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!open) {
|
|
setCoords(null);
|
|
return;
|
|
}
|
|
|
|
const update = () => {
|
|
const anchor = anchorRef.current;
|
|
const menu = menuRef.current;
|
|
if (!anchor || !menu) return;
|
|
|
|
const anchorRect = anchor.getBoundingClientRect();
|
|
const menuWidth = menu.offsetWidth;
|
|
const menuHeight = menu.offsetHeight;
|
|
|
|
let top = anchorRect.bottom + MENU_GAP;
|
|
if (top + menuHeight > window.innerHeight - VIEWPORT_PAD) {
|
|
top = anchorRect.top - menuHeight - MENU_GAP;
|
|
}
|
|
top = Math.max(
|
|
VIEWPORT_PAD,
|
|
Math.min(top, window.innerHeight - menuHeight - VIEWPORT_PAD)
|
|
);
|
|
|
|
let left = anchorRect.right - menuWidth;
|
|
left = Math.max(
|
|
VIEWPORT_PAD,
|
|
Math.min(left, window.innerWidth - menuWidth - VIEWPORT_PAD)
|
|
);
|
|
|
|
setCoords({ top, left });
|
|
};
|
|
|
|
update();
|
|
const raf = requestAnimationFrame(update);
|
|
window.addEventListener('resize', update);
|
|
window.addEventListener('scroll', update, true);
|
|
return () => {
|
|
cancelAnimationFrame(raf);
|
|
window.removeEventListener('resize', update);
|
|
window.removeEventListener('scroll', update, true);
|
|
};
|
|
}, [open, anchorRef, menuRef]);
|
|
|
|
return coords;
|
|
}
|
|
|
|
interface AnchoredMenuProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
anchorRef: React.RefObject<HTMLElement | null>;
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
export const AnchoredMenu: React.FC<AnchoredMenuProps> = ({
|
|
open,
|
|
onClose,
|
|
anchorRef,
|
|
children,
|
|
className = '',
|
|
}) => {
|
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
const coords = useAnchoredMenuPosition(open, anchorRef, menuRef);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handlePointerDown = (e: PointerEvent) => {
|
|
const target = e.target as Node;
|
|
if (menuRef.current?.contains(target) || anchorRef.current?.contains(target)) return;
|
|
onClose();
|
|
};
|
|
document.addEventListener('pointerdown', handlePointerDown);
|
|
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
|
}, [open, onClose, anchorRef]);
|
|
|
|
if (!open) return null;
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={menuRef}
|
|
role="menu"
|
|
className={className}
|
|
style={{
|
|
position: 'fixed',
|
|
top: coords?.top ?? 0,
|
|
left: coords?.left ?? 0,
|
|
visibility: coords ? 'visible' : 'hidden',
|
|
zIndex: 9999,
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{children}
|
|
</div>,
|
|
document.body
|
|
);
|
|
};
|