@fseehawer/react-circular-slider
Version:
A customizable circular slider with no dependencies.
687 lines (678 loc) • 23.3 kB
JavaScript
"use client";
var __defProp = Object.defineProperty;
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __propIsEnum = Object.prototype.propertyIsEnumerable;
var __pow = Math.pow;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __spreadValues = (a, b) => {
for (var prop in b || (b = {}))
if (__hasOwnProp.call(b, prop))
__defNormalProp(a, prop, b[prop]);
if (__getOwnPropSymbols)
for (var prop of __getOwnPropSymbols(b)) {
if (__propIsEnum.call(b, prop))
__defNormalProp(a, prop, b[prop]);
}
return a;
};
// src/CircularSlider/index.tsx
import React4, { useCallback, useEffect as useEffect3, useReducer, useRef as useRef3, forwardRef, useImperativeHandle } from "react";
// src/redux/reducer.ts
var reducer = (state, action) => {
switch (action.type) {
case "init":
case "setKnobPosition":
case "onMouseDown":
case "onMouseUp":
case "setInitialKnobPosition":
case "updateDimensions":
return __spreadValues(__spreadValues({}, state), action.payload);
default:
throw new Error("Unknown action type");
}
};
var reducer_default = reducer;
// src/hooks/useEventListener.ts
import { useEffect, useRef } from "react";
function useEventListener(eventName, handler) {
const savedHandler = useRef(void 0);
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
if (typeof window !== "undefined" && window.addEventListener) {
const eventListener = (event) => {
if (savedHandler.current) savedHandler.current(event);
};
window.addEventListener(eventName, eventListener, { passive: false });
return () => {
window.removeEventListener(eventName, eventListener);
};
}
}, [eventName]);
}
var useEventListener_default = useEventListener;
// src/hooks/useIsServer.ts
import { useEffect as useEffect2, useState } from "react";
var useIsServer = () => {
const [isServer, setIsServer] = useState(true);
useEffect2(() => {
setIsServer(false);
}, []);
return isServer;
};
var useIsServer_default = useIsServer;
// src/Knob/index.tsx
import React from "react";
var Knob = ({
isDragging,
knobPosition,
knobColor,
knobSize,
hideKnob = false,
hideKnobRing = false,
knobDraggable = true,
trackSize,
children,
onMouseDown
}) => {
const styles = {
knob: {
position: "absolute",
left: `-${knobSize / 2 - trackSize / 2}px`,
top: `-${knobSize / 2 - trackSize / 2}px`,
cursor: knobDraggable ? "grab" : "auto",
zIndex: 3
},
dragging: {
cursor: "grabbing"
},
pause: {
animationPlayState: "paused"
},
animation: {
transformOrigin: "50% 50%",
animationTimingFunction: "ease-out",
animationDuration: "1500ms",
animationIterationCount: "infinite",
animationName: "pulse"
},
hide: {
opacity: 0
}
};
return /* @__PURE__ */ React.createElement(
"div",
{
style: __spreadValues(__spreadValues(__spreadValues({
transform: `translate(${knobPosition.x}px, ${knobPosition.y}px)`
}, styles.knob), isDragging ? styles.dragging : {}), hideKnob ? styles.hide : {}),
onMouseDown,
onTouchStart: onMouseDown
},
/* @__PURE__ */ React.createElement("svg", { width: knobSize, height: knobSize, viewBox: `0 0 ${knobSize} ${knobSize}` }, !hideKnobRing && /* @__PURE__ */ React.createElement(
"circle",
{
style: __spreadValues(__spreadValues({}, styles.animation), isDragging ? styles.pause : {}),
fill: knobColor,
fillOpacity: 0.2,
stroke: "none",
cx: knobSize / 2,
cy: knobSize / 2,
r: knobSize / 2
}
), /* @__PURE__ */ React.createElement(
"circle",
{
fill: knobColor,
stroke: "none",
cx: knobSize / 2,
cy: knobSize / 2,
r: knobSize * 2 / 3 / 2
}
), children != null ? children : /* @__PURE__ */ React.createElement("svg", { width: knobSize, height: knobSize, viewBox: "0 0 36 36" }, /* @__PURE__ */ React.createElement("rect", { fill: "#FFFFFF", x: "14", y: "14", width: "8", height: "1" }), /* @__PURE__ */ React.createElement("rect", { fill: "#FFFFFF", x: "14", y: "17", width: "8", height: "1" }), /* @__PURE__ */ React.createElement("rect", { fill: "#FFFFFF", x: "14", y: "20", width: "8", height: "1" })))
);
};
var Knob_default = Knob;
// src/Labels/index.tsx
import React2 from "react";
var Labels = ({
label,
value,
labelColor = "#000",
labelBottom = false,
labelFontSize = "1rem",
valueFontSize = "3rem",
appendToValue = "",
prependToValue = "",
verticalOffset = "1.5rem",
hideLabelValue = false
}) => {
const styles = {
labels: {
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
color: labelColor,
userSelect: "none",
zIndex: 1
},
codeTop: {
verticalAlign: "text-top"
},
value: {
fontSize: valueFontSize,
position: "relative"
},
bottomMargin: {
marginBottom: `calc(${verticalOffset})`
},
appended: {
position: "absolute",
right: "0",
top: "0",
transform: "translate(100%, 0)"
},
prepended: {
position: "absolute",
left: "0",
top: "0",
transform: "translate(-100%, 0)"
},
hide: {
display: "none"
}
};
return /* @__PURE__ */ React2.createElement("div", { style: __spreadValues(__spreadValues({}, styles.labels), hideLabelValue ? styles.hide : {}) }, !labelBottom && /* @__PURE__ */ React2.createElement("div", { style: { fontSize: labelFontSize } }, label), /* @__PURE__ */ React2.createElement("div", { style: __spreadValues(__spreadValues({}, styles.value), !labelBottom ? styles.bottomMargin : {}) }, /* @__PURE__ */ React2.createElement("code", { style: styles.codeTop }, /* @__PURE__ */ React2.createElement("span", { style: styles.prepended }, prependToValue), value, /* @__PURE__ */ React2.createElement("span", { style: styles.appended }, appendToValue))), labelBottom && /* @__PURE__ */ React2.createElement("div", { style: { fontSize: labelFontSize, marginTop: "-0.5rem" } }, label));
};
var Labels_default = Labels;
// src/Svg/index.tsx
import React3, { useRef as useRef2 } from "react";
var Svg = ({
width,
label,
direction,
strokeDasharray,
strokeDashoffset,
progressColorFrom,
progressColorTo,
progressLineCap = "round",
progressSize,
trackColor,
trackSize,
radiansOffset,
svgFullPath,
onMouseDown,
isDragging
}) => {
const circleRef = useRef2(null);
const styles = {
svg: {
position: "relative",
zIndex: 2,
userSelect: isDragging ? "none" : "auto"
},
path: {
transform: `rotate(${radiansOffset}rad) ${direction === -1 ? "scale(-1, 1)" : "scale(1, 1)"}`,
transformOrigin: "center center",
// No transition to make movement instant
transition: "none"
}
};
const halfTrack = trackSize / 2;
const radius = width / 2 - halfTrack;
const validatedLineCap = progressLineCap === "round" || progressLineCap === "butt" ? progressLineCap : "round";
const handleClick = (event) => {
var _a;
if (!onMouseDown) return;
const bounds = (_a = circleRef.current) == null ? void 0 : _a.getBoundingClientRect();
if (!bounds) return;
const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
const clientY = "touches" in event ? event.touches[0].clientY : event.clientY;
const centerX = bounds.left + bounds.width / 2;
const centerY = bounds.top + bounds.height / 2;
const distance = Math.sqrt(__pow(clientX - centerX, 2) + __pow(clientY - centerY, 2));
const threshold = bounds.width / (isDragging ? 4 : 2) - trackSize;
if (distance < threshold) return;
onMouseDown();
};
const gradientId = `radial-${label}-${Math.random().toString(36).substr(2, 9)}`;
return /* @__PURE__ */ React3.createElement(
"svg",
{
width: `${width}px`,
height: `${width}px`,
viewBox: `0 0 ${width} ${width}`,
overflow: "visible",
style: styles.svg,
onMouseDown: handleClick,
onTouchStart: handleClick
},
/* @__PURE__ */ React3.createElement("defs", null, /* @__PURE__ */ React3.createElement("linearGradient", { id: gradientId, x1: "100%", x2: "0%" }, /* @__PURE__ */ React3.createElement("stop", { offset: "0%", stopColor: progressColorFrom }), /* @__PURE__ */ React3.createElement("stop", { offset: "100%", stopColor: progressColorTo }))),
/* @__PURE__ */ React3.createElement(
"circle",
{
ref: circleRef,
strokeWidth: trackSize,
fill: "none",
stroke: trackColor,
cx: width / 2,
cy: width / 2,
r: radius
}
),
/* @__PURE__ */ React3.createElement(
"path",
{
style: styles.path,
ref: svgFullPath,
strokeDasharray: strokeDasharray || 0,
strokeDashoffset: strokeDashoffset || 0,
strokeWidth: progressSize,
strokeLinecap: validatedLineCap,
fill: "none",
stroke: `url(#${gradientId})`,
d: `
M ${width / 2}, ${width / 2}
m 0, -${radius}
a ${radius},${radius} 0 0,1 0,${radius * 2}
a -${radius},-${radius} 0 0,1 0,-${radius * 2}
`
}
)
);
};
var Svg_default = Svg;
// src/CircularSlider/index.tsx
var spreadDegrees = 360;
var knobOffsetConsts = {
top: Math.PI / 2,
right: 0,
bottom: -Math.PI / 2,
left: -Math.PI
};
var getSliderRotation = (value) => value < 0 ? -1 : 1;
var getRadians = (degrees) => degrees * Math.PI / 180;
var generateRange = (min, max) => Array.from({ length: max - min + 1 }, (_, i) => i + min);
var getKnobOffsetAmount = (knobPosition) => {
if (typeof knobPosition === "string" && knobPosition in knobOffsetConsts) {
return knobOffsetConsts[knobPosition];
}
const parsed = typeof knobPosition === "number" ? knobPosition : parseFloat(knobPosition);
return getRadians(parsed);
};
var CircularSlider = forwardRef((props, ref) => {
const {
label = "ANGLE",
width = 280,
direction = 1,
min = 0,
max = 360,
initialValue = 0,
value = null,
knobColor = "#4e63ea",
knobSize = 36,
knobPosition = "top",
labelColor = "#272b77",
labelBottom = false,
labelFontSize = "1rem",
valueFontSize = "3rem",
appendToValue = "",
prependToValue = "",
verticalOffset = "1.5rem",
hideLabelValue = false,
hideKnob = false,
hideKnobRing = false,
knobDraggable = true,
progressColorFrom = "#80C3F3",
progressColorTo = "#4990E2",
useMouseAdditionalToTouch = false,
progressSize = 8,
trackColor = "#DDDEFB",
trackSize = 8,
trackDraggable = false,
data = [],
dataIndex = 0,
progressLineCap = "round",
renderLabelValue = null,
children,
onChange = () => {
},
isDragging = () => {
}
} = props;
const resizeObserverRef = useRef3(null);
const draggingRef = useRef3(false);
const currentPositionRef = useRef3(null);
const lastDataIndexRef = useRef3(dataIndex);
const clickInProgressRef = useRef3(false);
const disableEffectsRef = useRef3(false);
const preventPositionResetRef = useRef3(false);
const dataArray = data.length > 0 ? [...data] : [...generateRange(min, max)];
const initialState = {
mounted: false,
isDragging: false,
width,
radius: width / 2,
knobOffset: getKnobOffsetAmount(knobPosition),
label: initialValue,
data: dataArray,
radians: 0,
offset: 0,
knob: { x: 0, y: 0 },
dashFullArray: 0,
dashFullOffset: 0
};
const isServer = useIsServer_default();
const [state, dispatch] = useReducer(reducer_default, initialState);
const circularSlider = useRef3(null);
const svgFullPath = useRef3(null);
const touchSupported = !isServer && "ontouchstart" in window;
const useMouse = !touchSupported || touchSupported && useMouseAdditionalToTouch;
const valueFromParentRef = useRef3(void 0);
const initCompletedRef = useRef3(false);
const isMountedRef = useRef3(false);
const positionSetByDragRef = useRef3(false);
const setKnobPosition = useCallback((radians, fromDrag = false) => {
if (disableEffectsRef.current && !fromDrag) return;
if (fromDrag) {
positionSetByDragRef.current = true;
setTimeout(() => {
positionSetByDragRef.current = false;
}, 300);
}
const radius = state.radius - trackSize / 2;
const offsetRadians = radians + getKnobOffsetAmount(knobPosition);
let degrees = (offsetRadians > 0 ? offsetRadians : 2 * Math.PI + offsetRadians) * spreadDegrees / (2 * Math.PI);
degrees = getSliderRotation(direction) === -1 ? spreadDegrees - degrees : degrees;
const dashOffset = degrees / spreadDegrees * state.dashFullArray;
const dashOffsetValue = getSliderRotation(direction) === -1 ? dashOffset : state.dashFullArray - dashOffset;
const dataArrayLength = state.data.length;
const normalizedDegrees = (degrees + 360) % 360;
const dataPointIndex = Math.round(normalizedDegrees / 360 * (dataArrayLength - 1));
const safeIndex = Math.min(Math.max(0, dataPointIndex), dataArrayLength - 1);
const labelValue = state.data[safeIndex];
const knobXY = {
x: radius * Math.cos(radians) + radius,
y: radius * Math.sin(radians) + radius
};
const isZeroValue = !fromDrag && dataIndex === 0 || !fromDrag && safeIndex === 0 && dataArrayLength > 1 || typeof labelValue === "number" && labelValue === 0 && !fromDrag || typeof labelValue === "string" && labelValue === "0" && !fromDrag;
const finalDashOffset = isZeroValue ? state.dashFullArray : dashOffsetValue;
currentPositionRef.current = {
radians,
label: labelValue,
knob: knobXY,
dashOffset: finalDashOffset
};
if (labelValue !== state.label && initCompletedRef.current) {
onChange(labelValue);
}
dispatch({
type: "setKnobPosition",
payload: {
dashFullOffset: finalDashOffset,
label: labelValue,
knob: knobXY
}
});
}, [state, trackSize, knobPosition, direction, onChange, dataIndex]);
const positionForDataIndex = useCallback(() => {
if (!state.mounted || state.data.length === 0) return;
if (positionSetByDragRef.current) return;
if (preventPositionResetRef.current) return;
const dataIndexSafe = Math.min(Math.max(0, dataIndex), state.data.length - 1);
const degrees = dataIndexSafe / (state.data.length - 1) * 360 * getSliderRotation(direction);
const radians = getRadians(degrees) - state.knobOffset;
setKnobPosition(radians);
lastDataIndexRef.current = dataIndex;
}, [dataIndex, state.mounted, state.data.length, state.knobOffset, direction, setKnobPosition]);
const onMouseDown = (event) => {
if (clickInProgressRef.current) {
event.preventDefault();
event.stopPropagation();
return;
}
draggingRef.current = true;
disableEffectsRef.current = true;
clickInProgressRef.current = true;
preventPositionResetRef.current = true;
isDragging(true);
dispatch({ type: "onMouseDown", payload: { isDragging: true } });
setTimeout(() => {
clickInProgressRef.current = false;
}, 100);
};
const onMouseUp = () => {
if (state.isDragging) {
isDragging(false);
draggingRef.current = false;
dispatch({ type: "onMouseUp", payload: { isDragging: false } });
setTimeout(() => {
if (isMountedRef.current) {
disableEffectsRef.current = false;
setTimeout(() => {
if (isMountedRef.current) {
preventPositionResetRef.current = false;
}
}, 250);
}
}, 50);
}
};
const onMouseMove = useCallback((event) => {
if (!state.isDragging || !knobDraggable && !trackDraggable || event.type === "mousemove" && !useMouse) return;
event.preventDefault();
const touch = event.type === "touchmove" ? event.changedTouches[0] : null;
const getOffset = (ref2) => {
var _a, _b, _c, _d;
const element = ref2.current;
if (!element) {
return { top: 0, left: 0 };
}
const rect = element.getBoundingClientRect();
const scrollLeft = (_b = (_a = window.pageXOffset) != null ? _a : document.documentElement.scrollLeft) != null ? _b : 0;
const scrollTop = (_d = (_c = window.pageYOffset) != null ? _c : document.documentElement.scrollTop) != null ? _d : 0;
return {
top: rect.top + scrollTop,
left: rect.left + scrollLeft
};
};
const offset = getOffset(circularSlider);
const pageX = touch ? touch.pageX : event.pageX;
const pageY = touch ? touch.pageY : event.pageY;
const mouseX = pageX - (offset.left + state.radius);
const mouseY = pageY - (offset.top + state.radius);
const radians = Math.atan2(mouseY, mouseX);
setKnobPosition(radians, true);
}, [state.isDragging, state.radius, knobDraggable, trackDraggable, useMouse, setKnobPosition]);
const refresh = useCallback(() => {
var _a;
if (!circularSlider.current || !svgFullPath.current || !isMountedRef.current) return;
if (draggingRef.current) return;
const newWidth = width;
const newRadius = newWidth / 2;
dispatch({
type: "updateDimensions",
payload: {
width: newWidth,
radius: newRadius,
dashFullArray: ((_a = svgFullPath.current) == null ? void 0 : _a.getTotalLength()) || 0
}
});
if (currentPositionRef.current) {
setTimeout(() => {
var _a2, _b;
if (!draggingRef.current && currentPositionRef.current && isMountedRef.current) {
disableEffectsRef.current = true;
setKnobPosition((_b = (_a2 = currentPositionRef.current) == null ? void 0 : _a2.radians) != null ? _b : 0);
setTimeout(() => {
if (isMountedRef.current) {
disableEffectsRef.current = false;
}
}, 50);
}
}, 10);
}
}, [width, setKnobPosition]);
useImperativeHandle(ref, () => ({
refresh
}));
useEffect3(() => {
var _a, _b, _c;
isMountedRef.current = true;
disableEffectsRef.current = true;
dispatch({
type: "init",
payload: {
mounted: true,
dashFullArray: (_c = (_b = (_a = svgFullPath.current) == null ? void 0 : _a.getTotalLength) == null ? void 0 : _b.call(_a)) != null ? _c : 0
}
});
setTimeout(() => {
if (isMountedRef.current) {
disableEffectsRef.current = false;
}
}, 100);
return () => {
isMountedRef.current = false;
};
}, []);
useEffect3(() => {
if (!state.mounted || state.dashFullArray === 0 || disableEffectsRef.current || initCompletedRef.current) return;
dispatch({
type: "setInitialKnobPosition",
payload: {
radians: Math.PI / 2 - state.knobOffset,
offset: 0
}
});
positionForDataIndex();
initCompletedRef.current = true;
}, [state.mounted, state.dashFullArray, state.knobOffset, positionForDataIndex]);
useEffect3(() => {
if (!state.mounted || !initCompletedRef.current || disableEffectsRef.current || draggingRef.current) return;
if (dataIndex !== lastDataIndexRef.current) {
positionForDataIndex();
}
}, [dataIndex, positionForDataIndex, state.mounted]);
useEffect3(() => {
if (!state.mounted || disableEffectsRef.current || draggingRef.current || preventPositionResetRef.current) return;
if (typeof value === "number" && value !== valueFromParentRef.current) {
valueFromParentRef.current = value;
const radians = getRadians(value);
const offsetRadians = -state.knobOffset + radians * getSliderRotation(direction);
setTimeout(() => {
if (!draggingRef.current && isMountedRef.current && !preventPositionResetRef.current) {
setKnobPosition(offsetRadians);
}
}, 0);
}
}, [direction, state.knobOffset, value, state.mounted, setKnobPosition]);
useEffect3(() => {
if (typeof ResizeObserver === "undefined") return;
const observeResize = () => {
var _a;
if (circularSlider.current) {
resizeObserverRef.current = new ResizeObserver(() => {
if (!draggingRef.current && isMountedRef.current) {
refresh();
}
});
(_a = resizeObserverRef.current) == null ? void 0 : _a.observe(circularSlider.current);
}
};
observeResize();
return () => {
var _a;
if (resizeObserverRef.current) {
(_a = resizeObserverRef.current) == null ? void 0 : _a.disconnect();
}
};
}, [refresh]);
useEffect3(() => {
const handleResize = () => {
if (!draggingRef.current && isMountedRef.current) {
refresh();
}
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [refresh]);
useEventListener_default("touchend", onMouseUp);
useEventListener_default("mouseup", onMouseUp);
useEventListener_default("touchmove", onMouseMove);
useEventListener_default("mousemove", onMouseMove);
const sanitizedLabel = label.replace(/[^a-zA-Z0-9-_]/g, "_");
const sliderStyle = {
position: "relative",
display: "inline-block",
width: "max-content",
opacity: state.mounted ? 1 : 0,
transition: "opacity 1s ease-in"
};
const displayValue = typeof valueFromParentRef.current !== "undefined" ? `${valueFromParentRef.current}` : `${state.label}`;
return /* @__PURE__ */ React4.createElement("div", { style: sliderStyle, ref: circularSlider }, /* @__PURE__ */ React4.createElement(
Svg_default,
{
width,
label: sanitizedLabel,
direction,
strokeDasharray: state.dashFullArray,
strokeDashoffset: state.dashFullOffset,
svgFullPath,
progressSize,
progressColorFrom,
progressColorTo,
progressLineCap,
trackColor,
trackSize,
radiansOffset: state.radians,
onMouseDown: trackDraggable ? onMouseDown : void 0,
isDragging: state.isDragging
}
), /* @__PURE__ */ React4.createElement(
Knob_default,
{
isDragging: state.isDragging,
knobPosition: state.knob,
knobSize,
knobColor,
trackSize,
hideKnob,
hideKnobRing,
knobDraggable,
onMouseDown
},
children
), renderLabelValue != null ? renderLabelValue : /* @__PURE__ */ React4.createElement(
Labels_default,
{
label,
labelColor,
labelBottom,
labelFontSize,
verticalOffset,
valueFontSize,
appendToValue,
prependToValue,
hideLabelValue,
value: displayValue
}
));
});
var CircularSlider_default = CircularSlider;
// src/index.ts
var index_default = CircularSlider_default;
export {
index_default as default
};
//# sourceMappingURL=index.mjs.map