2026-02-06 14:27:37 +05:30
|
|
|
import { useState, useRef, useEffect } from 'react';
|
2026-01-27 18:27:29 +05:30
|
|
|
import { FaCaretRight } from "react-icons/fa";
|
|
|
|
|
|
|
|
|
|
import './PozoMenu.scss';
|
|
|
|
|
|
|
|
|
|
const PozoMenu = ({ items = [], mode = "vertical", style = {}, onClick, collapse = false, selectedKey: propSelectedKey, isBottomMenu = false }) => {
|
|
|
|
|
const [openKeys, setOpenKeys] = useState([]);
|
|
|
|
|
const [selectedKey, setSelectedKey] = useState('');
|
|
|
|
|
const [dropdownPositions, setDropdownPositions] = useState({});
|
|
|
|
|
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
|
|
|
|
const menuRef = useRef();
|
|
|
|
|
|
|
|
|
|
// Update selected key when prop changes
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (propSelectedKey !== undefined) {
|
|
|
|
|
setSelectedKey(propSelectedKey);
|
|
|
|
|
}
|
|
|
|
|
}, [propSelectedKey]);
|
|
|
|
|
|
|
|
|
|
// Close dropdown when clicking outside, window resize, or scroll
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleClickOutside = (event) => {
|
|
|
|
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
|
|
|
|
setOpenKeys([]);
|
|
|
|
|
setDropdownPositions({});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleResize = () => {
|
|
|
|
|
// Update mobile detection
|
|
|
|
|
setIsMobile(window.innerWidth <= 768);
|
|
|
|
|
|
|
|
|
|
// Recalculate positions on window resize
|
|
|
|
|
if (openKeys.length > 0) {
|
|
|
|
|
setOpenKeys([]);
|
|
|
|
|
setDropdownPositions({});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleScroll = () => {
|
|
|
|
|
// 🎯 FIXED: Recalculate positions on scroll to maintain proper positioning
|
|
|
|
|
if (openKeys.length > 0) {
|
|
|
|
|
// Find all currently open menu elements and recalculate their positions
|
|
|
|
|
const newPositions = {};
|
|
|
|
|
openKeys.forEach(key => {
|
|
|
|
|
const element = document.querySelector(`[data-menu-key="${key}"]`);
|
|
|
|
|
if (element) {
|
|
|
|
|
const level = key.split('-').length - 1;
|
|
|
|
|
newPositions[key] = calculatePosition(element, level);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
setDropdownPositions(prev => ({ ...prev, ...newPositions }));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
|
window.addEventListener('scroll', handleScroll, true); // Capture scroll events
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
|
|
|
window.removeEventListener('resize', handleResize);
|
|
|
|
|
window.removeEventListener('scroll', handleScroll, true);
|
|
|
|
|
};
|
|
|
|
|
}, [openKeys]);
|
|
|
|
|
|
|
|
|
|
// 🎯 FIXED: Smart positioning calculation - exactly like your image behavior
|
|
|
|
|
const calculatePosition = (element, level = 0) => {
|
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
|
|
|
const viewportWidth = window.innerWidth;
|
|
|
|
|
const viewportHeight = window.innerHeight;
|
|
|
|
|
const scrollX = window.pageXOffset || document.documentElement.scrollLeft;
|
|
|
|
|
const scrollY = window.pageYOffset || document.documentElement.scrollTop;
|
|
|
|
|
|
|
|
|
|
// Dynamic dropdown dimensions - responsive
|
|
|
|
|
const dropdownWidth = isMobile
|
|
|
|
|
? Math.min(280, viewportWidth - 20)
|
|
|
|
|
: (level > 0 ? 220 : 250);
|
|
|
|
|
const dropdownHeight = Math.min(
|
|
|
|
|
isMobile ? viewportHeight * 0.5 : 400,
|
|
|
|
|
viewportHeight * 0.6
|
|
|
|
|
);
|
|
|
|
|
const gap = isMobile ? 2 : 4;
|
|
|
|
|
const edgePadding = isMobile ? 10 : 8;
|
|
|
|
|
|
|
|
|
|
// Calculate available space on both sides
|
|
|
|
|
const spaceRight = viewportWidth - rect.right;
|
|
|
|
|
const spaceLeft = rect.left;
|
|
|
|
|
const spaceNeeded = dropdownWidth + gap + edgePadding;
|
|
|
|
|
|
|
|
|
|
let position = {
|
|
|
|
|
top: rect.top + scrollY,
|
|
|
|
|
left: rect.right + gap + scrollX,
|
|
|
|
|
direction: 'right',
|
|
|
|
|
verticalDirection: 'down'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 🎯 FIXED: HORIZONTAL POSITIONING - Exactly like your images
|
|
|
|
|
if (level === 0) {
|
|
|
|
|
console.log('🎯 Space Analysis:', {
|
|
|
|
|
spaceRight,
|
|
|
|
|
spaceLeft,
|
|
|
|
|
spaceNeeded,
|
|
|
|
|
elementPosition: { left: rect.left, right: rect.right }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (spaceRight >= spaceNeeded) {
|
|
|
|
|
// Image 1: Enough space on right - open to RIGHT
|
|
|
|
|
position.left = rect.right + gap + scrollX;
|
|
|
|
|
position.direction = 'right';
|
|
|
|
|
console.log('✅ Opening RIGHT - space available');
|
|
|
|
|
} else if (spaceLeft >= spaceNeeded) {
|
|
|
|
|
// Image 2: No space on right, space on left - open to LEFT
|
|
|
|
|
position.left = rect.left - dropdownWidth - gap + scrollX;
|
|
|
|
|
position.direction = 'left';
|
2026-02-06 14:27:37 +05:30
|
|
|
|
2026-01-27 18:27:29 +05:30
|
|
|
} else {
|
|
|
|
|
// Edge case: Limited space on both sides
|
|
|
|
|
if (spaceRight >= spaceLeft) {
|
|
|
|
|
position.left = viewportWidth - dropdownWidth - edgePadding + scrollX;
|
|
|
|
|
position.direction = 'right-edge';
|
|
|
|
|
} else {
|
|
|
|
|
position.left = edgePadding + scrollX;
|
|
|
|
|
position.direction = 'left-edge';
|
|
|
|
|
}
|
|
|
|
|
console.log('⚠️ Edge positioning applied');
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Nested menu positioning
|
|
|
|
|
const parentKeys = Object.keys(dropdownPositions);
|
|
|
|
|
const parentDirection = parentKeys.length > 0
|
|
|
|
|
? dropdownPositions[parentKeys[level - 1]]?.direction || 'right'
|
|
|
|
|
: 'right';
|
|
|
|
|
|
|
|
|
|
if (parentDirection.includes('right')) {
|
|
|
|
|
if (spaceRight >= spaceNeeded) {
|
|
|
|
|
position.left = rect.right + gap + scrollX;
|
|
|
|
|
position.direction = 'right';
|
|
|
|
|
} else {
|
|
|
|
|
position.left = rect.left - dropdownWidth - gap + scrollX;
|
|
|
|
|
position.direction = 'left';
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if (spaceLeft >= spaceNeeded) {
|
|
|
|
|
position.left = rect.left - dropdownWidth - gap + scrollX;
|
|
|
|
|
position.direction = 'left';
|
|
|
|
|
} else {
|
|
|
|
|
position.left = rect.right + gap + scrollX;
|
|
|
|
|
position.direction = 'right';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 🎯 FIXED: VERTICAL POSITIONING
|
|
|
|
|
const spaceBelow = viewportHeight - rect.bottom;
|
|
|
|
|
const spaceAbove = rect.top;
|
|
|
|
|
|
|
|
|
|
if (isBottomMenu && level === 0) {
|
|
|
|
|
if (spaceAbove >= dropdownHeight + edgePadding) {
|
|
|
|
|
position.top = rect.top - dropdownHeight - gap + scrollY;
|
|
|
|
|
position.verticalDirection = 'up';
|
|
|
|
|
} else {
|
|
|
|
|
position.top = rect.bottom + gap + scrollY;
|
|
|
|
|
position.verticalDirection = 'down';
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if (spaceBelow >= dropdownHeight + edgePadding) {
|
|
|
|
|
position.top = rect.top + scrollY;
|
|
|
|
|
position.verticalDirection = 'down';
|
|
|
|
|
} else if (spaceAbove >= dropdownHeight + edgePadding) {
|
|
|
|
|
position.top = rect.bottom - dropdownHeight + scrollY;
|
|
|
|
|
position.verticalDirection = 'up';
|
|
|
|
|
} else {
|
|
|
|
|
position.top = Math.max(edgePadding, (viewportHeight - dropdownHeight) / 2) + scrollY;
|
|
|
|
|
position.verticalDirection = 'center';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 🎯 FIXED: Final boundary checks
|
|
|
|
|
position.left = Math.max(
|
|
|
|
|
edgePadding + scrollX,
|
|
|
|
|
Math.min(position.left, viewportWidth - dropdownWidth - edgePadding + scrollX)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
position.top = Math.max(
|
|
|
|
|
edgePadding + scrollY,
|
|
|
|
|
Math.min(position.top, viewportHeight - dropdownHeight - edgePadding + scrollY)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Store dimensions for reference
|
|
|
|
|
position.width = dropdownWidth;
|
|
|
|
|
position.height = dropdownHeight;
|
|
|
|
|
|
|
|
|
|
console.log('🎯 FIXED Position:', {
|
|
|
|
|
clicked: { top: rect.top, left: rect.left, right: rect.right, bottom: rect.bottom },
|
|
|
|
|
spaces: { right: spaceRight, left: spaceLeft, above: spaceAbove, below: spaceBelow },
|
|
|
|
|
calculated: position,
|
|
|
|
|
direction: position.direction
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return position;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Handle menu item click
|
|
|
|
|
const handleMenuClick = (item, event, parentKeys = []) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
|
|
|
|
if (item.children && item.children.length > 0) {
|
|
|
|
|
// Has children - toggle submenu
|
|
|
|
|
const itemKey = item.key;
|
|
|
|
|
const isOpen = openKeys.includes(itemKey);
|
|
|
|
|
|
|
|
|
|
if (isOpen) {
|
|
|
|
|
// Close this submenu and all its children
|
|
|
|
|
setOpenKeys(prev => prev.filter(key => {
|
|
|
|
|
// Keep only keys that are not this item or its descendants
|
|
|
|
|
return !key.startsWith(itemKey) && key !== itemKey;
|
|
|
|
|
}));
|
|
|
|
|
setDropdownPositions(prev => {
|
|
|
|
|
const newPos = { ...prev };
|
|
|
|
|
Object.keys(newPos).forEach(key => {
|
|
|
|
|
if (key.startsWith(itemKey) || key === itemKey) {
|
|
|
|
|
delete newPos[key];
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return newPos;
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// Close all submenus at the same level or deeper
|
|
|
|
|
const currentPath = [...parentKeys, itemKey];
|
|
|
|
|
const newOpenKeys = openKeys.filter(key => {
|
|
|
|
|
// Keep only parent keys in the current path
|
|
|
|
|
return currentPath.some(pathKey => key === pathKey) ||
|
|
|
|
|
currentPath.every((pathKey, index) => {
|
|
|
|
|
const keyParts = key.split('-');
|
|
|
|
|
const pathParts = pathKey.split('-');
|
|
|
|
|
return index < keyParts.length && keyParts[index] === pathParts[index];
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Add current item to open keys
|
|
|
|
|
newOpenKeys.push(itemKey);
|
|
|
|
|
|
|
|
|
|
// Calculate position and add to dropdown positions
|
|
|
|
|
const position = calculatePosition(event.currentTarget, parentKeys.length);
|
2026-02-06 14:27:37 +05:30
|
|
|
|
2026-01-27 18:27:29 +05:30
|
|
|
|
|
|
|
|
setDropdownPositions(prev => {
|
|
|
|
|
// Remove positions for closed items
|
|
|
|
|
const newPos = {};
|
|
|
|
|
newOpenKeys.forEach(key => {
|
|
|
|
|
if (prev[key]) newPos[key] = prev[key];
|
|
|
|
|
});
|
|
|
|
|
newPos[itemKey] = position;
|
|
|
|
|
return newPos;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setOpenKeys(newOpenKeys);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// No children - select item and close all dropdowns
|
|
|
|
|
setSelectedKey(item.key);
|
|
|
|
|
setOpenKeys([]);
|
|
|
|
|
setDropdownPositions({});
|
|
|
|
|
|
|
|
|
|
// Trigger onClick callback if provided
|
|
|
|
|
if (onClick) {
|
|
|
|
|
onClick({
|
|
|
|
|
key: item.key,
|
|
|
|
|
keyPath: [...parentKeys, item.key],
|
|
|
|
|
item: item,
|
|
|
|
|
domEvent: event
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check if item is selected
|
|
|
|
|
const isSelected = (key) => selectedKey === key;
|
|
|
|
|
|
|
|
|
|
// Check if item is open
|
|
|
|
|
const isOpen = (key) => openKeys.includes(key);
|
|
|
|
|
|
|
|
|
|
// Check if item is in active path (has selected child)
|
|
|
|
|
const isInActivePath = (item, parentKeys = []) => {
|
|
|
|
|
if (!item.children) return false;
|
|
|
|
|
|
|
|
|
|
const checkChildren = (children, currentPath) => {
|
|
|
|
|
return children.some(child => {
|
|
|
|
|
const childPath = [...currentPath, child.key];
|
|
|
|
|
if (child.key === selectedKey) return true;
|
|
|
|
|
if (child.children) {
|
|
|
|
|
return checkChildren(child.children, childPath);
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return checkChildren(item.children, [...parentKeys, item.key]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const menuRefs = useRef({});
|
|
|
|
|
const submenuRefs = useRef({});
|
|
|
|
|
const [positions, setPositions] = useState({});
|
|
|
|
|
|
|
|
|
|
const renderMenuItems = (items, parentKeys = [], level = 0) => {
|
|
|
|
|
return items.map((item) => {
|
|
|
|
|
if (!item) return null;
|
|
|
|
|
|
|
|
|
|
const hasChildren = item.children && item.children.length > 0;
|
|
|
|
|
const isItemSelected = isSelected(item.key);
|
|
|
|
|
const isItemOpen = isOpen(item.key);
|
|
|
|
|
const isItemInActivePath = isInActivePath(item, parentKeys);
|
|
|
|
|
const currentKeys = [...parentKeys, item.key];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={item.key}
|
|
|
|
|
className="pozo-menu-item-wrapper"
|
|
|
|
|
ref={(el) => {
|
|
|
|
|
if (el) menuRefs.current[item.key] = el;
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className={`pozo-menu-item ${isItemSelected ? 'selected' : ''} ${isItemOpen ? 'open' : ''} ${isItemInActivePath ? 'active-path' : ''} ${hasChildren ? 'has-children' : ''} ${item.type === 'group' ? 'group-item' : ''}`}
|
|
|
|
|
onClick={(e) => handleMenuClick(item, e, parentKeys)}
|
|
|
|
|
data-menu-key={item.key}
|
|
|
|
|
style={{ justifyContent: "flex-start" }}
|
|
|
|
|
>
|
|
|
|
|
{item.icon && (
|
|
|
|
|
<span className="pozo-menu-icon" style={{ fontSize: collapse ? "1.3rem" : "" }}>
|
|
|
|
|
{item.icon}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!collapse && <span className="pozo-menu-label">{item.label}</span>}
|
|
|
|
|
|
|
|
|
|
{hasChildren && !collapse && (
|
|
|
|
|
// <span className="pozo-menu-arrow"><FaCaretRight /></span>
|
|
|
|
|
<span className={`${isItemOpen ? "open " : ""}pozo-menu-arrow`}>
|
|
|
|
|
<FaCaretRight />
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{hasChildren && isItemOpen && !collapse && (
|
|
|
|
|
<div
|
|
|
|
|
ref={(el) => {
|
|
|
|
|
if (el) submenuRefs.current[item.key] = el;
|
|
|
|
|
}}
|
|
|
|
|
className={`pozo-dropdown-submenu ${level > 0 ? 'nested-submenu' : ''}`}
|
|
|
|
|
style={{
|
|
|
|
|
position: 'fixed',
|
|
|
|
|
top: (() => {
|
|
|
|
|
const defaultTop = positions[item.key]?.top || 0;
|
|
|
|
|
const submenuHeight =
|
|
|
|
|
submenuRefs.current[item.key]?.getBoundingClientRect()?.height || 0;
|
|
|
|
|
const screenHeight = window.innerHeight;
|
|
|
|
|
|
|
|
|
|
// ✅ If submenu would overflow screen bottom, adjust top
|
|
|
|
|
if (defaultTop + submenuHeight > screenHeight) {
|
|
|
|
|
return Math.max(
|
|
|
|
|
8, // padding from top
|
|
|
|
|
screenHeight - submenuHeight - 8
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return defaultTop;
|
|
|
|
|
})(),
|
|
|
|
|
left: positions[item.key]?.left + 5 || 0,
|
|
|
|
|
// width: 220,
|
|
|
|
|
width: 200,
|
|
|
|
|
maxHeight: 400,
|
|
|
|
|
zIndex: 1000 + level
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="pozo-submenu-content">
|
|
|
|
|
{renderMenuItems(item.children, currentKeys, level + 1)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ✅ Compute positions after render
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const updated = {};
|
|
|
|
|
for (const [key, el] of Object.entries(menuRefs.current)) {
|
|
|
|
|
if (el && typeof el.getBoundingClientRect === "function") {
|
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
|
|
|
updated[key] = {
|
|
|
|
|
top: rect.top + window.scrollY,
|
|
|
|
|
left: rect.right + window.scrollX
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Object.entries(submenuRefs.current).forEach(([key, el]) => {
|
|
|
|
|
if (el) {
|
|
|
|
|
const height = el.getBoundingClientRect().height;
|
|
|
|
|
console.log(`Total submenu height for ${key}:`, height);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
setPositions(updated);
|
|
|
|
|
}, [items, openKeys]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className={`pozo-menu pozo-menu-${mode}`}
|
|
|
|
|
style={style}
|
|
|
|
|
>
|
|
|
|
|
<div className="pozo-menu-content" ref={menuRef}>
|
|
|
|
|
{renderMenuItems(items)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default PozoMenu;
|