UNPKG

@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
'use strict'; 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