mui / material-ui

Material UI: Ready-to-use foundational React components, free forever. It includes Material UI, which implements Google's Material Design.
https://mui.com/material-ui/
MIT License
92.41k stars 31.82k forks source link

[Slider] Support track dragging with range sliders #17228

Open lonssi opened 4 years ago

lonssi commented 4 years ago

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

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.

jatinAroraGit commented 4 years ago

Will like to work on this one.

oliviertassinari commented 4 years ago

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.

minikomi commented 4 years ago

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.

oliviertassinari commented 4 years ago

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

minikomi commented 4 years ago

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.

flyingnobita commented 3 years ago

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);
    }
  };
trevithj commented 3 years ago

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;
}
vloe commented 1 year ago

any fix?

maengseonu commented 1 year ago

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>
gusmagnago commented 11 months ago

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

JanMisker commented 11 months ago

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.

jhay-25 commented 11 months ago

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

gusmagnago commented 10 months ago

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

paul-if commented 5 months ago

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

adambjorgvins commented 4 months ago

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

niZmosis commented 2 months ago

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) => 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 ?? {}) }} />

}

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

const RangeSlider: React.FC = ({ max, min, onChange, value, panAreaHeight, railStyle, ...rest }) => { const [isPanning, setIsPanning] = useState(false) const startPosition = useRef(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) => { setIsPanning(true)

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

}

const handleMouseMove = (event: MouseEvent) => { 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, }, }} /> ) }

export default RangeSlider `

nottud commented 1 month ago

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

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