material-ui icon indicating copy to clipboard operation
material-ui copied to clipboard

[Slider] Support track dragging with range sliders

Open lonssi opened this issue 5 years ago • 17 comments

  • [x] I have searched the issues of this repository and believe that this is not a duplicate.

Summary 💡

It would be nice if there was an option which would enable a draggable track with a range slider. By dragging the track, the both ends of the slider would move simultaneously.

Examples 🌈

This behavior can be seen in this react-input-range demo (bottom-most slider).

Dec-26-2021 18-44-50

https://ourworldindata.org/explorers/coronavirus-data-explorer?zoomToSelection=true&time=2020-07-28..2021-12-25&facet=none&hideControls=true&Metric=Confirmed+cases&Interval=New+per+day&Relative+to+Population=false&Align+outbreaks=false&country=GBR~FRA

  • https://slider-react-component.vercel.app/demo/range

Motivation 🔦

I found myself needing this kind of functionality while implementing a time-of-day filter which has a default time interval. With this functionality, an user could move the filter with one click and drag, retaining the interval but could modify it when needed.

lonssi avatar Aug 29 '19 17:08 lonssi

Will like to work on this one.

jatinAroraGit avatar Nov 19 '19 18:11 jatinAroraGit

I have added the waiting for users upvotes tag. I'm closing the issue as we are not sure people are looking for such behavior. So please upvote this issue if you are. We will prioritize our effort based on the number of upvotes.

oliviertassinari avatar Jan 17 '20 13:01 oliviertassinari

Just putting forth a use case: would love to use this in an app which has a chart w/ range controlled by a range slider.

minikomi avatar May 07 '20 01:05 minikomi

@minikomi Thanks for sharing the use case, this sounds interesting.

oliviertassinari avatar May 17 '20 10:05 oliviertassinari

Thanks. It's like a very simple implementation of brushing. Sliding across the graph but keeping the same selected "zoom range" would be really useful.

minikomi avatar May 18 '20 04:05 minikomi

incase anyone needs a quick fix, here's a simple logic I made that restricts the range to a specific amount in the onChange event:

  const handlePrice = (event, newPrices) => {
    console.log(newPrices);
    if (newPrices && newPrices.length) {
      if (prices[0] !== newPrices[0]) {
        newPrices[1] = newPrices[0] + 5000; // restrict the range of both thumbs to be 5000
      } else {
        newPrices[0] = newPrices[1] - 5000; // restrict the range of both thumbs to be 5000
      }
      setPrice(newPrices);
    }
  };

flyingnobita avatar Sep 01 '20 14:09 flyingnobita

For what it is worth, below is an implementation of a form of range-dragging using similar idea from @flyingnobita . It requires SHIFT+click, and is not perfect. But it does the job.

const handleChange = (event, newVals) => {
	let vals2 = newVals;
	const { shiftKey } = event;
	if (shiftKey) {
		const d = getDelta(vals, newVals);
		vals2 = vals.map(v => v+d);
	}
	setVals(vals2);
};

const getDelta = (vals, newVals) => {
	const d0 = newVals[0] - vals[0];
	const d1 = newVals[1] - vals[1];
	return d0 === 0 ? d1 : d0;
}

trevithj avatar Nov 20 '20 02:11 trevithj

any fix?

vloe avatar Aug 20 '22 14:08 vloe

I can't upload the refactoring code because of the development deadline. hope it helps someone...

type AnimationController = {
    totalTime: number
    currentTime: number
    detailTimeRange: [number, number]
};

const sampleController: AnimationController = {
    totalTime: 135500,
    currentTime: 0,
    detailTimeRange: [0, 135500],
} 

const [controller, setController] = useState<AnimationController | undefined>(sampleController);

const mouseDownTarget = useRef<'track' | 'thumb' | null>(null);
const railWidth = useRef<number | null>(null);

    const onChnageFrameDetailRange = (event: Event, value: number | number[], activeThumb: number) => {
        if (event.type === 'mousedown' || typeof value === 'number' || value[1] - value[0] < 3000 || mouseDownTarget.current === null) return;

        if (mouseDownTarget.current === 'track') {
            if (!controller) return;
            const clientX = (event as MouseEvent).clientX;

            let start = controller.detailTimeRange[0];
            let end = controller.detailTimeRange[1];

            const range = end - start;
            
            const movePercent = (clientX - 100) / (railWidth.current || 0);
            const mousePointValue = controller.totalTime * movePercent;

            start = mousePointValue - (range / 2);
            end = mousePointValue + (range / 2);

            if (end > controller.totalTime) {
                start = controller.detailTimeRange[0];
                end = controller.totalTime;
            } else if (start < 0) {
                start = 0;
                end = controller.detailTimeRange[1];
            }

            handleChangeController('detailTimeRange', [start, end]);
        } else {
            handleChangeController('detailTimeRange', value);
        };
    };

    const handleMouseDonw = (event: React.MouseEvent<HTMLDivElement>) => {
        const target = event.target as HTMLElement;

        if (target.classList.contains('MuiSlider-track')) {
            mouseDownTarget.current = 'track';
            railWidth.current = (target.parentElement as HTMLDivElement).getBoundingClientRect().width;
        }
        if (target.classList.contains('MuiSlider-thumb')) {
            mouseDownTarget.current = 'thumb';
        }
    };
    const handleMouseUp = (event: React.MouseEvent<HTMLDivElement>) => {
        mouseDownTarget.current = null;
        railWidth.current = null;
    };



                     <FrameDetailControllerContainer onMouseDown={handleMouseDonw} onMouseUp={handleMouseUp}>
                        <Slider
                            sx={frameDetailControllerSliderSx}
                            getAriaLabel={() => 'Minimum distance'}
                            step={1000}
                            min={0}
                            max={controller.totalTime}
                            value={controller.detailTimeRange}
                            onChange={onChnageFrameDetailRange}
                            disableSwap
                        />
                    </FrameDetailControllerContainer>

maengseonu avatar May 03 '23 02:05 maengseonu

I know I am bit late, but this would be so useful.

gusmagnago avatar Aug 08 '23 06:08 gusmagnago

I also need a "scrubber" for our apps, and the workarounds here didn't work or were too complicated. So in the end we had to resort to another package just for the scrubbable slider: rc-slider.

JanMisker avatar Aug 08 '23 07:08 JanMisker

Same here. I badly needed this several months ago. I used rc-slider eventually.

jhay-25 avatar Aug 08 '23 07:08 jhay-25

I also need a "scrubber" for our apps, and the workarounds here didn't work or were too complicated. So in the end we had to resort to another package just for the scrubbable slider: rc-slider.

Yep, I see that this would be very useful, we shouldnt need to add another slider since we are already using mui

gusmagnago avatar Aug 09 '23 07:08 gusmagnago

Desperately need this, seems to be a common need for working in tandem with charts

paul-if avatar Jan 24 '24 21:01 paul-if

Finding smooth sliders with draggable tracks and marks can be quite challenging.. Let's bump this up!

adambjorgvins avatar Feb 06 '24 10:02 adambjorgvins

It's not perfect but it works.

<RangeSlider
          min={0}
          max={100}
          value={selectedRange}
          onChange={setSelectedRange}
          railStyle={{ height: 6, backgroundColor: 'rgba(0, 0, 0, 0.3)' }}
          panAreaHeight={44}
        />
import { useState, useRef, useEffect, type MouseEvent, } from 'react'
import { Slider, Box, useTheme, type SliderProps, type SxProps } from '@mui/material'

const CustomRail: React.FC<{
  onMouseDown: (event: MouseEvent<HTMLDivElement>) => void
  style?: React.CSSProperties
  panAreaHeight?: number
  railStyle?: React.CSSProperties
}> = ({ onMouseDown, panAreaHeight = 54, railStyle, ...props }) => {
  const theme = useTheme()
  const railTheme = theme.components?.MuiSlider?.styleOverrides?.rail as React.CSSProperties
  const height = panAreaHeight
  const centeringOffset = 2
  const marginTop = -((height / 2) - centeringOffset)

  return <div
    {...props}
    style={{ ...props.style, ...railTheme, height, marginTop, display: 'flex', alignItems: 'center', cursor: 'ew-resize' }}
    onMouseDown={onMouseDown}
    role="presentation"
  >
    <div style={{ height: railStyle?.height ?? '6px', width: '100%', ...(railStyle ?? {}) }} />
  </div>
}


type RangeSliderProps = SliderProps & {
  max: number
  min: number
  onChange: (newValue: number[]) => void
  value: number[]
  panAreaHeight?: number
  railStyle?: React.CSSProperties
}

const RangeSlider: React.FC<RangeSliderProps> = ({ max, min, onChange, value, panAreaHeight, railStyle, ...rest }) => {
  const [isPanning, setIsPanning] = useState(false)
  const startPosition = useRef<number>(0)
  const rangeRef = useRef<number[]>([min, max])

  useEffect(() => {
    const stopPanning = () => {
      if (isPanning) {
        setIsPanning(false)
      }
    }

    window.addEventListener('mouseup', stopPanning)

    return () => {
      window.removeEventListener('mouseup', stopPanning)
    }
  }, [isPanning])

  const handleMouseDownOnRail = (event: MouseEvent<HTMLSpanElement>) => {
    setIsPanning(true)

    startPosition.current = event.clientX
    rangeRef.current = value
  }

  const handleMouseMove = (event: MouseEvent<HTMLDivElement>) => {
    if (isPanning) {
      const diff = event.clientX - startPosition.current
      const scale = (max - min) / event.currentTarget.getBoundingClientRect().width
      const delta = diff * scale
      const newValue = rangeRef.current.map((val) => Math.max(min, Math.min(max, val + delta)))
      onChange(newValue)
    }
  }

  return (
    <Box
      sx={{ width: '100%', px: 2, }}
      onMouseMove={handleMouseMove}
    >
      <Slider
        {...rest}
        value={value}
        onChange={(_, newValue: number | number[]) => !isPanning && onChange(newValue as number[])}
        max={max}
        min={min}
        sx={{
          '& .MuiSlider-track': {
            pointerEvents: 'none',
          },
        }}
        slots={{
          rail: (slotProps) => (
            <CustomRail
              {...slotProps}
              panAreaHeight={panAreaHeight}
              railStyle={railStyle}
              onMouseDown={handleMouseDownOnRail}
            />
          ),
        }}
        slotProps={{
          rail: {
            onMouseDown: handleMouseDownOnRail,
          },
        }}
      />
    </Box>
  )
}

export default RangeSlider

niZmosis avatar Apr 06 '24 04:04 niZmosis

One solution that I came up with which is a bit simpler at a high level is the following.

  • Add mouse down to see if dragging thumb (I did by checking class on the mouse down event)
  • If not then have whole range slide instead of one end
  • Mouse up should set things back to normal

As mouse down triggers before slider move this approach seems to work least for me on MUI 4.

nottud avatar May 20 '24 09:05 nottud