@uinstinct/svelte-wheel-picker 0.1.22 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,11 +39,14 @@
39
39
  });
40
40
 
41
41
  // Controlled/uncontrolled state management
42
- const state = useControllableState({
43
- value,
44
- defaultValue,
45
- onChange: onValueChange,
46
- });
42
+ // untrack: constructor only needs initial values; effects below handle reactive updates
43
+ const state = untrack(() =>
44
+ useControllableState({
45
+ value,
46
+ defaultValue,
47
+ onChange: onValueChange,
48
+ }),
49
+ );
47
50
 
48
51
  // Derive the currently selected index
49
52
  const selectedIndex = $derived(options.findIndex((o) => o.value === state.current));
@@ -55,40 +58,51 @@
55
58
  return first >= 0 ? first : 0;
56
59
  });
57
60
 
58
- // Instantiate the physics engine
59
- const physics = new WheelPhysics({
60
- itemHeight: optionItemHeight,
61
- visibleCount: visibleCount,
62
- dragSensitivity,
63
- scrollSensitivity,
64
- options,
65
- initialIndex,
66
- infinite,
67
- onSnap: (index: number) => {
68
- console.log('[onSnap] index=', index, 'infinite=', infinite, 'offset=', physics.offset);
69
- if (infinite) {
70
- // D-04: Normalize offset on snap settle
71
- const wrappedIndex = wrapIndex(index, options.length);
72
- physics.jumpTo(wrappedIndex);
73
- const opt = options[wrappedIndex];
74
- console.log(
75
- '[onSnap] wrappedIndex=',
76
- wrappedIndex,
77
- 'opt=',
78
- opt?.value,
79
- 'jumpTo offset=',
80
- physics.offset,
81
- );
82
- if (opt && !opt.disabled) {
83
- state.current = opt.value;
84
- }
85
- } else {
86
- const opt = options[index];
87
- if (opt && !opt.disabled) {
88
- state.current = opt.value;
89
- }
61
+ // Named snap handler — extracted to allow $effect to reference it reactively
62
+ function handleSnap(index: number) {
63
+ if (infinite) {
64
+ // D-04: Normalize offset on snap settle
65
+ const wrappedIndex = wrapIndex(index, options.length);
66
+ physics.jumpTo(wrappedIndex);
67
+ const opt = options[wrappedIndex];
68
+ if (opt && !opt.disabled) {
69
+ state.current = opt.value;
90
70
  }
91
- },
71
+ } else {
72
+ const opt = options[index];
73
+ if (opt && !opt.disabled) {
74
+ state.current = opt.value;
75
+ }
76
+ }
77
+ }
78
+
79
+ // Instantiate the physics engine
80
+ // untrack: constructor only needs initial values; $effect below syncs all prop changes
81
+ const physics = untrack(
82
+ () =>
83
+ new WheelPhysics({
84
+ itemHeight: optionItemHeight,
85
+ visibleCount: visibleCount,
86
+ dragSensitivity,
87
+ scrollSensitivity,
88
+ options,
89
+ initialIndex,
90
+ infinite,
91
+ onSnap: handleSnap,
92
+ }),
93
+ );
94
+
95
+ // Sync prop changes to physics engine — fixes state_referenced_locally warnings
96
+ $effect(() => {
97
+ physics.update({
98
+ itemHeight: optionItemHeight,
99
+ visibleCount,
100
+ dragSensitivity,
101
+ scrollSensitivity,
102
+ infinite,
103
+ options,
104
+ onSnap: handleSnap,
105
+ });
92
106
  });
93
107
 
94
108
  // Typeahead search instance
@@ -196,20 +210,8 @@
196
210
 
197
211
  // Pointer event handlers (Pattern 2: Pointer Capture for reliable drag tracking)
198
212
  function onPointerDown(e: PointerEvent) {
199
- console.log(
200
- '[onPointerDown] type=',
201
- e.type,
202
- 'currentTarget=',
203
- e.currentTarget,
204
- 'target=',
205
- e.target,
206
- );
207
213
  const el = e.currentTarget as HTMLElement;
208
214
  el.setPointerCapture(e.pointerId);
209
- console.log(
210
- '[onPointerDown] setPointerCapture called, hasCapture=',
211
- el.hasPointerCapture(e.pointerId),
212
- );
213
215
  physics.startDrag(e.clientY);
214
216
  }
215
217
 
@@ -218,16 +220,6 @@
218
220
  }
219
221
 
220
222
  function onPointerUp(e: PointerEvent) {
221
- console.log(
222
- '[onPointerUp] type=',
223
- e.type,
224
- 'currentTarget=',
225
- e.currentTarget,
226
- 'target=',
227
- e.target,
228
- 'clientY=',
229
- e.clientY,
230
- );
231
223
  (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
232
224
  physics.endDrag();
233
225
  }
@@ -52,19 +52,28 @@ export declare class WheelPhysics {
52
52
  */
53
53
  endDrag(): void;
54
54
  /**
55
- * Called on wheel event. Debounced at 100ms to prevent rapid-fire scroll events.
55
+ * Called on wheel event. Moves proportionally to deltaY magnitude.
56
56
  *
57
- * deltaY > 0: scroll down → move to next item (higher index)
58
- * deltaY < 0: scroll up → move to previous item (lower index)
57
+ * deltaY > 0: scroll down → move to next item(s) (higher index)
58
+ * deltaY < 0: scroll up → move to previous item(s) (lower index)
59
+ *
60
+ * A typical mouse wheel notch sends deltaY ~100-150px. Dividing by itemHeight
61
+ * gives a proportional item count, so scrolling feels natural and fast.
62
+ * The in-flight animation is cancelled before computing the new target so
63
+ * rapid wheel events feel snappy (each event immediately updates the target).
59
64
  */
60
65
  handleWheel(deltaY: number): void;
61
66
  /**
62
67
  * Animates the offset to the position corresponding to targetIndex.
63
68
  * Uses easeOutCubic easing. Calls onSnap when complete.
64
69
  *
70
+ * When `velocity` is provided (inertia flick), duration is computed from
71
+ * velocity for natural deceleration proportional to flick speed. Without
72
+ * velocity (keyboard, wheel, slow release), duration is distance-based.
73
+ *
65
74
  * Cancels any currently running animation before starting.
66
75
  */
67
- animateTo(targetIndex: number): void;
76
+ animateTo(targetIndex: number, velocity?: number): void;
68
77
  /**
69
78
  * Returns the option index that the current offset visually corresponds to.
70
79
  * Used to guard against redundant animations when the wheel is already positioned correctly.
@@ -41,8 +41,6 @@ export class WheelPhysics {
41
41
  #dragStartY = 0;
42
42
  /** Recent pointer positions for velocity calculation: [clientY, timestamp][] */
43
43
  #yList = [];
44
- /** Timestamp of the last wheel event (100ms debounce guard) */
45
- #lastWheelTime = -Infinity;
46
44
  /** True while a snap or inertia animation is running */
47
45
  #animating = false;
48
46
  // ---------------------------------------------------------------------------
@@ -148,7 +146,6 @@ export class WheelPhysics {
148
146
  * Called on pointerup. Computes velocity and kicks off inertia or direct snap.
149
147
  */
150
148
  endDrag() {
151
- console.log('[endDrag] called, isDragging=', this.#isDragging, 'offset=', this.offset);
152
149
  if (!this.#isDragging)
153
150
  return;
154
151
  this.#isDragging = false;
@@ -158,11 +155,9 @@ export class WheelPhysics {
158
155
  const currentIndex = this.#infinite
159
156
  ? wrapIndex(rawIndex, N)
160
157
  : clampIndex(rawIndex, N);
161
- console.log('[endDrag] velocity=', velocity, 'rawIndex=', rawIndex, 'currentIndex=', currentIndex, 'infinite=', this.#infinite);
162
158
  if (Math.abs(velocity) < 0.5) {
163
159
  // Slow release — snap directly to nearest enabled option
164
160
  const snapIndex = snapToNearestEnabled(currentIndex, this.#options);
165
- console.log('[endDrag] slow path, snapIndex=', snapIndex);
166
161
  if (this.#infinite) {
167
162
  // Preserve ghost-section context so the snap animation continues in the
168
163
  // same direction as the drag rather than jumping backward to real-section.
@@ -191,13 +186,12 @@ export class WheelPhysics {
191
186
  // loop, so the animation moves in the same direction as the drag.
192
187
  // snapIndex is in [0,N-1]; loopOffset is -N, 0, or +N based on current section.
193
188
  const loopOffset = rawIndex < 0 ? -N : rawIndex >= N ? N : 0;
194
- console.log('[endDrag] inertia path, rawTarget=', rawTarget, 'wrapped=', wrapped, 'snapIndex=', snapIndex, 'loopOffset=', loopOffset);
195
- this.animateTo(snapIndex + loopOffset);
189
+ this.animateTo(snapIndex + loopOffset, velocity);
196
190
  }
197
191
  else {
198
192
  const clamped = clampIndex(rawTarget, N);
199
193
  const snapIndex = snapToNearestEnabled(clamped, this.#options);
200
- this.animateTo(snapIndex);
194
+ this.animateTo(snapIndex, velocity);
201
195
  }
202
196
  }
203
197
  }
@@ -205,30 +199,38 @@ export class WheelPhysics {
205
199
  // Wheel event handler
206
200
  // ---------------------------------------------------------------------------
207
201
  /**
208
- * Called on wheel event. Debounced at 100ms to prevent rapid-fire scroll events.
202
+ * Called on wheel event. Moves proportionally to deltaY magnitude.
203
+ *
204
+ * deltaY > 0: scroll down → move to next item(s) (higher index)
205
+ * deltaY < 0: scroll up → move to previous item(s) (lower index)
209
206
  *
210
- * deltaY > 0: scroll down move to next item (higher index)
211
- * deltaY < 0: scroll up move to previous item (lower index)
207
+ * A typical mouse wheel notch sends deltaY ~100-150px. Dividing by itemHeight
208
+ * gives a proportional item count, so scrolling feels natural and fast.
209
+ * The in-flight animation is cancelled before computing the new target so
210
+ * rapid wheel events feel snappy (each event immediately updates the target).
212
211
  */
213
212
  handleWheel(deltaY) {
214
- const now = performance.now();
215
- if (now - this.#lastWheelTime < 100)
216
- return;
217
- this.#lastWheelTime = now;
218
- const rawIndex = this.#offsetToIndex(this.offset);
219
- const currentIndex = this.#infinite
220
- ? wrapIndex(rawIndex, this.#options.length)
221
- : clampIndex(rawIndex, this.#options.length);
222
- // deltaY > 0 = scroll down = move to next item (increment index)
213
+ // Cancel any in-flight animation so rapid scrolling feels immediate
214
+ this.#cancelRaf();
215
+ // Calculate number of items to move based on deltaY magnitude and scroll sensitivity.
216
+ // A typical mouse wheel notch sends deltaY ~100-150px.
217
+ // Multiply by scrollSensitivity / DEFAULT to amplify or dampen the movement.
218
+ // sensitivity 1 → 0.2x (barely moves), sensitivity 5 → 1x (default), sensitivity 20 → 4x (fast)
219
+ const sensitivityMultiplier = this.#scrollSensitivity / DEFAULT_SCROLL_SENSITIVITY;
220
+ const itemsToMove = Math.max(1, Math.round((Math.abs(deltaY) * sensitivityMultiplier) / this.#itemHeight));
223
221
  const direction = deltaY > 0 ? 1 : -1;
222
+ const steps = itemsToMove * direction;
223
+ const rawIndex = this.#offsetToIndex(this.offset);
224
+ const N = this.#options.length;
225
+ const currentIndex = this.#infinite ? wrapIndex(rawIndex, N) : clampIndex(rawIndex, N);
224
226
  if (this.#infinite) {
225
- const next = currentIndex + direction;
226
- const wrapped = wrapIndex(next, this.#options.length);
227
+ const next = currentIndex + steps;
228
+ const wrapped = wrapIndex(next, N);
227
229
  const snapIndex = snapToNearestEnabled(wrapped, this.#options);
228
230
  this.animateTo(snapIndex);
229
231
  }
230
232
  else {
231
- const targetIndex = clampIndex(currentIndex + direction, this.#options.length);
233
+ const targetIndex = clampIndex(currentIndex + steps, N);
232
234
  const snapIndex = snapToNearestEnabled(targetIndex, this.#options);
233
235
  this.animateTo(snapIndex);
234
236
  }
@@ -240,16 +242,19 @@ export class WheelPhysics {
240
242
  * Animates the offset to the position corresponding to targetIndex.
241
243
  * Uses easeOutCubic easing. Calls onSnap when complete.
242
244
  *
245
+ * When `velocity` is provided (inertia flick), duration is computed from
246
+ * velocity for natural deceleration proportional to flick speed. Without
247
+ * velocity (keyboard, wheel, slow release), duration is distance-based.
248
+ *
243
249
  * Cancels any currently running animation before starting.
244
250
  */
245
- animateTo(targetIndex) {
246
- console.log('[animateTo] targetIndex=', targetIndex, 'from offset=', this.offset);
251
+ animateTo(targetIndex, velocity) {
247
252
  this.#cancelRaf();
248
253
  this.#animating = true;
249
254
  const startOffset = this.offset;
250
255
  const targetOffset = this.#indexToOffset(targetIndex);
251
256
  const distance = Math.abs(targetIndex - this.#offsetToIndex(startOffset));
252
- const durationSec = computeAnimationDuration(distance, this.#scrollSensitivity);
257
+ const durationSec = computeAnimationDuration(distance, this.#scrollSensitivity, velocity);
253
258
  const durationMs = durationSec * 1000;
254
259
  const startTime = performance.now();
255
260
  const tick = (now) => {
@@ -112,14 +112,21 @@ export declare function computeSnapTarget(currentIndexFromOffset: number, veloci
112
112
  /**
113
113
  * Computes the duration of a snap animation in seconds.
114
114
  *
115
- * Formula: `Math.sqrt(|distance| / scrollSensitivity)`
116
- * Clamped to [0.1, 0.6] seconds.
115
+ * When `velocity` is provided and |velocity| >= 0.5 (inertia flick), uses the
116
+ * kinematic formula: `|velocity| / (scrollSensitivity * 6)`, clamped to [0.1, 1.2].
117
+ * This matches the React reference's velocity-based duration model, producing
118
+ * natural deceleration where faster flicks get proportionally longer animations.
119
+ *
120
+ * When `velocity` is undefined or < 0.5 (keyboard, wheel, slow release), falls
121
+ * back to the distance-based formula: `Math.sqrt(|distance| / scrollSensitivity)`,
122
+ * clamped to [0.1, 0.6] — unchanged from the original implementation.
117
123
  *
118
124
  * @param distance - Distance in index steps to travel
119
125
  * @param scrollSensitivity - Scroll sensitivity (affects duration)
126
+ * @param velocity - Optional scroll velocity in items/second (from inertia flick)
120
127
  * @returns Animation duration in seconds
121
128
  */
122
- export declare function computeAnimationDuration(distance: number, scrollSensitivity: number): number;
129
+ export declare function computeAnimationDuration(distance: number, scrollSensitivity: number, velocity?: number): number;
123
130
  /**
124
131
  * Computes per-item scaleY (and opacity) for cylindrical drum mode.
125
132
  *
@@ -162,14 +162,30 @@ export function computeSnapTarget(currentIndexFromOffset, velocity, dragSensitiv
162
162
  /**
163
163
  * Computes the duration of a snap animation in seconds.
164
164
  *
165
- * Formula: `Math.sqrt(|distance| / scrollSensitivity)`
166
- * Clamped to [0.1, 0.6] seconds.
165
+ * When `velocity` is provided and |velocity| >= 0.5 (inertia flick), uses the
166
+ * kinematic formula: `|velocity| / (scrollSensitivity * 6)`, clamped to [0.1, 1.2].
167
+ * This matches the React reference's velocity-based duration model, producing
168
+ * natural deceleration where faster flicks get proportionally longer animations.
169
+ *
170
+ * When `velocity` is undefined or < 0.5 (keyboard, wheel, slow release), falls
171
+ * back to the distance-based formula: `Math.sqrt(|distance| / scrollSensitivity)`,
172
+ * clamped to [0.1, 0.6] — unchanged from the original implementation.
167
173
  *
168
174
  * @param distance - Distance in index steps to travel
169
175
  * @param scrollSensitivity - Scroll sensitivity (affects duration)
176
+ * @param velocity - Optional scroll velocity in items/second (from inertia flick)
170
177
  * @returns Animation duration in seconds
171
178
  */
172
- export function computeAnimationDuration(distance, scrollSensitivity) {
179
+ export function computeAnimationDuration(distance, scrollSensitivity, velocity) {
180
+ if (velocity !== undefined && Math.abs(velocity) >= 0.5) {
181
+ // Velocity-based duration: time proportional to flick speed.
182
+ // deceleration = scrollSensitivity * 6 produces duration=1.0s at v=30
183
+ // (MAX_VELOCITY) with scrollSensitivity=5, a reasonable maximum.
184
+ const deceleration = scrollSensitivity * 6;
185
+ const raw = Math.abs(velocity) / deceleration;
186
+ return Math.max(0.1, Math.min(1.2, raw));
187
+ }
188
+ // Distance-based fallback (keyboard, wheel events, slow releases)
173
189
  const raw = Math.sqrt(Math.abs(distance) / scrollSensitivity);
174
190
  return Math.max(0.1, Math.min(0.6, raw));
175
191
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uinstinct/svelte-wheel-picker",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "description": "iOS-style wheel picker for Svelte 5 with inertia scrolling, infinite loop, and keyboard navigation",
6
6
  "license": "MIT",