@codeshumon/popup-menu
Version:
A lightweight, customizable popup menu component for React with built-in CSS styling and TypeScript support. Features automatic positioning, smooth animations, and responsive design for modern web applications.
444 lines (412 loc) • 16.3 kB
JavaScript
;
var jsxRuntime = require('react/jsx-runtime');
var React = require('react');
var reactDom = require('react-dom');
let stylesInjected = false;
const injectStyles = () => {
if (stylesInjected || typeof document === 'undefined')
return;
const styleId = 'popup-menu-styles';
if (document.getElementById(styleId)) {
stylesInjected = true;
return;
}
const cssContent = `
.popup-menu-wrapper {
position: relative;
display: inline-block;
}
.popup-menu {
position: fixed;
opacity: 0;
transition: opacity 0.2s ease, transform 0.2s ease;
z-index: 50;
display: flex;
flex-direction: column;
background-color: white;
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid #e0e1e4;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
min-width: 120px;
}
.popup-menu.dark-theme {
background-color: #404040;
color: #F9F9F9;
border: 1px solid #606060;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.25);
}
.popup-menu-visible {
opacity: 1;
}
/* Default (scale) animations */
.popup-menu-from-top {
transform: scale(0.95);
transform-origin: bottom;
}
.popup-menu-from-bottom {
transform: scale(0.95);
transform-origin: top;
}
.popup-menu-from-left {
transform: scale(0.95);
transform-origin: right;
}
.popup-menu-from-right {
transform: scale(0.95);
transform-origin: left;
}
.popup-menu-visible {
transform: scale(1);
}
/* "In" animations */
.popup-menu-in {
transform: translate(0, 0);
opacity: 1;
}
.popup-menu-in-from-top {
transform: translateY(10px);
}
.popup-menu-in-from-bottom {
transform: translateY(-10px);
}
.popup-menu-in-from-left {
transform: translateX(10px);
}
.popup-menu-in-from-right {
transform: translateX(-10px);
}
/* "Out" animations */
.popup-menu-out {
transform: translate(0, 0);
opacity: 1;
}
.popup-menu-out-from-top {
transform: translateY(-10px);
}
.popup-menu-out-from-bottom {
transform: translateY(10px);
}
.popup-menu-out-from-left {
transform: translateX(-10px);
}
.popup-menu-out-from-right {
transform: translateX(10px);
}
.popup-menu-header {
border-bottom: 1px solid #e0e1e4;
padding: 0.5rem;
font-size: 1rem;
font-weight: 600;
text-align: center;
}
.popup-menu.dark-theme .popup-menu-header {
border-bottom: 1px solid #606060;
}
.popup-menu-footer {
border-top: 1px solid #e0e1e4;
padding: 0.5rem;
font-size: 1rem;
font-weight: 600;
text-align: center;
}
.popup-menu.dark-theme .popup-menu-footer {
border-top: 1px solid #606060;
}
.popup-menu-content {
display: flex;
flex-direction: column;
padding: 0.25rem;
max-height: 50vh;
overflow-y: auto;
}
.popup-menu-item {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
transition: background-color 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.popup-menu-item:hover {
background-color: #dee3ee;
}
.popup-menu.dark-theme .popup-menu-item:hover {
background-color: #555555;
}
.cursor-pointer {
cursor: pointer;
}
`;
const styleElement = document.createElement('style');
styleElement.id = styleId;
styleElement.textContent = cssContent;
document.head.appendChild(styleElement);
stylesInjected = true;
};
const PopupMenu = ({ trigger, children, header, footer, className = "", menuClassName = "", position = "auto", onOpenChange, onClose, animationDuration = 200, viewportPadding = 5, enableScroll = false, noDefaultStyle = false, theme = 'light', hoverTrigger = false, animation = 'scale', }) => {
const [open, setOpen] = React.useState(false);
const [isVisible, setIsVisible] = React.useState(false);
const [calculatedPosition, setCalculatedPosition] = React.useState({
top: 0,
left: 0,
direction: "bottom",
});
const menuRef = React.useRef(null);
const triggerRef = React.useRef(null);
const timeoutRef = React.useRef(null);
const hoverTimeoutRef = React.useRef(null);
const disableScroll = React.useCallback((e) => {
e.preventDefault();
}, []);
const handleOpenChange = (isOpen) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (isOpen) {
setOpen(true);
setTimeout(() => {
setIsVisible(true);
}, 10);
if (!enableScroll) {
document.addEventListener("wheel", disableScroll, { passive: false });
document.addEventListener("touchmove", disableScroll, { passive: false });
}
}
else {
setIsVisible(false);
timeoutRef.current = setTimeout(() => {
setOpen(false);
onClose === null || onClose === void 0 ? void 0 : onClose();
if (!enableScroll) {
document.removeEventListener("wheel", disableScroll);
document.removeEventListener("touchmove", disableScroll);
}
}, animationDuration);
}
onOpenChange === null || onOpenChange === void 0 ? void 0 : onOpenChange(isOpen);
};
const handleMouseEnter = () => {
if (hoverTrigger) {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
handleOpenChange(true);
}
};
const handleMouseLeave = () => {
if (hoverTrigger) {
hoverTimeoutRef.current = setTimeout(() => {
if (open) {
handleOpenChange(false);
}
}, 300);
}
};
const handleMenuMouseEnter = () => {
if (hoverTrigger) {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
}
};
const handleMenuMouseLeave = () => {
if (hoverTrigger) {
hoverTimeoutRef.current = setTimeout(() => {
if (open) {
handleOpenChange(false);
}
}, 150);
}
};
const calculatePosition = React.useCallback(() => {
if (!menuRef.current || !triggerRef.current)
return;
const menuRect = menuRef.current.getBoundingClientRect();
const triggerRect = triggerRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let finalTop = 0;
let finalLeft = 0;
let finalDirection = "bottom";
const getPositionCoordinates = (pos) => {
const [direction, alignment] = pos.split("-");
let menuLeft = 0;
let menuTop = 0;
switch (direction) {
case "top":
menuTop = triggerRect.top - menuRect.height - viewportPadding;
break;
case "bottom":
menuTop = triggerRect.bottom + viewportPadding;
break;
case "left":
menuLeft = triggerRect.left - menuRect.width - viewportPadding;
break;
case "right":
menuLeft = triggerRect.right + viewportPadding;
break;
}
switch (alignment) {
case "left":
menuLeft = triggerRect.left;
break;
case "right":
menuLeft = triggerRect.right - menuRect.width;
break;
case "center":
if (direction === "top" || direction === "bottom") {
menuLeft = triggerRect.left + (triggerRect.width - menuRect.width) / 2;
}
else {
menuTop = triggerRect.top + (triggerRect.height - menuRect.height) / 2;
}
break;
case "top":
menuTop = triggerRect.top;
break;
case "bottom":
menuTop = triggerRect.bottom - menuRect.height;
break;
}
return { top: menuTop, left: menuLeft };
};
const doesPositionFit = (pos) => {
const { top, left } = getPositionCoordinates(pos);
return (left >= viewportPadding &&
left + menuRect.width <= viewportWidth - viewportPadding &&
top >= viewportPadding &&
top + menuRect.height <= viewportHeight - viewportPadding);
};
const calculateOverflow = (pos) => {
const { top, left } = getPositionCoordinates(pos);
const leftOverflow = Math.max(0, viewportPadding - left);
const rightOverflow = Math.max(0, left + menuRect.width - (viewportWidth - viewportPadding));
const topOverflow = Math.max(0, viewportPadding - top);
const bottomOverflow = Math.max(0, top + menuRect.height - (viewportHeight - viewportPadding));
return leftOverflow + rightOverflow + topOverflow + bottomOverflow;
};
const findBestPosition = () => {
const preferredPositions = [
"bottom-left", "bottom-center", "bottom-right",
"top-left", "top-center", "top-right",
"right-top", "right-center", "right-bottom",
"left-top", "left-center", "left-bottom",
];
for (const pos of preferredPositions) {
if (doesPositionFit(pos)) {
return pos;
}
}
let bestPosition = "bottom-left";
let minOverflow = Infinity;
for (const pos of preferredPositions) {
const overflow = calculateOverflow(pos);
if (overflow < minOverflow) {
minOverflow = overflow;
bestPosition = pos;
}
}
return bestPosition;
};
let bestPosition;
if (position === "auto") {
bestPosition = findBestPosition();
}
else {
bestPosition = position;
if (!doesPositionFit(bestPosition)) {
bestPosition = findBestPosition();
}
}
const { top, left } = getPositionCoordinates(bestPosition);
finalTop = top;
finalLeft = left;
finalDirection = bestPosition.split("-")[0];
setCalculatedPosition({ top: finalTop, left: finalLeft, direction: finalDirection });
}, [position, viewportPadding]);
React.useLayoutEffect(() => {
injectStyles();
if (open) {
calculatePosition();
const handleClickOutside = (event) => {
if (menuRef.current &&
!menuRef.current.contains(event.target) &&
triggerRef.current &&
!triggerRef.current.contains(event.target)) {
handleOpenChange(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
window.addEventListener("resize", calculatePosition);
window.addEventListener("scroll", calculatePosition, true);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
window.removeEventListener("resize", calculatePosition);
window.removeEventListener("scroll", calculatePosition, true);
if (!enableScroll) {
document.removeEventListener("wheel", disableScroll);
document.removeEventListener("touchmove", disableScroll);
}
};
}
}, [open, calculatePosition, disableScroll, enableScroll]);
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
document.removeEventListener("wheel", disableScroll);
document.removeEventListener("touchmove", disableScroll);
};
}, [disableScroll]);
const getAnimationClass = () => {
if (animation === 'in') {
const directionClass = `popup-menu-in-from-${calculatedPosition.direction}`;
return isVisible ? `popup-menu-visible popup-menu-in` : directionClass;
}
if (animation === 'out') {
const directionClass = `popup-menu-out-from-${calculatedPosition.direction}`;
return isVisible ? `popup-menu-visible popup-menu-out` : directionClass;
}
const directionClass = `popup-menu-from-${calculatedPosition.direction}`;
return isVisible ? `popup-menu-visible` : directionClass;
};
const combineClassNames = (...classes) => {
return classes.filter(Boolean).join(" ");
};
const menu = open ? (jsxRuntime.jsxs("div", { ref: menuRef, className: combineClassNames(noDefaultStyle ? "" : "popup-menu", getAnimationClass(), theme === 'dark' && !noDefaultStyle ? 'dark-theme' : '', menuClassName), role: "menu", style: Object.assign({ top: calculatedPosition.top, left: calculatedPosition.left, transitionDuration: `${animationDuration}ms` }, (noDefaultStyle ? {
position: 'fixed',
zIndex: 50,
display: 'flex',
flexDirection: 'column',
opacity: isVisible ? 1 : 0,
transition: `opacity ${animationDuration}ms ease, transform ${animationDuration}ms ease`
} : {})), onMouseEnter: handleMenuMouseEnter, onMouseLeave: handleMenuMouseLeave, children: [header && jsxRuntime.jsx("div", { className: noDefaultStyle ? "" : "popup-menu-header", children: header }), jsxRuntime.jsx("div", { className: noDefaultStyle ? "" : "popup-menu-content", children: React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
return React.cloneElement(child, {
onClick: (e) => {
var _a, _b;
(_b = (_a = child.props).onClick) === null || _b === void 0 ? void 0 : _b.call(_a, e);
handleOpenChange(false);
},
className: combineClassNames(noDefaultStyle ? "" : "popup-menu-item", child.props.className || ""),
});
}
return child;
}) }), footer && jsxRuntime.jsx("div", { className: noDefaultStyle ? "" : "popup-menu-footer", children: footer })] })) : null;
return (jsxRuntime.jsxs("div", { className: combineClassNames("popup-menu-wrapper", className), children: [jsxRuntime.jsx("div", { ref: triggerRef, onClick: () => !hoverTrigger && handleOpenChange(!open), onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, role: "button", tabIndex: 0, onKeyPress: (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleOpenChange(!open);
}
}, className: "cursor-pointer", children: trigger }), menu && reactDom.createPortal(menu, document.body)] }));
};
exports.PopupMenu = PopupMenu;
//# sourceMappingURL=index.js.map