TanStack/ranger

Is there a way to click on a Track to set a new Range value ?

Marco-exports opened this issue · 8 comments

Hello,

We have switched to "react-ranger" from "tajo/react-range", in order to work with -- and for compatibility -- with Hooks...

Our web pages use many range sliders, and our users find it tedious to mouse-click and drag a button...

Is there a way to "click" on the Track, so that it will change the value -- and move the button ?

Thanks !

After a few variations of trying to use "Ref" hooks inside the react_ranger code, I finally found a way to implement this click-move feature from outside the hook:

export default function Slider(props) {

const [width, setWidth] = React.useState(0)

... various UseEffect functions ...

React.useEffect(() => {
let elem = document.getElementById("tracked")
const coords = elem.getBoundingClientRect()
setWidth(Math.ceil(coords.width))
}, [])

const showClick= (e) => {
e.preventDefault()
var x = e.nativeEvent.offsetX
if(values.length === 1){setValues([Math.round(x / width * 100)])}
}

... and finally ...

return (

        <div id="tracked" className={'track'} {...getTrackProps()} onClick={showClick}>
           {segments.map(({ getSegmentProps }, i) => (<div {...getSegmentProps()} index={i} />))}
           {handles.map(({ value, getHandleProps }) => (
              <button className={'slideButton'} {...getHandleProps()} onClick={e => e.stopPropagation()}>
                 <div className={'handle'}>{value}</div>
              </button>
           ))}
        </div>

I'd like to see this added as well

I also find it tedious to click on the track bar instead of picking the thumb. A must have imho.

Thanks for the example above @Marco-exports 🙏

I've taken it and expanded on it to support multi handle rangers too, I cant be the only one who needed this use case!

import clsx from "clsx";
import { useRef } from "react";
import { RangerOptions, useRanger } from "react-ranger";

export type SliderProps = {
    min?: number;
    max?: number;
    stepSize?: number;
    values: number[];
    onChange: RangerOptions["onChange"];
    colorClassName?: string;
    label: string;
    labelHidden?: boolean;
};

export const Slider: React.FC<SliderProps> = ({ min = 1, max = 100, stepSize = 5, values, onChange, colorClassName = "bg-ui-base-text-secondary", label, labelHidden = false }) => {
    const trackRef = useRef<HTMLDivElement>(null);
    const { getTrackProps, handles, segments } = useRanger({
        min,
        max,
        stepSize,
        values,
        onChange,
    });

    const handleTrackOnClick = (e: React.MouseEvent<HTMLElement>) => {
        e.preventDefault();

        if (!onChange || !trackRef.current) {
            return;
        }

        const clickPosition = e.clientX - trackRef.current.getBoundingClientRect().left;
        const trackWidth = trackRef.current.getBoundingClientRect().width;
        const x = Math.max(min, Math.round((clickPosition / trackWidth) * max));
        const closestCurrentValue = values.sort((a, b) => Math.abs(a - x) - Math.abs(b - x))[0];

        const nextValues = values
            .filter((v) => v !== closestCurrentValue)
            .concat(x)
            .sort((a, b) => a - b);

        onChange(nextValues);
    };

    return (
        <label>
            <span className={clsx("input__label", labelHidden && "hidden")}>{label}</span>
            <div
                {...getTrackProps({
                    className: "rounded bg-ui-base-3 h-[0.3rem] w-full shadow-sm cursor-pointer",
                    id: "tracked",
                    onClick: handleTrackOnClick,
                    ref: trackRef,
                })}
            >
                {segments.map(({ getSegmentProps }, i) => {
                    // Only render segments with values
                    if (i === values.length || (values.length > 1 && i === 0)) {
                        return null;
                    }

                    return (
                        <div
                            {...getSegmentProps({
                                className: `${colorClassName} h-full rounded`,
                            })}
                            key={`${label}-slider-segment-${i}`}
                        />
                    );
                })}
                {handles.map(({ getHandleProps }, i) => (
                    <div key={`${label}-slider-handle-${i}`}>
                        <button
                            {...getHandleProps({
                                className: `${colorClassName} w-xs h-xs rounded-full shadow-md`,
                            })}
                        />
                    </div>
                ))}
            </div>
        </label>
    );
};

I came up with this (currently unfinished idea) :

  • use concept from #21 (comment)
  • bind a onPointerMove handler on my track to handlePointerMove
  • unfinished concept for handlePointerMove:
    • on pointerMove, test if mouse buttons are down (verify how this works on touch screens)
    • if no touching/no buttons, bail here
    • use location of touch/drag to find nearest handles[number], extract onChange from its getHandleProps and run that.

This way you can click and drag on the track and it moves the nearest handle to where ever your pointer is dragging.

function RangeInput({
  name,
  values,
  min = 1,
  max = 100,
  stepSize = 1,
  showTicks,
  className,
  onChange,
}: {
  name: string;
  min?: number;
  max?: number;
  values: string[];
  stepSize?: number;
  className?: string;
  showTicks?: boolean;
  onChange: (value: string[]) => void;
}) {
  const [numberValues, setNumberValues] = useState(() =>
    (values || []).map((value) => parseInt(value))
  );

  const trackRef = useRef<HTMLDivElement>(null);

  const handleChange = useCallback(
    (values: number[]) => {
      setNumberValues(values);
      onChange(values.map((value) => value.toString()));
    },
    [onChange]
  );

  const { getTrackProps, ticks, segments, handles } = useRanger({
    min,
    max,
    stepSize,
    values: numberValues,
    onChange: handleChange,
    onDrag: handleChange,
  });

  const handleTrackOnClick = useCallback(
    (event: React.MouseEvent<HTMLElement>) => {
      event.preventDefault();
      if (!onChange || !trackRef.current) {
        return;
      }

      const clickPosition =
        event.clientX - trackRef.current.getBoundingClientRect().left;
      const trackWidth = trackRef.current.getBoundingClientRect().width;
      const x = Math.max(min, Math.round((clickPosition / trackWidth) * max));
      const closestCurrentValue = numberValues.sort(
        (a, b) => Math.abs(a - x) - Math.abs(b - x)
      )[0];

      const nextValues = numberValues
        .filter((v) => v !== closestCurrentValue)
        .concat(x)
        .sort((a, b) => a - b);

      setNumberValues(() => {
        return nextValues;
      });

      handleChange(nextValues);
    },
    [handleChange, max, min, numberValues, onChange]
  );

  return (
    <div
      className={classnames("flex w-full h-4 my-2 items-center", className)}
      ref={trackRef}
    >
      <div
        {...getTrackProps()}
        className={classnames("block  w-full h-1 bg-gray-200 cursor-pointer")}
        onClick={handleTrackOnClick}
        // TODO: this will make the handle move, but which one?
        // use position to find closest handles[number], extract props from
        // its getHandleProps() and run the onChange handler from that
        // onPointerMove={(event) => {
        //
        //   if (event.buttons > 0) handleTrackOnClick(event);
        // }}
      >
        {showTicks &&
          ticks.map(({ value, getTickProps }) => (
            <div className="h-2" {...getTickProps()} key={value}>
              <div>{value}</div>
            </div>
          ))}

        {segments.map(({ getSegmentProps }, index) => (
          <div
            {...getSegmentProps({})}
            className={classnames("h-1", ["bg-blue-300", "bg-blue-100"][index])}
            key={`${name}-slider-segment-${index}`}
          />
        ))}

        {handles.map(({ value, active, getHandleProps }, index) => (
          <div
            {...getHandleProps()}
            className="flex items-center justify-center"
            key={`${name}-slider-handle-${index}`}
          >
            <div
              className={classnames(
                "absolute",
                "flex items-center justify-center",
                "rounded-full min-w-8 h-8 px-4",
                "transition-all",
                "text-white bg-blue-500",
                active && "font-bold ring"
              )}
              style={{
                transform: active
                  ? `translateY(-50%) scale(1.1)`
                  : "translateY(0) scale(0.9)",
              }}
            >
              {value}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

I see this is referring to the old version. Please take a look if your case is supported in new version. If not please open new issue or submit a pull request.

@rkulinski how is it supported in the newer version? I cannot find an example of how to click on the track to move a handle to that point (or the closest step)