UNPKG

@gfazioli/mantine-rings-progress

Version:

A Mantine 9 concentric ring progress component inspired by Apple Watch activity rings — animated, glowing, accessible, with per-ring customization.

387 lines (383 loc) 14.7 kB
'use client'; 'use strict'; var core = require('@mantine/core'); var hooks = require('@mantine/hooks'); var React = require('react'); var RingsProgress_module = require('./RingsProgress.module.css.cjs'); const defaultProps = { size: 120, thickness: 12, gap: 8, animate: false, animateValueChanges: false, roundCaps: true, showValues: false, transitionDuration: 0, rootColorAlpha: 0.15, staggerDelay: 0, glow: false, pulseOnComplete: false, startAngle: 0, direction: "clockwise", withTooltip: false }; const RingsProgress = core.factory((_props) => { const theme = core.useMantineTheme(); const reduceMotion = hooks.useReducedMotion(); const props = core.useProps("RingsProgress", defaultProps, _props); const { rings, size, thickness, gap, rootColorAlpha, label, animate, animateValueChanges, transitionDuration, roundCaps, staggerDelay, tooltipProps: globalTooltipProps, glow, pulseOnComplete, startAngle, direction, onRingComplete, withTooltip, showValues, formatValue, classNames, styles, unstyled, vars, ...others } = props; const getStyles = core.useStyles({ name: "RingsProgress", props, classes: RingsProgress_module, classNames, styles, unstyled, vars }); const [instanceId] = React.useState(() => Math.random().toString(36).slice(2, 9)); const ringsRef = React.useRef(rings); ringsRef.current = rings; const ringCount = rings.length; const [mountedRings, setMountedRings] = React.useState( () => rings.map(() => !animate || reduceMotion) ); const timeoutsRef = React.useRef([]); const cleanupTimeouts = React.useCallback(() => { timeoutsRef.current.forEach(clearTimeout); timeoutsRef.current = []; }, []); React.useEffect(() => { if (!animate || reduceMotion) { setMountedRings(Array.from({ length: ringCount }, () => true)); return; } setMountedRings(Array.from({ length: ringCount }, () => false)); cleanupTimeouts(); if (staggerDelay && staggerDelay > 0) { for (let index = 0; index < ringCount; index++) { const timeout = setTimeout(() => { setMountedRings((prev) => { const next = [...prev]; next[index] = true; return next; }); }, index * staggerDelay); timeoutsRef.current.push(timeout); } } else { requestAnimationFrame(() => { setMountedRings(Array.from({ length: ringCount }, () => true)); }); } return cleanupTimeouts; }, [animate, reduceMotion, ringCount, staggerDelay, cleanupTimeouts]); const prevValuesRef = React.useRef(rings.map((r) => r.value)); const [pulsingRings, setPulsingRings] = React.useState(rings.map(() => false)); const ringValuesKey = rings.map((r) => r.value).join(","); React.useEffect(() => { const current = ringsRef.current; prevValuesRef.current = current.map((r) => r.value); setPulsingRings(current.map(() => false)); }, [ringCount]); React.useEffect(() => { const current = ringsRef.current; const currentValues = current.map((r) => r.value); const crossedComplete = currentValues.map((value, i) => { const prev = prevValuesRef.current[i] ?? 0; return value >= 100 && prev < 100; }); if (onRingComplete) { crossedComplete.forEach((crossed, i) => { if (crossed) { onRingComplete(i, current[i]); } }); } if (pulseOnComplete && !reduceMotion && crossedComplete.some(Boolean)) { setPulsingRings(crossedComplete); } prevValuesRef.current = currentValues; }, [ringValuesKey, pulseOnComplete, reduceMotion, onRingComplete]); const gradientDefs = rings.map((ring, index) => { if (!ring.gradient) { return null; } const id = `rp-grad-${instanceId}-${index}`; const cssDeg = ring.gradient.deg ?? 0; const svgDeg = cssDeg - 90; const fromColor = core.parseThemeColor({ color: ring.gradient.from, theme }).value; const toColor = core.parseThemeColor({ color: ring.gradient.to, theme }).value; return { id, fromColor, toColor, svgDeg }; }).filter( (g) => g !== null ); const handleAnimationEnd = React.useCallback((index) => { setPulsingRings((prev) => { const next = [...prev]; next[index] = false; return next; }); }, []); const allMounted = mountedRings.every(Boolean); const effectiveTransitionDuration = reduceMotion ? 0 : animate && !allMounted ? transitionDuration || 1e3 : animateValueChanges ? transitionDuration || 500 : transitionDuration; const offsets = []; let cumulativeOffset = 0; for (let i = 0; i < rings.length; i++) { offsets.push(cumulativeOffset); const ringThickness = rings[i].thickness ?? thickness; cumulativeOffset += (ringThickness ?? 0) + (gap ?? 0); } const hasInteractive = rings.some((r) => r.onClick || r.onHover); const [hoveredIndex, setHoveredIndex] = React.useState(null); const hoveredIndexRef = React.useRef(null); hoveredIndexRef.current = hoveredIndex; const ringAtPoint = React.useCallback( (clientX, clientY, rect) => { const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; const r = Math.hypot(clientX - cx, clientY - cy); const current = ringsRef.current; for (let i = 0; i < current.length; i++) { const t = current[i].thickness ?? thickness ?? 12; const ringSize = (size ?? 120) - offsets[i] * 2; const centerR = (ringSize * 0.9 - t * 2) / 2; if (Math.abs(r - centerR) <= t / 2) { return i; } } return null; }, // offsets is recomputed every render; capture by closure rather than listing it // (size/thickness are stable refs through props). // eslint-disable-next-line react-hooks/exhaustive-deps [size, thickness, ringCount] ); const handleContainerClick = React.useCallback( (event) => { if (!hasInteractive) { return; } const rect = event.currentTarget.getBoundingClientRect(); const idx = ringAtPoint(event.clientX, event.clientY, rect); if (idx === null) { return; } const ring = ringsRef.current[idx]; ring.onClick?.(ring, idx); }, [hasInteractive, ringAtPoint] ); const handleContainerMouseMove = React.useCallback( (event) => { if (!hasInteractive) { return; } const rect = event.currentTarget.getBoundingClientRect(); const idx = ringAtPoint(event.clientX, event.clientY, rect); if (idx === hoveredIndexRef.current) { return; } const prev = hoveredIndexRef.current; if (prev !== null) { const prevRing = ringsRef.current[prev]; prevRing.onHover?.(prevRing, prev, false); } setHoveredIndex(idx); if (idx !== null) { const newRing = ringsRef.current[idx]; newRing.onHover?.(newRing, idx, true); } }, [hasInteractive, ringAtPoint] ); const handleContainerMouseLeave = React.useCallback(() => { if (hoveredIndexRef.current === null) { return; } const prev = hoveredIndexRef.current; const prevRing = ringsRef.current[prev]; prevRing.onHover?.(prevRing, prev, false); setHoveredIndex(null); }, []); const containerCursor = hoveredIndex !== null && rings[hoveredIndex]?.onClick ? "pointer" : void 0; const glowBlur = glow === true ? 6 : typeof glow === "number" ? glow : 0; const svgTransform = startAngle !== 0 || direction === "counterclockwise" ? `rotate(${ -90 + (startAngle ?? 0)}deg)${direction === "counterclockwise" ? " scaleX(-1)" : ""}` : void 0; const content = /* @__PURE__ */ React.createElement( core.Box, { ...getStyles("root", { style: { width: size, height: size, cursor: containerCursor } }), ...others, role: others.role ?? "group", "aria-label": others["aria-label"] ?? (others["aria-labelledby"] ? void 0 : "Progress rings"), onClick: hasInteractive ? handleContainerClick : others.onClick, onMouseMove: hasInteractive ? handleContainerMouseMove : others.onMouseMove, onMouseLeave: hasInteractive ? handleContainerMouseLeave : others.onMouseLeave, "data-interactive": hasInteractive || void 0, "data-hovered-ring": hoveredIndex ?? void 0 }, 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( "linearGradient", { key: g.id, id: g.id, gradientUnits: "objectBoundingBox", gradientTransform: `rotate(${g.svgDeg}, 0.5, 0.5)` }, /* @__PURE__ */ React.createElement("stop", { offset: "0%", stopColor: g.fromColor }), /* @__PURE__ */ React.createElement("stop", { offset: "100%", stopColor: g.toColor }) )))), rings.map((ring, index) => { const { thickness: ringThicknessOverride, roundCaps: ringRoundCapsOverride, ariaLabel: ringAriaLabelOverride, glowIntensity, glowColor, rootColor: ringRootColor, onClick: ringOnClick, onHover: _ringOnHover, showValue: _ringShowValue, formatValue: _ringFormatValue, gradient: _ringGradient, ...ringSection } = ring; const parsedColor = core.parseThemeColor({ color: ring.color, theme }); const effectiveValue = animate && !mountedRings[index] ? 0 : ring.value; const ringThickness = ringThicknessOverride ?? thickness; const ringRoundCaps = ringRoundCapsOverride ?? roundCaps; const ringAriaLabel = ringAriaLabelOverride ?? `Ring ${index + 1}: ${Math.round(ring.value)}%`; const ringGlowBlur = glowIntensity ?? glowBlur; const ringGlowColor = glowColor ? core.parseThemeColor({ color: glowColor, theme }).value : parsedColor.value; const glowFilter = ringGlowBlur > 0 ? `drop-shadow(0 0 ${ringGlowBlur}px ${ringGlowColor})` : void 0; const { tooltip: _tooltip, ...sectionWithoutTooltip } = ringSection; const sectionColor = ring.gradient ? `url(#rp-grad-${instanceId}-${index})` : sectionWithoutTooltip.color; const isPulsing = pulseOnComplete && pulsingRings[index]; const interactive = Boolean(ringOnClick); const handleKeyDown = ringOnClick ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); ringOnClick(ring, index); } } : void 0; return /* @__PURE__ */ React.createElement( core.RingProgress, { key: index, rootColor: ringRootColor ? core.parseThemeColor({ color: ringRootColor, theme }).value : core.alpha(parsedColor.value, rootColorAlpha ?? 0.1), size: (size ?? 0) - offsets[index] * 2, thickness: ringThickness, roundCaps: ringRoundCaps, transitionDuration: effectiveTransitionDuration, sections: [ { ...sectionWithoutTooltip, value: effectiveValue, color: sectionColor } ], role: interactive ? "button" : "progressbar", "aria-valuenow": Math.round(ring.value), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": ringAriaLabel, tabIndex: interactive ? 0 : void 0, onKeyDown: handleKeyDown, styles: glowFilter || svgTransform ? { svg: { ...glowFilter ? { filter: glowFilter } : {}, ...svgTransform ? { transform: svgTransform } : {} } } : void 0, ...getStyles("ring", { style: { position: "absolute", top: offsets[index], left: offsets[index] } }), "data-pulsing": isPulsing || void 0, onAnimationEnd: isPulsing ? () => handleAnimationEnd(index) : void 0 } ); }), (showValues || rings.some((r) => r.showValue)) && rings.map((ring, index) => { const shouldShow = ring.showValue ?? showValues; if (!shouldShow) { return null; } const ringT = ring.thickness ?? thickness ?? 12; const ringSize = (size ?? 120) - offsets[index] * 2; const ringR = (ringSize * 0.9 - ringT * 2) / 2; const clampedValue = Math.max(0, Math.min(100, ring.value)); const directionMultiplier = direction === "counterclockwise" ? -1 : 1; const endAngleDeg = (startAngle ?? 0) + clampedValue / 100 * 360 * directionMultiplier; const angleRad = endAngleDeg * Math.PI / 180; const center = (size ?? 120) / 2; const x = center + ringR * Math.sin(angleRad); const y = center - ringR * Math.cos(angleRad); const formatter = ring.formatValue ?? formatValue ?? ((v) => `${Math.round(v)}%`); const labelColor = core.parseThemeColor({ color: ring.color, theme }).value; return /* @__PURE__ */ React.createElement( core.Box, { key: `value-${index}`, ...getStyles("valueLabel", { style: { position: "absolute", left: x, top: y, transform: "translate(-50%, -50%)", pointerEvents: "none", color: labelColor } }), "data-ring-index": index }, formatter(ring.value) ); }), label && /* @__PURE__ */ React.createElement(core.Box, { ...getStyles("label") }, label) ); if (withTooltip) { const tooltipLabel = /* @__PURE__ */ React.createElement(core.Stack, { gap: 4 }, rings.map((ring, index) => { const parsedColor = core.parseThemeColor({ color: ring.color, theme }); const tooltipContent = ring.tooltip ?? `${Math.round(ring.value)}%`; return /* @__PURE__ */ React.createElement(core.Group, { key: index, gap: "xs", wrap: "nowrap" }, /* @__PURE__ */ React.createElement(core.ColorSwatch, { color: parsedColor.value, size: 12, withShadow: false }), typeof tooltipContent === "string" ? /* @__PURE__ */ React.createElement(core.Text, { size: "xs" }, tooltipContent) : tooltipContent); })); return /* @__PURE__ */ React.createElement(core.Tooltip.Floating, { label: tooltipLabel, ...globalTooltipProps }, content); } return content; }); RingsProgress.classes = RingsProgress_module; RingsProgress.displayName = "RingsProgress"; exports.RingsProgress = RingsProgress; //# sourceMappingURL=RingsProgress.cjs.map