I have implemented a dat-gui range slider lookalike in React (with material-ui). Perhaps this will help someone trying to create their own, so I thought I'd post it here. Here's what the slider looks like
import React, { useEffect, useRef, useCallback } from 'react';
import cx from 'classnames';
import { makeStyles, Theme, createStyles } from '@material-ui/core';
import clamp from 'lodash/clamp';
import useEventCallback from 'utils/useEventCallback';
function asc(a: number, b: number) {
return a - b;
}
function findClosest(values: any, currentValue: number) {
const { index: closestIndex } = values.reduce((acc: { distance: number; } | null, value: number, index: number) => {
const distance = Math.abs(currentValue - value);
if (acc === null || distance < acc.distance || distance === acc.distance) {
return {
distance,
index,
};
}
return acc;
}, null);
return closestIndex;
}
function valueToPercent(value: number, min: number, max: number) {
return ((value - min) * 100) / (max - min);
}
function percentToValue(percent: number, min: number, max: number) {
return (max - min) * percent + min;
}
function getDecimalPrecision(num: number) {
// This handles the case when num is very small (0.00000001), js will turn this into 1e-8.
// When num is bigger than 1 or less than -1 it won't get converted to this notation so it's fine.
if (Math.abs(num) < 1) {
const parts = num.toExponential().split('e-');
const matissaDecimalPart = parts[0].split('.')[1];
return (
(matissaDecimalPart ? matissaDecimalPart.length : 0) +
parseInt(parts[1], 10)
);
}
const decimalPart = num.toString().split('.')[1];
return decimalPart ? decimalPart.length : 0;
}
function roundValueToStep(value: number, step: number) {
const nearest = Math.round(value / step) * step;
return Number(nearest.toFixed(getDecimalPrecision(step)));
}
function setValueIndex({ values, source, newValue, index }: any) {
// Performance shortcut
if (values[index] === newValue) {
return source;
}
const output = [...values];
output[index] = newValue;
return output;
}
const axisProps = {
offset: (percent: number) => ({ left: `${percent}%` }),
leap: (percent: number) => ({ width: `${percent}%` }),
};
const trackMouse = (event: React.MouseEvent) => ({ x: event.clientX, y: event.clientY, });
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
width: '100%',
boxSizing: 'border-box',
display: 'inline-block',
cursor: 'ew-resize',
touchAction: 'none',
border: '3px solid #1a1a1a',
},
slider: {
display: 'block',
position: 'relative',
backgroundColor: '#141414',
backgroundImage: `linear-gradient(90deg, ${theme.palette.primary.light}, ${theme.palette.primary.light})`, //#2fa1d6, #2fa1d6)",
backgroundRepeat: 'no-repeat',
height: '14px',
},
})
)
interface Props {
values: number[];
min: number;
max: number;
step: number;
className?: string;
color?: string;
defaultValue?: number[];
disabled?: boolean;
onChange?: (event: React.MouseEvent, value: any) => void;
onChangeCommitted?: (event: React.MouseEvent, value: any) => void;
onMouseDown?: (event: React.MouseEvent) => void;
}
export default function RangeSlider({
className,
color = 'primary',
defaultValue,
disabled = false,
max,
min,
onChange,
onChangeCommitted,
onMouseDown,
step,
values: valuesProp,
...other
}: Props) {
const classes = useStyles();
const sliderRef = useRef<any>();
const previousIndex = useRef<any>();
let values = [...valuesProp].sort(asc);
values = values.map((value: number) => clamp(value, min, max));
const getNewValue = useCallback(
({ mouse, move = false, values: values2, source }) => {
const { current: slider } = sliderRef;
const { width, left } = slider.getBoundingClientRect();
const percent = (mouse.x - left) / width;
let newValue;
newValue = percentToValue(percent, min, max);
newValue = roundValueToStep(newValue, step);
newValue = clamp(newValue, min, max);
let activeIndex = 0;
if (!move) {
activeIndex = findClosest(values2, newValue);
} else {
activeIndex = previousIndex.current;
}
const previousValue = newValue;
newValue = setValueIndex({
values: values2,
source,
newValue,
index: activeIndex,
}).sort(asc);
activeIndex = newValue.indexOf(previousValue);
previousIndex.current = activeIndex;
return { newValue, activeIndex };
},
[max, min, step]
);
const handleMouseMove = useEventCallback((event: React.MouseEvent) => {
const mouse = trackMouse(event);
const { newValue } = getNewValue({
mouse,
move: true,
values,
source: valuesProp,
});
if (onChange) {
onChange(event, newValue);
}
});
const handleMouseEnter = useEventCallback((event: React.MouseEvent) => {
// If the slider was being interacted with but the mouse went off the window
// and then re-entered while unclicked then end the interaction.
if (event.buttons === 0) {
handleMouseEnd(event);
}
});
const handleMouseEnd = useEventCallback((event: React.MouseEvent) => {
const mouse = trackMouse(event);
const { newValue } = getNewValue({
mouse,
values,
source: valuesProp,
});
if (onChangeCommitted) {
onChangeCommitted(event, newValue);
}
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseEnd);
window.removeEventListener('mouseenter', handleMouseEnter);
});
useEffect(() => {
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseEnd);
window.removeEventListener('mouseenter', handleMouseEnter);
};
}, [disabled, handleMouseEnter, handleMouseEnd, handleMouseMove]);
const handleMouseDown = useEventCallback((event: React.MouseEvent) => {
if (onMouseDown) {
onMouseDown(event);
}
if (disabled) {
return;
}
event.preventDefault();
const mouse = trackMouse(event);
const { newValue } = getNewValue({
mouse,
values,
source: valuesProp,
});
if (onChange) {
onChange(event, newValue);
}
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseenter', handleMouseEnter);
window.addEventListener('mouseup', handleMouseEnd);
});
const sliderOffset = valueToPercent(values[0], min, max);
const sliderLeap = valueToPercent(values[values.length - 1], min, max) - sliderOffset;
const widthBackground = axisProps.leap(sliderLeap).width;
const sliderStyle = {
...axisProps.offset(sliderOffset),
...axisProps.leap(sliderLeap),
backgroundSize: `${widthBackground}% 100%`,
};
return (
<span
ref={sliderRef}
className={cx(classes.root, className)}
onMouseDown={handleMouseDown}
{...other}
>
<span className={classes.slider} style={sliderStyle} />
{values.map((value, index) => {
const percent = valueToPercent(value, min, max);
const style = axisProps.offset(percent);
return (
<span
key={index}
role="slider"
style={style}
data-index={index}
/>
);
})}
</span>
);
}
RangeSlider.defaultProps = {
min: 0,
max: 100,
step: 1
}