import { type CSSProperties, type FC, type MouseEvent as RMouseEvent, useCallback } from "react";
|
import { cn } from "../../utils/bem";
|
import { clamp, isDefined } from "../../utils/utilities";
|
import { useValueTracker } from "../Utils/useValueTracker";
|
import "./Range.scss";
|
|
type RangeAlignment = "horizontal" | "vertical";
|
|
type RangeValueType = number | number[] | string;
|
|
export interface RangeProps {
|
value?: RangeValueType;
|
defaultValue?: number;
|
multi?: boolean;
|
reverse?: boolean;
|
continuous?: boolean;
|
min?: number;
|
max?: number;
|
step?: number;
|
size?: number;
|
align?: RangeAlignment;
|
minIcon?: JSX.Element;
|
maxIcon?: JSX.Element;
|
resetValue?: RangeValueType;
|
onChange?: (value: RangeValueType) => void;
|
onMinIconClick?: (value: RangeValueType) => void;
|
onMaxIconClick?: (value: RangeValueType) => void;
|
}
|
|
const arrayReverse = <T extends any[] = any[]>(array: T, reverse = false) => {
|
return reverse ? [...array].reverse() : array;
|
};
|
|
export const Range: FC<RangeProps> = ({
|
value,
|
defaultValue,
|
multi = false,
|
reverse = false,
|
continuous = false,
|
min = 0,
|
max = 100,
|
step = 1,
|
size = 120,
|
align = "horizontal",
|
resetValue,
|
minIcon,
|
maxIcon,
|
onChange,
|
onMinIconClick,
|
onMaxIconClick,
|
}) => {
|
const initialValue = value ?? defaultValue ?? (multi ? [0, 100] : 0);
|
|
const [currentValue, setValue] = useValueTracker<RangeValueType>(initialValue, defaultValue ?? initialValue);
|
|
let currentValueShadow = currentValue;
|
|
const isMultiArray = multi && Array.isArray(currentValue);
|
|
const roundToStep = (value: number) => {
|
return clamp(Math.round(value / step) * step, min, max);
|
};
|
|
const updateValue = (value: RangeValueType, notify = true, force = false) => {
|
const newValue = multi && Array.isArray(value) ? value.map(roundToStep) : roundToStep(value as number);
|
|
if (currentValueShadow !== newValue || force) {
|
setValue(newValue);
|
if (notify || continuous || force) onChange?.(value);
|
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;
|
if (onMaxIconClick) return onMaxIconClick(currentValue);
|
updateValue((currentValue as number) + step);
|
}, [step, multi, currentValue]);
|
|
const decrease = useCallback(() => {
|
if (multi) return;
|
if (onMinIconClick) return onMinIconClick(currentValue);
|
updateValue((currentValue as number) - step);
|
}, [step, multi, currentValue]);
|
|
const onClick = useCallback(
|
(e: RMouseEvent<HTMLElement>) => {
|
const target = e.currentTarget as HTMLElement;
|
const rect = target.getBoundingClientRect();
|
const isHorizontal = align === "horizontal";
|
|
// Extract all the values regarding current orientation
|
const directionDimension = isHorizontal ? rect.width : rect.height;
|
const parentOffset = isHorizontal ? rect.left : rect.top;
|
const mousePosition = isHorizontal ? e.clientX : e.clientY;
|
|
// Calculate relative offset
|
const offset = clamp(mousePosition - parentOffset, 0, directionDimension);
|
const position = offset / directionDimension;
|
let newValue = (max - min) * position + min;
|
|
if (reverse) newValue = max - newValue;
|
|
if (multi && Array.isArray(currentValue)) {
|
const valueIndex = position > 0.5 ? 1 : 0;
|
const patch = [...currentValue];
|
|
patch[valueIndex] = newValue;
|
|
updateValue(patch, true, false);
|
} else {
|
updateValue(newValue, true, false);
|
}
|
},
|
[align, min, max, reverse, currentValue],
|
);
|
|
const sizeProperty = align === "horizontal" ? "minWidth" : "minHeight";
|
|
return (
|
<div className={cn("range").mod({ align }).toClassName()} style={{ [sizeProperty]: size }}>
|
{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").toClassName()} onClick={onClick}>
|
<div className={cn("range").elem("line").toClassName()} />
|
<RangeIndicator align={align} reverse={reverse} value={currentValue} valueConvert={valueToPercentage} />
|
{isMultiArray ? (
|
arrayReverse(currentValue, reverse).map((value, i) => {
|
const index = reverse ? (i === 0 ? 1 : 0) : i;
|
const preservedValueIndex = index === 0 ? 1 : 0;
|
|
const getValue = (val: number) => {
|
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}
|
bodySize={size}
|
reverse={reverse}
|
resetValue={(resetValue as number[])[index]}
|
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}
|
resetValue={resetValue as number}
|
onChangePosition={(val) => updateValue(val, false)}
|
onChange={(val) => updateValue(val, true, true)}
|
/>
|
)}
|
</div>
|
{reverse
|
? minIcon && (
|
<div className={cn("range").elem("icon").toClassName()} onMouseDown={decrease}>
|
{minIcon}
|
</div>
|
)
|
: maxIcon && (
|
<div className={cn("range").elem("icon").toClassName()} onMouseDown={increase}>
|
{maxIcon}
|
</div>
|
)}
|
</div>
|
);
|
};
|
|
export interface RangeHandleProps {
|
align: RangeAlignment;
|
bodySize: number;
|
reverse: boolean;
|
resetValue: number;
|
value: RangeValueType;
|
valueConvert: (value: RangeValueType) => number;
|
offsetConvert: (value: RangeValueType) => number;
|
onChangePosition: (value: number) => void;
|
onChange: (value: number) => void;
|
}
|
|
const RangeHandle: FC<RangeHandleProps> = ({
|
value,
|
valueConvert,
|
offsetConvert,
|
onChangePosition,
|
onChange,
|
resetValue,
|
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: MouseEvent) => {
|
e.stopPropagation();
|
|
const initialOffset = e[mouseProperty];
|
let newValue: number;
|
|
const handleMouseMove = (e: MouseEvent) => {
|
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 = (e: MouseEvent) => {
|
e.stopPropagation();
|
|
if (isDefined(newValue)) onChange?.(newValue);
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mouseup", handleMouseUp);
|
};
|
|
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mouseup", handleMouseUp);
|
};
|
|
const handleDoubleClick = () => {
|
if (isDefined(resetValue)) {
|
onChange?.(resetValue);
|
}
|
};
|
|
return (
|
<div
|
className={cn("range").elem("range-handle").toClassName()}
|
style={{ [offsetProperty]: `${valueConvert(value)}%` }}
|
onMouseDownCapture={handleMouseDown}
|
onDoubleClick={handleDoubleClick}
|
/>
|
);
|
};
|
|
export interface RangeIndicatorProps {
|
value: RangeValueType;
|
valueConvert: (value: RangeValueType) => number;
|
align: RangeAlignment;
|
reverse: boolean;
|
}
|
|
const RangeIndicator: FC<RangeIndicatorProps> = ({ value, valueConvert, align, reverse }) => {
|
const style: CSSProperties = {};
|
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} />;
|
};
|