| // 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.