material-ui
material-ui copied to clipboard
[Slider] Support track dragging with range sliders
- [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).
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.
Will like to work on this one.
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.
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 Thanks for sharing the use case, this sounds interesting.
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.
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);
}
};
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;
}
any fix?
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>
I know I am bit late, but this would be so useful.
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.
Same here. I badly needed this several months ago. I used rc-slider eventually.
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
Desperately need this, seems to be a common need for working in tandem with charts
Finding smooth sliders with draggable tracks and marks can be quite challenging.. Let's bump this up!
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
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.