pattanshetti / src /components /RadialMenu.tsx
triflix's picture
Upload 99 files
4be2b2b verified
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<number | null>(null);
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null);
const longPressTimer = useRef<NodeJS.Timeout | null>(null);
const menuRef = useRef<HTMLButtonElement>(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 && (
<div
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
onTouchEnd={handleTouchEnd}
onMouseUp={handleMouseUp}
/>
)}
{/* FAB Button */}
<div className={cn("fixed bottom-20 right-4 md:bottom-8 md:right-8 z-50", className)}>
<button
ref={menuRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
className={cn(
"relative flex items-center justify-center",
"w-16 h-16 rounded-full",
"bg-primary text-primary-foreground",
"shadow-lg hover:shadow-xl",
"transition-all duration-200",
"touch-none select-none",
isOpen && "scale-110 shadow-2xl"
)}
style={{
WebkitTouchCallout: 'none',
WebkitUserSelect: 'none',
}}
>
<Plus className={cn(
"h-7 w-7 transition-transform duration-300",
isOpen && "rotate-45"
)} />
{/* Ripple animation on long press */}
{isOpen && (
<span className="absolute inset-0 rounded-full border-4 border-primary animate-ping" />
)}
</button>
{/* Radial Menu Items */}
{isOpen && (
<div
className="absolute"
style={{
width: size,
height: size,
left: -(size / 2) + 32,
bottom: -(size / 2) + 32,
}}
>
{/* Center circle guide */}
<div
className="absolute rounded-full border-2 border-primary/30 pointer-events-none"
style={{
width: centerSize,
height: centerSize,
left: radius - centerSize / 2,
top: radius - centerSize / 2,
}}
/>
{/* Menu Items */}
{items.map((item, index) => {
const pos = getItemPosition(index);
const isSelected = selectedIndex === index;
const Icon = item.icon;
return (
<div
key={index}
className={cn(
"absolute flex flex-col items-center justify-center gap-1",
"rounded-full transition-all duration-200",
"pointer-events-none"
)}
style={{
width: itemRadius,
height: itemRadius,
left: pos.x,
top: pos.y,
transform: `scale(${isSelected ? 1.3 : 1})`,
zIndex: isSelected ? 10 : 5,
}}
>
{/* Item circle */}
<div className={cn(
"w-14 h-14 rounded-full flex items-center justify-center",
"bg-card border-2 shadow-lg",
"transition-all duration-200",
isSelected ? `border-${item.color}-500 bg-${item.color}-50 shadow-${item.color}-500/50` : "border-border"
)}>
<Icon className={cn(
"h-6 w-6 transition-colors",
isSelected ? `text-${item.color}-600` : "text-foreground"
)} />
</div>
{/* Label */}
<span className={cn(
"text-xs font-semibold px-2 py-1 rounded-full",
"bg-card/90 backdrop-blur-sm shadow-md border whitespace-nowrap",
"transition-all duration-200",
isSelected ? `border-${item.color}-500 text-${item.color}-600` : "border-border text-foreground"
)}>
{item.label}
</span>
{/* Selection indicator */}
{isSelected && (
<div className="absolute inset-0 rounded-full animate-pulse">
<div className={`w-full h-full rounded-full border-4 border-${item.color}-500/50`} />
</div>
)}
</div>
);
})}
{/* Connection lines from center (optional visual enhancement) */}
<svg className="absolute inset-0 pointer-events-none opacity-20">
{items.map((_, index) => {
const pos = getItemPosition(index);
const isSelected = selectedIndex === index;
return (
<line
key={index}
x1={radius}
y1={radius}
x2={pos.x + itemRadius / 2}
y2={pos.y + itemRadius / 2}
stroke={isSelected ? "currentColor" : "#666"}
strokeWidth={isSelected ? "3" : "1"}
strokeDasharray={isSelected ? "0" : "4 4"}
className="transition-all duration-200"
/>
);
})}
</svg>
</div>
)}
</div>
</>
);
}
// 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 (
<div className="fixed bottom-36 right-4 md:bottom-24 md:right-8 z-40 animate-in fade-in slide-in-from-bottom-4">
<div className="bg-card border shadow-lg rounded-lg p-3 max-w-[200px]">
<p className="text-xs text-muted-foreground">
<span className="font-semibold text-foreground">Long press</span> the + button
<br />
to open quick menu
</p>
<div className="absolute -bottom-2 right-8 w-0 h-0 border-l-8 border-r-8 border-t-8 border-transparent border-t-card" />
</div>
</div>
);
}