Add a RadialMenu component (#4096)

This commit is contained in:
Jonathan Dobson
2025-11-25 14:35:57 -05:00
committed by GitHub
parent 285694cefe
commit 0a12f4dfb8
3 changed files with 359 additions and 38 deletions

View File

@@ -0,0 +1,233 @@
import { useState, useRef, useEffect, ReactNode } from "react";
export interface RadialMenuItem {
id: string;
enabled?: boolean;
hidden?: boolean;
text?: string;
icon: React.ReactNode;
onClick: () => void;
}
interface RadialMenuProps {
items: RadialMenuItem[];
children: ReactNode;
/**
* The size of the buttons in CSS pixel units (e.g., "40px"). If not provided,
* defaults to "40px".
*/
buttonSize?: string;
/**
* The gap between items in degrees. If not provided, items are evenly spaced
* around the circle.
*/
gap?: number;
/**
* The radius of the radial menu, in CSS pixel units (e.g., "100px").
*/
radius?: string;
/**
* The starting angle offset in degrees for the first item. If not provided,
* defaults to 0 degrees (top of the circle).
*/
startAt?: number;
/**
* If true, rotates the text so its baseline runs parallel to the radial line.
*/
rotateText?: boolean;
}
const proportionalAngle = (
index: number,
numItems: number,
startAtDegrees: number = 0,
) => {
const normalizedStart = ((startAtDegrees % 360) + 360) % 360;
const startRadians = (normalizedStart * Math.PI) / 180;
const angleStep = (2 * Math.PI) / numItems;
const angle = angleStep * index - Math.PI / 2 + startRadians;
return angle;
};
const gappedAngle = (
index: number,
gapDegrees: number,
startAtDegrees: number = 0,
) => {
const normalizedGap = ((gapDegrees % 360) + 360) % 360;
const normalizedStart = ((startAtDegrees % 360) + 360) % 360;
const gapRadians = (normalizedGap * Math.PI) / 180;
const startRadians = (normalizedStart * Math.PI) / 180;
// each item is the previous item's angle + gap tart from top (-PI/2) + startAt offset,
// then add gap * index
const angle = -Math.PI / 2 + startRadians + index * gapRadians;
return angle;
};
export function RadialMenu({
items,
children,
buttonSize,
radius,
gap,
startAt,
rotateText,
}: RadialMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [calculatedRadius, setCalculatedRadius] = useState<number>(100);
const wrapperRef = useRef<HTMLDivElement>(null);
const dom = {
root: useRef<HTMLDivElement>(null),
};
// effect to calculate radius based on wrapped component size if not provided
useEffect(() => {
if (!radius && wrapperRef.current) {
const { width, height } = wrapperRef.current.getBoundingClientRect();
const minDimension = Math.min(width, height);
setCalculatedRadius(minDimension);
} else if (radius) {
const numericRadius = parseFloat(radius);
setCalculatedRadius(numericRadius);
}
}, [radius, children]);
const radiusValue = radius || `${calculatedRadius}px`;
const numRadius = parseFloat(radiusValue);
const visibleItems = items.filter((item) => !item.hidden);
const padSize = numRadius * 2.75;
return (
<div
ref={dom.root}
className="relative z-[1000000]"
onMouseLeave={(e) => {
// only close if we're leaving the entire component area
const rect = dom.root.current?.getBoundingClientRect();
if (!rect) return;
const { clientX, clientY } = e;
const padding = padSize / 2;
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// if mouse is outside the pad area, we deactivate
const distance = Math.sqrt(
Math.pow(clientX - centerX, 2) + Math.pow(clientY - centerY, 2),
);
if (distance > padding) {
setIsOpen(false);
}
}}
>
{/* a pad (buffer) to increase the deactivation area when leaving the component */}
<div
className="absolute left-1/2 top-1/2 z-10"
style={{
width: `${padSize}px`,
height: `${padSize}px`,
transform: "translate(-50%, -50%)",
}}
/>
<div
ref={wrapperRef}
className="relative z-20"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsOpen(!isOpen);
}}
onMouseEnter={() => {
setIsOpen(true);
}}
>
<div className="pointer-events-none">{children}</div>
</div>
{visibleItems.map((item, index) => {
const angle =
gap !== undefined
? gappedAngle(index, gap, startAt)
: proportionalAngle(index, visibleItems.length, startAt);
const x = Math.cos(angle) * parseFloat(radiusValue);
const y = Math.sin(angle) * parseFloat(radiusValue);
const isEnabled = item.enabled !== false;
// calculate text offset along the radial line
const textDistance = 0.375 * numRadius;
const textX = Math.cos(angle) * textDistance;
const textY = Math.sin(angle) * textDistance;
// convert angle from radians to degrees for CSS rotation
const angleDegrees = (angle * 180) / Math.PI;
// normalize angle to 0-360 range
const normalizedAngle = ((angleDegrees % 360) + 360) % 360;
// flip text if it would be upside-down (between 90° and 270°)
const textTransform =
normalizedAngle > 90 && normalizedAngle < 270
? "scaleY(-1) scaleX(-1)"
: "scaleY(1)";
return (
<>
<button
key={item.id}
onClick={() => {
item.onClick();
setIsOpen(false);
}}
disabled={!isEnabled}
className="absolute left-1/2 top-1/2 z-30 flex items-center justify-center rounded-full bg-white shadow-lg transition-all duration-300 ease-out hover:bg-gray-50 disabled:cursor-not-allowed"
style={{
width: buttonSize ?? "40px",
height: buttonSize ?? "40px",
transform: isOpen
? `translate(-50%, -50%) translate(${x}px, ${y}px) scale(1)`
: "translate(-50%, -50%) translate(0, 0) scale(0)",
opacity: isOpen ? (isEnabled ? 1 : 0.5) : 0,
pointerEvents: isOpen ? "auto" : "none",
transitionDelay: isOpen ? `${index * 50}ms` : "0ms",
}}
>
<div className="text-gray-700">{item.icon}</div>
</button>
{item.text && (
<div
key={`${item.id}-text`}
className="absolute left-1/2 top-1/2 z-30 whitespace-nowrap rounded bg-white px-2 py-1 text-xs text-gray-700 shadow-md transition-all duration-300 ease-out"
style={{
transform: isOpen
? rotateText
? `translate(0%, -50%) translate(${x + textX}px, ${y + textY}px) rotate(${angleDegrees}deg) scale(1)`
: `translate(0%, -50%) translate(${x + textX}px, ${y + textY}px) scale(1)`
: "translate(0%, -50%) translate(0, 0) scale(0.5)",
opacity: isOpen ? (isEnabled ? 1 : 0.5) : 0,
transformOrigin: "left center",
pointerEvents: "none",
transitionDelay: isOpen ? `${index * 50}ms` : "0ms",
}}
>
<span
style={{
display: "inline-block",
transform: textTransform,
opacity: isEnabled ? 1 : 0.5,
cursor: isEnabled ? "default" : "not-allowed",
}}
>
{item.text}
</span>
</div>
)}
</>
);
})}
</div>
);
}