@gfazioli/mantine-rings-progress 3.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,19 +18,24 @@
18
18
  ## Overview
19
19
 
20
20
  This component is created on top of the [Mantine](https://mantine.dev/) library.
21
+ It requires **Mantine 9.x** and **React 19**.
21
22
 
22
23
  [Mantine Rings Progress](https://gfazioli.github.io/mantine-rings-progress/) is a Mantine UI extension that renders multiple concentric ring progress indicators — inspired by the Apple Watch activity rings. Each ring is defined by a value and color, and the component wraps native Mantine `RingProgress` instances with rich customization options.
23
24
 
24
25
  ### Features
25
26
 
26
27
  - **Per-ring customization** — Override `thickness`, `roundCaps`, and `rootColor` on individual rings
28
+ - **Per-ring linear gradients** — Paint a ring's stroke with a two-stop gradient via `gradient: { from, to, deg? }`
29
+ - **Per-ring value labels** — `showValues` (or per-ring `showValue`) drops a styled pill at the endpoint of each arc, with custom `formatValue`
30
+ - **Per-ring interactions** — `onClick` and `onHover` callbacks with **geometric hit-testing**, so the right ring fires even when their wrappers overlap
27
31
  - **Entrance animation** — Animate rings from 0 to their target values on mount
28
32
  - **Staggered animation** — Animate rings one after another with configurable delay
33
+ - **Animated value changes** — `animateValueChanges` interpolates value updates after mount (great for live dashboards)
29
34
  - **Glow / neon effect** — `drop-shadow` glow that follows the ring shape, with per-ring intensity and color
30
35
  - **Pulse on completion** — Subtle pulse animation when a ring reaches 100%, with `onRingComplete` callback
31
36
  - **Start angle & direction** — Customize where rings start filling and in which direction (clockwise/counterclockwise)
32
37
  - **Unified tooltip** — `withTooltip` shows a chart-like tooltip with color swatches for all rings
33
- - **Accessibility** — `role="progressbar"` with ARIA attributes on each ring, `prefers-reduced-motion` support
38
+ - **Accessibility** — `role="progressbar"` with ARIA attributes on each ring, keyboard activation for clickable rings, `prefers-reduced-motion` support
34
39
  - **Central label** — Display any React node (text, emoji, component) centered in the rings
35
40
 
36
41
  > [!note]
@@ -1,9 +1,9 @@
1
1
  'use client';
2
2
  'use strict';
3
3
 
4
- var React = require('react');
5
4
  var core = require('@mantine/core');
6
5
  var hooks = require('@mantine/hooks');
6
+ var React = require('react');
7
7
  var RingsProgress_module = require('./RingsProgress.module.css.cjs');
8
8
 
9
9
  const defaultProps = {
@@ -11,7 +11,9 @@ const defaultProps = {
11
11
  thickness: 12,
12
12
  gap: 8,
13
13
  animate: false,
14
+ animateValueChanges: false,
14
15
  roundCaps: true,
16
+ showValues: false,
15
17
  transitionDuration: 0,
16
18
  rootColorAlpha: 0.15,
17
19
  staggerDelay: 0,
@@ -21,7 +23,7 @@ const defaultProps = {
21
23
  direction: "clockwise",
22
24
  withTooltip: false
23
25
  };
24
- const RingsProgress = core.factory((_props, ref) => {
26
+ const RingsProgress = core.factory((_props) => {
25
27
  const theme = core.useMantineTheme();
26
28
  const reduceMotion = hooks.useReducedMotion();
27
29
  const props = core.useProps("RingsProgress", defaultProps, _props);
@@ -33,6 +35,7 @@ const RingsProgress = core.factory((_props, ref) => {
33
35
  rootColorAlpha,
34
36
  label,
35
37
  animate,
38
+ animateValueChanges,
36
39
  transitionDuration,
37
40
  roundCaps,
38
41
  staggerDelay,
@@ -43,6 +46,8 @@ const RingsProgress = core.factory((_props, ref) => {
43
46
  direction,
44
47
  onRingComplete,
45
48
  withTooltip,
49
+ showValues,
50
+ formatValue,
46
51
  classNames,
47
52
  styles,
48
53
  unstyled,
@@ -58,6 +63,10 @@ const RingsProgress = core.factory((_props, ref) => {
58
63
  unstyled,
59
64
  vars
60
65
  });
66
+ const [instanceId] = React.useState(() => Math.random().toString(36).slice(2, 9));
67
+ const ringsRef = React.useRef(rings);
68
+ ringsRef.current = rings;
69
+ const ringCount = rings.length;
61
70
  const [mountedRings, setMountedRings] = React.useState(
62
71
  () => rings.map(() => !animate || reduceMotion)
63
72
  );
@@ -68,13 +77,13 @@ const RingsProgress = core.factory((_props, ref) => {
68
77
  }, []);
69
78
  React.useEffect(() => {
70
79
  if (!animate || reduceMotion) {
71
- setMountedRings(rings.map(() => true));
80
+ setMountedRings(Array.from({ length: ringCount }, () => true));
72
81
  return;
73
82
  }
74
- setMountedRings(rings.map(() => false));
83
+ setMountedRings(Array.from({ length: ringCount }, () => false));
75
84
  cleanupTimeouts();
76
85
  if (staggerDelay && staggerDelay > 0) {
77
- rings.forEach((_, index) => {
86
+ for (let index = 0; index < ringCount; index++) {
78
87
  const timeout = setTimeout(() => {
79
88
  setMountedRings((prev) => {
80
89
  const next = [...prev];
@@ -83,23 +92,25 @@ const RingsProgress = core.factory((_props, ref) => {
83
92
  });
84
93
  }, index * staggerDelay);
85
94
  timeoutsRef.current.push(timeout);
86
- });
95
+ }
87
96
  } else {
88
97
  requestAnimationFrame(() => {
89
- setMountedRings(rings.map(() => true));
98
+ setMountedRings(Array.from({ length: ringCount }, () => true));
90
99
  });
91
100
  }
92
101
  return cleanupTimeouts;
93
- }, [animate, reduceMotion, rings.length, staggerDelay]);
102
+ }, [animate, reduceMotion, ringCount, staggerDelay, cleanupTimeouts]);
94
103
  const prevValuesRef = React.useRef(rings.map((r) => r.value));
95
104
  const [pulsingRings, setPulsingRings] = React.useState(rings.map(() => false));
96
105
  const ringValuesKey = rings.map((r) => r.value).join(",");
97
106
  React.useEffect(() => {
98
- prevValuesRef.current = rings.map((r) => r.value);
99
- setPulsingRings(rings.map(() => false));
100
- }, [rings.length]);
107
+ const current = ringsRef.current;
108
+ prevValuesRef.current = current.map((r) => r.value);
109
+ setPulsingRings(current.map(() => false));
110
+ }, [ringCount]);
101
111
  React.useEffect(() => {
102
- const currentValues = rings.map((r) => r.value);
112
+ const current = ringsRef.current;
113
+ const currentValues = current.map((r) => r.value);
103
114
  const crossedComplete = currentValues.map((value, i) => {
104
115
  const prev = prevValuesRef.current[i] ?? 0;
105
116
  return value >= 100 && prev < 100;
@@ -107,7 +118,7 @@ const RingsProgress = core.factory((_props, ref) => {
107
118
  if (onRingComplete) {
108
119
  crossedComplete.forEach((crossed, i) => {
109
120
  if (crossed) {
110
- onRingComplete(i, rings[i]);
121
+ onRingComplete(i, current[i]);
111
122
  }
112
123
  });
113
124
  }
@@ -115,7 +126,20 @@ const RingsProgress = core.factory((_props, ref) => {
115
126
  setPulsingRings(crossedComplete);
116
127
  }
117
128
  prevValuesRef.current = currentValues;
118
- }, [ringValuesKey, pulseOnComplete, reduceMotion, onRingComplete, rings]);
129
+ }, [ringValuesKey, pulseOnComplete, reduceMotion, onRingComplete]);
130
+ const gradientDefs = rings.map((ring, index) => {
131
+ if (!ring.gradient) {
132
+ return null;
133
+ }
134
+ const id = `rp-grad-${instanceId}-${index}`;
135
+ const cssDeg = ring.gradient.deg ?? 0;
136
+ const svgDeg = cssDeg - 90;
137
+ const fromColor = core.parseThemeColor({ color: ring.gradient.from, theme }).value;
138
+ const toColor = core.parseThemeColor({ color: ring.gradient.to, theme }).value;
139
+ return { id, fromColor, toColor, svgDeg };
140
+ }).filter(
141
+ (g) => g !== null
142
+ );
119
143
  const handleAnimationEnd = React.useCallback((index) => {
120
144
  setPulsingRings((prev) => {
121
145
  const next = [...prev];
@@ -124,25 +148,115 @@ const RingsProgress = core.factory((_props, ref) => {
124
148
  });
125
149
  }, []);
126
150
  const allMounted = mountedRings.every(Boolean);
127
- const effectiveTransitionDuration = reduceMotion ? 0 : animate && !allMounted ? transitionDuration || 1e3 : transitionDuration;
151
+ const effectiveTransitionDuration = reduceMotion ? 0 : animate && !allMounted ? transitionDuration || 1e3 : animateValueChanges ? transitionDuration || 500 : transitionDuration;
128
152
  const offsets = [];
129
153
  let cumulativeOffset = 0;
130
154
  for (let i = 0; i < rings.length; i++) {
131
155
  offsets.push(cumulativeOffset);
132
156
  const ringThickness = rings[i].thickness ?? thickness;
133
- cumulativeOffset += ringThickness + gap;
157
+ cumulativeOffset += (ringThickness ?? 0) + (gap ?? 0);
134
158
  }
159
+ const hasInteractive = rings.some((r) => r.onClick || r.onHover);
160
+ const [hoveredIndex, setHoveredIndex] = React.useState(null);
161
+ const hoveredIndexRef = React.useRef(null);
162
+ hoveredIndexRef.current = hoveredIndex;
163
+ const ringAtPoint = React.useCallback(
164
+ (clientX, clientY, rect) => {
165
+ const cx = rect.left + rect.width / 2;
166
+ const cy = rect.top + rect.height / 2;
167
+ const r = Math.hypot(clientX - cx, clientY - cy);
168
+ const current = ringsRef.current;
169
+ for (let i = 0; i < current.length; i++) {
170
+ const t = current[i].thickness ?? thickness ?? 12;
171
+ const ringSize = (size ?? 120) - offsets[i] * 2;
172
+ const centerR = (ringSize * 0.9 - t * 2) / 2;
173
+ if (Math.abs(r - centerR) <= t / 2) {
174
+ return i;
175
+ }
176
+ }
177
+ return null;
178
+ },
179
+ // offsets is recomputed every render; capture by closure rather than listing it
180
+ // (size/thickness are stable refs through props).
181
+ // eslint-disable-next-line react-hooks/exhaustive-deps
182
+ [size, thickness, ringCount]
183
+ );
184
+ const handleContainerClick = React.useCallback(
185
+ (event) => {
186
+ if (!hasInteractive) {
187
+ return;
188
+ }
189
+ const rect = event.currentTarget.getBoundingClientRect();
190
+ const idx = ringAtPoint(event.clientX, event.clientY, rect);
191
+ if (idx === null) {
192
+ return;
193
+ }
194
+ const ring = ringsRef.current[idx];
195
+ ring.onClick?.(ring, idx);
196
+ },
197
+ [hasInteractive, ringAtPoint]
198
+ );
199
+ const handleContainerMouseMove = React.useCallback(
200
+ (event) => {
201
+ if (!hasInteractive) {
202
+ return;
203
+ }
204
+ const rect = event.currentTarget.getBoundingClientRect();
205
+ const idx = ringAtPoint(event.clientX, event.clientY, rect);
206
+ if (idx === hoveredIndexRef.current) {
207
+ return;
208
+ }
209
+ const prev = hoveredIndexRef.current;
210
+ if (prev !== null) {
211
+ const prevRing = ringsRef.current[prev];
212
+ prevRing.onHover?.(prevRing, prev, false);
213
+ }
214
+ setHoveredIndex(idx);
215
+ if (idx !== null) {
216
+ const newRing = ringsRef.current[idx];
217
+ newRing.onHover?.(newRing, idx, true);
218
+ }
219
+ },
220
+ [hasInteractive, ringAtPoint]
221
+ );
222
+ const handleContainerMouseLeave = React.useCallback(() => {
223
+ if (hoveredIndexRef.current === null) {
224
+ return;
225
+ }
226
+ const prev = hoveredIndexRef.current;
227
+ const prevRing = ringsRef.current[prev];
228
+ prevRing.onHover?.(prevRing, prev, false);
229
+ setHoveredIndex(null);
230
+ }, []);
231
+ const containerCursor = hoveredIndex !== null && rings[hoveredIndex]?.onClick ? "pointer" : void 0;
135
232
  const glowBlur = glow === true ? 6 : typeof glow === "number" ? glow : 0;
136
- const svgTransform = startAngle !== 0 || direction === "counterclockwise" ? `rotate(${ -90 + startAngle}deg)${direction === "counterclockwise" ? " scaleX(-1)" : ""}` : void 0;
233
+ const svgTransform = startAngle !== 0 || direction === "counterclockwise" ? `rotate(${ -90 + (startAngle ?? 0)}deg)${direction === "counterclockwise" ? " scaleX(-1)" : ""}` : void 0;
137
234
  const content = /* @__PURE__ */ React.createElement(
138
235
  core.Box,
139
236
  {
140
- ref,
141
- ...getStyles("root", { style: { width: size, height: size } }),
142
- role: "group",
143
- "aria-label": "Progress rings",
144
- ...others
237
+ ...getStyles("root", {
238
+ style: { width: size, height: size, cursor: containerCursor }
239
+ }),
240
+ ...others,
241
+ role: others.role ?? "group",
242
+ "aria-label": others["aria-label"] ?? (others["aria-labelledby"] ? void 0 : "Progress rings"),
243
+ onClick: hasInteractive ? handleContainerClick : others.onClick,
244
+ onMouseMove: hasInteractive ? handleContainerMouseMove : others.onMouseMove,
245
+ onMouseLeave: hasInteractive ? handleContainerMouseLeave : others.onMouseLeave,
246
+ "data-interactive": hasInteractive || void 0,
247
+ "data-hovered-ring": hoveredIndex ?? void 0
145
248
  },
249
+ 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(
250
+ "linearGradient",
251
+ {
252
+ key: g.id,
253
+ id: g.id,
254
+ gradientUnits: "objectBoundingBox",
255
+ gradientTransform: `rotate(${g.svgDeg}, 0.5, 0.5)`
256
+ },
257
+ /* @__PURE__ */ React.createElement("stop", { offset: "0%", stopColor: g.fromColor }),
258
+ /* @__PURE__ */ React.createElement("stop", { offset: "100%", stopColor: g.toColor })
259
+ )))),
146
260
  rings.map((ring, index) => {
147
261
  const {
148
262
  thickness: ringThicknessOverride,
@@ -151,6 +265,11 @@ const RingsProgress = core.factory((_props, ref) => {
151
265
  glowIntensity,
152
266
  glowColor,
153
267
  rootColor: ringRootColor,
268
+ onClick: ringOnClick,
269
+ onHover: _ringOnHover,
270
+ showValue: _ringShowValue,
271
+ formatValue: _ringFormatValue,
272
+ gradient: _ringGradient,
154
273
  ...ringSection
155
274
  } = ring;
156
275
  const parsedColor = core.parseThemeColor({ color: ring.color, theme });
@@ -162,22 +281,38 @@ const RingsProgress = core.factory((_props, ref) => {
162
281
  const ringGlowColor = glowColor ? core.parseThemeColor({ color: glowColor, theme }).value : parsedColor.value;
163
282
  const glowFilter = ringGlowBlur > 0 ? `drop-shadow(0 0 ${ringGlowBlur}px ${ringGlowColor})` : void 0;
164
283
  const { tooltip: _tooltip, ...sectionWithoutTooltip } = ringSection;
284
+ const sectionColor = ring.gradient ? `url(#rp-grad-${instanceId}-${index})` : sectionWithoutTooltip.color;
165
285
  const isPulsing = pulseOnComplete && pulsingRings[index];
286
+ const interactive = Boolean(ringOnClick);
287
+ const handleKeyDown = ringOnClick ? (event) => {
288
+ if (event.key === "Enter" || event.key === " ") {
289
+ event.preventDefault();
290
+ ringOnClick(ring, index);
291
+ }
292
+ } : void 0;
166
293
  return /* @__PURE__ */ React.createElement(
167
294
  core.RingProgress,
168
295
  {
169
296
  key: index,
170
- rootColor: ringRootColor ? core.parseThemeColor({ color: ringRootColor, theme }).value : core.alpha(parsedColor.value, rootColorAlpha),
171
- size: size - offsets[index] * 2,
297
+ rootColor: ringRootColor ? core.parseThemeColor({ color: ringRootColor, theme }).value : core.alpha(parsedColor.value, rootColorAlpha ?? 0.1),
298
+ size: (size ?? 0) - offsets[index] * 2,
172
299
  thickness: ringThickness,
173
300
  roundCaps: ringRoundCaps,
174
301
  transitionDuration: effectiveTransitionDuration,
175
- sections: [{ ...sectionWithoutTooltip, value: effectiveValue }],
176
- role: "progressbar",
302
+ sections: [
303
+ {
304
+ ...sectionWithoutTooltip,
305
+ value: effectiveValue,
306
+ color: sectionColor
307
+ }
308
+ ],
309
+ role: interactive ? "button" : "progressbar",
177
310
  "aria-valuenow": Math.round(ring.value),
178
311
  "aria-valuemin": 0,
179
312
  "aria-valuemax": 100,
180
313
  "aria-label": ringAriaLabel,
314
+ tabIndex: interactive ? 0 : void 0,
315
+ onKeyDown: handleKeyDown,
181
316
  styles: glowFilter || svgTransform ? {
182
317
  svg: {
183
318
  ...glowFilter ? { filter: glowFilter } : {},
@@ -196,6 +331,42 @@ const RingsProgress = core.factory((_props, ref) => {
196
331
  }
197
332
  );
198
333
  }),
334
+ (showValues || rings.some((r) => r.showValue)) && rings.map((ring, index) => {
335
+ const shouldShow = ring.showValue ?? showValues;
336
+ if (!shouldShow) {
337
+ return null;
338
+ }
339
+ const ringT = ring.thickness ?? thickness ?? 12;
340
+ const ringSize = (size ?? 120) - offsets[index] * 2;
341
+ const ringR = (ringSize * 0.9 - ringT * 2) / 2;
342
+ const clampedValue = Math.max(0, Math.min(100, ring.value));
343
+ const directionMultiplier = direction === "counterclockwise" ? -1 : 1;
344
+ const endAngleDeg = (startAngle ?? 0) + clampedValue / 100 * 360 * directionMultiplier;
345
+ const angleRad = endAngleDeg * Math.PI / 180;
346
+ const center = (size ?? 120) / 2;
347
+ const x = center + ringR * Math.sin(angleRad);
348
+ const y = center - ringR * Math.cos(angleRad);
349
+ const formatter = ring.formatValue ?? formatValue ?? ((v) => `${Math.round(v)}%`);
350
+ const labelColor = core.parseThemeColor({ color: ring.color, theme }).value;
351
+ return /* @__PURE__ */ React.createElement(
352
+ core.Box,
353
+ {
354
+ key: `value-${index}`,
355
+ ...getStyles("valueLabel", {
356
+ style: {
357
+ position: "absolute",
358
+ left: x,
359
+ top: y,
360
+ transform: "translate(-50%, -50%)",
361
+ pointerEvents: "none",
362
+ color: labelColor
363
+ }
364
+ }),
365
+ "data-ring-index": index
366
+ },
367
+ formatter(ring.value)
368
+ );
369
+ }),
199
370
  label && /* @__PURE__ */ React.createElement(core.Box, { ...getStyles("label") }, label)
200
371
  );
201
372
  if (withTooltip) {