171 lines
4.2 KiB
TypeScript

import {
Dispatch,
FC,
PropsWithChildren,
SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import { bulkRound } from "../../utils/bulkRound";
import { clamp } from "../../utils/clamp";
import { Portal } from "../../portal/components";
type edge = "top" | "bottom" | "left" | "right";
type alignment = edge | "centered";
interface IProps {
preferredEdge: edge;
preferredAlign: alignment;
relativeElement: HTMLElement;
spacing?: number;
isClosing: boolean;
isClosed: boolean;
setHover: Dispatch<SetStateAction<boolean>>;
}
type position = { top: number; left: number; width?: number };
export const PoppableContent: FC<PropsWithChildren<IProps>> = (
{
preferredAlign,
preferredEdge,
children,
relativeElement,
spacing = 10,
setHover,
isClosing,
isClosed,
},
) => {
const [popRef, setPopRef] = useState<HTMLDivElement>();
const updateRef = useCallback((node: HTMLDivElement) => {
if (!node) return;
setPopRef(node);
}, []);
const getAlignment = useCallback(
(
relX: number,
relY: number,
relWidth: number,
relHeight: number,
popWidth: number,
popHeight: number,
edge: edge,
align: alignment,
): position => {
const pos = {
top: relY,
left: relX,
};
switch (align) {
case "centered":
pos.top = relY + (relHeight / 2) - (popHeight / 2);
pos.left = relX + (relWidth / 2) - (popWidth / 2);
break;
case "top":
pos.top = relY;
break;
case "bottom":
pos.top = relY + relHeight - popHeight;
break;
case "left":
pos.left = relX;
break;
case "right":
pos.left = relX + relWidth - popWidth;
break;
}
return pos;
},
[],
);
const getPosition = useCallback(
(popWidth: number, popHeight: number, edge: edge, align: alignment) => {
const rel = relativeElement.getBoundingClientRect();
const [relX, relY, relWidth, relHeight] = bulkRound(
rel.x,
rel.y,
rel.width,
rel.height,
);
const pos: position = { top: 100, left: 100 };
const alignment = getAlignment(
relX,
relY,
relWidth,
relHeight,
popWidth,
popHeight,
edge,
align,
);
switch (edge) {
case "top":
pos.top = relY - popHeight - spacing +
document.documentElement.scrollTop;
pos.left = alignment.left;
break;
case "bottom":
pos.top = relY + relHeight + spacing +
document.documentElement.scrollTop;
pos.left = alignment.left;
break;
case "left":
pos.left = relX - popWidth - spacing;
pos.top = alignment.top + document.documentElement.scrollTop;
break;
case "right":
pos.left = relX + relWidth + spacing;
pos.top = alignment.top + document.documentElement.scrollTop;
break;
}
return pos;
},
[getAlignment, relativeElement, spacing],
);
const getClampedPosition = useCallback(() => {
if (!popRef) return { opacity: 0 };
const pop = popRef.getBoundingClientRect();
const [popWidth, popHeight] = bulkRound(pop.width, pop.height);
const pos = getPosition(popWidth, popHeight, preferredEdge, preferredAlign);
const { innerHeight, innerWidth } = window;
pos.top = ["left", "right"].includes(preferredEdge)
? clamp(pos.top, spacing, innerHeight - popHeight - spacing)
: pos.top;
pos.left = ["top", "bottom"].includes(preferredEdge)
? clamp(pos.left, spacing, innerWidth - popWidth - spacing)
: pos.left;
return pos;
}, [popRef, getPosition, preferredEdge, preferredAlign, spacing]);
return (
<Portal>
<div
ref={updateRef}
style={getClampedPosition()}
data-fading={isClosing}
data-visible={!isClosing}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
// className="absolute w-[400px] border"
className="poppable"
>
{children}
</div>
</Portal>
);
};