feat: support dragging range slider track to move entire range#8698
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
All contributors have signed the CLA ✍️ ✅ |
for more information, see https://pre-commit.ci
|
I have read the CLA Document and I hereby sign the CLA |
|
I'm confused, I thought we didn't want to add a new parameter |
Hi @Light2Dark! Thanks for the review. I understand the confusion, based on the discussion, I can update this to make dragging the middle range bar (between the two handles) always move both handles together as default behaviour, without needing the fixed_range parameter at all. Would that be the preferred approach? Happy to update the PR! |
akshayka
left a comment
There was a problem hiding this comment.
This should not add a new argument to range_slider
manzt
left a comment
There was a problem hiding this comment.
From the discussion (#5240 (comment)), we just want to update the default behavior to support the range and avoid the new parameter.
for more information, see https://pre-commit.ci
|
Updated the PR based on feedback, removed the fixed_range parameter entirely from both Python and frontend. Middle-drag on the range bar now works as default behaviour for all range sliders, implemented as a frontend-only change in range-slider.tsx. No new Python parameters added. Dragging the blue bar between the two thumbs moves both handles together while keeping the range width constant. |
|
The only failing backend test is test_ibis_table.py::TestTemporalColSummaries::test_time_column which appears to be a pre-existing issue with sqlglot/ibis unrelated to this PR. All other 6623 tests pass. The frontend changes are complete middle-drag on the range bar now works as default behavior with no new parameters added. |
There was a problem hiding this comment.
Pull request overview
This PR updates the range slider UX so users can drag the filled track to move both handles together while keeping the selected range width constant (implemented in the frontend), and documents the behavior in the Python UI docstring.
Changes:
- Implement middle-drag (filled track drag) behavior for range sliders in the frontend
RangeSlidercomponent. - Update Python
range_sliderdocstring to document the new default interaction. - Minor cleanup in the frontend plugin component (comment/formatting).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| marimo/_plugins/ui/_impl/input.py | Adds a Notes: section documenting filled-track dragging behavior for range_slider. |
| frontend/src/plugins/impl/RangeSliderPlugin.tsx | Minor formatting/comment cleanup around props/onValueChange. |
| frontend/src/components/ui/range-slider.tsx | Adds pointer-driven filled-track dragging logic (range-preserving drag). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const trackLength = rect.height; | ||
| delta = -((e.clientY - dragStartY.current) / trackLength) * totalRange; | ||
| } else { | ||
| const trackLength = rect.width; |
| const rangeWidth = origRight - origLeft; | ||
|
|
||
| // Snap delta to step | ||
| const step = props.step ?? 1; |
| const handleRangePointerUp = (e: React.PointerEvent<HTMLSpanElement>) => { | ||
| if (!isDraggingRange.current) { | ||
| return; | ||
| } | ||
| isDraggingRange.current = false; | ||
| if (props.value && props.value.length === 2) { | ||
| props.onValueCommit?.(props.value); | ||
| } | ||
| }; |
| isDraggingRange.current = false; | ||
| if (props.value && props.value.length === 2) { | ||
| props.onValueCommit?.(props.value); | ||
| } |
| const handleRangePointerMove = (e: React.PointerEvent<HTMLSpanElement>) => { | ||
| if (!isDraggingRange.current || !props.value || props.value.length !== 2) { | ||
| return; | ||
| } | ||
|
|
||
| const rootEl = rootRef.current; | ||
| if (!rootEl) { | ||
| return; | ||
| } | ||
|
|
||
| const rect = rootEl.getBoundingClientRect(); | ||
| const isVertical = props.orientation === "vertical"; | ||
|
|
||
| const min = props.min ?? 0; | ||
| const max = props.max ?? 100; | ||
| const totalRange = max - min; | ||
|
|
||
| // Delta is always from the ORIGINAL drag start position | ||
| // so the movement stays proportional and doesn't drift | ||
| let delta: number; | ||
| if (isVertical) { | ||
| const trackLength = rect.height; | ||
| delta = -((e.clientY - dragStartY.current) / trackLength) * totalRange; | ||
| } else { | ||
| const trackLength = rect.width; | ||
| delta = ((e.clientX - dragStartX.current) / trackLength) * totalRange; | ||
| } | ||
|
|
||
| // Always use the ORIGINAL values from when drag started — never current props.value | ||
| const [origLeft, origRight] = dragStartValue.current; | ||
| const rangeWidth = origRight - origLeft; | ||
|
|
||
| // Snap delta to step | ||
| const step = props.step ?? 1; | ||
| const snappedDelta = Math.round(delta / step) * step; | ||
|
|
||
| // Clamp so neither thumb exceeds min/max | ||
| const clampedDelta = Math.max( | ||
| min - origLeft, | ||
| Math.min(max - origRight, snappedDelta), | ||
| ); | ||
|
|
||
| const newLeft = origLeft + clampedDelta; | ||
| const newRight = newLeft + rangeWidth; | ||
|
|
||
| // Only fire if value actually changed | ||
| if (newLeft !== props.value[0] || newRight !== props.value[1]) { | ||
| props.onValueChange?.([newLeft, newRight]); | ||
| } |
| const mergedRef = (node: React.ElementRef<typeof SliderPrimitive.Root>) => { | ||
| rootRef.current = node; | ||
| if (typeof ref === "function") { | ||
| ref(node); | ||
| } else if (ref) { | ||
| ref.current = node; | ||
| } | ||
| }; |
| Notes: | ||
| Dragging the filled track (the colored bar between the two handles) | ||
| moves both handles together while preserving the selected range width. | ||
| Individual handles can still be dragged independently to adjust the range. |
|
Thanks for iterating! A few of the copilot issues look valid. Additionally I think you can undo some of the python changes. The implementation also seems a little broken right now: Screen.Recording.2026-03-17.at.11.51.20.AM.movPing back when you have a screencast of it working! Thanks again! |
- use internal currentDragValue ref independent of controlled props.value - capture trackRect once on pointerdown to avoid drift from re-renders - release pointer capture on pointerup - add stopPropagation on pointermove to prevent Radix interference - fix stale value in onValueCommit - revert unnecessary Python docstring changes # Conflicts: # frontend/src/components/ui/range-slider.tsx # marimo/_plugins/ui/_impl/input.py
|
Hi @dmadisetti, fixed all the issues. Here's a summary of what was addressed:
Also updated the PR title to reflect the actual implementation. fixed.mp4 |
|
Super smooth! I was still able to drag when disabled though |
|
@dmadisetti Fixed track drag is now prevented when the |
dmadisetti
left a comment
There was a problem hiding this comment.
LGTM and functionality feels right.
It's be great if you could remove the changes you have in marimo/_plugins/ui/_impl/input.py, frontend/src/plugins/impl/RangeSliderPlugin.tsx (just whitespace but still)
Thanks! I'll let someone a little more familiar with typescript merge in case there's something I'm missing
|
Done! Reverted all changes in input.py and RangeSliderPlugin.tsx |
|
🚀 Development release published. You may be able to view the changes at https://marimo.app?v=0.21.2-dev21 |
…o-team#8698) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Closes #5240
What changed
Dragging the filled track between the two handles now moves
the entire range together, preserving the selected width.
Individual handles still work independently. No new parameters
— this is default behavior for all range sliders.
Frontend-only change in
range-slider.tsx.How it works
On pointerdown, the drag start position and value are captured
once. On pointermove, the delta is computed from that fixed
anchor and applied to both handles simultaneously. Pointer
capture ensures smooth dragging even when the cursor leaves
the element.
Testing