@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.
@@ -1,7 +1,7 @@
1
1
  'use client';
2
- import React, { useState, useRef, useCallback, useEffect } from 'react';
3
- import { factory, useMantineTheme, useProps, useStyles, Box, parseThemeColor, RingProgress, alpha, Stack, Group, ColorSwatch, Text, Tooltip } from '@mantine/core';
2
+ import { factory, useMantineTheme, useProps, useStyles, parseThemeColor, Box, RingProgress, alpha, Stack, Group, ColorSwatch, Text, Tooltip } from '@mantine/core';
4
3
  import { useReducedMotion } from '@mantine/hooks';
4
+ import React, { useState, useRef, useCallback, useEffect } from 'react';
5
5
  import classes from './RingsProgress.module.css.mjs';
6
6
 
7
7
  const defaultProps = {
@@ -9,7 +9,9 @@ const defaultProps = {
9
9
  thickness: 12,
10
10
  gap: 8,
11
11
  animate: false,
12
+ animateValueChanges: false,
12
13
  roundCaps: true,
14
+ showValues: false,
13
15
  transitionDuration: 0,
14
16
  rootColorAlpha: 0.15,
15
17
  staggerDelay: 0,
@@ -31,6 +33,7 @@ const RingsProgress = factory((_props) => {
31
33
  rootColorAlpha,
32
34
  label,
33
35
  animate,
36
+ animateValueChanges,
34
37
  transitionDuration,
35
38
  roundCaps,
36
39
  staggerDelay,
@@ -41,6 +44,8 @@ const RingsProgress = factory((_props) => {
41
44
  direction,
42
45
  onRingComplete,
43
46
  withTooltip,
47
+ showValues,
48
+ formatValue,
44
49
  classNames,
45
50
  styles,
46
51
  unstyled,
@@ -56,6 +61,10 @@ const RingsProgress = factory((_props) => {
56
61
  unstyled,
57
62
  vars
58
63
  });
64
+ const [instanceId] = useState(() => Math.random().toString(36).slice(2, 9));
65
+ const ringsRef = useRef(rings);
66
+ ringsRef.current = rings;
67
+ const ringCount = rings.length;
59
68
  const [mountedRings, setMountedRings] = useState(
60
69
  () => rings.map(() => !animate || reduceMotion)
61
70
  );
@@ -65,15 +74,14 @@ const RingsProgress = factory((_props) => {
65
74
  timeoutsRef.current = [];
66
75
  }, []);
67
76
  useEffect(() => {
68
- const ringCount2 = rings.length;
69
77
  if (!animate || reduceMotion) {
70
- setMountedRings(Array.from({ length: ringCount2 }, () => true));
78
+ setMountedRings(Array.from({ length: ringCount }, () => true));
71
79
  return;
72
80
  }
73
- setMountedRings(Array.from({ length: ringCount2 }, () => false));
81
+ setMountedRings(Array.from({ length: ringCount }, () => false));
74
82
  cleanupTimeouts();
75
83
  if (staggerDelay && staggerDelay > 0) {
76
- for (let index = 0; index < ringCount2; index++) {
84
+ for (let index = 0; index < ringCount; index++) {
77
85
  const timeout = setTimeout(() => {
78
86
  setMountedRings((prev) => {
79
87
  const next = [...prev];
@@ -85,21 +93,22 @@ const RingsProgress = factory((_props) => {
85
93
  }
86
94
  } else {
87
95
  requestAnimationFrame(() => {
88
- setMountedRings(Array.from({ length: ringCount2 }, () => true));
96
+ setMountedRings(Array.from({ length: ringCount }, () => true));
89
97
  });
90
98
  }
91
99
  return cleanupTimeouts;
92
- }, [animate, reduceMotion, rings, staggerDelay, cleanupTimeouts]);
100
+ }, [animate, reduceMotion, ringCount, staggerDelay, cleanupTimeouts]);
93
101
  const prevValuesRef = useRef(rings.map((r) => r.value));
94
102
  const [pulsingRings, setPulsingRings] = useState(rings.map(() => false));
95
103
  const ringValuesKey = rings.map((r) => r.value).join(",");
96
- const ringCount = rings.length;
97
104
  useEffect(() => {
98
- prevValuesRef.current = rings.map((r) => r.value);
99
- setPulsingRings(rings.map(() => false));
105
+ const current = ringsRef.current;
106
+ prevValuesRef.current = current.map((r) => r.value);
107
+ setPulsingRings(current.map(() => false));
100
108
  }, [ringCount]);
101
109
  useEffect(() => {
102
- const currentValues = rings.map((r) => r.value);
110
+ const current = ringsRef.current;
111
+ const currentValues = current.map((r) => r.value);
103
112
  const crossedComplete = currentValues.map((value, i) => {
104
113
  const prev = prevValuesRef.current[i] ?? 0;
105
114
  return value >= 100 && prev < 100;
@@ -107,7 +116,7 @@ const RingsProgress = factory((_props) => {
107
116
  if (onRingComplete) {
108
117
  crossedComplete.forEach((crossed, i) => {
109
118
  if (crossed) {
110
- onRingComplete(i, rings[i]);
119
+ onRingComplete(i, current[i]);
111
120
  }
112
121
  });
113
122
  }
@@ -115,7 +124,20 @@ const RingsProgress = factory((_props) => {
115
124
  setPulsingRings(crossedComplete);
116
125
  }
117
126
  prevValuesRef.current = currentValues;
118
- }, [ringValuesKey, pulseOnComplete, reduceMotion, onRingComplete, rings]);
127
+ }, [ringValuesKey, pulseOnComplete, reduceMotion, onRingComplete]);
128
+ const gradientDefs = rings.map((ring, index) => {
129
+ if (!ring.gradient) {
130
+ return null;
131
+ }
132
+ const id = `rp-grad-${instanceId}-${index}`;
133
+ const cssDeg = ring.gradient.deg ?? 0;
134
+ const svgDeg = cssDeg - 90;
135
+ const fromColor = parseThemeColor({ color: ring.gradient.from, theme }).value;
136
+ const toColor = parseThemeColor({ color: ring.gradient.to, theme }).value;
137
+ return { id, fromColor, toColor, svgDeg };
138
+ }).filter(
139
+ (g) => g !== null
140
+ );
119
141
  const handleAnimationEnd = useCallback((index) => {
120
142
  setPulsingRings((prev) => {
121
143
  const next = [...prev];
@@ -124,7 +146,7 @@ const RingsProgress = factory((_props) => {
124
146
  });
125
147
  }, []);
126
148
  const allMounted = mountedRings.every(Boolean);
127
- const effectiveTransitionDuration = reduceMotion ? 0 : animate && !allMounted ? transitionDuration || 1e3 : transitionDuration;
149
+ const effectiveTransitionDuration = reduceMotion ? 0 : animate && !allMounted ? transitionDuration || 1e3 : animateValueChanges ? transitionDuration || 500 : transitionDuration;
128
150
  const offsets = [];
129
151
  let cumulativeOffset = 0;
130
152
  for (let i = 0; i < rings.length; i++) {
@@ -132,16 +154,107 @@ const RingsProgress = factory((_props) => {
132
154
  const ringThickness = rings[i].thickness ?? thickness;
133
155
  cumulativeOffset += (ringThickness ?? 0) + (gap ?? 0);
134
156
  }
157
+ const hasInteractive = rings.some((r) => r.onClick || r.onHover);
158
+ const [hoveredIndex, setHoveredIndex] = useState(null);
159
+ const hoveredIndexRef = useRef(null);
160
+ hoveredIndexRef.current = hoveredIndex;
161
+ const ringAtPoint = useCallback(
162
+ (clientX, clientY, rect) => {
163
+ const cx = rect.left + rect.width / 2;
164
+ const cy = rect.top + rect.height / 2;
165
+ const r = Math.hypot(clientX - cx, clientY - cy);
166
+ const current = ringsRef.current;
167
+ for (let i = 0; i < current.length; i++) {
168
+ const t = current[i].thickness ?? thickness ?? 12;
169
+ const ringSize = (size ?? 120) - offsets[i] * 2;
170
+ const centerR = (ringSize * 0.9 - t * 2) / 2;
171
+ if (Math.abs(r - centerR) <= t / 2) {
172
+ return i;
173
+ }
174
+ }
175
+ return null;
176
+ },
177
+ // offsets is recomputed every render; capture by closure rather than listing it
178
+ // (size/thickness are stable refs through props).
179
+ // eslint-disable-next-line react-hooks/exhaustive-deps
180
+ [size, thickness, ringCount]
181
+ );
182
+ const handleContainerClick = useCallback(
183
+ (event) => {
184
+ if (!hasInteractive) {
185
+ return;
186
+ }
187
+ const rect = event.currentTarget.getBoundingClientRect();
188
+ const idx = ringAtPoint(event.clientX, event.clientY, rect);
189
+ if (idx === null) {
190
+ return;
191
+ }
192
+ const ring = ringsRef.current[idx];
193
+ ring.onClick?.(ring, idx);
194
+ },
195
+ [hasInteractive, ringAtPoint]
196
+ );
197
+ const handleContainerMouseMove = useCallback(
198
+ (event) => {
199
+ if (!hasInteractive) {
200
+ return;
201
+ }
202
+ const rect = event.currentTarget.getBoundingClientRect();
203
+ const idx = ringAtPoint(event.clientX, event.clientY, rect);
204
+ if (idx === hoveredIndexRef.current) {
205
+ return;
206
+ }
207
+ const prev = hoveredIndexRef.current;
208
+ if (prev !== null) {
209
+ const prevRing = ringsRef.current[prev];
210
+ prevRing.onHover?.(prevRing, prev, false);
211
+ }
212
+ setHoveredIndex(idx);
213
+ if (idx !== null) {
214
+ const newRing = ringsRef.current[idx];
215
+ newRing.onHover?.(newRing, idx, true);
216
+ }
217
+ },
218
+ [hasInteractive, ringAtPoint]
219
+ );
220
+ const handleContainerMouseLeave = useCallback(() => {
221
+ if (hoveredIndexRef.current === null) {
222
+ return;
223
+ }
224
+ const prev = hoveredIndexRef.current;
225
+ const prevRing = ringsRef.current[prev];
226
+ prevRing.onHover?.(prevRing, prev, false);
227
+ setHoveredIndex(null);
228
+ }, []);
229
+ const containerCursor = hoveredIndex !== null && rings[hoveredIndex]?.onClick ? "pointer" : void 0;
135
230
  const glowBlur = glow === true ? 6 : typeof glow === "number" ? glow : 0;
136
231
  const svgTransform = startAngle !== 0 || direction === "counterclockwise" ? `rotate(${ -90 + (startAngle ?? 0)}deg)${direction === "counterclockwise" ? " scaleX(-1)" : ""}` : void 0;
137
232
  const content = /* @__PURE__ */ React.createElement(
138
233
  Box,
139
234
  {
140
- ...getStyles("root", { style: { width: size, height: size } }),
235
+ ...getStyles("root", {
236
+ style: { width: size, height: size, cursor: containerCursor }
237
+ }),
141
238
  ...others,
142
239
  role: others.role ?? "group",
143
- "aria-label": others["aria-label"] ?? (others["aria-labelledby"] ? void 0 : "Progress rings")
240
+ "aria-label": others["aria-label"] ?? (others["aria-labelledby"] ? void 0 : "Progress rings"),
241
+ onClick: hasInteractive ? handleContainerClick : others.onClick,
242
+ onMouseMove: hasInteractive ? handleContainerMouseMove : others.onMouseMove,
243
+ onMouseLeave: hasInteractive ? handleContainerMouseLeave : others.onMouseLeave,
244
+ "data-interactive": hasInteractive || void 0,
245
+ "data-hovered-ring": hoveredIndex ?? void 0
144
246
  },
247
+ 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(
248
+ "linearGradient",
249
+ {
250
+ key: g.id,
251
+ id: g.id,
252
+ gradientUnits: "objectBoundingBox",
253
+ gradientTransform: `rotate(${g.svgDeg}, 0.5, 0.5)`
254
+ },
255
+ /* @__PURE__ */ React.createElement("stop", { offset: "0%", stopColor: g.fromColor }),
256
+ /* @__PURE__ */ React.createElement("stop", { offset: "100%", stopColor: g.toColor })
257
+ )))),
145
258
  rings.map((ring, index) => {
146
259
  const {
147
260
  thickness: ringThicknessOverride,
@@ -150,6 +263,11 @@ const RingsProgress = factory((_props) => {
150
263
  glowIntensity,
151
264
  glowColor,
152
265
  rootColor: ringRootColor,
266
+ onClick: ringOnClick,
267
+ onHover: _ringOnHover,
268
+ showValue: _ringShowValue,
269
+ formatValue: _ringFormatValue,
270
+ gradient: _ringGradient,
153
271
  ...ringSection
154
272
  } = ring;
155
273
  const parsedColor = parseThemeColor({ color: ring.color, theme });
@@ -161,7 +279,15 @@ const RingsProgress = factory((_props) => {
161
279
  const ringGlowColor = glowColor ? parseThemeColor({ color: glowColor, theme }).value : parsedColor.value;
162
280
  const glowFilter = ringGlowBlur > 0 ? `drop-shadow(0 0 ${ringGlowBlur}px ${ringGlowColor})` : void 0;
163
281
  const { tooltip: _tooltip, ...sectionWithoutTooltip } = ringSection;
282
+ const sectionColor = ring.gradient ? `url(#rp-grad-${instanceId}-${index})` : sectionWithoutTooltip.color;
164
283
  const isPulsing = pulseOnComplete && pulsingRings[index];
284
+ const interactive = Boolean(ringOnClick);
285
+ const handleKeyDown = ringOnClick ? (event) => {
286
+ if (event.key === "Enter" || event.key === " ") {
287
+ event.preventDefault();
288
+ ringOnClick(ring, index);
289
+ }
290
+ } : void 0;
165
291
  return /* @__PURE__ */ React.createElement(
166
292
  RingProgress,
167
293
  {
@@ -171,12 +297,20 @@ const RingsProgress = factory((_props) => {
171
297
  thickness: ringThickness,
172
298
  roundCaps: ringRoundCaps,
173
299
  transitionDuration: effectiveTransitionDuration,
174
- sections: [{ ...sectionWithoutTooltip, value: effectiveValue }],
175
- role: "progressbar",
300
+ sections: [
301
+ {
302
+ ...sectionWithoutTooltip,
303
+ value: effectiveValue,
304
+ color: sectionColor
305
+ }
306
+ ],
307
+ role: interactive ? "button" : "progressbar",
176
308
  "aria-valuenow": Math.round(ring.value),
177
309
  "aria-valuemin": 0,
178
310
  "aria-valuemax": 100,
179
311
  "aria-label": ringAriaLabel,
312
+ tabIndex: interactive ? 0 : void 0,
313
+ onKeyDown: handleKeyDown,
180
314
  styles: glowFilter || svgTransform ? {
181
315
  svg: {
182
316
  ...glowFilter ? { filter: glowFilter } : {},
@@ -195,6 +329,42 @@ const RingsProgress = factory((_props) => {
195
329
  }
196
330
  );
197
331
  }),
332
+ (showValues || rings.some((r) => r.showValue)) && rings.map((ring, index) => {
333
+ const shouldShow = ring.showValue ?? showValues;
334
+ if (!shouldShow) {
335
+ return null;
336
+ }
337
+ const ringT = ring.thickness ?? thickness ?? 12;
338
+ const ringSize = (size ?? 120) - offsets[index] * 2;
339
+ const ringR = (ringSize * 0.9 - ringT * 2) / 2;
340
+ const clampedValue = Math.max(0, Math.min(100, ring.value));
341
+ const directionMultiplier = direction === "counterclockwise" ? -1 : 1;
342
+ const endAngleDeg = (startAngle ?? 0) + clampedValue / 100 * 360 * directionMultiplier;
343
+ const angleRad = endAngleDeg * Math.PI / 180;
344
+ const center = (size ?? 120) / 2;
345
+ const x = center + ringR * Math.sin(angleRad);
346
+ const y = center - ringR * Math.cos(angleRad);
347
+ const formatter = ring.formatValue ?? formatValue ?? ((v) => `${Math.round(v)}%`);
348
+ const labelColor = parseThemeColor({ color: ring.color, theme }).value;
349
+ return /* @__PURE__ */ React.createElement(
350
+ Box,
351
+ {
352
+ key: `value-${index}`,
353
+ ...getStyles("valueLabel", {
354
+ style: {
355
+ position: "absolute",
356
+ left: x,
357
+ top: y,
358
+ transform: "translate(-50%, -50%)",
359
+ pointerEvents: "none",
360
+ color: labelColor
361
+ }
362
+ }),
363
+ "data-ring-index": index
364
+ },
365
+ formatter(ring.value)
366
+ );
367
+ }),
198
368
  label && /* @__PURE__ */ React.createElement(Box, { ...getStyles("label") }, label)
199
369
  );
200
370
  if (withTooltip) {