@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 +6 -1
- package/dist/cjs/RingsProgress.cjs +197 -26
- package/dist/cjs/RingsProgress.cjs.map +1 -1
- package/dist/cjs/RingsProgress.module.css.cjs +1 -1
- package/dist/esm/RingsProgress.mjs +198 -27
- package/dist/esm/RingsProgress.mjs.map +1 -1
- package/dist/esm/RingsProgress.module.css.mjs +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.layer.css +1 -1
- package/dist/types/RingsProgress.d.ts +31 -2
- package/package.json +9 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import
|
|
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
|
|
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(
|
|
78
|
+
setMountedRings(Array.from({ length: ringCount }, () => true));
|
|
70
79
|
return;
|
|
71
80
|
}
|
|
72
|
-
setMountedRings(
|
|
81
|
+
setMountedRings(Array.from({ length: ringCount }, () => false));
|
|
73
82
|
cleanupTimeouts();
|
|
74
83
|
if (staggerDelay && staggerDelay > 0) {
|
|
75
|
-
|
|
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(
|
|
96
|
+
setMountedRings(Array.from({ length: ringCount }, () => true));
|
|
88
97
|
});
|
|
89
98
|
}
|
|
90
99
|
return cleanupTimeouts;
|
|
91
|
-
}, [animate, reduceMotion,
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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: [
|
|
174
|
-
|
|
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) {
|