@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.
@@ -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,
@@ -19,7 +21,7 @@ const defaultProps = {
19
21
  direction: "clockwise",
20
22
  withTooltip: false
21
23
  };
22
- const RingsProgress = factory((_props, ref) => {
24
+ const RingsProgress = factory((_props) => {
23
25
  const theme = useMantineTheme();
24
26
  const reduceMotion = useReducedMotion();
25
27
  const props = useProps("RingsProgress", defaultProps, _props);
@@ -31,6 +33,7 @@ const RingsProgress = factory((_props, ref) => {
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, ref) => {
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, ref) => {
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
  );
@@ -66,13 +75,13 @@ const RingsProgress = factory((_props, ref) => {
66
75
  }, []);
67
76
  useEffect(() => {
68
77
  if (!animate || reduceMotion) {
69
- setMountedRings(rings.map(() => true));
78
+ setMountedRings(Array.from({ length: ringCount }, () => true));
70
79
  return;
71
80
  }
72
- setMountedRings(rings.map(() => false));
81
+ setMountedRings(Array.from({ length: ringCount }, () => false));
73
82
  cleanupTimeouts();
74
83
  if (staggerDelay && staggerDelay > 0) {
75
- rings.forEach((_, index) => {
84
+ for (let index = 0; index < ringCount; index++) {
76
85
  const timeout = setTimeout(() => {
77
86
  setMountedRings((prev) => {
78
87
  const next = [...prev];
@@ -81,23 +90,25 @@ const RingsProgress = factory((_props, ref) => {
81
90
  });
82
91
  }, index * staggerDelay);
83
92
  timeoutsRef.current.push(timeout);
84
- });
93
+ }
85
94
  } else {
86
95
  requestAnimationFrame(() => {
87
- setMountedRings(rings.map(() => true));
96
+ setMountedRings(Array.from({ length: ringCount }, () => true));
88
97
  });
89
98
  }
90
99
  return cleanupTimeouts;
91
- }, [animate, reduceMotion, rings.length, staggerDelay]);
100
+ }, [animate, reduceMotion, ringCount, staggerDelay, cleanupTimeouts]);
92
101
  const prevValuesRef = useRef(rings.map((r) => r.value));
93
102
  const [pulsingRings, setPulsingRings] = useState(rings.map(() => false));
94
103
  const ringValuesKey = rings.map((r) => r.value).join(",");
95
104
  useEffect(() => {
96
- prevValuesRef.current = rings.map((r) => r.value);
97
- setPulsingRings(rings.map(() => false));
98
- }, [rings.length]);
105
+ const current = ringsRef.current;
106
+ prevValuesRef.current = current.map((r) => r.value);
107
+ setPulsingRings(current.map(() => false));
108
+ }, [ringCount]);
99
109
  useEffect(() => {
100
- const currentValues = rings.map((r) => r.value);
110
+ const current = ringsRef.current;
111
+ const currentValues = current.map((r) => r.value);
101
112
  const crossedComplete = currentValues.map((value, i) => {
102
113
  const prev = prevValuesRef.current[i] ?? 0;
103
114
  return value >= 100 && prev < 100;
@@ -105,7 +116,7 @@ const RingsProgress = factory((_props, ref) => {
105
116
  if (onRingComplete) {
106
117
  crossedComplete.forEach((crossed, i) => {
107
118
  if (crossed) {
108
- onRingComplete(i, rings[i]);
119
+ onRingComplete(i, current[i]);
109
120
  }
110
121
  });
111
122
  }
@@ -113,7 +124,20 @@ const RingsProgress = factory((_props, ref) => {
113
124
  setPulsingRings(crossedComplete);
114
125
  }
115
126
  prevValuesRef.current = currentValues;
116
- }, [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
+ );
117
141
  const handleAnimationEnd = useCallback((index) => {
118
142
  setPulsingRings((prev) => {
119
143
  const next = [...prev];
@@ -122,25 +146,115 @@ const RingsProgress = factory((_props, ref) => {
122
146
  });
123
147
  }, []);
124
148
  const allMounted = mountedRings.every(Boolean);
125
- const effectiveTransitionDuration = reduceMotion ? 0 : animate && !allMounted ? transitionDuration || 1e3 : transitionDuration;
149
+ const effectiveTransitionDuration = reduceMotion ? 0 : animate && !allMounted ? transitionDuration || 1e3 : animateValueChanges ? transitionDuration || 500 : transitionDuration;
126
150
  const offsets = [];
127
151
  let cumulativeOffset = 0;
128
152
  for (let i = 0; i < rings.length; i++) {
129
153
  offsets.push(cumulativeOffset);
130
154
  const ringThickness = rings[i].thickness ?? thickness;
131
- cumulativeOffset += ringThickness + gap;
155
+ cumulativeOffset += (ringThickness ?? 0) + (gap ?? 0);
132
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;
133
230
  const glowBlur = glow === true ? 6 : typeof glow === "number" ? glow : 0;
134
- const svgTransform = startAngle !== 0 || direction === "counterclockwise" ? `rotate(${ -90 + startAngle}deg)${direction === "counterclockwise" ? " scaleX(-1)" : ""}` : void 0;
231
+ const svgTransform = startAngle !== 0 || direction === "counterclockwise" ? `rotate(${ -90 + (startAngle ?? 0)}deg)${direction === "counterclockwise" ? " scaleX(-1)" : ""}` : void 0;
135
232
  const content = /* @__PURE__ */ React.createElement(
136
233
  Box,
137
234
  {
138
- ref,
139
- ...getStyles("root", { style: { width: size, height: size } }),
140
- role: "group",
141
- "aria-label": "Progress rings",
142
- ...others
235
+ ...getStyles("root", {
236
+ style: { width: size, height: size, cursor: containerCursor }
237
+ }),
238
+ ...others,
239
+ role: others.role ?? "group",
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
143
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
+ )))),
144
258
  rings.map((ring, index) => {
145
259
  const {
146
260
  thickness: ringThicknessOverride,
@@ -149,6 +263,11 @@ const RingsProgress = factory((_props, ref) => {
149
263
  glowIntensity,
150
264
  glowColor,
151
265
  rootColor: ringRootColor,
266
+ onClick: ringOnClick,
267
+ onHover: _ringOnHover,
268
+ showValue: _ringShowValue,
269
+ formatValue: _ringFormatValue,
270
+ gradient: _ringGradient,
152
271
  ...ringSection
153
272
  } = ring;
154
273
  const parsedColor = parseThemeColor({ color: ring.color, theme });
@@ -160,22 +279,38 @@ const RingsProgress = factory((_props, ref) => {
160
279
  const ringGlowColor = glowColor ? parseThemeColor({ color: glowColor, theme }).value : parsedColor.value;
161
280
  const glowFilter = ringGlowBlur > 0 ? `drop-shadow(0 0 ${ringGlowBlur}px ${ringGlowColor})` : void 0;
162
281
  const { tooltip: _tooltip, ...sectionWithoutTooltip } = ringSection;
282
+ const sectionColor = ring.gradient ? `url(#rp-grad-${instanceId}-${index})` : sectionWithoutTooltip.color;
163
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;
164
291
  return /* @__PURE__ */ React.createElement(
165
292
  RingProgress,
166
293
  {
167
294
  key: index,
168
- rootColor: ringRootColor ? parseThemeColor({ color: ringRootColor, theme }).value : alpha(parsedColor.value, rootColorAlpha),
169
- size: size - offsets[index] * 2,
295
+ rootColor: ringRootColor ? parseThemeColor({ color: ringRootColor, theme }).value : alpha(parsedColor.value, rootColorAlpha ?? 0.1),
296
+ size: (size ?? 0) - offsets[index] * 2,
170
297
  thickness: ringThickness,
171
298
  roundCaps: ringRoundCaps,
172
299
  transitionDuration: effectiveTransitionDuration,
173
- sections: [{ ...sectionWithoutTooltip, value: effectiveValue }],
174
- role: "progressbar",
300
+ sections: [
301
+ {
302
+ ...sectionWithoutTooltip,
303
+ value: effectiveValue,
304
+ color: sectionColor
305
+ }
306
+ ],
307
+ role: interactive ? "button" : "progressbar",
175
308
  "aria-valuenow": Math.round(ring.value),
176
309
  "aria-valuemin": 0,
177
310
  "aria-valuemax": 100,
178
311
  "aria-label": ringAriaLabel,
312
+ tabIndex: interactive ? 0 : void 0,
313
+ onKeyDown: handleKeyDown,
179
314
  styles: glowFilter || svgTransform ? {
180
315
  svg: {
181
316
  ...glowFilter ? { filter: glowFilter } : {},
@@ -194,6 +329,42 @@ const RingsProgress = factory((_props, ref) => {
194
329
  }
195
330
  );
196
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
+ }),
197
368
  label && /* @__PURE__ */ React.createElement(Box, { ...getStyles("label") }, label)
198
369
  );
199
370
  if (withTooltip) {