import { forwardRef, useCallback } from "react";
|
import { cn } from "../../../utils/bem";
|
import { isDefined } from "../../../utils/utils";
|
import { clamp } from "../../../utils/helpers";
|
import { useValueTracker } from "../Form/Utils";
|
import "./Range.scss";
|
|
const arrayReverse = (array, reverse = false) => {
|
return reverse ? [...array].reverse() : array;
|
};
|
|
export const Range = forwardRef(
|
(
|
{
|
name,
|
value,
|
defaultValue,
|
onChange,
|
onPositionChange,
|
multi = false,
|
reverse = false,
|
continuous = false,
|
min = 0,
|
max = 100,
|
step = 1,
|
size = 120,
|
align = "horizontal",
|
minIcon,
|
maxIcon,
|
},
|
ref,
|
) => {
|
const initialValue = value ?? defaultValue ?? (multi ? [0, 100] : 0);
|
const [currentValue, setValue] = useValueTracker(initialValue, defaultValue ?? initialValue);
|
let currentValueShadow = currentValue;
|
|
const roundToStep = (value) => {
|
return clamp(Math.round(value / step) * step, min, max);
|
};
|
|
const updateValue = (value, notify = true, force = false) => {
|
const newValue = multi ? value.map(roundToStep) : roundToStep(value);
|
|
onPositionChange?.(newValue);
|
|
if (currentValueShadow !== newValue || force) {
|
setValue(newValue);
|
if (notify || continuous || force) {
|
onChange?.(newValue);
|
}
|
currentValueShadow = newValue;
|
}
|
};
|
|
const valueToPercentage = useCallback(
|
(value) => {
|
const realMax = max - min;
|
const realValue = value - min;
|
|
return (realValue / realMax) * 100;
|
},
|
[min, max],
|
);
|
|
const offsetToValue = useCallback(
|
(offset) => {
|
const realMax = max - min;
|
const value = clamp(realMax * (offset / size) + min, min, max);
|
|
return value;
|
},
|
[min, max, size],
|
);
|
|
const increase = useCallback(() => {
|
if (multi) return;
|
updateValue(currentValue + step);
|
}, [step, multi, currentValue]);
|
|
const decrease = useCallback(() => {
|
if (multi) return;
|
updateValue(currentValue - step);
|
}, [step, multi, currentValue]);
|
|
const sizeProperty = align === "horizontal" ? "minWidth" : "minHeight";
|
|
return (
|
<div className={cn("range").mod({ align }).toClassName()}>
|
<input
|
ref={ref}
|
type="hidden"
|
name={name}
|
value={currentValue}
|
data-type="number"
|
onChange={(e) => {
|
const value = multi
|
? e.target.value.split(",").map((v) => Number.parseInt(v))
|
: Number.parseInt(e.target.value);
|
|
updateValue(value);
|
}}
|
/>
|
|
{reverse
|
? maxIcon && (
|
<div className={cn("range").elem("icon").toClassName()} onMouseDown={increase}>
|
{maxIcon}
|
</div>
|
)
|
: minIcon && (
|
<div className={cn("range").elem("icon").toClassName()} onMouseDown={decrease}>
|
{minIcon}
|
</div>
|
)}
|
<div
|
className={cn("range")
|
.elem("body")
|
.mod({ "with-icon": isDefined(minIcon) || isDefined(maxIcon) })
|
.toClassName()}
|
style={{ [sizeProperty]: size }}
|
>
|
<div className={cn("range").elem("line").toClassName()} />
|
<RangeIndicator align={align} reverse={reverse} value={currentValue} valueConvert={valueToPercentage} />
|
{multi ? (
|
arrayReverse(currentValue, reverse).map((value, i, list) => {
|
const index = reverse ? (i === 0 ? 1 : 0) : i;
|
const preservedValueIndex = index === 0 ? 1 : 0;
|
|
const getValue = (val) => {
|
const result = [];
|
const secondValue = currentValue[preservedValueIndex];
|
|
result[index] = index === 0 ? clamp(val, min, secondValue) : clamp(val, secondValue, max);
|
result[preservedValueIndex] = currentValue[preservedValueIndex];
|
|
return result;
|
};
|
|
return (
|
<RangeHandle
|
key={`handle-${index}`}
|
align={align}
|
value={value}
|
values={list}
|
bodySize={size}
|
reverese={reverse}
|
valueConvert={valueToPercentage}
|
offsetConvert={offsetToValue}
|
onChangePosition={(val) => updateValue(getValue(val), false)}
|
onChange={(val) => updateValue(getValue(val), true, true)}
|
/>
|
);
|
})
|
) : (
|
<RangeHandle
|
align={align}
|
bodySize={size}
|
reverse={reverse}
|
value={currentValue}
|
valueConvert={valueToPercentage}
|
offsetConvert={offsetToValue}
|
onChangePosition={(val) => updateValue(val, false)}
|
onChange={(val) => updateValue(val, true, true)}
|
/>
|
)}
|
</div>
|
{}
|
</div>
|
);
|
},
|
);
|
|
const RangeHandle = ({
|
value,
|
valueConvert,
|
offsetConvert,
|
onChangePosition,
|
onChange,
|
align,
|
bodySize,
|
reverse = false,
|
}) => {
|
const currentOffset = valueConvert(value);
|
const offsetProperty = align === "horizontal" ? (reverse ? "right" : "left") : reverse ? "bottom" : "top";
|
const mouseProperty = align === "horizontal" ? "pageX" : "pageY";
|
|
const handleMouseDown = (e) => {
|
const initialOffset = e[mouseProperty];
|
let newValue;
|
|
const handleMouseMove = (e) => {
|
const mouseOffset = reverse ? initialOffset - e[mouseProperty] : e[mouseProperty] - initialOffset;
|
const offset = clamp(mouseOffset + (currentOffset / 100) * bodySize, 0, bodySize);
|
|
newValue = offsetConvert(offset);
|
|
requestAnimationFrame(() => {
|
onChangePosition?.(newValue);
|
});
|
};
|
|
const handleMouseUp = () => {
|
onChange?.(newValue);
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mouseup", handleMouseUp);
|
};
|
|
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mouseup", handleMouseUp);
|
};
|
|
return (
|
<div
|
className={cn("range").elem("range-handle").toClassName()}
|
style={{ [offsetProperty]: `${valueConvert(value)}%` }}
|
onMouseDownCapture={handleMouseDown}
|
/>
|
);
|
};
|
|
const RangeIndicator = ({ value, valueConvert, align, reverse }) => {
|
const style = {};
|
const multi = Array.isArray(value);
|
|
if (align === "horizontal") {
|
if (multi) {
|
style.left = `${valueConvert(value[0])}%`;
|
style.right = `${100 - valueConvert(value[1])}%`;
|
} else {
|
style.left = 0;
|
style.right = `${100 - valueConvert(value)}%`;
|
}
|
|
if (reverse && !multi) [style.left, style.right] = [style.right, style.left];
|
} else if (align === "vertical") {
|
if (multi) {
|
style.top = `${valueConvert(value[0])}%`;
|
style.bottom = `${100 - valueConvert(value[1])}%`;
|
} else {
|
style.top = 0;
|
style.bottom = `${100 - valueConvert(value)}%`;
|
}
|
|
if (reverse && !multi) [style.top, style.bottom] = [style.bottom, style.top];
|
}
|
|
return <div className={cn("range").elem("indicator").toClassName()} style={style} />;
|
};
|