@gfazioli/mantine-rings-progress
Version:
A Mantine 9 concentric ring progress component inspired by Apple Watch activity rings — animated, glowing, accessible, with per-ring customization.
385 lines (382 loc) • 14.6 kB
JavaScript
'use client';
import { factory, useMantineTheme, useProps, useStyles, parseThemeColor, Box, RingProgress, alpha, Stack, Group, ColorSwatch, Text, Tooltip } from '@mantine/core';
import { useReducedMotion } from '@mantine/hooks';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import classes from './RingsProgress.module.css.mjs';
const defaultProps = {
size: 120,
thickness: 12,
gap: 8,
animate: false,
animateValueChanges: false,
roundCaps: true,
showValues: false,
transitionDuration: 0,
rootColorAlpha: 0.15,
staggerDelay: 0,
glow: false,
pulseOnComplete: false,
startAngle: 0,
direction: "clockwise",
withTooltip: false
};
const RingsProgress = factory((_props) => {
const theme = useMantineTheme();
const reduceMotion = useReducedMotion();
const props = useProps("RingsProgress", defaultProps, _props);
const {
rings,
size,
thickness,
gap,
rootColorAlpha,
label,
animate,
animateValueChanges,
transitionDuration,
roundCaps,
staggerDelay,
tooltipProps: globalTooltipProps,
glow,
pulseOnComplete,
startAngle,
direction,
onRingComplete,
withTooltip,
showValues,
formatValue,
classNames,
styles,
unstyled,
vars,
...others
} = props;
const getStyles = useStyles({
name: "RingsProgress",
props,
classes,
classNames,
styles,
unstyled,
vars
});
const [instanceId] = useState(() => Math.random().toString(36).slice(2, 9));
const ringsRef = useRef(rings);
ringsRef.current = rings;
const ringCount = rings.length;
const [mountedRings, setMountedRings] = useState(
() => rings.map(() => !animate || reduceMotion)
);
const timeoutsRef = useRef([]);
const cleanupTimeouts = useCallback(() => {
timeoutsRef.current.forEach(clearTimeout);
timeoutsRef.current = [];
}, []);
useEffect(() => {
if (!animate || reduceMotion) {
setMountedRings(Array.from({ length: ringCount }, () => true));
return;
}
setMountedRings(Array.from({ length: ringCount }, () => false));
cleanupTimeouts();
if (staggerDelay && staggerDelay > 0) {
for (let index = 0; index < ringCount; index++) {
const timeout = setTimeout(() => {
setMountedRings((prev) => {
const next = [...prev];
next[index] = true;
return next;
});
}, index * staggerDelay);
timeoutsRef.current.push(timeout);
}
} else {
requestAnimationFrame(() => {
setMountedRings(Array.from({ length: ringCount }, () => true));
});
}
return cleanupTimeouts;
}, [animate, reduceMotion, ringCount, staggerDelay, cleanupTimeouts]);
const prevValuesRef = useRef(rings.map((r) => r.value));
const [pulsingRings, setPulsingRings] = useState(rings.map(() => false));
const ringValuesKey = rings.map((r) => r.value).join(",");
useEffect(() => {
const current = ringsRef.current;
prevValuesRef.current = current.map((r) => r.value);
setPulsingRings(current.map(() => false));
}, [ringCount]);
useEffect(() => {
const current = ringsRef.current;
const currentValues = current.map((r) => r.value);
const crossedComplete = currentValues.map((value, i) => {
const prev = prevValuesRef.current[i] ?? 0;
return value >= 100 && prev < 100;
});
if (onRingComplete) {
crossedComplete.forEach((crossed, i) => {
if (crossed) {
onRingComplete(i, current[i]);
}
});
}
if (pulseOnComplete && !reduceMotion && crossedComplete.some(Boolean)) {
setPulsingRings(crossedComplete);
}
prevValuesRef.current = currentValues;
}, [ringValuesKey, pulseOnComplete, reduceMotion, onRingComplete]);
const gradientDefs = rings.map((ring, index) => {
if (!ring.gradient) {
return null;
}
const id = `rp-grad-${instanceId}-${index}`;
const cssDeg = ring.gradient.deg ?? 0;
const svgDeg = cssDeg - 90;
const fromColor = parseThemeColor({ color: ring.gradient.from, theme }).value;
const toColor = parseThemeColor({ color: ring.gradient.to, theme }).value;
return { id, fromColor, toColor, svgDeg };
}).filter(
(g) => g !== null
);
const handleAnimationEnd = useCallback((index) => {
setPulsingRings((prev) => {
const next = [...prev];
next[index] = false;
return next;
});
}, []);
const allMounted = mountedRings.every(Boolean);
const effectiveTransitionDuration = reduceMotion ? 0 : animate && !allMounted ? transitionDuration || 1e3 : animateValueChanges ? transitionDuration || 500 : transitionDuration;
const offsets = [];
let cumulativeOffset = 0;
for (let i = 0; i < rings.length; i++) {
offsets.push(cumulativeOffset);
const ringThickness = rings[i].thickness ?? thickness;
cumulativeOffset += (ringThickness ?? 0) + (gap ?? 0);
}
const hasInteractive = rings.some((r) => r.onClick || r.onHover);
const [hoveredIndex, setHoveredIndex] = useState(null);
const hoveredIndexRef = useRef(null);
hoveredIndexRef.current = hoveredIndex;
const ringAtPoint = useCallback(
(clientX, clientY, rect) => {
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const r = Math.hypot(clientX - cx, clientY - cy);
const current = ringsRef.current;
for (let i = 0; i < current.length; i++) {
const t = current[i].thickness ?? thickness ?? 12;
const ringSize = (size ?? 120) - offsets[i] * 2;
const centerR = (ringSize * 0.9 - t * 2) / 2;
if (Math.abs(r - centerR) <= t / 2) {
return i;
}
}
return null;
},
// offsets is recomputed every render; capture by closure rather than listing it
// (size/thickness are stable refs through props).
// eslint-disable-next-line react-hooks/exhaustive-deps
[size, thickness, ringCount]
);
const handleContainerClick = useCallback(
(event) => {
if (!hasInteractive) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const idx = ringAtPoint(event.clientX, event.clientY, rect);
if (idx === null) {
return;
}
const ring = ringsRef.current[idx];
ring.onClick?.(ring, idx);
},
[hasInteractive, ringAtPoint]
);
const handleContainerMouseMove = useCallback(
(event) => {
if (!hasInteractive) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const idx = ringAtPoint(event.clientX, event.clientY, rect);
if (idx === hoveredIndexRef.current) {
return;
}
const prev = hoveredIndexRef.current;
if (prev !== null) {
const prevRing = ringsRef.current[prev];
prevRing.onHover?.(prevRing, prev, false);
}
setHoveredIndex(idx);
if (idx !== null) {
const newRing = ringsRef.current[idx];
newRing.onHover?.(newRing, idx, true);
}
},
[hasInteractive, ringAtPoint]
);
const handleContainerMouseLeave = useCallback(() => {
if (hoveredIndexRef.current === null) {
return;
}
const prev = hoveredIndexRef.current;
const prevRing = ringsRef.current[prev];
prevRing.onHover?.(prevRing, prev, false);
setHoveredIndex(null);
}, []);
const containerCursor = hoveredIndex !== null && rings[hoveredIndex]?.onClick ? "pointer" : void 0;
const glowBlur = glow === true ? 6 : typeof glow === "number" ? glow : 0;
const svgTransform = startAngle !== 0 || direction === "counterclockwise" ? `rotate(${ -90 + (startAngle ?? 0)}deg)${direction === "counterclockwise" ? " scaleX(-1)" : ""}` : void 0;
const content = /* @__PURE__ */ React.createElement(
Box,
{
...getStyles("root", {
style: { width: size, height: size, cursor: containerCursor }
}),
...others,
role: others.role ?? "group",
"aria-label": others["aria-label"] ?? (others["aria-labelledby"] ? void 0 : "Progress rings"),
onClick: hasInteractive ? handleContainerClick : others.onClick,
onMouseMove: hasInteractive ? handleContainerMouseMove : others.onMouseMove,
onMouseLeave: hasInteractive ? handleContainerMouseLeave : others.onMouseLeave,
"data-interactive": hasInteractive || void 0,
"data-hovered-ring": hoveredIndex ?? void 0
},
gradientDefs.length > 0 && /* @__PURE__ */ React.createElement("svg", { "aria-hidden": true, style: { position: "absolute", width: 0, height: 0, overflow: "hidden" } }, /* @__PURE__ */ React.createElement("defs", null, gradientDefs.map((g) => /* @__PURE__ */ React.createElement(
"linearGradient",
{
key: g.id,
id: g.id,
gradientUnits: "objectBoundingBox",
gradientTransform: `rotate(${g.svgDeg}, 0.5, 0.5)`
},
/* @__PURE__ */ React.createElement("stop", { offset: "0%", stopColor: g.fromColor }),
/* @__PURE__ */ React.createElement("stop", { offset: "100%", stopColor: g.toColor })
)))),
rings.map((ring, index) => {
const {
thickness: ringThicknessOverride,
roundCaps: ringRoundCapsOverride,
ariaLabel: ringAriaLabelOverride,
glowIntensity,
glowColor,
rootColor: ringRootColor,
onClick: ringOnClick,
onHover: _ringOnHover,
showValue: _ringShowValue,
formatValue: _ringFormatValue,
gradient: _ringGradient,
...ringSection
} = ring;
const parsedColor = parseThemeColor({ color: ring.color, theme });
const effectiveValue = animate && !mountedRings[index] ? 0 : ring.value;
const ringThickness = ringThicknessOverride ?? thickness;
const ringRoundCaps = ringRoundCapsOverride ?? roundCaps;
const ringAriaLabel = ringAriaLabelOverride ?? `Ring ${index + 1}: ${Math.round(ring.value)}%`;
const ringGlowBlur = glowIntensity ?? glowBlur;
const ringGlowColor = glowColor ? parseThemeColor({ color: glowColor, theme }).value : parsedColor.value;
const glowFilter = ringGlowBlur > 0 ? `drop-shadow(0 0 ${ringGlowBlur}px ${ringGlowColor})` : void 0;
const { tooltip: _tooltip, ...sectionWithoutTooltip } = ringSection;
const sectionColor = ring.gradient ? `url(#rp-grad-${instanceId}-${index})` : sectionWithoutTooltip.color;
const isPulsing = pulseOnComplete && pulsingRings[index];
const interactive = Boolean(ringOnClick);
const handleKeyDown = ringOnClick ? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
ringOnClick(ring, index);
}
} : void 0;
return /* @__PURE__ */ React.createElement(
RingProgress,
{
key: index,
rootColor: ringRootColor ? parseThemeColor({ color: ringRootColor, theme }).value : alpha(parsedColor.value, rootColorAlpha ?? 0.1),
size: (size ?? 0) - offsets[index] * 2,
thickness: ringThickness,
roundCaps: ringRoundCaps,
transitionDuration: effectiveTransitionDuration,
sections: [
{
...sectionWithoutTooltip,
value: effectiveValue,
color: sectionColor
}
],
role: interactive ? "button" : "progressbar",
"aria-valuenow": Math.round(ring.value),
"aria-valuemin": 0,
"aria-valuemax": 100,
"aria-label": ringAriaLabel,
tabIndex: interactive ? 0 : void 0,
onKeyDown: handleKeyDown,
styles: glowFilter || svgTransform ? {
svg: {
...glowFilter ? { filter: glowFilter } : {},
...svgTransform ? { transform: svgTransform } : {}
}
} : void 0,
...getStyles("ring", {
style: {
position: "absolute",
top: offsets[index],
left: offsets[index]
}
}),
"data-pulsing": isPulsing || void 0,
onAnimationEnd: isPulsing ? () => handleAnimationEnd(index) : void 0
}
);
}),
(showValues || rings.some((r) => r.showValue)) && rings.map((ring, index) => {
const shouldShow = ring.showValue ?? showValues;
if (!shouldShow) {
return null;
}
const ringT = ring.thickness ?? thickness ?? 12;
const ringSize = (size ?? 120) - offsets[index] * 2;
const ringR = (ringSize * 0.9 - ringT * 2) / 2;
const clampedValue = Math.max(0, Math.min(100, ring.value));
const directionMultiplier = direction === "counterclockwise" ? -1 : 1;
const endAngleDeg = (startAngle ?? 0) + clampedValue / 100 * 360 * directionMultiplier;
const angleRad = endAngleDeg * Math.PI / 180;
const center = (size ?? 120) / 2;
const x = center + ringR * Math.sin(angleRad);
const y = center - ringR * Math.cos(angleRad);
const formatter = ring.formatValue ?? formatValue ?? ((v) => `${Math.round(v)}%`);
const labelColor = parseThemeColor({ color: ring.color, theme }).value;
return /* @__PURE__ */ React.createElement(
Box,
{
key: `value-${index}`,
...getStyles("valueLabel", {
style: {
position: "absolute",
left: x,
top: y,
transform: "translate(-50%, -50%)",
pointerEvents: "none",
color: labelColor
}
}),
"data-ring-index": index
},
formatter(ring.value)
);
}),
label && /* @__PURE__ */ React.createElement(Box, { ...getStyles("label") }, label)
);
if (withTooltip) {
const tooltipLabel = /* @__PURE__ */ React.createElement(Stack, { gap: 4 }, rings.map((ring, index) => {
const parsedColor = parseThemeColor({ color: ring.color, theme });
const tooltipContent = ring.tooltip ?? `${Math.round(ring.value)}%`;
return /* @__PURE__ */ React.createElement(Group, { key: index, gap: "xs", wrap: "nowrap" }, /* @__PURE__ */ React.createElement(ColorSwatch, { color: parsedColor.value, size: 12, withShadow: false }), typeof tooltipContent === "string" ? /* @__PURE__ */ React.createElement(Text, { size: "xs" }, tooltipContent) : tooltipContent);
}));
return /* @__PURE__ */ React.createElement(Tooltip.Floating, { label: tooltipLabel, ...globalTooltipProps }, content);
}
return content;
});
RingsProgress.classes = classes;
RingsProgress.displayName = "RingsProgress";
export { RingsProgress };
//# sourceMappingURL=RingsProgress.mjs.map