UNPKG

@fseehawer/react-circular-slider

Version:

A customizable circular slider with no dependencies.

687 lines (678 loc) 23.3 kB
"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