@uinstinct/svelte-wheel-picker 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +304 -0
- package/dist/WheelPicker.svelte +349 -0
- package/dist/WheelPicker.svelte.d.ts +4 -0
- package/dist/WheelPickerWrapper.svelte +10 -0
- package/dist/WheelPickerWrapper.svelte.d.ts +8 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.js +1 -0
- package/dist/use-controllable-state.svelte.d.ts +21 -0
- package/dist/use-controllable-state.svelte.js +42 -0
- package/dist/use-typeahead-search.svelte.d.ts +8 -0
- package/dist/use-typeahead-search.svelte.js +59 -0
- package/dist/use-wheel-physics.svelte.d.ts +86 -0
- package/dist/use-wheel-physics.svelte.js +337 -0
- package/dist/wheel-physics-utils.d.ts +141 -0
- package/dist/wheel-physics-utils.js +198 -0
- package/package.json +61 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single option in the wheel picker.
|
|
3
|
+
* @template T - The value type, constrained to string or number.
|
|
4
|
+
*/
|
|
5
|
+
export type WheelPickerOption<T extends string | number = string> = {
|
|
6
|
+
value: T;
|
|
7
|
+
label: string;
|
|
8
|
+
/** Fallback text for type-ahead search when label is not plain text. */
|
|
9
|
+
textValue?: string;
|
|
10
|
+
/** Whether this option is disabled (skipped in navigation). */
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Per-element class name overrides for WheelPicker.
|
|
15
|
+
* All fields are optional — only provided classes are applied.
|
|
16
|
+
*/
|
|
17
|
+
export type WheelPickerClassNames = {
|
|
18
|
+
/** Outer container div */
|
|
19
|
+
wrapper?: string;
|
|
20
|
+
/** Each option row */
|
|
21
|
+
option?: string;
|
|
22
|
+
/** Text span inside each option */
|
|
23
|
+
optionText?: string;
|
|
24
|
+
/** Center selection highlight overlay */
|
|
25
|
+
selection?: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Props for the WheelPicker component.
|
|
29
|
+
* @template T - The option value type.
|
|
30
|
+
*
|
|
31
|
+
* Controlled mode: pass `value` + `onValueChange`.
|
|
32
|
+
* Uncontrolled mode: pass `defaultValue` (or nothing), omit `onValueChange`.
|
|
33
|
+
*/
|
|
34
|
+
export interface WheelPickerProps<T extends string | number = string> {
|
|
35
|
+
/** The list of selectable options. */
|
|
36
|
+
options: WheelPickerOption<T>[];
|
|
37
|
+
/** Current value (controlled mode). undefined means "no option selected". */
|
|
38
|
+
value?: T;
|
|
39
|
+
/** Initial value (uncontrolled mode). */
|
|
40
|
+
defaultValue?: T;
|
|
41
|
+
/** Callback when value changes. Presence signals controlled mode. */
|
|
42
|
+
onValueChange?: (value: T) => void;
|
|
43
|
+
/** Per-element CSS class overrides. */
|
|
44
|
+
classNames?: WheelPickerClassNames;
|
|
45
|
+
/** Number of visible option rows. Must be odd. Default: 5. */
|
|
46
|
+
visibleCount?: number;
|
|
47
|
+
/** Height in pixels of each option row. Default: 30. */
|
|
48
|
+
optionItemHeight?: number;
|
|
49
|
+
/** Pointer drag delta multiplier (affects inertia deceleration). Default: 3. */
|
|
50
|
+
dragSensitivity?: number;
|
|
51
|
+
/** Scroll wheel delta multiplier (affects snap animation duration). Default: 5. */
|
|
52
|
+
scrollSensitivity?: number;
|
|
53
|
+
/** Enable infinite loop scrolling (wraps at both ends). Default: false. */
|
|
54
|
+
infinite?: boolean;
|
|
55
|
+
/** Enable rotating drum/cylinder visual style with faux-3D scaleY compression. Default: false. */
|
|
56
|
+
cylindrical?: boolean;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Per-element class name overrides for WheelPickerWrapper.
|
|
60
|
+
*/
|
|
61
|
+
export type WheelPickerWrapperClassNames = {
|
|
62
|
+
/** Outer group container div */
|
|
63
|
+
group?: string;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Props for the WheelPickerWrapper component.
|
|
67
|
+
*/
|
|
68
|
+
export interface WheelPickerWrapperProps {
|
|
69
|
+
/** Per-element CSS class overrides for the wrapper group. */
|
|
70
|
+
classNames?: WheelPickerWrapperClassNames;
|
|
71
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
declare class ControllableState<T extends string | number> {
|
|
2
|
+
#private;
|
|
3
|
+
constructor(opts: {
|
|
4
|
+
value?: T;
|
|
5
|
+
defaultValue?: T;
|
|
6
|
+
onChange?: (value: T | undefined) => void;
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* Update the tracked controlled value when the external `value` prop changes.
|
|
10
|
+
* Must be called from a $effect in the consuming component whenever `value` changes.
|
|
11
|
+
*/
|
|
12
|
+
updateControlledValue(value: T | undefined): void;
|
|
13
|
+
get current(): T | undefined;
|
|
14
|
+
set current(next: T | undefined);
|
|
15
|
+
}
|
|
16
|
+
export declare function useControllableState<T extends string | number>(opts: {
|
|
17
|
+
value?: T;
|
|
18
|
+
defaultValue?: T;
|
|
19
|
+
onChange?: (value: T | undefined) => void;
|
|
20
|
+
}): ControllableState<T>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
class ControllableState {
|
|
2
|
+
#internal = $state(undefined);
|
|
3
|
+
#onChange;
|
|
4
|
+
#isControlled;
|
|
5
|
+
// $state so that reactive consumers (selectedIndex $derived) re-evaluate when prop changes
|
|
6
|
+
#controlledValue = $state(undefined);
|
|
7
|
+
constructor(opts) {
|
|
8
|
+
this.#isControlled = typeof opts.onChange === 'function';
|
|
9
|
+
this.#onChange = opts.onChange;
|
|
10
|
+
this.#controlledValue = opts.value;
|
|
11
|
+
if (this.#isControlled) {
|
|
12
|
+
this.#internal = opts.value;
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
this.#internal = opts.defaultValue;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Update the tracked controlled value when the external `value` prop changes.
|
|
20
|
+
* Must be called from a $effect in the consuming component whenever `value` changes.
|
|
21
|
+
*/
|
|
22
|
+
updateControlledValue(value) {
|
|
23
|
+
this.#controlledValue = value;
|
|
24
|
+
}
|
|
25
|
+
get current() {
|
|
26
|
+
if (this.#isControlled) {
|
|
27
|
+
return this.#controlledValue;
|
|
28
|
+
}
|
|
29
|
+
return this.#internal;
|
|
30
|
+
}
|
|
31
|
+
set current(next) {
|
|
32
|
+
if (this.#isControlled) {
|
|
33
|
+
this.#onChange?.(next);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
this.#internal = next;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function useControllableState(opts) {
|
|
41
|
+
return new ControllableState(opts);
|
|
42
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { WheelPickerOption } from './types.js';
|
|
2
|
+
declare class TypeaheadSearch {
|
|
3
|
+
#private;
|
|
4
|
+
search(key: string, options: WheelPickerOption[], currentIndex: number): number;
|
|
5
|
+
destroy(): void;
|
|
6
|
+
}
|
|
7
|
+
export declare function useTypeaheadSearch(): TypeaheadSearch;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
class TypeaheadSearch {
|
|
2
|
+
#buffer = $state('');
|
|
3
|
+
#lastKey = $state('');
|
|
4
|
+
#lastTime = 0;
|
|
5
|
+
#timer = null;
|
|
6
|
+
search(key, options, currentIndex) {
|
|
7
|
+
const now = Date.now();
|
|
8
|
+
const withinWindow = now - this.#lastTime < 500;
|
|
9
|
+
const singleChar = key.length === 1;
|
|
10
|
+
if (!singleChar)
|
|
11
|
+
return -1;
|
|
12
|
+
const lowerKey = key.toLowerCase();
|
|
13
|
+
const isSameKey = withinWindow && lowerKey === this.#lastKey;
|
|
14
|
+
this.#lastKey = lowerKey;
|
|
15
|
+
this.#lastTime = now;
|
|
16
|
+
// Reset timer
|
|
17
|
+
if (this.#timer)
|
|
18
|
+
clearTimeout(this.#timer);
|
|
19
|
+
this.#timer = setTimeout(() => {
|
|
20
|
+
this.#buffer = '';
|
|
21
|
+
this.#lastKey = '';
|
|
22
|
+
}, 500);
|
|
23
|
+
if (isSameKey) {
|
|
24
|
+
// Same key cycling (D-02): find next match after currentIndex
|
|
25
|
+
return this.#cycleMatch(lowerKey, options, currentIndex);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
// Accumulate or start fresh
|
|
29
|
+
this.#buffer = withinWindow ? this.#buffer + lowerKey : lowerKey;
|
|
30
|
+
return this.#findFirst(this.#buffer, options);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
#getSearchText(option) {
|
|
34
|
+
return (option.textValue ?? option.label).toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
#findFirst(prefix, options) {
|
|
37
|
+
return options.findIndex((o) => !o.disabled && this.#getSearchText(o).startsWith(prefix));
|
|
38
|
+
}
|
|
39
|
+
#cycleMatch(key, options, fromIndex) {
|
|
40
|
+
const prefix = key.toLowerCase();
|
|
41
|
+
const matches = options
|
|
42
|
+
.map((o, i) => ({ i, matches: !o.disabled && this.#getSearchText(o).startsWith(prefix) }))
|
|
43
|
+
.filter((x) => x.matches)
|
|
44
|
+
.map((x) => x.i);
|
|
45
|
+
if (matches.length === 0)
|
|
46
|
+
return -1;
|
|
47
|
+
const afterCurrent = matches.find((i) => i > fromIndex);
|
|
48
|
+
return afterCurrent ?? matches[0]; // wrap around
|
|
49
|
+
}
|
|
50
|
+
destroy() {
|
|
51
|
+
if (this.#timer) {
|
|
52
|
+
clearTimeout(this.#timer);
|
|
53
|
+
this.#timer = null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function useTypeaheadSearch() {
|
|
58
|
+
return new TypeaheadSearch();
|
|
59
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WheelPhysics — reactive physics class for the WheelPicker component.
|
|
3
|
+
*
|
|
4
|
+
* Manages a RAF-driven inertia loop and snap animation. The `offset` field is
|
|
5
|
+
* `$state` so any consumer that reads it will re-render when it changes.
|
|
6
|
+
*
|
|
7
|
+
* Design notes:
|
|
8
|
+
* - Only `offset` is $state — everything else is plain class fields (Pitfall 2).
|
|
9
|
+
* - The RAF loop sets `this.offset` imperatively; do NOT read it inside $effect.
|
|
10
|
+
* - Boundary resistance uses RESISTANCE constant from React v1.2.2 source.
|
|
11
|
+
* - Disabled options are always skipped when computing snap targets.
|
|
12
|
+
*/
|
|
13
|
+
import type { WheelPickerOption } from './types.js';
|
|
14
|
+
import { DEFAULT_DRAG_SENSITIVITY, DEFAULT_SCROLL_SENSITIVITY, DEFAULT_ITEM_HEIGHT, DEFAULT_VISIBLE_COUNT } from './wheel-physics-utils.js';
|
|
15
|
+
export { DEFAULT_DRAG_SENSITIVITY, DEFAULT_SCROLL_SENSITIVITY, DEFAULT_ITEM_HEIGHT, DEFAULT_VISIBLE_COUNT, };
|
|
16
|
+
export declare class WheelPhysics {
|
|
17
|
+
#private;
|
|
18
|
+
offset: number;
|
|
19
|
+
constructor(opts: {
|
|
20
|
+
itemHeight?: number;
|
|
21
|
+
visibleCount?: number;
|
|
22
|
+
dragSensitivity?: number;
|
|
23
|
+
scrollSensitivity?: number;
|
|
24
|
+
infinite?: boolean;
|
|
25
|
+
options: WheelPickerOption[];
|
|
26
|
+
initialIndex: number;
|
|
27
|
+
onSnap: (index: number) => void;
|
|
28
|
+
});
|
|
29
|
+
/**
|
|
30
|
+
* Update configuration when parent props change.
|
|
31
|
+
* This does NOT re-trigger animation — it takes effect on the next interaction.
|
|
32
|
+
*/
|
|
33
|
+
update(opts: {
|
|
34
|
+
itemHeight?: number;
|
|
35
|
+
visibleCount?: number;
|
|
36
|
+
dragSensitivity?: number;
|
|
37
|
+
scrollSensitivity?: number;
|
|
38
|
+
infinite?: boolean;
|
|
39
|
+
options?: WheelPickerOption[];
|
|
40
|
+
onSnap?: (index: number) => void;
|
|
41
|
+
}): void;
|
|
42
|
+
/**
|
|
43
|
+
* Called on pointerdown. Cancels any running animation and begins tracking.
|
|
44
|
+
*/
|
|
45
|
+
startDrag(clientY: number): void;
|
|
46
|
+
/**
|
|
47
|
+
* Called on pointermove. Updates offset applying boundary resistance at ends.
|
|
48
|
+
*/
|
|
49
|
+
moveDrag(clientY: number): void;
|
|
50
|
+
/**
|
|
51
|
+
* Called on pointerup. Computes velocity and kicks off inertia or direct snap.
|
|
52
|
+
*/
|
|
53
|
+
endDrag(): void;
|
|
54
|
+
/**
|
|
55
|
+
* Called on wheel event. Debounced at 100ms to prevent rapid-fire scroll events.
|
|
56
|
+
*
|
|
57
|
+
* deltaY > 0: scroll down → move to next item (higher index)
|
|
58
|
+
* deltaY < 0: scroll up → move to previous item (lower index)
|
|
59
|
+
*/
|
|
60
|
+
handleWheel(deltaY: number): void;
|
|
61
|
+
/**
|
|
62
|
+
* Animates the offset to the position corresponding to targetIndex.
|
|
63
|
+
* Uses easeOutCubic easing. Calls onSnap when complete.
|
|
64
|
+
*
|
|
65
|
+
* Cancels any currently running animation before starting.
|
|
66
|
+
*/
|
|
67
|
+
animateTo(targetIndex: number): void;
|
|
68
|
+
/**
|
|
69
|
+
* Returns the option index that the current offset visually corresponds to.
|
|
70
|
+
* Used to guard against redundant animations when the wheel is already positioned correctly.
|
|
71
|
+
*/
|
|
72
|
+
get currentIndex(): number;
|
|
73
|
+
/**
|
|
74
|
+
* Immediately sets the offset to the position for the given index, no animation.
|
|
75
|
+
* Used for initial render and controlled value updates.
|
|
76
|
+
*/
|
|
77
|
+
jumpTo(index: number): void;
|
|
78
|
+
/**
|
|
79
|
+
* Cancels any in-progress animation.
|
|
80
|
+
*/
|
|
81
|
+
cancelAnimation(): void;
|
|
82
|
+
/**
|
|
83
|
+
* Cleans up RAF on component destroy.
|
|
84
|
+
*/
|
|
85
|
+
destroy(): void;
|
|
86
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WheelPhysics — reactive physics class for the WheelPicker component.
|
|
3
|
+
*
|
|
4
|
+
* Manages a RAF-driven inertia loop and snap animation. The `offset` field is
|
|
5
|
+
* `$state` so any consumer that reads it will re-render when it changes.
|
|
6
|
+
*
|
|
7
|
+
* Design notes:
|
|
8
|
+
* - Only `offset` is $state — everything else is plain class fields (Pitfall 2).
|
|
9
|
+
* - The RAF loop sets `this.offset` imperatively; do NOT read it inside $effect.
|
|
10
|
+
* - Boundary resistance uses RESISTANCE constant from React v1.2.2 source.
|
|
11
|
+
* - Disabled options are always skipped when computing snap targets.
|
|
12
|
+
*/
|
|
13
|
+
import { easeOutCubic, indexToOffset, offsetToIndex, clampIndex, wrapIndex, snapToNearestEnabled, calculateVelocity, computeSnapTarget, computeAnimationDuration, RESISTANCE, DEFAULT_DRAG_SENSITIVITY, DEFAULT_SCROLL_SENSITIVITY, DEFAULT_ITEM_HEIGHT, DEFAULT_VISIBLE_COUNT, SNAP_BACK_DECELERATION, } from './wheel-physics-utils.js';
|
|
14
|
+
// Re-export for convenience so consumers only need one import
|
|
15
|
+
export { DEFAULT_DRAG_SENSITIVITY, DEFAULT_SCROLL_SENSITIVITY, DEFAULT_ITEM_HEIGHT, DEFAULT_VISIBLE_COUNT, };
|
|
16
|
+
export class WheelPhysics {
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Public reactive state ($state) — the ONLY field bound to the DOM transform
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
offset = $state(0);
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Private configuration (set in constructor, may be updated by update())
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
#itemHeight;
|
|
25
|
+
#visibleCount;
|
|
26
|
+
#dragSensitivity;
|
|
27
|
+
#scrollSensitivity;
|
|
28
|
+
#options;
|
|
29
|
+
#onSnap;
|
|
30
|
+
#infinite;
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Private non-reactive animation/drag state (NOT $state — Pitfall 2)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/** Current RAF handle — null when no animation running */
|
|
35
|
+
#rafId = null;
|
|
36
|
+
/** True between pointerdown and pointerup */
|
|
37
|
+
#isDragging = false;
|
|
38
|
+
/** The offset value when the current drag began */
|
|
39
|
+
#dragStartOffset = 0;
|
|
40
|
+
/** clientY at the start of the current drag (used for direct delta) */
|
|
41
|
+
#dragStartY = 0;
|
|
42
|
+
/** Recent pointer positions for velocity calculation: [clientY, timestamp][] */
|
|
43
|
+
#yList = [];
|
|
44
|
+
/** Timestamp of the last wheel event (100ms debounce guard) */
|
|
45
|
+
#lastWheelTime = -Infinity;
|
|
46
|
+
/** True while a snap or inertia animation is running */
|
|
47
|
+
#animating = false;
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Constructor
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
constructor(opts) {
|
|
52
|
+
this.#itemHeight = opts.itemHeight ?? DEFAULT_ITEM_HEIGHT;
|
|
53
|
+
this.#visibleCount = opts.visibleCount ?? DEFAULT_VISIBLE_COUNT;
|
|
54
|
+
this.#dragSensitivity = opts.dragSensitivity ?? DEFAULT_DRAG_SENSITIVITY;
|
|
55
|
+
this.#scrollSensitivity = opts.scrollSensitivity ?? DEFAULT_SCROLL_SENSITIVITY;
|
|
56
|
+
this.#infinite = opts.infinite ?? false;
|
|
57
|
+
this.#options = opts.options;
|
|
58
|
+
this.#onSnap = opts.onSnap;
|
|
59
|
+
this.offset = this.#indexToOffset(opts.initialIndex);
|
|
60
|
+
}
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Configuration update (called when props change in parent component)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
/**
|
|
65
|
+
* Update configuration when parent props change.
|
|
66
|
+
* This does NOT re-trigger animation — it takes effect on the next interaction.
|
|
67
|
+
*/
|
|
68
|
+
update(opts) {
|
|
69
|
+
if (opts.itemHeight !== undefined)
|
|
70
|
+
this.#itemHeight = opts.itemHeight;
|
|
71
|
+
if (opts.visibleCount !== undefined)
|
|
72
|
+
this.#visibleCount = opts.visibleCount;
|
|
73
|
+
if (opts.dragSensitivity !== undefined)
|
|
74
|
+
this.#dragSensitivity = opts.dragSensitivity;
|
|
75
|
+
if (opts.scrollSensitivity !== undefined)
|
|
76
|
+
this.#scrollSensitivity = opts.scrollSensitivity;
|
|
77
|
+
if (opts.infinite !== undefined)
|
|
78
|
+
this.#infinite = opts.infinite;
|
|
79
|
+
if (opts.options !== undefined)
|
|
80
|
+
this.#options = opts.options;
|
|
81
|
+
if (opts.onSnap !== undefined)
|
|
82
|
+
this.#onSnap = opts.onSnap;
|
|
83
|
+
}
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Drag handlers (pointer events)
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
/**
|
|
88
|
+
* Called on pointerdown. Cancels any running animation and begins tracking.
|
|
89
|
+
*/
|
|
90
|
+
startDrag(clientY) {
|
|
91
|
+
this.#cancelRaf();
|
|
92
|
+
this.#isDragging = true;
|
|
93
|
+
this.#animating = false;
|
|
94
|
+
this.#dragStartOffset = this.offset;
|
|
95
|
+
this.#dragStartY = clientY;
|
|
96
|
+
this.#yList = [[clientY, performance.now()]];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Called on pointermove. Updates offset applying boundary resistance at ends.
|
|
100
|
+
*/
|
|
101
|
+
moveDrag(clientY) {
|
|
102
|
+
if (!this.#isDragging)
|
|
103
|
+
return;
|
|
104
|
+
const delta = clientY - this.#dragStartY;
|
|
105
|
+
const maxOffset = this.#indexToOffset(0);
|
|
106
|
+
const minOffset = this.#indexToOffset(this.#options.length - 1);
|
|
107
|
+
let newOffset = this.#dragStartOffset + delta;
|
|
108
|
+
if (this.#infinite) {
|
|
109
|
+
// Infinite mode: normalize offset when the drag exceeds the ghost item bounds.
|
|
110
|
+
// The DOM has 3×N items (before-ghosts + real + after-ghosts), covering rawIndex
|
|
111
|
+
// -N..2N-1. When the pointer is captured outside the container, the user can drag
|
|
112
|
+
// past these bounds into empty space. Normalizing by ±N*itemHeight keeps the drag
|
|
113
|
+
// within the populated DOM region and ensures seamless infinite scroll.
|
|
114
|
+
//
|
|
115
|
+
// Applying the same shift to #dragStartOffset keeps future delta computations
|
|
116
|
+
// consistent so the drag feels continuous across the normalization boundary.
|
|
117
|
+
const loopDistance = this.#options.length * this.#itemHeight;
|
|
118
|
+
// After-ghost overflow: newOffset went past the last after-ghost (rawIndex >= 2N)
|
|
119
|
+
const afterGhostEnd = this.#indexToOffset(2 * this.#options.length);
|
|
120
|
+
// Before-ghost overflow: newOffset went past the first before-ghost (rawIndex < -N)
|
|
121
|
+
const beforeGhostEnd = this.#indexToOffset(-this.#options.length - 1);
|
|
122
|
+
while (newOffset < afterGhostEnd) {
|
|
123
|
+
newOffset += loopDistance;
|
|
124
|
+
this.#dragStartOffset += loopDistance;
|
|
125
|
+
}
|
|
126
|
+
while (newOffset > beforeGhostEnd) {
|
|
127
|
+
newOffset -= loopDistance;
|
|
128
|
+
this.#dragStartOffset -= loopDistance;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
// Apply rubber-band resistance at boundaries
|
|
133
|
+
if (newOffset > maxOffset) {
|
|
134
|
+
newOffset = maxOffset + (newOffset - maxOffset) * RESISTANCE;
|
|
135
|
+
}
|
|
136
|
+
else if (newOffset < minOffset) {
|
|
137
|
+
newOffset = minOffset + (newOffset - minOffset) * RESISTANCE;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
this.offset = newOffset;
|
|
141
|
+
// Track last 5 pointer positions for velocity calculation
|
|
142
|
+
this.#yList.push([clientY, performance.now()]);
|
|
143
|
+
if (this.#yList.length > 5) {
|
|
144
|
+
this.#yList.shift();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Called on pointerup. Computes velocity and kicks off inertia or direct snap.
|
|
149
|
+
*/
|
|
150
|
+
endDrag() {
|
|
151
|
+
console.log('[endDrag] called, isDragging=', this.#isDragging, 'offset=', this.offset);
|
|
152
|
+
if (!this.#isDragging)
|
|
153
|
+
return;
|
|
154
|
+
this.#isDragging = false;
|
|
155
|
+
const velocity = calculateVelocity(this.#yList, this.#itemHeight);
|
|
156
|
+
const rawIndex = this.#offsetToIndex(this.offset);
|
|
157
|
+
const N = this.#options.length;
|
|
158
|
+
const currentIndex = this.#infinite
|
|
159
|
+
? wrapIndex(rawIndex, N)
|
|
160
|
+
: clampIndex(rawIndex, N);
|
|
161
|
+
console.log('[endDrag] velocity=', velocity, 'rawIndex=', rawIndex, 'currentIndex=', currentIndex, 'infinite=', this.#infinite);
|
|
162
|
+
if (Math.abs(velocity) < 0.5) {
|
|
163
|
+
// Slow release — snap directly to nearest enabled option
|
|
164
|
+
const snapIndex = snapToNearestEnabled(currentIndex, this.#options);
|
|
165
|
+
console.log('[endDrag] slow path, snapIndex=', snapIndex);
|
|
166
|
+
if (this.#infinite) {
|
|
167
|
+
// Preserve ghost-section context so the snap animation continues in the
|
|
168
|
+
// same direction as the drag rather than jumping backward to real-section.
|
|
169
|
+
// rawIndex is in [-N, 2N-1] (guaranteed by moveDrag normalization).
|
|
170
|
+
// Determine which "loop offset" we are in and apply to snapIndex:
|
|
171
|
+
// before-ghost (rawIndex < 0): animate to snapIndex - N
|
|
172
|
+
// after-ghost (rawIndex >= N): animate to snapIndex + N
|
|
173
|
+
// real section (rawIndex in [0, N-1]): animate to snapIndex directly
|
|
174
|
+
const loopOffset = rawIndex < 0 ? -N : rawIndex >= N ? N : 0;
|
|
175
|
+
this.animateTo(snapIndex + loopOffset);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
this.animateTo(snapIndex);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
// Inertia — compute overshoot target
|
|
183
|
+
// Use rawIndex (not currentIndex) so the overshoot accounts for the current
|
|
184
|
+
// ghost-loop position, giving the correct item index after inertia deceleration.
|
|
185
|
+
const rawTarget = computeSnapTarget(rawIndex, velocity, this.#dragSensitivity);
|
|
186
|
+
if (this.#infinite) {
|
|
187
|
+
// snapIndex is the nearest enabled item to the overshoot target (in [0, N-1])
|
|
188
|
+
const wrapped = wrapIndex(rawTarget, N);
|
|
189
|
+
const snapIndex = snapToNearestEnabled(wrapped, this.#options);
|
|
190
|
+
// Animate to the ghost-section position of snapIndex matching the current
|
|
191
|
+
// loop, so the animation moves in the same direction as the drag.
|
|
192
|
+
// snapIndex is in [0,N-1]; loopOffset is -N, 0, or +N based on current section.
|
|
193
|
+
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);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
const clamped = clampIndex(rawTarget, N);
|
|
199
|
+
const snapIndex = snapToNearestEnabled(clamped, this.#options);
|
|
200
|
+
this.animateTo(snapIndex);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Wheel event handler
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
/**
|
|
208
|
+
* Called on wheel event. Debounced at 100ms to prevent rapid-fire scroll events.
|
|
209
|
+
*
|
|
210
|
+
* deltaY > 0: scroll down → move to next item (higher index)
|
|
211
|
+
* deltaY < 0: scroll up → move to previous item (lower index)
|
|
212
|
+
*/
|
|
213
|
+
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)
|
|
223
|
+
const direction = deltaY > 0 ? 1 : -1;
|
|
224
|
+
if (this.#infinite) {
|
|
225
|
+
const next = currentIndex + direction;
|
|
226
|
+
const wrapped = wrapIndex(next, this.#options.length);
|
|
227
|
+
const snapIndex = snapToNearestEnabled(wrapped, this.#options);
|
|
228
|
+
this.animateTo(snapIndex);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
const targetIndex = clampIndex(currentIndex + direction, this.#options.length);
|
|
232
|
+
const snapIndex = snapToNearestEnabled(targetIndex, this.#options);
|
|
233
|
+
this.animateTo(snapIndex);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Animation
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
/**
|
|
240
|
+
* Animates the offset to the position corresponding to targetIndex.
|
|
241
|
+
* Uses easeOutCubic easing. Calls onSnap when complete.
|
|
242
|
+
*
|
|
243
|
+
* Cancels any currently running animation before starting.
|
|
244
|
+
*/
|
|
245
|
+
animateTo(targetIndex) {
|
|
246
|
+
console.log('[animateTo] targetIndex=', targetIndex, 'from offset=', this.offset);
|
|
247
|
+
this.#cancelRaf();
|
|
248
|
+
this.#animating = true;
|
|
249
|
+
const startOffset = this.offset;
|
|
250
|
+
const targetOffset = this.#indexToOffset(targetIndex);
|
|
251
|
+
const distance = Math.abs(targetIndex - this.#offsetToIndex(startOffset));
|
|
252
|
+
const durationSec = computeAnimationDuration(distance, this.#scrollSensitivity);
|
|
253
|
+
const durationMs = durationSec * 1000;
|
|
254
|
+
const startTime = performance.now();
|
|
255
|
+
const tick = (now) => {
|
|
256
|
+
if (!this.#animating)
|
|
257
|
+
return;
|
|
258
|
+
const elapsed = now - startTime;
|
|
259
|
+
const progress = Math.min(elapsed / durationMs, 1);
|
|
260
|
+
const eased = easeOutCubic(progress);
|
|
261
|
+
this.offset = startOffset + (targetOffset - startOffset) * eased;
|
|
262
|
+
if (progress < 1) {
|
|
263
|
+
this.#rafId = requestAnimationFrame(tick);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
// Snap to exact target to avoid float accumulation
|
|
267
|
+
this.offset = targetOffset;
|
|
268
|
+
this.#rafId = null;
|
|
269
|
+
this.#animating = false;
|
|
270
|
+
this.#onSnap(targetIndex);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
this.#rafId = requestAnimationFrame(tick);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Returns the option index that the current offset visually corresponds to.
|
|
277
|
+
* Used to guard against redundant animations when the wheel is already positioned correctly.
|
|
278
|
+
*/
|
|
279
|
+
get currentIndex() {
|
|
280
|
+
const raw = this.#offsetToIndex(this.offset);
|
|
281
|
+
return this.#infinite
|
|
282
|
+
? wrapIndex(raw, this.#options.length)
|
|
283
|
+
: clampIndex(raw, this.#options.length);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Immediately sets the offset to the position for the given index, no animation.
|
|
287
|
+
* Used for initial render and controlled value updates.
|
|
288
|
+
*/
|
|
289
|
+
jumpTo(index) {
|
|
290
|
+
this.#cancelRaf();
|
|
291
|
+
this.offset = this.#indexToOffset(index);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Cancels any in-progress animation.
|
|
295
|
+
*/
|
|
296
|
+
cancelAnimation() {
|
|
297
|
+
this.#cancelRaf();
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Cleans up RAF on component destroy.
|
|
301
|
+
*/
|
|
302
|
+
destroy() {
|
|
303
|
+
this.cancelAnimation();
|
|
304
|
+
}
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// Private helpers
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
#cancelRaf() {
|
|
309
|
+
if (this.#rafId !== null) {
|
|
310
|
+
cancelAnimationFrame(this.#rafId);
|
|
311
|
+
this.#rafId = null;
|
|
312
|
+
}
|
|
313
|
+
this.#animating = false;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Converts an option index to a translateY offset, accounting for before-ghost
|
|
317
|
+
* rows prepended to the DOM container in infinite mode.
|
|
318
|
+
*
|
|
319
|
+
* In infinite mode the container begins with N = options.length ghost rows, so
|
|
320
|
+
* real item[i] sits at DOM position (N + i) * itemHeight. The offset must be
|
|
321
|
+
* shifted by -N * itemHeight relative to the non-infinite formula.
|
|
322
|
+
*/
|
|
323
|
+
#indexToOffset(index) {
|
|
324
|
+
const ghostCount = this.#infinite ? this.#options.length : 0;
|
|
325
|
+
return indexToOffset(index + ghostCount, this.#itemHeight, this.#visibleCount);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Converts a translateY offset back to an option index, accounting for the
|
|
329
|
+
* before-ghost prefix in infinite mode (inverse of #indexToOffset).
|
|
330
|
+
*/
|
|
331
|
+
#offsetToIndex(offset) {
|
|
332
|
+
const ghostCount = this.#infinite ? this.#options.length : 0;
|
|
333
|
+
return offsetToIndex(offset, this.#itemHeight, this.#visibleCount) - ghostCount;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Unused export to prevent unused import warnings
|
|
337
|
+
void SNAP_BACK_DECELERATION;
|