@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 +5 -1
- package/dist/cjs/RingsProgress.cjs +188 -18
- package/dist/cjs/RingsProgress.cjs.map +1 -1
- package/dist/cjs/RingsProgress.module.css.cjs +1 -1
- package/dist/esm/RingsProgress.mjs +189 -19
- 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 +1 -1
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:
|
|
80
|
+
setMountedRings(Array.from({ length: ringCount }, () => true));
|
|
73
81
|
return;
|
|
74
82
|
}
|
|
75
|
-
setMountedRings(Array.from({ length:
|
|
83
|
+
setMountedRings(Array.from({ length: ringCount }, () => false));
|
|
76
84
|
cleanupTimeouts();
|
|
77
85
|
if (staggerDelay && staggerDelay > 0) {
|
|
78
|
-
for (let index = 0; 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:
|
|
98
|
+
setMountedRings(Array.from({ length: ringCount }, () => true));
|
|
91
99
|
});
|
|
92
100
|
}
|
|
93
101
|
return cleanupTimeouts;
|
|
94
|
-
}, [animate, reduceMotion,
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
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,
|
|
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
|
|
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", {
|
|
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: [
|
|
177
|
-
|
|
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) {
|