feat: Tweeq-style drag scrubbing for ScrubableNumberInput#12418
feat: Tweeq-style drag scrubbing for ScrubableNumberInput#12418LittleSound wants to merge 1 commit into
Conversation
X-axis drags value, Y-axis adjusts sensitivity with an SVG dot-ruler overlay showing the active precision. Pointer-lock hides the cursor during scrub and returns it to the press position on release.
📝 WalkthroughWalkthroughThis PR refactors ChangesDrag-based Number Input Scrubbing System
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 6 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (6 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🎭 Playwright: ❌ 1628 passed, 3 failed · 3 flaky❌ Failed Tests📊 Browser Reports
|
🎨 Storybook: ✅ Built — View Storybook |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/components/common/scrubableNumberInput/useDragGesture.ts`:
- Around line 35-141: Add behavioral unit tests for the useDragGesture
composable covering its lifecycle: write tests that mount a dummy element with
useDragGesture and assert that onClick is called for short taps,
onDrag/onDragEnd are called when movement exceeds the threshold in
onPointerMove, the dragDelay path triggers fireStart after the timeout, pointer
lock is requested when lockPointer is true and unlock is called on pointer up,
and teardown behavior runs on pointercancel/pointerleave (pointer capture
released and timers cleared). Target the functions useDragGesture,
onPointerDown, onPointerMove, onPointerUp, fireStart and teardown by dispatching
synthetic PointerEvents (varying pointerType, button, isPrimary, movement
distances) and use fake timers to simulate dragDelay; assert calls to
onDrag/onClick/onDragStart/onDragEnd and that lock/unlock are invoked and
pointer capture/release are performed.
- Around line 87-113: In onPointerMove (inside useDragGesture) stop relying
solely on event.movementX/movementY for dx/dy; detect when movementX/movementY
are 0/undefined (common for touch) and compute deltas from successive
event.clientX/clientY instead. Add a small state (e.g., lastClientX/lastClientY
or pointerLastPos) that you initialize from pointerDownAt when drag starts and
update on every pointer move; compute dx/dy = (movementX/movementY) /
browserZoom when available else (event.clientX - lastClientX) / browserZoom and
similarly for Y, then call options.onDrag(dx, dy, event) and update the
lastClient values. Ensure this logic runs after fireStart() creates the drag
state so the fallback has an initial lastClient value.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: d8f134d2-94a5-44e5-a861-f56fe612d939
📒 Files selected for processing (6)
src/components/common/ScrubableNumberInput.vuesrc/components/common/scrubableNumberInput/BallRuler.vuesrc/components/common/scrubableNumberInput/interpretGesture.test.tssrc/components/common/scrubableNumberInput/interpretGesture.tssrc/components/common/scrubableNumberInput/useDragGesture.tssrc/components/common/scrubableNumberInput/useScrubValue.ts
| export function useDragGesture( | ||
| target: MaybeRef<HTMLElement | null | undefined>, | ||
| options: DragGestureOptions = {} | ||
| ): { dragging: Readonly<ReturnType<typeof ref<boolean>>> } { | ||
| const dragging = ref(false) | ||
| const allowedTypes = options.pointerType ?? ['mouse', 'pen', 'touch'] | ||
| const dragDelay = options.dragDelaySeconds ?? 0 | ||
|
|
||
| const { lock, unlock } = usePointerLock(target) | ||
|
|
||
| let pointerId: number | null = null | ||
| let pointerDownAt: [number, number] | null = null | ||
| let dragDelayTimer: ReturnType<typeof setTimeout> | undefined | ||
| let pointerLocked = false | ||
|
|
||
| function teardown() { | ||
| if (dragDelayTimer !== undefined) { | ||
| clearTimeout(dragDelayTimer) | ||
| dragDelayTimer = undefined | ||
| } | ||
| pointerDownAt = null | ||
| pointerId = null | ||
| } | ||
|
|
||
| function fireStart(event: PointerEvent) { | ||
| dragging.value = true | ||
| if (unref(options.lockPointer) && !pointerLocked) { | ||
| pointerLocked = true | ||
| void lock(event).catch(() => { | ||
| pointerLocked = false | ||
| }) | ||
| } | ||
| options.onDragStart?.(event) | ||
| } | ||
|
|
||
| function onPointerDown(event: PointerEvent) { | ||
| if (unref(options.disabled)) return | ||
| if (event.button !== 0 || !event.isPrimary) return | ||
| if (!allowedTypes.includes(event.pointerType as DragPointerType)) return | ||
|
|
||
| pointerId = event.pointerId | ||
| pointerDownAt = [event.clientX, event.clientY] | ||
| const el = unref(target) | ||
| el?.setPointerCapture(pointerId) | ||
|
|
||
| // Drag commitment is decided later — either by the movement-distance | ||
| // threshold in onPointerMove, or by this long-press timer expiring while | ||
| // the pointer is still down. Until then it's just a potential click. | ||
| if (dragDelay === 0) return | ||
| dragDelayTimer = setTimeout(() => fireStart(event), dragDelay * 1000) | ||
| } | ||
|
|
||
| function onPointerMove(event: PointerEvent) { | ||
| if (pointerId !== event.pointerId || pointerDownAt === null) return | ||
|
|
||
| if (!dragging.value) { | ||
| // Lock engages inside fireStart, not yet — so clientX/Y is still valid | ||
| // for the drag-vs-click distance threshold. | ||
| const minDist = event.pointerType === 'mouse' ? 1 : 5 | ||
| const moved = Math.hypot( | ||
| event.clientX - pointerDownAt[0], | ||
| event.clientY - pointerDownAt[1] | ||
| ) | ||
| if (moved < minDist) return | ||
| if (dragDelayTimer !== undefined) { | ||
| clearTimeout(dragDelayTimer) | ||
| dragDelayTimer = undefined | ||
| } | ||
| fireStart(event) | ||
| } | ||
|
|
||
| // Compensate for browser zoom (Cmd +/-). event.movementX/Y report in | ||
| // device-pixel-like units that don't honor the browser zoom level; the | ||
| // ratio outerWidth/innerWidth backs that out. | ||
| const browserZoom = window.outerWidth / window.innerWidth || 1 | ||
| const dx = (event.movementX || 0) / browserZoom | ||
| const dy = (event.movementY || 0) / browserZoom | ||
| options.onDrag?.(dx, dy, event) | ||
| } | ||
|
|
||
| function onPointerUp(event: PointerEvent) { | ||
| if (pointerId !== event.pointerId) return | ||
| const el = unref(target) | ||
| el?.releasePointerCapture(event.pointerId) | ||
|
|
||
| const wasDragging = dragging.value | ||
| if (pointerLocked) { | ||
| void unlock() | ||
| pointerLocked = false | ||
| } | ||
| if (wasDragging) { | ||
| options.onDragEnd?.(event) | ||
| } else { | ||
| options.onClick?.(event) | ||
| } | ||
| dragging.value = false | ||
| teardown() | ||
| } | ||
|
|
||
| useEventListener(target, 'pointerdown', onPointerDown) | ||
| useEventListener(target, 'pointermove', onPointerMove) | ||
| useEventListener(target, 'pointerup', onPointerUp) | ||
| useEventListener(target, 'pointercancel', onPointerUp) | ||
| useEventListener(target, 'pointerleave', onPointerUp) | ||
|
|
||
| return { dragging: readonly(dragging) } | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Add behavioral tests for drag lifecycle paths in this composable.
This file introduces core interaction logic (delay threshold, click-vs-drag split, pointer lock path, pointer cancel/leave cleanup), but there are no direct tests for it in this PR. Please add src/**/*.test.ts coverage for these paths.
As per coding guidelines "src/**/*.test.ts: ... Aim for behavioral coverage of critical and new features in unit tests" and "**/*.{test,spec}.ts: Write tests for all changes, especially bug fixes to catch future regressions".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/common/scrubableNumberInput/useDragGesture.ts` around lines 35
- 141, Add behavioral unit tests for the useDragGesture composable covering its
lifecycle: write tests that mount a dummy element with useDragGesture and assert
that onClick is called for short taps, onDrag/onDragEnd are called when movement
exceeds the threshold in onPointerMove, the dragDelay path triggers fireStart
after the timeout, pointer lock is requested when lockPointer is true and unlock
is called on pointer up, and teardown behavior runs on
pointercancel/pointerleave (pointer capture released and timers cleared). Target
the functions useDragGesture, onPointerDown, onPointerMove, onPointerUp,
fireStart and teardown by dispatching synthetic PointerEvents (varying
pointerType, button, isPrimary, movement distances) and use fake timers to
simulate dragDelay; assert calls to onDrag/onClick/onDragStart/onDragEnd and
that lock/unlock are invoked and pointer capture/release are performed.
| function onPointerMove(event: PointerEvent) { | ||
| if (pointerId !== event.pointerId || pointerDownAt === null) return | ||
|
|
||
| if (!dragging.value) { | ||
| // Lock engages inside fireStart, not yet — so clientX/Y is still valid | ||
| // for the drag-vs-click distance threshold. | ||
| const minDist = event.pointerType === 'mouse' ? 1 : 5 | ||
| const moved = Math.hypot( | ||
| event.clientX - pointerDownAt[0], | ||
| event.clientY - pointerDownAt[1] | ||
| ) | ||
| if (moved < minDist) return | ||
| if (dragDelayTimer !== undefined) { | ||
| clearTimeout(dragDelayTimer) | ||
| dragDelayTimer = undefined | ||
| } | ||
| fireStart(event) | ||
| } | ||
|
|
||
| // Compensate for browser zoom (Cmd +/-). event.movementX/Y report in | ||
| // device-pixel-like units that don't honor the browser zoom level; the | ||
| // ratio outerWidth/innerWidth backs that out. | ||
| const browserZoom = window.outerWidth / window.innerWidth || 1 | ||
| const dx = (event.movementX || 0) / browserZoom | ||
| const dy = (event.movementY || 0) / browserZoom | ||
| options.onDrag?.(dx, dy, event) | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Do PointerEvent movementX/movementY reliably report non-zero deltas for touch pointers across major browsers?
💡 Result:
No. On major browsers, PointerEvent.movementX/movementY are not reliably non-zero for touch pointers. What the platform says 1) movementX/movementY are defined as deltas from the previous event, and are only specified to be non-zero for pointermove/pointerrawupdate; they are “always zero” for other event types (and for pointer events other than pointermove/pointerrawupdate) [1]. What happens in real engines (touch pointers) 2) There are cross-browser reports that movementX/movementY don’t behave consistently for touch/pointer input; for example, one w3c/pointerevents issue reports movementX is always 0 on pointerup (Chrome and Firefox) [2]. While that’s about pointerup (and the spec says non-deltas are expected there), it demonstrates lack of reliability across event types and engines. 3) Additionally, Mozilla has tracked platform/compat work specifically around whether movementX/movementY for pointerrawupdate should exist, noting Chrome support and uncertainty elsewhere (e.g., “not sure about Safari”)—again implying incomplete/variable support across major browsers [3]. 4) Even when movementX/movementY aren’t usable, the spec/MDN guidance is to compute deltas yourself from coordinates instead (MDN warns that movementX units vary and suggests avoiding the movement properties in favor of computing deltas from client coordinates) [1]. Practical conclusion - You should not depend on PointerEvent.movementX/movementY to reliably provide non-zero deltas for touch pointers across Chrome/Firefox/Safari. - For touch/pointer drag deltas, compute your own delta using event.clientX/event.clientY (or similar) against the previous pointermove for the same pointerId, per MDN’s recommendation [1] and Pointer Events’ requirement that pointermove fires for coordinate changes [4].
Citations:
- 1: https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/movementX
- 2: event.movementX is always zero in pointerup event. w3c/pointerevents#344
- 3: https://groups.google.com/a/mozilla.org/g/dev-platform/c/9I9Lp11Vymc/
- 4: https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events
Fix touch scrubbing: don’t rely on PointerEvent.movementX/movementY for deltas
movementX/movementY aren’t consistently non-zero for touch pointers across major browsers, so this can feed onDrag(0, 0, ...) during real finger movement. Compute deltas from successive clientX/clientY (optionally gated to the cases where movementX/Y are unusable).
💡 Suggested fix (fallback to client-position delta when movement is unavailable)
@@
let pointerId: number | null = null
let pointerDownAt: [number, number] | null = null
+ let lastClientPos: [number, number] | null = null
let dragDelayTimer: ReturnType<typeof setTimeout> | undefined
let pointerLocked = false
@@
pointerDownAt = null
+ lastClientPos = null
pointerId = null
}
@@
pointerId = event.pointerId
pointerDownAt = [event.clientX, event.clientY]
+ lastClientPos = [event.clientX, event.clientY]
@@
const browserZoom = window.outerWidth / window.innerWidth || 1
- const dx = (event.movementX || 0) / browserZoom
- const dy = (event.movementY || 0) / browserZoom
+ const movementDx = event.movementX || 0
+ const movementDy = event.movementY || 0
+ const fallbackDx = lastClientPos ? event.clientX - lastClientPos[0] : 0
+ const fallbackDy = lastClientPos ? event.clientY - lastClientPos[1] : 0
+ const useFallback =
+ event.pointerType !== 'mouse' &&
+ movementDx === 0 &&
+ movementDy === 0
+ const dx = (useFallback ? fallbackDx : movementDx) / browserZoom
+ const dy = (useFallback ? fallbackDy : movementDy) / browserZoom
+ lastClientPos = [event.clientX, event.clientY]
options.onDrag?.(dx, dy, event)
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/common/scrubableNumberInput/useDragGesture.ts` around lines 87
- 113, In onPointerMove (inside useDragGesture) stop relying solely on
event.movementX/movementY for dx/dy; detect when movementX/movementY are
0/undefined (common for touch) and compute deltas from successive
event.clientX/clientY instead. Add a small state (e.g., lastClientX/lastClientY
or pointerLastPos) that you initialize from pointerDownAt when drag starts and
update on every pointer move; compute dx/dy = (movementX/movementY) /
browserZoom when available else (event.clientX - lastClientX) / browserZoom and
similarly for Y, then call options.onDrag(dx, dy, event) and update the
lastClient values. Ensure this logic runs after fireStart() creates the drag
state so the fallback has an initial lastClient value.
Codecov Report❌ Patch coverage is @@ Coverage Diff @@
## main #12418 +/- ##
===========================================
- Coverage 75.07% 59.99% -15.08%
===========================================
Files 1526 1421 -105
Lines 96710 72679 -24031
Branches 28279 20193 -8086
===========================================
- Hits 72602 43602 -29000
- Misses 23214 28602 +5388
+ Partials 894 475 -419
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 1042 files with indirect coverage changes 🚀 New features to boost your workflow:
|
Summary
Reworks
ScrubableNumberInputinto a Tweeq-style two-axis drag scrub: X moves value, Y rescales sensitivity through orders of magnitude. Pointer-lock hides the cursor during scrub; an SVG dot-ruler overlay visualises the active precision.Changes
scrubableNumberInput/folder with a layered split —interpretGesture.ts(pure algorithm),useScrubValue.ts(state),useDragGesture.ts(DOM pointer wrangling + pointer-lock),BallRuler.vue(pure SVG view).ScrubableNumberInput.vuebecomes a thin orchestrator. Drag now uses long-press (0.5s) or 1-px threshold to commit; click still focuses the input. While scrubbing, the ± buttons fade out and four chevron hints appear on the edges.WidgetInputNumberInput,WidgetBoundingBox,RangeEditor,LinearControls) work unmodified.Review Focus
A few design tastes worth knowing before reviewing:
DRAG_PX_FOR_FULL_RANGEandDRAG_PX_PER_STEP_AT_FLOOR— describe the Y-axis bounds in plain English ("drag N px to cover the range at max sensitivity"). Translate tominSpeed/maxSpeedinline; no hidden coefficients.useScrubValueaccumulates a raw value and runs the validator only on the way out — a tiny per-frame delta still accrues until it crosses a step boundary.reset()clears the EMA / weight but notspeedMult, so a slip-up release doesn't force re-calibration. Each input has its own closure → independent per slider for free.useElementSize(ResizeObservercontentRect→ logical px, transform-independent) and the bar fill iswidth: %(also logical). Sensitivity usesstep, no width involved.BallRulerdashoffset anchoring is mathematically exact in bar mode.dashOffset = (value − min)/(max − min) × widthguarantees one ball lies on the handle for any dash gap (j = ⌊D/N⌋trick). In free mode the+width/2term phase-aligns a ball to centre at value = 0, paired with pointer-lock so the locked cursor is the visual anchor.Screenshots
CleanShot.2026-05-21.at.22.10.22-converted.mp4
CleanShot.2026-05-22.at.19.10.04.mp4