@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.
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Pure physics utility functions for the WheelPicker.
3
+ *
4
+ * All functions here are side-effect-free and do not reference DOM or Svelte
5
+ * reactivity — they can be unit-tested in a plain Node environment.
6
+ *
7
+ * Physics constants are copied from @ncdai/react-wheel-picker v1.2.2 to
8
+ * achieve UX parity with the React version.
9
+ */
10
+ /** Boundary resistance factor — how much drag is applied when pulling past the ends. */
11
+ export declare const RESISTANCE = 0.3;
12
+ /** Maximum scroll velocity in items/second. */
13
+ export declare const MAX_VELOCITY = 30;
14
+ /** Default drag sensitivity (pointer drag delta multiplier for inertia). */
15
+ export declare const DEFAULT_DRAG_SENSITIVITY = 3;
16
+ /** Default scroll sensitivity (wheel event multiplier for snap animation duration). */
17
+ export declare const DEFAULT_SCROLL_SENSITIVITY = 5;
18
+ /** Default height in pixels of each option row. */
19
+ export declare const DEFAULT_ITEM_HEIGHT = 30;
20
+ /** Default number of visible option rows. */
21
+ export declare const DEFAULT_VISIBLE_COUNT = 5;
22
+ /** Deceleration constant used in snap-back calculations. */
23
+ export declare const SNAP_BACK_DECELERATION = 10;
24
+ /** Minimum scaleY for cylindrical mode to prevent items collapsing to zero height. */
25
+ export declare const MIN_CYLINDRICAL_SCALE = 0.1;
26
+ /**
27
+ * Cubic ease-out easing function.
28
+ * Matches the React version's easing: `Math.pow(p - 1, 3) + 1`
29
+ *
30
+ * @param p - Progress from 0 to 1
31
+ * @returns Eased progress from 0 to 1
32
+ */
33
+ export declare function easeOutCubic(p: number): number;
34
+ /**
35
+ * Converts a selected option index to a translateY offset value.
36
+ *
37
+ * The center slot is at `Math.floor(visibleCount / 2) * itemHeight`.
38
+ * Index 0 sits at the center slot (largest positive offset).
39
+ *
40
+ * @param index - The option index (0-based)
41
+ * @param itemHeight - Height in pixels of each option row
42
+ * @param visibleCount - Number of visible rows
43
+ * @returns The translateY offset value in pixels
44
+ */
45
+ export declare function indexToOffset(index: number, itemHeight: number, visibleCount: number): number;
46
+ /**
47
+ * Converts a translateY offset value to the nearest option index.
48
+ * Inverse of `indexToOffset`.
49
+ *
50
+ * @param offset - Current translateY offset in pixels
51
+ * @param itemHeight - Height in pixels of each option row
52
+ * @param visibleCount - Number of visible rows
53
+ * @returns The nearest option index
54
+ */
55
+ export declare function offsetToIndex(offset: number, itemHeight: number, visibleCount: number): number;
56
+ /**
57
+ * Clamps an index to the valid range [0, optionsLength - 1].
58
+ *
59
+ * @param index - The index to clamp
60
+ * @param optionsLength - Total number of options
61
+ * @returns The clamped index
62
+ */
63
+ export declare function clampIndex(index: number, optionsLength: number): number;
64
+ /**
65
+ * Wraps an index into the valid range [0, optionsLength) using modulo.
66
+ * Handles negative indices correctly (JavaScript modulo returns negative for negative inputs).
67
+ *
68
+ * Formula from @ncdai/react-wheel-picker v1.2.2: ((index % n) + n) % n
69
+ *
70
+ * @param index - The index to wrap (may be negative or >= optionsLength)
71
+ * @param optionsLength - Total number of options
72
+ * @returns The wrapped index in [0, optionsLength - 1]
73
+ */
74
+ export declare function wrapIndex(index: number, optionsLength: number): number;
75
+ /**
76
+ * Finds the nearest enabled option from a target index.
77
+ *
78
+ * Walks outward in both directions (lower-delta first), returning the nearest
79
+ * enabled option. If all options are disabled, returns the original targetIndex.
80
+ *
81
+ * @param targetIndex - The desired index
82
+ * @param options - Array of options (only `disabled` field is used)
83
+ * @returns The nearest enabled index
84
+ */
85
+ export declare function snapToNearestEnabled(targetIndex: number, options: Array<{
86
+ disabled?: boolean;
87
+ }>): number;
88
+ /**
89
+ * Calculates the scroll velocity from recent pointer positions.
90
+ *
91
+ * Uses the last two entries in yList to compute items/second.
92
+ * Result is clamped to [-MAX_VELOCITY, MAX_VELOCITY].
93
+ *
94
+ * @param yList - Array of [clientY, timestamp] tuples (newest last)
95
+ * @param itemHeight - Height in pixels of each option row
96
+ * @returns Velocity in items/second (positive = scrolling down)
97
+ */
98
+ export declare function calculateVelocity(yList: Array<[number, number]>, itemHeight: number): number;
99
+ /**
100
+ * Computes the inertia overshoot snap target index.
101
+ *
102
+ * Based on: `baseDeceleration = dragSensitivity * 10`,
103
+ * `overshoot = 0.5 * v^2 / baseDeceleration`,
104
+ * `rawTarget = currentIndex + sign(v) * overshoot`
105
+ *
106
+ * @param currentIndexFromOffset - The index at current offset
107
+ * @param velocity - Current velocity in items/second
108
+ * @param dragSensitivity - Drag sensitivity (affects deceleration)
109
+ * @returns The rounded target index after inertia overshoot
110
+ */
111
+ export declare function computeSnapTarget(currentIndexFromOffset: number, velocity: number, dragSensitivity: number): number;
112
+ /**
113
+ * Computes the duration of a snap animation in seconds.
114
+ *
115
+ * Formula: `Math.sqrt(|distance| / scrollSensitivity)`
116
+ * Clamped to [0.1, 0.6] seconds.
117
+ *
118
+ * @param distance - Distance in index steps to travel
119
+ * @param scrollSensitivity - Scroll sensitivity (affects duration)
120
+ * @returns Animation duration in seconds
121
+ */
122
+ export declare function computeAnimationDuration(distance: number, scrollSensitivity: number): number;
123
+ /**
124
+ * Computes per-item scaleY (and opacity) for cylindrical drum mode.
125
+ *
126
+ * Derived from the cosine projection of a virtual cylinder: an item at angle
127
+ * theta from front-face has projected height cos(theta) * fullHeight.
128
+ * Maps floor(visibleCount/2) slots from center to PI/2, auto-scaling with visibleCount.
129
+ *
130
+ * slotIndex assignments:
131
+ * - Non-infinite real item i: slotIndex = i
132
+ * - Infinite before-ghost g: slotIndex = g - options.length
133
+ * - Infinite after-ghost j: slotIndex = options.length + j
134
+ *
135
+ * @param slotIndex - The item's position in the combined DOM list
136
+ * @param offset - Current physics.offset (translateY px)
137
+ * @param itemHeight - Height of each option row in pixels
138
+ * @param visibleCount - Number of visible rows (must be odd)
139
+ * @returns scaleY in range [MIN_CYLINDRICAL_SCALE, 1.0]
140
+ */
141
+ export declare function cylindricalScaleY(slotIndex: number, offset: number, itemHeight: number, visibleCount: number): number;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Pure physics utility functions for the WheelPicker.
3
+ *
4
+ * All functions here are side-effect-free and do not reference DOM or Svelte
5
+ * reactivity — they can be unit-tested in a plain Node environment.
6
+ *
7
+ * Physics constants are copied from @ncdai/react-wheel-picker v1.2.2 to
8
+ * achieve UX parity with the React version.
9
+ */
10
+ // ---------------------------------------------------------------------------
11
+ // Physics constants (from React source v1.2.2)
12
+ // ---------------------------------------------------------------------------
13
+ /** Boundary resistance factor — how much drag is applied when pulling past the ends. */
14
+ export const RESISTANCE = 0.3;
15
+ /** Maximum scroll velocity in items/second. */
16
+ export const MAX_VELOCITY = 30;
17
+ /** Default drag sensitivity (pointer drag delta multiplier for inertia). */
18
+ export const DEFAULT_DRAG_SENSITIVITY = 3;
19
+ /** Default scroll sensitivity (wheel event multiplier for snap animation duration). */
20
+ export const DEFAULT_SCROLL_SENSITIVITY = 5;
21
+ /** Default height in pixels of each option row. */
22
+ export const DEFAULT_ITEM_HEIGHT = 30;
23
+ /** Default number of visible option rows. */
24
+ export const DEFAULT_VISIBLE_COUNT = 5;
25
+ /** Deceleration constant used in snap-back calculations. */
26
+ export const SNAP_BACK_DECELERATION = 10;
27
+ /** Minimum scaleY for cylindrical mode to prevent items collapsing to zero height. */
28
+ export const MIN_CYLINDRICAL_SCALE = 0.1;
29
+ // ---------------------------------------------------------------------------
30
+ // Pure physics functions
31
+ // ---------------------------------------------------------------------------
32
+ /**
33
+ * Cubic ease-out easing function.
34
+ * Matches the React version's easing: `Math.pow(p - 1, 3) + 1`
35
+ *
36
+ * @param p - Progress from 0 to 1
37
+ * @returns Eased progress from 0 to 1
38
+ */
39
+ export function easeOutCubic(p) {
40
+ return Math.pow(p - 1, 3) + 1;
41
+ }
42
+ /**
43
+ * Converts a selected option index to a translateY offset value.
44
+ *
45
+ * The center slot is at `Math.floor(visibleCount / 2) * itemHeight`.
46
+ * Index 0 sits at the center slot (largest positive offset).
47
+ *
48
+ * @param index - The option index (0-based)
49
+ * @param itemHeight - Height in pixels of each option row
50
+ * @param visibleCount - Number of visible rows
51
+ * @returns The translateY offset value in pixels
52
+ */
53
+ export function indexToOffset(index, itemHeight, visibleCount) {
54
+ return Math.floor(visibleCount / 2) * itemHeight - index * itemHeight;
55
+ }
56
+ /**
57
+ * Converts a translateY offset value to the nearest option index.
58
+ * Inverse of `indexToOffset`.
59
+ *
60
+ * @param offset - Current translateY offset in pixels
61
+ * @param itemHeight - Height in pixels of each option row
62
+ * @param visibleCount - Number of visible rows
63
+ * @returns The nearest option index
64
+ */
65
+ export function offsetToIndex(offset, itemHeight, visibleCount) {
66
+ return Math.round((Math.floor(visibleCount / 2) * itemHeight - offset) / itemHeight);
67
+ }
68
+ /**
69
+ * Clamps an index to the valid range [0, optionsLength - 1].
70
+ *
71
+ * @param index - The index to clamp
72
+ * @param optionsLength - Total number of options
73
+ * @returns The clamped index
74
+ */
75
+ export function clampIndex(index, optionsLength) {
76
+ return Math.max(0, Math.min(index, optionsLength - 1));
77
+ }
78
+ /**
79
+ * Wraps an index into the valid range [0, optionsLength) using modulo.
80
+ * Handles negative indices correctly (JavaScript modulo returns negative for negative inputs).
81
+ *
82
+ * Formula from @ncdai/react-wheel-picker v1.2.2: ((index % n) + n) % n
83
+ *
84
+ * @param index - The index to wrap (may be negative or >= optionsLength)
85
+ * @param optionsLength - Total number of options
86
+ * @returns The wrapped index in [0, optionsLength - 1]
87
+ */
88
+ export function wrapIndex(index, optionsLength) {
89
+ return ((index % optionsLength) + optionsLength) % optionsLength;
90
+ }
91
+ /**
92
+ * Finds the nearest enabled option from a target index.
93
+ *
94
+ * Walks outward in both directions (lower-delta first), returning the nearest
95
+ * enabled option. If all options are disabled, returns the original targetIndex.
96
+ *
97
+ * @param targetIndex - The desired index
98
+ * @param options - Array of options (only `disabled` field is used)
99
+ * @returns The nearest enabled index
100
+ */
101
+ export function snapToNearestEnabled(targetIndex, options) {
102
+ if (!options[targetIndex]?.disabled) {
103
+ return targetIndex;
104
+ }
105
+ // Walk outward from targetIndex to find the nearest enabled option
106
+ for (let delta = 1; delta < options.length; delta++) {
107
+ const lower = targetIndex - delta;
108
+ const upper = targetIndex + delta;
109
+ if (lower >= 0 && !options[lower]?.disabled) {
110
+ return lower;
111
+ }
112
+ if (upper < options.length && !options[upper]?.disabled) {
113
+ return upper;
114
+ }
115
+ }
116
+ // All options are disabled — return original target
117
+ return targetIndex;
118
+ }
119
+ /**
120
+ * Calculates the scroll velocity from recent pointer positions.
121
+ *
122
+ * Uses the last two entries in yList to compute items/second.
123
+ * Result is clamped to [-MAX_VELOCITY, MAX_VELOCITY].
124
+ *
125
+ * @param yList - Array of [clientY, timestamp] tuples (newest last)
126
+ * @param itemHeight - Height in pixels of each option row
127
+ * @returns Velocity in items/second (positive = scrolling down)
128
+ */
129
+ export function calculateVelocity(yList, itemHeight) {
130
+ if (yList.length < 2) {
131
+ return 0;
132
+ }
133
+ const [y1, t1] = yList[yList.length - 2];
134
+ const [y2, t2] = yList[yList.length - 1];
135
+ if (t2 === t1) {
136
+ return 0;
137
+ }
138
+ const velocity = ((y2 - y1) / itemHeight) * (1000 / (t2 - t1));
139
+ const clamped = Math.max(-MAX_VELOCITY, Math.min(MAX_VELOCITY, velocity));
140
+ return clamped;
141
+ }
142
+ /**
143
+ * Computes the inertia overshoot snap target index.
144
+ *
145
+ * Based on: `baseDeceleration = dragSensitivity * 10`,
146
+ * `overshoot = 0.5 * v^2 / baseDeceleration`,
147
+ * `rawTarget = currentIndex + sign(v) * overshoot`
148
+ *
149
+ * @param currentIndexFromOffset - The index at current offset
150
+ * @param velocity - Current velocity in items/second
151
+ * @param dragSensitivity - Drag sensitivity (affects deceleration)
152
+ * @returns The rounded target index after inertia overshoot
153
+ */
154
+ export function computeSnapTarget(currentIndexFromOffset, velocity, dragSensitivity) {
155
+ const baseDeceleration = dragSensitivity * 10;
156
+ const overshoot = (0.5 * velocity * velocity) / baseDeceleration;
157
+ // Velocity sign is inverted relative to index direction:
158
+ // drag down (positive velocity) increases offset → decreases index → overshoot toward lower index.
159
+ const rawTarget = currentIndexFromOffset - Math.sign(velocity) * overshoot;
160
+ return Math.round(rawTarget);
161
+ }
162
+ /**
163
+ * Computes the duration of a snap animation in seconds.
164
+ *
165
+ * Formula: `Math.sqrt(|distance| / scrollSensitivity)`
166
+ * Clamped to [0.1, 0.6] seconds.
167
+ *
168
+ * @param distance - Distance in index steps to travel
169
+ * @param scrollSensitivity - Scroll sensitivity (affects duration)
170
+ * @returns Animation duration in seconds
171
+ */
172
+ export function computeAnimationDuration(distance, scrollSensitivity) {
173
+ const raw = Math.sqrt(Math.abs(distance) / scrollSensitivity);
174
+ return Math.max(0.1, Math.min(0.6, raw));
175
+ }
176
+ /**
177
+ * Computes per-item scaleY (and opacity) for cylindrical drum mode.
178
+ *
179
+ * Derived from the cosine projection of a virtual cylinder: an item at angle
180
+ * theta from front-face has projected height cos(theta) * fullHeight.
181
+ * Maps floor(visibleCount/2) slots from center to PI/2, auto-scaling with visibleCount.
182
+ *
183
+ * slotIndex assignments:
184
+ * - Non-infinite real item i: slotIndex = i
185
+ * - Infinite before-ghost g: slotIndex = g - options.length
186
+ * - Infinite after-ghost j: slotIndex = options.length + j
187
+ *
188
+ * @param slotIndex - The item's position in the combined DOM list
189
+ * @param offset - Current physics.offset (translateY px)
190
+ * @param itemHeight - Height of each option row in pixels
191
+ * @param visibleCount - Number of visible rows (must be odd)
192
+ * @returns scaleY in range [MIN_CYLINDRICAL_SCALE, 1.0]
193
+ */
194
+ export function cylindricalScaleY(slotIndex, offset, itemHeight, visibleCount) {
195
+ const dist = slotIndex + offset / itemHeight - Math.floor(visibleCount / 2);
196
+ const angle = (dist * Math.PI) / visibleCount;
197
+ return Math.max(MIN_CYLINDRICAL_SCALE, Math.cos(angle));
198
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@uinstinct/svelte-wheel-picker",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "iOS-style wheel picker for Svelte 5 with inertia scrolling, infinite loop, and keyboard navigation",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=18"
9
+ },
10
+ "svelte": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "svelte": "./dist/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "sideEffects": false,
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "peerDependencies": {
26
+ "svelte": "^5.0.0"
27
+ },
28
+ "scripts": {
29
+ "dev": "vite dev",
30
+ "build": "vite build",
31
+ "package": "svelte-package && rm -rf dist/__tests__ && rm -f dist/*.test.js dist/*.test.d.ts && publint",
32
+ "prepack": "npm run package",
33
+ "test": "PLAYWRIGHT_BROWSERS_PATH=.playwright vitest run",
34
+ "test:watch": "PLAYWRIGHT_BROWSERS_PATH=.playwright vitest",
35
+ "lint": "eslint .",
36
+ "format": "prettier --write .",
37
+ "registry:build": "shadcn-svelte registry build"
38
+ },
39
+ "devDependencies": {
40
+ "@eslint/js": "10.0.1",
41
+ "@sveltejs/adapter-vercel": "^6.3.3",
42
+ "@sveltejs/kit": "2.55.0",
43
+ "@sveltejs/package": "2.5.7",
44
+ "@sveltejs/vite-plugin-svelte": "7.0.0",
45
+ "@vitest/browser-playwright": "4.1.0",
46
+ "eslint": "10.1.0",
47
+ "eslint-plugin-svelte": "3.16.0",
48
+ "globals": "17.4.0",
49
+ "prettier": "3.8.1",
50
+ "prettier-plugin-svelte": "3.5.1",
51
+ "publint": "0.3.18",
52
+ "shadcn-svelte": "1.2.3",
53
+ "svelte": "5.54.1",
54
+ "svelte-eslint-parser": "1.6.0",
55
+ "typescript": "5.9.3",
56
+ "typescript-eslint": "8.57.1",
57
+ "vite": "8.0.1",
58
+ "vitest": "4.1.0",
59
+ "vitest-browser-svelte": "2.1.0"
60
+ }
61
+ }