import { useState, useRef, useEffect } from 'react'; import { LucideIcon, Plus, FileText, Package, Receipt, Users, BookOpen } from 'lucide-react'; import { cn } from '@/lib/utils'; interface RadialMenuItem { icon: LucideIcon; label: string; onClick: () => void; color: string; } interface RadialMenuProps { items: RadialMenuItem[]; size?: number; className?: string; } export function RadialMenu({ items, size = 280, className }: RadialMenuProps) { const [isOpen, setIsOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(null); const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null); const longPressTimer = useRef(null); const menuRef = useRef(null); const radius = size / 2; const itemRadius = 60; const centerSize = 64; // Calculate positions in thumb-friendly arc (180° from top-left to top-right, avoiding right side) const getItemPosition = (index: number) => { // Start from -135° (top-left) and spread to -45° (top-right) // This keeps all items in the reachable thumb zone const startAngle = -135; // Top-left const arcSpan = 180; // 180° arc (left side to top) const angle = startAngle + (arcSpan / (items.length - 1)) * index; const radian = (angle * Math.PI) / 180; const distance = radius - itemRadius; return { x: Math.cos(radian) * distance + radius - itemRadius / 2, y: Math.sin(radian) * distance + radius - itemRadius / 2, }; }; // Get angle from center to point (returns -180 to 180) const getAngleFromCenter = (x: number, y: number) => { const rect = menuRef.current?.getBoundingClientRect(); if (!rect) return 0; const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const dx = x - centerX; const dy = y - centerY; // atan2 returns -180 to 180, we want -90° to be top let angle = Math.atan2(dy, dx) * 180 / Math.PI; return angle; }; // Get selected item index based on angle (180° arc from top-left to top-right) const getSelectedItemIndex = (angle: number) => { // Map angle to our 180° arc (-135° to +45°) const startAngle = -135; const arcSpan = 180; const segmentAngle = arcSpan / (items.length - 1); // Normalize angle to -180 to 180 range let normalizedAngle = angle; while (normalizedAngle > 180) normalizedAngle -= 360; while (normalizedAngle < -180) normalizedAngle += 360; // Check if angle is within our arc if (normalizedAngle < startAngle || normalizedAngle > startAngle + arcSpan) { // Return closest edge item if outside arc if (normalizedAngle < startAngle) return 0; return items.length - 1; } // Calculate index within arc const relativeAngle = normalizedAngle - startAngle; const index = Math.round(relativeAngle / segmentAngle); return Math.max(0, Math.min(items.length - 1, index)); }; // Handle long press start const handleTouchStart = (e: React.TouchEvent) => { const touch = e.touches[0]; setTouchStart({ x: touch.clientX, y: touch.clientY }); longPressTimer.current = setTimeout(() => { setIsOpen(true); // Haptic feedback if available if (navigator.vibrate) { navigator.vibrate(50); } }, 400); // 400ms long press }; const handleMouseDown = (e: React.MouseEvent) => { setTouchStart({ x: e.clientX, y: e.clientY }); longPressTimer.current = setTimeout(() => { setIsOpen(true); }, 400); }; // Handle touch move - select item const handleTouchMove = (e: React.TouchEvent) => { if (!isOpen) return; const touch = e.touches[0]; const angle = getAngleFromCenter(touch.clientX, touch.clientY); const index = getSelectedItemIndex(angle); setSelectedIndex(index); }; const handleMouseMove = (e: React.MouseEvent) => { if (!isOpen) return; const angle = getAngleFromCenter(e.clientX, e.clientY); const index = getSelectedItemIndex(angle); setSelectedIndex(index); }; // Handle release - execute action const handleTouchEnd = () => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); } if (isOpen && selectedIndex !== null) { items[selectedIndex].onClick(); // Haptic feedback if (navigator.vibrate) { navigator.vibrate([30, 20, 30]); } } setIsOpen(false); setSelectedIndex(null); setTouchStart(null); }; const handleMouseUp = () => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); } if (isOpen && selectedIndex !== null) { items[selectedIndex].onClick(); } setIsOpen(false); setSelectedIndex(null); setTouchStart(null); }; // Cleanup on unmount useEffect(() => { return () => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); } }; }, []); return ( <> {/* Overlay */} {isOpen && (
)} {/* FAB Button */}
{/* Radial Menu Items */} {isOpen && (
{/* Center circle guide */}
{/* Menu Items */} {items.map((item, index) => { const pos = getItemPosition(index); const isSelected = selectedIndex === index; const Icon = item.icon; return (
{/* Item circle */}
{/* Label */} {item.label} {/* Selection indicator */} {isSelected && (
)}
); })} {/* Connection lines from center (optional visual enhancement) */} {items.map((_, index) => { const pos = getItemPosition(index); const isSelected = selectedIndex === index; return ( ); })}
)}
); } // Usage hint component export function RadialMenuHint() { const [show, setShow] = useState(true); useEffect(() => { const hasSeenHint = localStorage.getItem('radial-menu-hint-seen'); if (hasSeenHint) { setShow(false); } else { setTimeout(() => { setShow(false); localStorage.setItem('radial-menu-hint-seen', 'true'); }, 5000); } }, []); if (!show) return null; return (

Long press the + button
to open quick menu

); }