tahamajs/IE / Projects /front /src /components /ui /ContextMenu.tsx
tahamajs's picture
download
raw
9.72 kB
// src/components/ui/ContextMenu.tsx
import { useEffect, useRef, useState, useCallback, KeyboardEvent } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronRight, Check } from "lucide-react";
export interface ContextMenuItem {
label?: string;
icon?: React.ReactNode;
onClick?: () => void;
danger?: boolean;
disabled?: boolean;
divider?: boolean;
shortcut?: string;
submenu?: ContextMenuItem[];
selected?: boolean;
type?: "normal" | "checkbox" | "radio";
value?: string;
group?: string;
}
export interface ContextMenuProps {
items: ContextMenuItem[];
position: { x: number; y: number };
onClose: () => void;
className?: string;
theme?: "light" | "dark" | "auto";
animation?: "fade" | "scale" | "slide";
}
// Animation variants
const animations = {
fade: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
},
scale: {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.95 },
},
slide: {
initial: { opacity: 0, y: -10 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -10 },
},
};
export function ContextMenu({
items,
position,
onClose,
className = "",
theme = "auto",
animation = "scale",
}: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [adjustedPosition, setAdjustedPosition] = useState(position);
const [activeSubmenu, setActiveSubmenu] = useState<{ index: number; position: { x: number; y: number } } | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
// Adjust position to stay within viewport
const adjustPosition = useCallback(() => {
if (!menuRef.current) return position;
const rect = menuRef.current.getBoundingClientRect();
let { x, y } = position;
const padding = 10;
if (x + rect.width > window.innerWidth - padding) {
x = window.innerWidth - rect.width - padding;
}
if (y + rect.height > window.innerHeight - padding) {
y = window.innerHeight - rect.height - padding;
}
if (x < padding) x = padding;
if (y < padding) y = padding;
return { x, y };
}, [position]);
// Update position after render
useEffect(() => {
setAdjustedPosition(adjustPosition());
}, [adjustPosition]);
// Handle click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
const handleScroll = () => {
onClose();
};
const handleResize = () => {
onClose();
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape as any);
window.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", handleResize);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape as any);
window.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("resize", handleResize);
};
}, [onClose]);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!menuRef.current) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex(prev => {
let next = prev + 1;
while (next < items.length && items[next]?.disabled) next++;
return next >= items.length ? -1 : next;
});
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex(prev => {
let next = prev - 1;
while (next >= 0 && items[next]?.disabled) next--;
return next;
});
break;
case "ArrowRight":
e.preventDefault();
if (selectedIndex >= 0 && items[selectedIndex]?.submenu) {
const item = items[selectedIndex];
if (item.submenu && itemRefs.current[selectedIndex]) {
const rect = itemRefs.current[selectedIndex]!.getBoundingClientRect();
setActiveSubmenu({
index: selectedIndex,
position: { x: rect.right + 5, y: rect.top },
});
}
}
break;
case "ArrowLeft":
e.preventDefault();
setActiveSubmenu(null);
break;
case "Enter":
e.preventDefault();
if (selectedIndex >= 0 && items[selectedIndex]?.onClick && !items[selectedIndex].disabled) {
items[selectedIndex].onClick!();
onClose();
}
break;
}
};
document.addEventListener("keydown", handleKeyDown as any);
return () => document.removeEventListener("keydown", handleKeyDown as any);
}, [items, selectedIndex, onClose]);
// Scroll selected item into view
useEffect(() => {
if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
}
}, [selectedIndex]);
// Reset selected index when menu opens
useEffect(() => {
setSelectedIndex(-1);
}, [items]);
const handleItemClick = (item: ContextMenuItem, e: React.MouseEvent) => {
e.stopPropagation();
if (item.disabled) return;
if (item.onClick) {
item.onClick();
}
if (!item.submenu) {
onClose();
}
};
const handleMouseEnter = (index: number, item: ContextMenuItem, e: React.MouseEvent) => {
setSelectedIndex(index);
if (item.submenu) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setActiveSubmenu({
index,
position: { x: rect.right + 5, y: rect.top },
});
}
};
const handleMouseLeave = () => {
// Delay closing submenu to allow moving into it
setTimeout(() => {
if (!activeSubmenu) {
setSelectedIndex(-1);
}
}, 100);
};
const isDark = theme === "dark" || (theme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches);
return (
<AnimatePresence>
<motion.div
key="context-menu"
ref={menuRef}
initial={animations[animation].initial}
animate={animations[animation].animate}
exit={animations[animation].exit}
transition={{ duration: 0.1 }}
style={{
position: "fixed",
top: adjustedPosition.y,
left: adjustedPosition.x,
zIndex: 1000,
}}
className={`min-w-[200px] max-w-[300px] rounded-lg shadow-2xl overflow-hidden py-1 ${
isDark
? "bg-gray-800 border border-gray-700"
: "bg-white border border-gray-200"
} ${className}`}
>
{items.map((item, index) => {
if (item.divider) {
return (
<div
key={`divider-${index}`}
className={`h-px my-1 ${
isDark ? "bg-gray-700" : "bg-gray-200"
}`}
/>
);
}
const isSelected = selectedIndex === index;
const hasSubmenu = !!item.submenu && item.submenu.length > 0;
const isActiveSubmenu = activeSubmenu?.index === index;
return (
<div
key={item.label}
ref={(el) => { itemRefs.current[index] = el; }}
onClick={(e) => handleItemClick(item, e)}
onMouseEnter={(e) => handleMouseEnter(index, item, e)}
onMouseLeave={handleMouseLeave}
className={`
relative flex items-center justify-between px-3 py-2 text-sm cursor-pointer transition-colors
${isSelected ? (isDark ? "bg-gray-700" : "bg-gray-100") : ""}
${item.disabled ? "opacity-50 cursor-not-allowed" : ""}
${item.danger ? (isDark ? "text-red-400 hover:bg-red-900/20" : "text-red-600 hover:bg-red-50") : ""}
${!item.disabled && !item.danger ? (isDark ? "hover:bg-gray-700" : "hover:bg-gray-100") : ""}
`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{item.icon && (
<span className="w-4 h-4 flex-shrink-0 text-gray-400">
{item.icon}
</span>
)}
<span className="truncate">{item.label}</span>
{item.selected && (
<Check className="w-3.5 h-3.5 ml-auto text-primary" />
)}
{item.shortcut && (
<span className={`text-xs ml-auto ${isDark ? "text-gray-500" : "text-gray-400"}`}>
{item.shortcut}
</span>
)}
{hasSubmenu && (
<ChevronRight className="w-3.5 h-3.5 ml-2 flex-shrink-0 text-gray-400" />
)}
</div>
{/* Submenu */}
{hasSubmenu && isActiveSubmenu && activeSubmenu && (
<ContextMenu
items={item.submenu!}
position={activeSubmenu.position}
onClose={() => setActiveSubmenu(null)}
theme={theme}
animation={animation}
/>
)}
</div>
);
})}
</motion.div>
</AnimatePresence>
);
}

Xet Storage Details

Size:
9.72 kB
·
Xet hash:
37f9c1fd7738a37103c46164194a8934f060056aaba41b85ecf402be178febcc

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.