@gfazioli/mantine-rings-progress 4.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
@@ -25,13 +25,17 @@ It requires **Mantine 9.x** and **React 19**.
25
25
  ### Features
26
26
 
27
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
28
31
  - **Entrance animation** — Animate rings from 0 to their target values on mount
29
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)
30
34
  - **Glow / neon effect** — `drop-shadow` glow that follows the ring shape, with per-ring intensity and color
31
35
  - **Pulse on completion** — Subtle pulse animation when a ring reaches 100%, with `onRingComplete` callback
32
36
  - **Start angle & direction** — Customize where rings start filling and in which direction (clockwise/counterclockwise)
33
37
  - **Unified tooltip** — `withTooltip` shows a chart-like tooltip with color swatches for all rings
34
- - **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
35
39
  - **Central label** — Display any React node (text, emoji, component) centered in the rings
36
40
 
37
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,
@@ -33,6 +35,7 @@ const RingsProgress = core.factory((_props) => {
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) => {
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) => {
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
  );
@@ -67,15 +76,14 @@ const RingsProgress = core.factory((_props) => {
67
76
  timeoutsRef.current = [];
68
77
  }, []);
69
78
  React.useEffect(() => {
70
- const ringCount2 = rings.length;
71
79
  if (!animate || reduceMotion) {
72
- setMountedRings(Array.from({ length: ringCount2 }, () => true));
80
+ setMountedRings(Array.from({ length: ringCount }, () => true));
73
81
  return;
74
82
  }
75
- setMountedRings(Array.from({ length: ringCount2 }, () => false));
83
+ setMountedRings(Array.from({ length: ringCount }, () => false));
76
84
  cleanupTimeouts();
77
85
  if (staggerDelay && staggerDelay > 0) {
78
- for (let index = 0; index < ringCount2; index++) {
86
+ for (let index = 0; index < ringCount; index++) {
79
87
  const timeout = setTimeout(() => {
80
88
  setMountedRings((prev) => {
81
89
  const next = [...prev];
@@ -87,21 +95,22 @@ const RingsProgress = core.factory((_props) => {
87
95
  }
88
96
  } else {
89
97
  requestAnimationFrame(() => {
90
- setMountedRings(Array.from({ length: ringCount2 }, () => true));
98
+ setMountedRings(Array.from({ length: ringCount }, () => true));
91
99
  });
92
100
  }
93
101
  return cleanupTimeouts;
94
- }, [animate, reduceMotion, rings, staggerDelay, cleanupTimeouts]);
102
+ }, [animate, reduceMotion, ringCount, staggerDelay, cleanupTimeouts]);
95
103
  const prevValuesRef = React.useRef(rings.map((r) => r.value));
96
104
  const [pulsingRings, setPulsingRings] = React.useState(rings.map(() => false));
97
105
  const ringValuesKey = rings.map((r) => r.value).join(",");
98
- const ringCount = rings.length;
99
106
  React.useEffect(() => {
100
- prevValuesRef.current = rings.map((r) => r.value);
101
- setPulsingRings(rings.map(() => false));
107
+ const current = ringsRef.current;
108
+ prevValuesRef.current = current.map((r) => r.value);
109
+ setPulsingRings(current.map(() => false));
102
110
  }, [ringCount]);
103
111
  React.useEffect(() => {
104
- const currentValues = rings.map((r) => r.value);
112
+ const current = ringsRef.current;
113
+ const currentValues = current.map((r) => r.value);
105
114
  const crossedComplete = currentValues.map((value, i) => {
106
115
  const prev = prevValuesRef.current[i] ?? 0;
107
116
  return value >= 100 && prev < 100;
@@ -109,7 +118,7 @@ const RingsProgress = core.factory((_props) => {
109
118
  if (onRingComplete) {
110
119
  crossedComplete.forEach((crossed, i) => {
111
120
  if (crossed) {
112
- onRingComplete(i, rings[i]);
121
+ onRingComplete(i, current[i]);
113
122
  }
114
123
  });
115
124
  }
@@ -117,7 +126,20 @@ const RingsProgress = core.factory((_props) => {
117
126
  setPulsingRings(crossedComplete);
118
127
  }
119
128
  prevValuesRef.current = currentValues;
120
- }, [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
+ );
121
143
  const handleAnimationEnd = React.useCallback((index) => {
122
144
  setPulsingRings((prev) => {
123
145
  const next = [...prev];
@@ -126,7 +148,7 @@ const RingsProgress = core.factory((_props) => {
126
148
  });
127
149
  }, []);
128
150
  const allMounted = mountedRings.every(Boolean);
129
- const effectiveTransitionDuration = reduceMotion ? 0 : animate && !allMounted ? transitionDuration || 1e3 : transitionDuration;
151
+ const effectiveTransitionDuration = reduceMotion ? 0 : animate && !allMounted ? transitionDuration || 1e3 : animateValueChanges ? transitionDuration || 500 : transitionDuration;
130
152
  const offsets = [];
131
153
  let cumulativeOffset = 0;
132
154
  for (let i = 0; i < rings.length; i++) {
@@ -134,16 +156,107 @@ const RingsProgress = core.factory((_props) => {
134
156
  const ringThickness = rings[i].thickness ?? thickness;
135
157
  cumulativeOffset += (ringThickness ?? 0) + (gap ?? 0);
136
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;
137
232
  const glowBlur = glow === true ? 6 : typeof glow === "number" ? glow : 0;
138
233
  const svgTransform = startAngle !== 0 || direction === "counterclockwise" ? `rotate(${ -90 + (startAngle ?? 0)}deg)${direction === "counterclockwise" ? " scaleX(-1)" : ""}` : void 0;
139
234
  const content = /* @__PURE__ */ React.createElement(
140
235
  core.Box,
141
236
  {
142
- ...getStyles("root", { style: { width: size, height: size } }),
237
+ ...getStyles("root", {
238
+ style: { width: size, height: size, cursor: containerCursor }
239
+ }),
143
240
  ...others,
144
241
  role: others.role ?? "group",
145
- "aria-label": others["aria-label"] ?? (others["aria-labelledby"] ? void 0 : "Progress rings")
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
146
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
+ )))),
147
260
  rings.map((ring, index) => {
148
261
  const {
149
262
  thickness: ringThicknessOverride,
@@ -152,6 +265,11 @@ const RingsProgress = core.factory((_props) => {
152
265
  glowIntensity,
153
266
  glowColor,
154
267
  rootColor: ringRootColor,
268
+ onClick: ringOnClick,
269
+ onHover: _ringOnHover,
270
+ showValue: _ringShowValue,
271
+ formatValue: _ringFormatValue,
272
+ gradient: _ringGradient,
155
273
  ...ringSection
156
274
  } = ring;
157
275
  const parsedColor = core.parseThemeColor({ color: ring.color, theme });
@@ -163,7 +281,15 @@ const RingsProgress = core.factory((_props) => {
163
281
  const ringGlowColor = glowColor ? core.parseThemeColor({ color: glowColor, theme }).value : parsedColor.value;
164
282
  const glowFilter = ringGlowBlur > 0 ? `drop-shadow(0 0 ${ringGlowBlur}px ${ringGlowColor})` : void 0;
165
283
  const { tooltip: _tooltip, ...sectionWithoutTooltip } = ringSection;
284
+ const sectionColor = ring.gradient ? `url(#rp-grad-${instanceId}-${index})` : sectionWithoutTooltip.color;
166
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;
167
293
  return /* @__PURE__ */ React.createElement(
168
294
  core.RingProgress,
169
295
  {
@@ -173,12 +299,20 @@ const RingsProgress = core.factory((_props) => {
173
299
  thickness: ringThickness,
174
300
  roundCaps: ringRoundCaps,
175
301
  transitionDuration: effectiveTransitionDuration,
176
- sections: [{ ...sectionWithoutTooltip, value: effectiveValue }],
177
- role: "progressbar",
302
+ sections: [
303
+ {
304
+ ...sectionWithoutTooltip,
305
+ value: effectiveValue,
306
+ color: sectionColor
307
+ }
308
+ ],
309
+ role: interactive ? "button" : "progressbar",
178
310
  "aria-valuenow": Math.round(ring.value),
179
311
  "aria-valuemin": 0,
180
312
  "aria-valuemax": 100,
181
313
  "aria-label": ringAriaLabel,
314
+ tabIndex: interactive ? 0 : void 0,
315
+ onKeyDown: handleKeyDown,
182
316
  styles: glowFilter || svgTransform ? {
183
317
  svg: {
184
318
  ...glowFilter ? { filter: glowFilter } : {},
@@ -197,6 +331,42 @@ const RingsProgress = core.factory((_props) => {
197
331
  }
198
332
  );
199
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
+ }),
200
370
  label && /* @__PURE__ */ React.createElement(core.Box, { ...getStyles("label") }, label)
201
371
  );
202
372
  if (withTooltip) {