199 lines
5.6 KiB
JavaScript
199 lines
5.6 KiB
JavaScript
import React, { useEffect, useRef } from "react";
|
|
import { useDispatch, useSelector } from "react-redux";
|
|
import {
|
|
closePopup,
|
|
openPopup,
|
|
togglePopup,
|
|
} from "../../slices/localVariablesSlice";
|
|
|
|
/**
|
|
* align: "auto" | "left" | "right"
|
|
* boundaryRef: optional ref to the drawer/container element to use as boundary
|
|
*/
|
|
const HoverPopup = ({
|
|
id,
|
|
title,
|
|
content,
|
|
children,
|
|
className = "",
|
|
Mode = "hover",
|
|
align = "auto",
|
|
boundaryRef = null,
|
|
}) => {
|
|
const dispatch = useDispatch();
|
|
const visible = useSelector((s) => s.localVariables.popups[id] || false);
|
|
|
|
const triggerRef = useRef(null);
|
|
const popupRef = useRef(null);
|
|
|
|
const handleMouseEnter = () => {
|
|
if (Mode === "hover") dispatch(openPopup(id));
|
|
};
|
|
const handleMouseLeave = () => {
|
|
if (Mode === "hover") dispatch(closePopup(id));
|
|
};
|
|
const handleClick = (e) => {
|
|
if (Mode === "click") {
|
|
e.stopPropagation();
|
|
dispatch(togglePopup(id));
|
|
}
|
|
};
|
|
|
|
// Close on outside click when using click mode
|
|
useEffect(() => {
|
|
if (Mode !== "click" || !visible) return;
|
|
|
|
const handler = (e) => {
|
|
if (
|
|
popupRef.current &&
|
|
!popupRef.current.contains(e.target) &&
|
|
triggerRef.current &&
|
|
!triggerRef.current.contains(e.target)
|
|
) {
|
|
dispatch(closePopup(id));
|
|
}
|
|
};
|
|
|
|
document.addEventListener("click", handler);
|
|
return () => document.removeEventListener("click", handler);
|
|
}, [Mode, visible, dispatch, id]);
|
|
|
|
// Positioning effect: respects align prop and stays inside boundary (drawer)
|
|
useEffect(() => {
|
|
if (!visible || !popupRef.current || !triggerRef.current) return;
|
|
|
|
// run in next frame so DOM/layout settles
|
|
requestAnimationFrame(() => {
|
|
const popup = popupRef.current;
|
|
|
|
// choose boundary: provided boundaryRef or nearest positioned parent (popup.parentElement)
|
|
const boundaryEl =
|
|
(boundaryRef && boundaryRef.current) || popup.parentElement;
|
|
if (!boundaryEl) return;
|
|
|
|
const boundaryRect = boundaryEl.getBoundingClientRect();
|
|
const triggerRect = triggerRef.current.getBoundingClientRect();
|
|
|
|
// reset styles first
|
|
popup.style.left = "";
|
|
popup.style.right = "";
|
|
popup.style.transform = "";
|
|
popup.style.top = "";
|
|
|
|
const popupRect = popup.getBoundingClientRect();
|
|
const parentRect = boundaryRect; // alias
|
|
|
|
// Convert trigger center to parent coordinates
|
|
const triggerCenterX =
|
|
triggerRect.left + triggerRect.width / 2 - parentRect.left;
|
|
|
|
// preferred left so popup center aligns to trigger center:
|
|
const preferredLeft = triggerCenterX - popupRect.width / 2;
|
|
|
|
// Helpers to set styles in parent's coordinate system:
|
|
const setLeft = (leftPx) => {
|
|
popup.style.left = `${leftPx}px`;
|
|
popup.style.right = "auto";
|
|
popup.style.transform = "none";
|
|
};
|
|
const setRight = (rightPx) => {
|
|
popup.style.left = "auto";
|
|
popup.style.right = `${rightPx}px`;
|
|
popup.style.transform = "none";
|
|
};
|
|
|
|
// If user forced align:
|
|
if (align === "left") {
|
|
// align popup's left to parent's left (0)
|
|
setLeft(0);
|
|
return;
|
|
}
|
|
if (align === "right") {
|
|
// align popup's right to parent's right (0)
|
|
setRight(0);
|
|
return;
|
|
}
|
|
if (align === "center") {
|
|
popup.style.left = "50%";
|
|
popup.style.right = "auto";
|
|
popup.style.transform = "translateX(-50%)";
|
|
return;
|
|
}
|
|
|
|
// align === "auto": try preferred centered position, but flip fully if overflow
|
|
// clamp preferredLeft to boundaries so it doesn't render partially outside
|
|
const leftIfCentered = Math.max(
|
|
0,
|
|
Math.min(preferredLeft, parentRect.width - popupRect.width)
|
|
);
|
|
|
|
// if centered fits, use it
|
|
if (leftIfCentered === preferredLeft) {
|
|
setLeft(leftIfCentered);
|
|
return;
|
|
}
|
|
|
|
// if centering would overflow right -> stick popup fully to left (left=0)
|
|
if (preferredLeft > parentRect.width - popupRect.width) {
|
|
// place popup so its right aligns to parent's right
|
|
// i.e., left = parent width - popup width
|
|
setLeft(parentRect.width - popupRect.width);
|
|
return;
|
|
}
|
|
|
|
// if centering would overflow left -> stick popup fully to left=0
|
|
if (preferredLeft < 0) {
|
|
setLeft(0);
|
|
return;
|
|
}
|
|
|
|
// fallback center
|
|
setLeft(leftIfCentered);
|
|
});
|
|
}, [visible, align, boundaryRef]);
|
|
|
|
return (
|
|
<div
|
|
className="d-inline-block "
|
|
style={{
|
|
maxWidth: "calc(700px - 100px)",
|
|
width: "100%",
|
|
wordWrap: "break-word",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<div
|
|
className="d-inline-block"
|
|
ref={triggerRef}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
onClick={handleClick}
|
|
style={{ cursor: "pointer" }}
|
|
>
|
|
{children}
|
|
</div>
|
|
|
|
{visible && (
|
|
<div
|
|
ref={popupRef}
|
|
// position absolute; it should be inside a positioned parent (the drawer)
|
|
className={`hover-popup bg-white border rounded shadow-sm p-3 position-absolute mt-2 ${className}`}
|
|
style={{
|
|
zIndex: 2000,
|
|
top: "100%", // open below trigger
|
|
// left/right will be set by effect in parent coordinates
|
|
width: "max-content",
|
|
minWidth: "120px",
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{title && <h6 className="fw-semibold mb-2">{title}</h6>}
|
|
<div>{content}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default HoverPopup;
|