@ncdai/react-wheel-picker 1.0.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.md +9 -0
- package/README.md +3 -0
- package/dist/index.d.mts +58 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +492 -0
- package/dist/index.mjs +485 -0
- package/dist/style.css +75 -0
- package/package.json +58 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 ncdai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React$1 from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Represents a single option in the wheel picker
|
|
5
|
+
*/
|
|
6
|
+
type WheelPickerOption = {
|
|
7
|
+
/** The value that will be returned when this option is selected */
|
|
8
|
+
value: string;
|
|
9
|
+
/** The text label displayed for this option */
|
|
10
|
+
label: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Custom class names for styling different parts of the wheel picker
|
|
14
|
+
*/
|
|
15
|
+
type WheelPickerClassNames = {
|
|
16
|
+
/** Class name for individual option items */
|
|
17
|
+
optionItem?: string;
|
|
18
|
+
/** Class name for the wrapper of the highlighted area */
|
|
19
|
+
highlightWrapper?: string;
|
|
20
|
+
/** Class name for the highlighted item */
|
|
21
|
+
highlightItem?: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Props for the WheelPicker component
|
|
25
|
+
*/
|
|
26
|
+
type WheelPickerProps = {
|
|
27
|
+
/** Initial value of the picker when uncontrolled */
|
|
28
|
+
defaultValue?: string;
|
|
29
|
+
/** Current value of the picker when controlled */
|
|
30
|
+
value?: string;
|
|
31
|
+
/** Callback fired when the selected value changes */
|
|
32
|
+
onValueChange?: (value: string) => void;
|
|
33
|
+
/** Array of options to display in the wheel */
|
|
34
|
+
options: WheelPickerOption[];
|
|
35
|
+
/** Whether the wheel should loop infinitely */
|
|
36
|
+
infinite?: boolean;
|
|
37
|
+
/** The number of options visible on the circular ring, must be a multiple of 4 */
|
|
38
|
+
visibleCount?: number;
|
|
39
|
+
/** Sensitivity of the drag interaction (higher = more sensitive) */
|
|
40
|
+
dragSensitivity?: number;
|
|
41
|
+
/** Custom class names for styling different parts of the wheel */
|
|
42
|
+
classNames?: WheelPickerClassNames;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Props for the WheelPicker wrapper component
|
|
46
|
+
*/
|
|
47
|
+
type WheelPickerWrapperProps = {
|
|
48
|
+
/** Additional CSS class name for the wrapper */
|
|
49
|
+
className?: string;
|
|
50
|
+
/** Child elements to be rendered inside the wrapper */
|
|
51
|
+
children: React.ReactNode;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
declare const WheelPickerWrapper: React$1.FC<WheelPickerWrapperProps>;
|
|
55
|
+
declare const WheelPicker: React$1.FC<WheelPickerProps>;
|
|
56
|
+
|
|
57
|
+
export { WheelPicker, WheelPickerWrapper };
|
|
58
|
+
export type { WheelPickerClassNames, WheelPickerOption };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React$1 from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Represents a single option in the wheel picker
|
|
5
|
+
*/
|
|
6
|
+
type WheelPickerOption = {
|
|
7
|
+
/** The value that will be returned when this option is selected */
|
|
8
|
+
value: string;
|
|
9
|
+
/** The text label displayed for this option */
|
|
10
|
+
label: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Custom class names for styling different parts of the wheel picker
|
|
14
|
+
*/
|
|
15
|
+
type WheelPickerClassNames = {
|
|
16
|
+
/** Class name for individual option items */
|
|
17
|
+
optionItem?: string;
|
|
18
|
+
/** Class name for the wrapper of the highlighted area */
|
|
19
|
+
highlightWrapper?: string;
|
|
20
|
+
/** Class name for the highlighted item */
|
|
21
|
+
highlightItem?: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Props for the WheelPicker component
|
|
25
|
+
*/
|
|
26
|
+
type WheelPickerProps = {
|
|
27
|
+
/** Initial value of the picker when uncontrolled */
|
|
28
|
+
defaultValue?: string;
|
|
29
|
+
/** Current value of the picker when controlled */
|
|
30
|
+
value?: string;
|
|
31
|
+
/** Callback fired when the selected value changes */
|
|
32
|
+
onValueChange?: (value: string) => void;
|
|
33
|
+
/** Array of options to display in the wheel */
|
|
34
|
+
options: WheelPickerOption[];
|
|
35
|
+
/** Whether the wheel should loop infinitely */
|
|
36
|
+
infinite?: boolean;
|
|
37
|
+
/** The number of options visible on the circular ring, must be a multiple of 4 */
|
|
38
|
+
visibleCount?: number;
|
|
39
|
+
/** Sensitivity of the drag interaction (higher = more sensitive) */
|
|
40
|
+
dragSensitivity?: number;
|
|
41
|
+
/** Custom class names for styling different parts of the wheel */
|
|
42
|
+
classNames?: WheelPickerClassNames;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Props for the WheelPicker wrapper component
|
|
46
|
+
*/
|
|
47
|
+
type WheelPickerWrapperProps = {
|
|
48
|
+
/** Additional CSS class name for the wrapper */
|
|
49
|
+
className?: string;
|
|
50
|
+
/** Child elements to be rendered inside the wrapper */
|
|
51
|
+
children: React.ReactNode;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
declare const WheelPickerWrapper: React$1.FC<WheelPickerWrapperProps>;
|
|
55
|
+
declare const WheelPicker: React$1.FC<WheelPickerProps>;
|
|
56
|
+
|
|
57
|
+
export { WheelPicker, WheelPickerWrapper };
|
|
58
|
+
export type { WheelPickerClassNames, WheelPickerOption };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
3
|
+
|
|
4
|
+
var React = require('react');
|
|
5
|
+
|
|
6
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
7
|
+
|
|
8
|
+
var React__default = /*#__PURE__*/_interopDefault(React);
|
|
9
|
+
|
|
10
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */ // This code comes from https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx
|
|
11
|
+
function useCallbackRef(callback) {
|
|
12
|
+
const callbackRef = React__default.default.useRef(callback);
|
|
13
|
+
React__default.default.useEffect(()=>{
|
|
14
|
+
callbackRef.current = callback;
|
|
15
|
+
});
|
|
16
|
+
// https://github.com/facebook/react/issues/19240
|
|
17
|
+
return React__default.default.useMemo(()=>(...args)=>callbackRef.current == null ? void 0 : callbackRef.current.call(callbackRef, ...args), []);
|
|
18
|
+
}
|
|
19
|
+
function useUncontrolledState({ defaultProp, onChange }) {
|
|
20
|
+
const uncontrolledState = React__default.default.useState(defaultProp);
|
|
21
|
+
const [value] = uncontrolledState;
|
|
22
|
+
const prevValueRef = React__default.default.useRef(value);
|
|
23
|
+
const handleChange = useCallbackRef(onChange);
|
|
24
|
+
React__default.default.useEffect(()=>{
|
|
25
|
+
if (prevValueRef.current !== value) {
|
|
26
|
+
handleChange(value);
|
|
27
|
+
prevValueRef.current = value;
|
|
28
|
+
}
|
|
29
|
+
}, [
|
|
30
|
+
value,
|
|
31
|
+
prevValueRef,
|
|
32
|
+
handleChange
|
|
33
|
+
]);
|
|
34
|
+
return uncontrolledState;
|
|
35
|
+
}
|
|
36
|
+
function useControllableState({ prop, defaultProp, onChange = ()=>{} }) {
|
|
37
|
+
const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({
|
|
38
|
+
defaultProp,
|
|
39
|
+
onChange
|
|
40
|
+
});
|
|
41
|
+
const isControlled = prop !== undefined;
|
|
42
|
+
const value = isControlled ? prop : uncontrolledProp;
|
|
43
|
+
const handleChange = useCallbackRef(onChange);
|
|
44
|
+
const setValue = React__default.default.useCallback((nextValue)=>{
|
|
45
|
+
if (isControlled) {
|
|
46
|
+
const setter = nextValue;
|
|
47
|
+
const value = typeof nextValue === "function" ? setter(prop) : nextValue;
|
|
48
|
+
if (value !== prop) handleChange(value);
|
|
49
|
+
} else {
|
|
50
|
+
setUncontrolledProp(nextValue);
|
|
51
|
+
}
|
|
52
|
+
}, [
|
|
53
|
+
isControlled,
|
|
54
|
+
prop,
|
|
55
|
+
setUncontrolledProp,
|
|
56
|
+
handleChange
|
|
57
|
+
]);
|
|
58
|
+
return [
|
|
59
|
+
value,
|
|
60
|
+
setValue
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const WHEEL_THROTTLE = 150; // ms
|
|
65
|
+
const RESISTANCE = 0.3; // Resistance when scrolling above the top or below the bottom
|
|
66
|
+
const MAX_VELOCITY = 30; // Maximum velocity for the scroll animation
|
|
67
|
+
const easeOutCubic = (p)=>Math.pow(p - 1, 3) + 1;
|
|
68
|
+
const WheelPickerWrapper = ({ className, children })=>{
|
|
69
|
+
return /*#__PURE__*/ React__default.default.createElement("div", {
|
|
70
|
+
className: className,
|
|
71
|
+
"data-rwp-wrapper": true
|
|
72
|
+
}, children);
|
|
73
|
+
};
|
|
74
|
+
const WheelPicker = ({ defaultValue, value: valueProp, onValueChange, options: optionsProp, infinite: infiniteProp = false, visibleCount: countProp = 20, dragSensitivity: dragSensitivityProp = 3, classNames })=>{
|
|
75
|
+
var _optionsProp__value;
|
|
76
|
+
const [value = (()=>{
|
|
77
|
+
var _optionsProp_;
|
|
78
|
+
return (_optionsProp__value = (_optionsProp_ = optionsProp[0]) == null ? void 0 : _optionsProp_.value) != null ? _optionsProp__value : "";
|
|
79
|
+
})(), setValue] = useControllableState({
|
|
80
|
+
defaultProp: defaultValue,
|
|
81
|
+
prop: valueProp,
|
|
82
|
+
onChange: onValueChange
|
|
83
|
+
});
|
|
84
|
+
const options = React.useMemo(()=>{
|
|
85
|
+
if (!infiniteProp) {
|
|
86
|
+
return optionsProp;
|
|
87
|
+
}
|
|
88
|
+
const result = [];
|
|
89
|
+
const halfCount = Math.ceil(countProp / 2);
|
|
90
|
+
if (optionsProp.length === 0) {
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
while(result.length < halfCount){
|
|
94
|
+
result.push(...optionsProp);
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
}, [
|
|
98
|
+
countProp,
|
|
99
|
+
optionsProp,
|
|
100
|
+
infiniteProp
|
|
101
|
+
]);
|
|
102
|
+
const itemHeight = 28;
|
|
103
|
+
const halfItemHeight = itemHeight * 0.5;
|
|
104
|
+
const itemAngle = 360 / countProp;
|
|
105
|
+
const radius = itemHeight / Math.tan(itemAngle * Math.PI / 180);
|
|
106
|
+
const containerHeight = Math.round(radius * 2 + itemHeight * 0.25);
|
|
107
|
+
const quarterCount = countProp >> 2; // Divide by 4
|
|
108
|
+
const baseDeceleration = dragSensitivityProp * 10;
|
|
109
|
+
const snapBackDeceleration = 10;
|
|
110
|
+
const containerRef = React.useRef(null);
|
|
111
|
+
const wheelItemsRef = React.useRef(null);
|
|
112
|
+
const highlightListRef = React.useRef(null);
|
|
113
|
+
const scrollRef = React.useRef(0);
|
|
114
|
+
const moveId = React.useRef(0);
|
|
115
|
+
const dragingRef = React.useRef(false);
|
|
116
|
+
const lastWheelRef = React.useRef(0);
|
|
117
|
+
const touchDataRef = React.useRef({
|
|
118
|
+
startY: 0,
|
|
119
|
+
yList: []
|
|
120
|
+
});
|
|
121
|
+
const dragControllerRef = React.useRef(null);
|
|
122
|
+
const renderWheelItems = React.useMemo(()=>{
|
|
123
|
+
const renderItem = (item, index, angle)=>/*#__PURE__*/ React__default.default.createElement("li", {
|
|
124
|
+
key: index,
|
|
125
|
+
className: classNames == null ? void 0 : classNames.optionItem,
|
|
126
|
+
"data-rwp-option": true,
|
|
127
|
+
"data-index": index,
|
|
128
|
+
style: {
|
|
129
|
+
top: -14,
|
|
130
|
+
height: itemHeight,
|
|
131
|
+
lineHeight: `${itemHeight}px`,
|
|
132
|
+
transform: `rotateX(${angle}deg) translateZ(${radius}px)`,
|
|
133
|
+
visibility: "hidden"
|
|
134
|
+
}
|
|
135
|
+
}, item.label);
|
|
136
|
+
const items = options.map((option, index)=>renderItem(option, index, -itemAngle * index));
|
|
137
|
+
if (infiniteProp) {
|
|
138
|
+
for(let i = 0; i < quarterCount; ++i){
|
|
139
|
+
const prependIndex = -i - 1;
|
|
140
|
+
const appendIndex = i + options.length;
|
|
141
|
+
items.unshift(renderItem(options[options.length - i - 1], prependIndex, itemAngle * (i + 1)));
|
|
142
|
+
items.push(renderItem(options[i], appendIndex, -itemAngle * appendIndex));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return items;
|
|
146
|
+
}, [
|
|
147
|
+
halfItemHeight,
|
|
148
|
+
infiniteProp,
|
|
149
|
+
itemAngle,
|
|
150
|
+
options,
|
|
151
|
+
quarterCount,
|
|
152
|
+
radius,
|
|
153
|
+
classNames == null ? void 0 : classNames.optionItem
|
|
154
|
+
]);
|
|
155
|
+
const renderHighlightItems = React.useMemo(()=>{
|
|
156
|
+
const renderItem = (item, key)=>/*#__PURE__*/ React__default.default.createElement("li", {
|
|
157
|
+
key: key,
|
|
158
|
+
"data-slot": "highlight-item",
|
|
159
|
+
className: classNames == null ? void 0 : classNames.highlightItem,
|
|
160
|
+
style: {
|
|
161
|
+
height: itemHeight
|
|
162
|
+
}
|
|
163
|
+
}, item.label);
|
|
164
|
+
const items = options.map((option, index)=>renderItem(option, index));
|
|
165
|
+
if (infiniteProp) {
|
|
166
|
+
const firstItem = options[0];
|
|
167
|
+
const lastItem = options[options.length - 1];
|
|
168
|
+
items.unshift(renderItem(lastItem, "infinite-start"));
|
|
169
|
+
items.push(renderItem(firstItem, "infinite-end"));
|
|
170
|
+
}
|
|
171
|
+
return items;
|
|
172
|
+
}, [
|
|
173
|
+
classNames == null ? void 0 : classNames.highlightItem,
|
|
174
|
+
infiniteProp,
|
|
175
|
+
options
|
|
176
|
+
]);
|
|
177
|
+
const normalizeScroll = (scroll)=>(scroll % options.length + options.length) % options.length;
|
|
178
|
+
const scrollTo = (scroll)=>{
|
|
179
|
+
const normalizedScroll = infiniteProp ? normalizeScroll(scroll) : scroll;
|
|
180
|
+
if (wheelItemsRef.current) {
|
|
181
|
+
const transform = `translateZ(${-radius}px) rotateX(${itemAngle * normalizedScroll}deg)`;
|
|
182
|
+
wheelItemsRef.current.style.transform = transform;
|
|
183
|
+
wheelItemsRef.current.childNodes.forEach((node)=>{
|
|
184
|
+
const li = node;
|
|
185
|
+
const distance = Math.abs(Number(li.dataset.index) - normalizedScroll);
|
|
186
|
+
li.style.visibility = distance > quarterCount ? "hidden" : "visible";
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (highlightListRef.current) {
|
|
190
|
+
highlightListRef.current.style.transform = `translateY(${-normalizedScroll * itemHeight}px)`;
|
|
191
|
+
}
|
|
192
|
+
return normalizedScroll;
|
|
193
|
+
};
|
|
194
|
+
const cancelAnimation = ()=>{
|
|
195
|
+
cancelAnimationFrame(moveId.current);
|
|
196
|
+
};
|
|
197
|
+
const animateScroll = (startScroll, endScroll, duration, onComplete)=>{
|
|
198
|
+
if (startScroll === endScroll || duration === 0) {
|
|
199
|
+
scrollTo(startScroll);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const startTime = performance.now();
|
|
203
|
+
const totalDistance = endScroll - startScroll;
|
|
204
|
+
const tick = (currentTime)=>{
|
|
205
|
+
const elapsed = (currentTime - startTime) / 1000;
|
|
206
|
+
if (elapsed < duration) {
|
|
207
|
+
const progress = easeOutCubic(elapsed / duration);
|
|
208
|
+
scrollRef.current = scrollTo(startScroll + progress * totalDistance);
|
|
209
|
+
moveId.current = requestAnimationFrame(tick);
|
|
210
|
+
} else {
|
|
211
|
+
cancelAnimation();
|
|
212
|
+
scrollRef.current = scrollTo(endScroll);
|
|
213
|
+
onComplete == null ? void 0 : onComplete();
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
requestAnimationFrame(tick);
|
|
217
|
+
};
|
|
218
|
+
const selectByScroll = (scroll)=>{
|
|
219
|
+
const normalized = normalizeScroll(scroll) | 0;
|
|
220
|
+
const boundedScroll = infiniteProp ? normalized : Math.min(Math.max(normalized, 0), options.length - 1);
|
|
221
|
+
if (!infiniteProp && boundedScroll !== scroll) return;
|
|
222
|
+
scrollRef.current = scrollTo(boundedScroll);
|
|
223
|
+
const selected = options[scrollRef.current];
|
|
224
|
+
setValue(selected.value);
|
|
225
|
+
};
|
|
226
|
+
const selectByValue = (value)=>{
|
|
227
|
+
const index = options.findIndex((opt)=>opt.value === value);
|
|
228
|
+
if (index === -1) {
|
|
229
|
+
console.error("Invalid value selected:", value);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
cancelAnimation();
|
|
233
|
+
selectByScroll(index);
|
|
234
|
+
};
|
|
235
|
+
const updateScrollDuringDrag = (e)=>{
|
|
236
|
+
try {
|
|
237
|
+
var _e_touches_, _e_touches;
|
|
238
|
+
const currentY = (e instanceof MouseEvent ? e.clientY : (_e_touches = e.touches) == null ? void 0 : (_e_touches_ = _e_touches[0]) == null ? void 0 : _e_touches_.clientY) || 0;
|
|
239
|
+
const touchData = touchDataRef.current;
|
|
240
|
+
// Record current Y position with timestamp
|
|
241
|
+
touchData.yList.push([
|
|
242
|
+
currentY,
|
|
243
|
+
Date.now()
|
|
244
|
+
]);
|
|
245
|
+
if (touchData.yList.length > 5) {
|
|
246
|
+
touchData.yList.shift(); // Keep latest 5 points for velocity calc
|
|
247
|
+
}
|
|
248
|
+
// Calculate delta in scroll position based on drag distance
|
|
249
|
+
const dragDelta = (touchData.startY - currentY) / itemHeight;
|
|
250
|
+
let nextScroll = scrollRef.current + dragDelta;
|
|
251
|
+
if (infiniteProp) {
|
|
252
|
+
// Wrap scroll for infinite lists
|
|
253
|
+
nextScroll = normalizeScroll(nextScroll);
|
|
254
|
+
} else {
|
|
255
|
+
const maxIndex = options.length;
|
|
256
|
+
if (nextScroll < 0) {
|
|
257
|
+
// Apply resistance when dragging above top
|
|
258
|
+
nextScroll *= RESISTANCE;
|
|
259
|
+
} else if (nextScroll > maxIndex) {
|
|
260
|
+
// Apply resistance when dragging below bottom
|
|
261
|
+
nextScroll = maxIndex + (nextScroll - maxIndex) * RESISTANCE;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Update visual scroll and store position
|
|
265
|
+
touchData.touchScroll = scrollTo(nextScroll);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
console.error("Error in updateScrollDuringDrag:", error);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const handleDragMoveEvent = (event)=>{
|
|
271
|
+
if (!dragingRef.current && !containerRef.current.contains(event.target) && event.target !== containerRef.current) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (event.cancelable) {
|
|
275
|
+
event.preventDefault();
|
|
276
|
+
}
|
|
277
|
+
if (options.length) {
|
|
278
|
+
updateScrollDuringDrag(event);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
const initiateDragGesture = (event)=>{
|
|
282
|
+
try {
|
|
283
|
+
var _containerRef_current, _event_touches_, _event_touches;
|
|
284
|
+
dragingRef.current = true;
|
|
285
|
+
const controller = new AbortController();
|
|
286
|
+
const { signal } = controller;
|
|
287
|
+
dragControllerRef.current = controller;
|
|
288
|
+
// Listen to movement events
|
|
289
|
+
const passiveOpts = {
|
|
290
|
+
signal,
|
|
291
|
+
passive: false
|
|
292
|
+
};
|
|
293
|
+
(_containerRef_current = containerRef.current) == null ? void 0 : _containerRef_current.addEventListener("touchmove", handleDragMoveEvent, passiveOpts);
|
|
294
|
+
document.addEventListener("mousemove", handleDragMoveEvent, passiveOpts);
|
|
295
|
+
const startY = (event instanceof MouseEvent ? event.clientY : (_event_touches = event.touches) == null ? void 0 : (_event_touches_ = _event_touches[0]) == null ? void 0 : _event_touches_.clientY) || 0;
|
|
296
|
+
// Initialize touch tracking
|
|
297
|
+
const touchData = touchDataRef.current;
|
|
298
|
+
touchData.startY = startY;
|
|
299
|
+
touchData.yList = [
|
|
300
|
+
[
|
|
301
|
+
startY,
|
|
302
|
+
Date.now()
|
|
303
|
+
]
|
|
304
|
+
];
|
|
305
|
+
touchData.touchScroll = scrollRef.current;
|
|
306
|
+
// Stop any ongoing scroll animation
|
|
307
|
+
cancelAnimation();
|
|
308
|
+
} catch (error) {
|
|
309
|
+
console.error("Error in initiateDragGesture:", error);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
const handleDragStartEvent = React.useCallback((e)=>{
|
|
313
|
+
const isDragging = dragingRef.current;
|
|
314
|
+
const isTargetValid = containerRef.current.contains(e.target) || e.target === containerRef.current;
|
|
315
|
+
if ((isDragging || isTargetValid) && e.cancelable) {
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
if (options.length) {
|
|
318
|
+
initiateDragGesture(e);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}, // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
322
|
+
[
|
|
323
|
+
initiateDragGesture
|
|
324
|
+
]);
|
|
325
|
+
const decelerateAndAnimateScroll = (initialVelocity)=>{
|
|
326
|
+
const currentScroll = scrollRef.current;
|
|
327
|
+
let targetScroll = currentScroll;
|
|
328
|
+
let deceleration = initialVelocity > 0 ? -baseDeceleration : baseDeceleration;
|
|
329
|
+
let duration = 0;
|
|
330
|
+
// Clamp utility to constrain a value within bounds
|
|
331
|
+
const clamp = (value, min, max)=>Math.max(min, Math.min(value, max));
|
|
332
|
+
if (infiniteProp) {
|
|
333
|
+
// Infinite mode: apply uniform deceleration to calculate scroll distance
|
|
334
|
+
duration = Math.abs(initialVelocity / deceleration);
|
|
335
|
+
const scrollDistance = initialVelocity * duration + 0.5 * deceleration * duration * duration;
|
|
336
|
+
targetScroll = Math.round(currentScroll + scrollDistance);
|
|
337
|
+
} else if (currentScroll < 0 || currentScroll > options.length - 1) {
|
|
338
|
+
// Out-of-bounds: snap back to nearest valid scroll index
|
|
339
|
+
const target = clamp(currentScroll, 0, options.length - 1);
|
|
340
|
+
const scrollDistance = currentScroll - target;
|
|
341
|
+
deceleration = snapBackDeceleration;
|
|
342
|
+
duration = Math.sqrt(Math.abs(scrollDistance / deceleration));
|
|
343
|
+
initialVelocity = deceleration * duration;
|
|
344
|
+
initialVelocity = currentScroll > 0 ? -initialVelocity : initialVelocity;
|
|
345
|
+
targetScroll = target;
|
|
346
|
+
} else {
|
|
347
|
+
// Normal decelerated scroll within bounds
|
|
348
|
+
duration = Math.abs(initialVelocity / deceleration);
|
|
349
|
+
const scrollDistance = initialVelocity * duration + 0.5 * deceleration * duration * duration;
|
|
350
|
+
targetScroll = Math.round(currentScroll + scrollDistance);
|
|
351
|
+
targetScroll = clamp(targetScroll, 0, options.length - 1);
|
|
352
|
+
const adjustedDistance = targetScroll - currentScroll;
|
|
353
|
+
duration = Math.sqrt(Math.abs(adjustedDistance / deceleration));
|
|
354
|
+
}
|
|
355
|
+
// Start animation to target scroll position with calculated duration
|
|
356
|
+
animateScroll(currentScroll, targetScroll, duration, ()=>{
|
|
357
|
+
selectByScroll(scrollRef.current); // Ensure selected item updates at end
|
|
358
|
+
});
|
|
359
|
+
// Fallback selection update (in case animation callback fails)
|
|
360
|
+
selectByScroll(scrollRef.current);
|
|
361
|
+
};
|
|
362
|
+
const finalizeDragAndStartInertiaScroll = ()=>{
|
|
363
|
+
try {
|
|
364
|
+
var _dragControllerRef_current;
|
|
365
|
+
(_dragControllerRef_current = dragControllerRef.current) == null ? void 0 : _dragControllerRef_current.abort();
|
|
366
|
+
dragControllerRef.current = null;
|
|
367
|
+
const touchData = touchDataRef.current;
|
|
368
|
+
const yList = touchData.yList;
|
|
369
|
+
let velocity = 0;
|
|
370
|
+
if (yList.length > 1) {
|
|
371
|
+
const len = yList.length;
|
|
372
|
+
var _yList_;
|
|
373
|
+
const [startY, startTime] = (_yList_ = yList[len - 2]) != null ? _yList_ : [
|
|
374
|
+
0,
|
|
375
|
+
0
|
|
376
|
+
];
|
|
377
|
+
var _yList_1;
|
|
378
|
+
const [endY, endTime] = (_yList_1 = yList[len - 1]) != null ? _yList_1 : [
|
|
379
|
+
0,
|
|
380
|
+
0
|
|
381
|
+
];
|
|
382
|
+
const timeDiff = endTime - startTime;
|
|
383
|
+
if (timeDiff > 0) {
|
|
384
|
+
const distance = startY - endY;
|
|
385
|
+
const velocityPerSecond = distance / itemHeight * 1000 / timeDiff;
|
|
386
|
+
const maxVelocity = MAX_VELOCITY;
|
|
387
|
+
const direction = velocityPerSecond > 0 ? 1 : -1;
|
|
388
|
+
const absVelocity = Math.min(Math.abs(velocityPerSecond), maxVelocity);
|
|
389
|
+
velocity = absVelocity * direction;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
var _touchData_touchScroll;
|
|
393
|
+
scrollRef.current = (_touchData_touchScroll = touchData.touchScroll) != null ? _touchData_touchScroll : scrollRef.current;
|
|
394
|
+
decelerateAndAnimateScroll(velocity);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
console.error("Error in finalizeDragAndStartInertiaScroll:", error);
|
|
397
|
+
} finally{
|
|
398
|
+
dragingRef.current = false;
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
const handleDragEndEvent = React.useCallback((event)=>{
|
|
402
|
+
if (!options.length) return;
|
|
403
|
+
const isDragging = dragingRef.current;
|
|
404
|
+
const isTargetValid = containerRef.current.contains(event.target) || event.target === containerRef.current;
|
|
405
|
+
if ((isDragging || isTargetValid) && event.cancelable) {
|
|
406
|
+
event.preventDefault();
|
|
407
|
+
finalizeDragAndStartInertiaScroll();
|
|
408
|
+
}
|
|
409
|
+
}, // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
410
|
+
[
|
|
411
|
+
finalizeDragAndStartInertiaScroll
|
|
412
|
+
]);
|
|
413
|
+
const scrollByWheel = (event)=>{
|
|
414
|
+
event.preventDefault();
|
|
415
|
+
const now = Date.now();
|
|
416
|
+
if (now - lastWheelRef.current < WHEEL_THROTTLE) return;
|
|
417
|
+
const direction = Math.sign(event.deltaY);
|
|
418
|
+
if (direction !== 0) {
|
|
419
|
+
selectByScroll(scrollRef.current + direction);
|
|
420
|
+
lastWheelRef.current = now;
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
const handleWheelEvent = React.useCallback((event)=>{
|
|
424
|
+
if (!options.length || !containerRef.current) return;
|
|
425
|
+
const isDragging = dragingRef.current;
|
|
426
|
+
const isTargetValid = containerRef.current.contains(event.target) || event.target === containerRef.current;
|
|
427
|
+
if ((isDragging || isTargetValid) && event.cancelable) {
|
|
428
|
+
event.preventDefault();
|
|
429
|
+
scrollByWheel(event);
|
|
430
|
+
}
|
|
431
|
+
}, // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
432
|
+
[
|
|
433
|
+
scrollByWheel
|
|
434
|
+
]);
|
|
435
|
+
React.useEffect(()=>{
|
|
436
|
+
const container = containerRef.current;
|
|
437
|
+
if (!container) return;
|
|
438
|
+
const controller = new AbortController();
|
|
439
|
+
const { signal } = controller;
|
|
440
|
+
const opts = {
|
|
441
|
+
signal,
|
|
442
|
+
passive: false
|
|
443
|
+
};
|
|
444
|
+
container.addEventListener("touchstart", handleDragStartEvent, opts);
|
|
445
|
+
container.addEventListener("touchend", handleDragEndEvent, opts);
|
|
446
|
+
container.addEventListener("wheel", handleWheelEvent, opts);
|
|
447
|
+
document.addEventListener("mousedown", handleDragStartEvent, opts);
|
|
448
|
+
document.addEventListener("mouseup", handleDragEndEvent, opts);
|
|
449
|
+
return ()=>{
|
|
450
|
+
console.log("cleanup");
|
|
451
|
+
controller.abort();
|
|
452
|
+
};
|
|
453
|
+
}, [
|
|
454
|
+
handleDragEndEvent,
|
|
455
|
+
handleDragStartEvent,
|
|
456
|
+
handleWheelEvent
|
|
457
|
+
]);
|
|
458
|
+
React.useEffect(()=>{
|
|
459
|
+
selectByValue(value);
|
|
460
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
461
|
+
}, [
|
|
462
|
+
value,
|
|
463
|
+
valueProp
|
|
464
|
+
]);
|
|
465
|
+
console.log("render WheelPicker", valueProp);
|
|
466
|
+
return /*#__PURE__*/ React__default.default.createElement("div", {
|
|
467
|
+
ref: containerRef,
|
|
468
|
+
"data-rwp": true,
|
|
469
|
+
style: {
|
|
470
|
+
height: containerHeight
|
|
471
|
+
}
|
|
472
|
+
}, /*#__PURE__*/ React__default.default.createElement("ul", {
|
|
473
|
+
ref: wheelItemsRef,
|
|
474
|
+
"data-rwp-options": true
|
|
475
|
+
}, renderWheelItems), /*#__PURE__*/ React__default.default.createElement("div", {
|
|
476
|
+
className: classNames == null ? void 0 : classNames.highlightWrapper,
|
|
477
|
+
"data-rwp-highlight-wrapper": true,
|
|
478
|
+
style: {
|
|
479
|
+
height: itemHeight,
|
|
480
|
+
lineHeight: itemHeight + "px"
|
|
481
|
+
}
|
|
482
|
+
}, /*#__PURE__*/ React__default.default.createElement("ul", {
|
|
483
|
+
ref: highlightListRef,
|
|
484
|
+
"data-rwp-highlight-list": true,
|
|
485
|
+
style: {
|
|
486
|
+
top: infiniteProp ? -28 : undefined
|
|
487
|
+
}
|
|
488
|
+
}, renderHighlightItems)));
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
exports.WheelPicker = WheelPicker;
|
|
492
|
+
exports.WheelPickerWrapper = WheelPickerWrapper;
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useMemo, useRef, useCallback, useEffect } from 'react';
|
|
3
|
+
|
|
4
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */ // This code comes from https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx
|
|
5
|
+
function useCallbackRef(callback) {
|
|
6
|
+
const callbackRef = React.useRef(callback);
|
|
7
|
+
React.useEffect(()=>{
|
|
8
|
+
callbackRef.current = callback;
|
|
9
|
+
});
|
|
10
|
+
// https://github.com/facebook/react/issues/19240
|
|
11
|
+
return React.useMemo(()=>(...args)=>callbackRef.current == null ? void 0 : callbackRef.current.call(callbackRef, ...args), []);
|
|
12
|
+
}
|
|
13
|
+
function useUncontrolledState({ defaultProp, onChange }) {
|
|
14
|
+
const uncontrolledState = React.useState(defaultProp);
|
|
15
|
+
const [value] = uncontrolledState;
|
|
16
|
+
const prevValueRef = React.useRef(value);
|
|
17
|
+
const handleChange = useCallbackRef(onChange);
|
|
18
|
+
React.useEffect(()=>{
|
|
19
|
+
if (prevValueRef.current !== value) {
|
|
20
|
+
handleChange(value);
|
|
21
|
+
prevValueRef.current = value;
|
|
22
|
+
}
|
|
23
|
+
}, [
|
|
24
|
+
value,
|
|
25
|
+
prevValueRef,
|
|
26
|
+
handleChange
|
|
27
|
+
]);
|
|
28
|
+
return uncontrolledState;
|
|
29
|
+
}
|
|
30
|
+
function useControllableState({ prop, defaultProp, onChange = ()=>{} }) {
|
|
31
|
+
const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({
|
|
32
|
+
defaultProp,
|
|
33
|
+
onChange
|
|
34
|
+
});
|
|
35
|
+
const isControlled = prop !== undefined;
|
|
36
|
+
const value = isControlled ? prop : uncontrolledProp;
|
|
37
|
+
const handleChange = useCallbackRef(onChange);
|
|
38
|
+
const setValue = React.useCallback((nextValue)=>{
|
|
39
|
+
if (isControlled) {
|
|
40
|
+
const setter = nextValue;
|
|
41
|
+
const value = typeof nextValue === "function" ? setter(prop) : nextValue;
|
|
42
|
+
if (value !== prop) handleChange(value);
|
|
43
|
+
} else {
|
|
44
|
+
setUncontrolledProp(nextValue);
|
|
45
|
+
}
|
|
46
|
+
}, [
|
|
47
|
+
isControlled,
|
|
48
|
+
prop,
|
|
49
|
+
setUncontrolledProp,
|
|
50
|
+
handleChange
|
|
51
|
+
]);
|
|
52
|
+
return [
|
|
53
|
+
value,
|
|
54
|
+
setValue
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const WHEEL_THROTTLE = 150; // ms
|
|
59
|
+
const RESISTANCE = 0.3; // Resistance when scrolling above the top or below the bottom
|
|
60
|
+
const MAX_VELOCITY = 30; // Maximum velocity for the scroll animation
|
|
61
|
+
const easeOutCubic = (p)=>Math.pow(p - 1, 3) + 1;
|
|
62
|
+
const WheelPickerWrapper = ({ className, children })=>{
|
|
63
|
+
return /*#__PURE__*/ React.createElement("div", {
|
|
64
|
+
className: className,
|
|
65
|
+
"data-rwp-wrapper": true
|
|
66
|
+
}, children);
|
|
67
|
+
};
|
|
68
|
+
const WheelPicker = ({ defaultValue, value: valueProp, onValueChange, options: optionsProp, infinite: infiniteProp = false, visibleCount: countProp = 20, dragSensitivity: dragSensitivityProp = 3, classNames })=>{
|
|
69
|
+
var _optionsProp__value;
|
|
70
|
+
const [value = (()=>{
|
|
71
|
+
var _optionsProp_;
|
|
72
|
+
return (_optionsProp__value = (_optionsProp_ = optionsProp[0]) == null ? void 0 : _optionsProp_.value) != null ? _optionsProp__value : "";
|
|
73
|
+
})(), setValue] = useControllableState({
|
|
74
|
+
defaultProp: defaultValue,
|
|
75
|
+
prop: valueProp,
|
|
76
|
+
onChange: onValueChange
|
|
77
|
+
});
|
|
78
|
+
const options = useMemo(()=>{
|
|
79
|
+
if (!infiniteProp) {
|
|
80
|
+
return optionsProp;
|
|
81
|
+
}
|
|
82
|
+
const result = [];
|
|
83
|
+
const halfCount = Math.ceil(countProp / 2);
|
|
84
|
+
if (optionsProp.length === 0) {
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
while(result.length < halfCount){
|
|
88
|
+
result.push(...optionsProp);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}, [
|
|
92
|
+
countProp,
|
|
93
|
+
optionsProp,
|
|
94
|
+
infiniteProp
|
|
95
|
+
]);
|
|
96
|
+
const itemHeight = 28;
|
|
97
|
+
const halfItemHeight = itemHeight * 0.5;
|
|
98
|
+
const itemAngle = 360 / countProp;
|
|
99
|
+
const radius = itemHeight / Math.tan(itemAngle * Math.PI / 180);
|
|
100
|
+
const containerHeight = Math.round(radius * 2 + itemHeight * 0.25);
|
|
101
|
+
const quarterCount = countProp >> 2; // Divide by 4
|
|
102
|
+
const baseDeceleration = dragSensitivityProp * 10;
|
|
103
|
+
const snapBackDeceleration = 10;
|
|
104
|
+
const containerRef = useRef(null);
|
|
105
|
+
const wheelItemsRef = useRef(null);
|
|
106
|
+
const highlightListRef = useRef(null);
|
|
107
|
+
const scrollRef = useRef(0);
|
|
108
|
+
const moveId = useRef(0);
|
|
109
|
+
const dragingRef = useRef(false);
|
|
110
|
+
const lastWheelRef = useRef(0);
|
|
111
|
+
const touchDataRef = useRef({
|
|
112
|
+
startY: 0,
|
|
113
|
+
yList: []
|
|
114
|
+
});
|
|
115
|
+
const dragControllerRef = useRef(null);
|
|
116
|
+
const renderWheelItems = useMemo(()=>{
|
|
117
|
+
const renderItem = (item, index, angle)=>/*#__PURE__*/ React.createElement("li", {
|
|
118
|
+
key: index,
|
|
119
|
+
className: classNames == null ? void 0 : classNames.optionItem,
|
|
120
|
+
"data-rwp-option": true,
|
|
121
|
+
"data-index": index,
|
|
122
|
+
style: {
|
|
123
|
+
top: -14,
|
|
124
|
+
height: itemHeight,
|
|
125
|
+
lineHeight: `${itemHeight}px`,
|
|
126
|
+
transform: `rotateX(${angle}deg) translateZ(${radius}px)`,
|
|
127
|
+
visibility: "hidden"
|
|
128
|
+
}
|
|
129
|
+
}, item.label);
|
|
130
|
+
const items = options.map((option, index)=>renderItem(option, index, -itemAngle * index));
|
|
131
|
+
if (infiniteProp) {
|
|
132
|
+
for(let i = 0; i < quarterCount; ++i){
|
|
133
|
+
const prependIndex = -i - 1;
|
|
134
|
+
const appendIndex = i + options.length;
|
|
135
|
+
items.unshift(renderItem(options[options.length - i - 1], prependIndex, itemAngle * (i + 1)));
|
|
136
|
+
items.push(renderItem(options[i], appendIndex, -itemAngle * appendIndex));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return items;
|
|
140
|
+
}, [
|
|
141
|
+
halfItemHeight,
|
|
142
|
+
infiniteProp,
|
|
143
|
+
itemAngle,
|
|
144
|
+
options,
|
|
145
|
+
quarterCount,
|
|
146
|
+
radius,
|
|
147
|
+
classNames == null ? void 0 : classNames.optionItem
|
|
148
|
+
]);
|
|
149
|
+
const renderHighlightItems = useMemo(()=>{
|
|
150
|
+
const renderItem = (item, key)=>/*#__PURE__*/ React.createElement("li", {
|
|
151
|
+
key: key,
|
|
152
|
+
"data-slot": "highlight-item",
|
|
153
|
+
className: classNames == null ? void 0 : classNames.highlightItem,
|
|
154
|
+
style: {
|
|
155
|
+
height: itemHeight
|
|
156
|
+
}
|
|
157
|
+
}, item.label);
|
|
158
|
+
const items = options.map((option, index)=>renderItem(option, index));
|
|
159
|
+
if (infiniteProp) {
|
|
160
|
+
const firstItem = options[0];
|
|
161
|
+
const lastItem = options[options.length - 1];
|
|
162
|
+
items.unshift(renderItem(lastItem, "infinite-start"));
|
|
163
|
+
items.push(renderItem(firstItem, "infinite-end"));
|
|
164
|
+
}
|
|
165
|
+
return items;
|
|
166
|
+
}, [
|
|
167
|
+
classNames == null ? void 0 : classNames.highlightItem,
|
|
168
|
+
infiniteProp,
|
|
169
|
+
options
|
|
170
|
+
]);
|
|
171
|
+
const normalizeScroll = (scroll)=>(scroll % options.length + options.length) % options.length;
|
|
172
|
+
const scrollTo = (scroll)=>{
|
|
173
|
+
const normalizedScroll = infiniteProp ? normalizeScroll(scroll) : scroll;
|
|
174
|
+
if (wheelItemsRef.current) {
|
|
175
|
+
const transform = `translateZ(${-radius}px) rotateX(${itemAngle * normalizedScroll}deg)`;
|
|
176
|
+
wheelItemsRef.current.style.transform = transform;
|
|
177
|
+
wheelItemsRef.current.childNodes.forEach((node)=>{
|
|
178
|
+
const li = node;
|
|
179
|
+
const distance = Math.abs(Number(li.dataset.index) - normalizedScroll);
|
|
180
|
+
li.style.visibility = distance > quarterCount ? "hidden" : "visible";
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (highlightListRef.current) {
|
|
184
|
+
highlightListRef.current.style.transform = `translateY(${-normalizedScroll * itemHeight}px)`;
|
|
185
|
+
}
|
|
186
|
+
return normalizedScroll;
|
|
187
|
+
};
|
|
188
|
+
const cancelAnimation = ()=>{
|
|
189
|
+
cancelAnimationFrame(moveId.current);
|
|
190
|
+
};
|
|
191
|
+
const animateScroll = (startScroll, endScroll, duration, onComplete)=>{
|
|
192
|
+
if (startScroll === endScroll || duration === 0) {
|
|
193
|
+
scrollTo(startScroll);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const startTime = performance.now();
|
|
197
|
+
const totalDistance = endScroll - startScroll;
|
|
198
|
+
const tick = (currentTime)=>{
|
|
199
|
+
const elapsed = (currentTime - startTime) / 1000;
|
|
200
|
+
if (elapsed < duration) {
|
|
201
|
+
const progress = easeOutCubic(elapsed / duration);
|
|
202
|
+
scrollRef.current = scrollTo(startScroll + progress * totalDistance);
|
|
203
|
+
moveId.current = requestAnimationFrame(tick);
|
|
204
|
+
} else {
|
|
205
|
+
cancelAnimation();
|
|
206
|
+
scrollRef.current = scrollTo(endScroll);
|
|
207
|
+
onComplete == null ? void 0 : onComplete();
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
requestAnimationFrame(tick);
|
|
211
|
+
};
|
|
212
|
+
const selectByScroll = (scroll)=>{
|
|
213
|
+
const normalized = normalizeScroll(scroll) | 0;
|
|
214
|
+
const boundedScroll = infiniteProp ? normalized : Math.min(Math.max(normalized, 0), options.length - 1);
|
|
215
|
+
if (!infiniteProp && boundedScroll !== scroll) return;
|
|
216
|
+
scrollRef.current = scrollTo(boundedScroll);
|
|
217
|
+
const selected = options[scrollRef.current];
|
|
218
|
+
setValue(selected.value);
|
|
219
|
+
};
|
|
220
|
+
const selectByValue = (value)=>{
|
|
221
|
+
const index = options.findIndex((opt)=>opt.value === value);
|
|
222
|
+
if (index === -1) {
|
|
223
|
+
console.error("Invalid value selected:", value);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
cancelAnimation();
|
|
227
|
+
selectByScroll(index);
|
|
228
|
+
};
|
|
229
|
+
const updateScrollDuringDrag = (e)=>{
|
|
230
|
+
try {
|
|
231
|
+
var _e_touches_, _e_touches;
|
|
232
|
+
const currentY = (e instanceof MouseEvent ? e.clientY : (_e_touches = e.touches) == null ? void 0 : (_e_touches_ = _e_touches[0]) == null ? void 0 : _e_touches_.clientY) || 0;
|
|
233
|
+
const touchData = touchDataRef.current;
|
|
234
|
+
// Record current Y position with timestamp
|
|
235
|
+
touchData.yList.push([
|
|
236
|
+
currentY,
|
|
237
|
+
Date.now()
|
|
238
|
+
]);
|
|
239
|
+
if (touchData.yList.length > 5) {
|
|
240
|
+
touchData.yList.shift(); // Keep latest 5 points for velocity calc
|
|
241
|
+
}
|
|
242
|
+
// Calculate delta in scroll position based on drag distance
|
|
243
|
+
const dragDelta = (touchData.startY - currentY) / itemHeight;
|
|
244
|
+
let nextScroll = scrollRef.current + dragDelta;
|
|
245
|
+
if (infiniteProp) {
|
|
246
|
+
// Wrap scroll for infinite lists
|
|
247
|
+
nextScroll = normalizeScroll(nextScroll);
|
|
248
|
+
} else {
|
|
249
|
+
const maxIndex = options.length;
|
|
250
|
+
if (nextScroll < 0) {
|
|
251
|
+
// Apply resistance when dragging above top
|
|
252
|
+
nextScroll *= RESISTANCE;
|
|
253
|
+
} else if (nextScroll > maxIndex) {
|
|
254
|
+
// Apply resistance when dragging below bottom
|
|
255
|
+
nextScroll = maxIndex + (nextScroll - maxIndex) * RESISTANCE;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Update visual scroll and store position
|
|
259
|
+
touchData.touchScroll = scrollTo(nextScroll);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
console.error("Error in updateScrollDuringDrag:", error);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
const handleDragMoveEvent = (event)=>{
|
|
265
|
+
if (!dragingRef.current && !containerRef.current.contains(event.target) && event.target !== containerRef.current) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (event.cancelable) {
|
|
269
|
+
event.preventDefault();
|
|
270
|
+
}
|
|
271
|
+
if (options.length) {
|
|
272
|
+
updateScrollDuringDrag(event);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
const initiateDragGesture = (event)=>{
|
|
276
|
+
try {
|
|
277
|
+
var _containerRef_current, _event_touches_, _event_touches;
|
|
278
|
+
dragingRef.current = true;
|
|
279
|
+
const controller = new AbortController();
|
|
280
|
+
const { signal } = controller;
|
|
281
|
+
dragControllerRef.current = controller;
|
|
282
|
+
// Listen to movement events
|
|
283
|
+
const passiveOpts = {
|
|
284
|
+
signal,
|
|
285
|
+
passive: false
|
|
286
|
+
};
|
|
287
|
+
(_containerRef_current = containerRef.current) == null ? void 0 : _containerRef_current.addEventListener("touchmove", handleDragMoveEvent, passiveOpts);
|
|
288
|
+
document.addEventListener("mousemove", handleDragMoveEvent, passiveOpts);
|
|
289
|
+
const startY = (event instanceof MouseEvent ? event.clientY : (_event_touches = event.touches) == null ? void 0 : (_event_touches_ = _event_touches[0]) == null ? void 0 : _event_touches_.clientY) || 0;
|
|
290
|
+
// Initialize touch tracking
|
|
291
|
+
const touchData = touchDataRef.current;
|
|
292
|
+
touchData.startY = startY;
|
|
293
|
+
touchData.yList = [
|
|
294
|
+
[
|
|
295
|
+
startY,
|
|
296
|
+
Date.now()
|
|
297
|
+
]
|
|
298
|
+
];
|
|
299
|
+
touchData.touchScroll = scrollRef.current;
|
|
300
|
+
// Stop any ongoing scroll animation
|
|
301
|
+
cancelAnimation();
|
|
302
|
+
} catch (error) {
|
|
303
|
+
console.error("Error in initiateDragGesture:", error);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const handleDragStartEvent = useCallback((e)=>{
|
|
307
|
+
const isDragging = dragingRef.current;
|
|
308
|
+
const isTargetValid = containerRef.current.contains(e.target) || e.target === containerRef.current;
|
|
309
|
+
if ((isDragging || isTargetValid) && e.cancelable) {
|
|
310
|
+
e.preventDefault();
|
|
311
|
+
if (options.length) {
|
|
312
|
+
initiateDragGesture(e);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}, // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
316
|
+
[
|
|
317
|
+
initiateDragGesture
|
|
318
|
+
]);
|
|
319
|
+
const decelerateAndAnimateScroll = (initialVelocity)=>{
|
|
320
|
+
const currentScroll = scrollRef.current;
|
|
321
|
+
let targetScroll = currentScroll;
|
|
322
|
+
let deceleration = initialVelocity > 0 ? -baseDeceleration : baseDeceleration;
|
|
323
|
+
let duration = 0;
|
|
324
|
+
// Clamp utility to constrain a value within bounds
|
|
325
|
+
const clamp = (value, min, max)=>Math.max(min, Math.min(value, max));
|
|
326
|
+
if (infiniteProp) {
|
|
327
|
+
// Infinite mode: apply uniform deceleration to calculate scroll distance
|
|
328
|
+
duration = Math.abs(initialVelocity / deceleration);
|
|
329
|
+
const scrollDistance = initialVelocity * duration + 0.5 * deceleration * duration * duration;
|
|
330
|
+
targetScroll = Math.round(currentScroll + scrollDistance);
|
|
331
|
+
} else if (currentScroll < 0 || currentScroll > options.length - 1) {
|
|
332
|
+
// Out-of-bounds: snap back to nearest valid scroll index
|
|
333
|
+
const target = clamp(currentScroll, 0, options.length - 1);
|
|
334
|
+
const scrollDistance = currentScroll - target;
|
|
335
|
+
deceleration = snapBackDeceleration;
|
|
336
|
+
duration = Math.sqrt(Math.abs(scrollDistance / deceleration));
|
|
337
|
+
initialVelocity = deceleration * duration;
|
|
338
|
+
initialVelocity = currentScroll > 0 ? -initialVelocity : initialVelocity;
|
|
339
|
+
targetScroll = target;
|
|
340
|
+
} else {
|
|
341
|
+
// Normal decelerated scroll within bounds
|
|
342
|
+
duration = Math.abs(initialVelocity / deceleration);
|
|
343
|
+
const scrollDistance = initialVelocity * duration + 0.5 * deceleration * duration * duration;
|
|
344
|
+
targetScroll = Math.round(currentScroll + scrollDistance);
|
|
345
|
+
targetScroll = clamp(targetScroll, 0, options.length - 1);
|
|
346
|
+
const adjustedDistance = targetScroll - currentScroll;
|
|
347
|
+
duration = Math.sqrt(Math.abs(adjustedDistance / deceleration));
|
|
348
|
+
}
|
|
349
|
+
// Start animation to target scroll position with calculated duration
|
|
350
|
+
animateScroll(currentScroll, targetScroll, duration, ()=>{
|
|
351
|
+
selectByScroll(scrollRef.current); // Ensure selected item updates at end
|
|
352
|
+
});
|
|
353
|
+
// Fallback selection update (in case animation callback fails)
|
|
354
|
+
selectByScroll(scrollRef.current);
|
|
355
|
+
};
|
|
356
|
+
const finalizeDragAndStartInertiaScroll = ()=>{
|
|
357
|
+
try {
|
|
358
|
+
var _dragControllerRef_current;
|
|
359
|
+
(_dragControllerRef_current = dragControllerRef.current) == null ? void 0 : _dragControllerRef_current.abort();
|
|
360
|
+
dragControllerRef.current = null;
|
|
361
|
+
const touchData = touchDataRef.current;
|
|
362
|
+
const yList = touchData.yList;
|
|
363
|
+
let velocity = 0;
|
|
364
|
+
if (yList.length > 1) {
|
|
365
|
+
const len = yList.length;
|
|
366
|
+
var _yList_;
|
|
367
|
+
const [startY, startTime] = (_yList_ = yList[len - 2]) != null ? _yList_ : [
|
|
368
|
+
0,
|
|
369
|
+
0
|
|
370
|
+
];
|
|
371
|
+
var _yList_1;
|
|
372
|
+
const [endY, endTime] = (_yList_1 = yList[len - 1]) != null ? _yList_1 : [
|
|
373
|
+
0,
|
|
374
|
+
0
|
|
375
|
+
];
|
|
376
|
+
const timeDiff = endTime - startTime;
|
|
377
|
+
if (timeDiff > 0) {
|
|
378
|
+
const distance = startY - endY;
|
|
379
|
+
const velocityPerSecond = distance / itemHeight * 1000 / timeDiff;
|
|
380
|
+
const maxVelocity = MAX_VELOCITY;
|
|
381
|
+
const direction = velocityPerSecond > 0 ? 1 : -1;
|
|
382
|
+
const absVelocity = Math.min(Math.abs(velocityPerSecond), maxVelocity);
|
|
383
|
+
velocity = absVelocity * direction;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
var _touchData_touchScroll;
|
|
387
|
+
scrollRef.current = (_touchData_touchScroll = touchData.touchScroll) != null ? _touchData_touchScroll : scrollRef.current;
|
|
388
|
+
decelerateAndAnimateScroll(velocity);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
console.error("Error in finalizeDragAndStartInertiaScroll:", error);
|
|
391
|
+
} finally{
|
|
392
|
+
dragingRef.current = false;
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
const handleDragEndEvent = useCallback((event)=>{
|
|
396
|
+
if (!options.length) return;
|
|
397
|
+
const isDragging = dragingRef.current;
|
|
398
|
+
const isTargetValid = containerRef.current.contains(event.target) || event.target === containerRef.current;
|
|
399
|
+
if ((isDragging || isTargetValid) && event.cancelable) {
|
|
400
|
+
event.preventDefault();
|
|
401
|
+
finalizeDragAndStartInertiaScroll();
|
|
402
|
+
}
|
|
403
|
+
}, // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
404
|
+
[
|
|
405
|
+
finalizeDragAndStartInertiaScroll
|
|
406
|
+
]);
|
|
407
|
+
const scrollByWheel = (event)=>{
|
|
408
|
+
event.preventDefault();
|
|
409
|
+
const now = Date.now();
|
|
410
|
+
if (now - lastWheelRef.current < WHEEL_THROTTLE) return;
|
|
411
|
+
const direction = Math.sign(event.deltaY);
|
|
412
|
+
if (direction !== 0) {
|
|
413
|
+
selectByScroll(scrollRef.current + direction);
|
|
414
|
+
lastWheelRef.current = now;
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
const handleWheelEvent = useCallback((event)=>{
|
|
418
|
+
if (!options.length || !containerRef.current) return;
|
|
419
|
+
const isDragging = dragingRef.current;
|
|
420
|
+
const isTargetValid = containerRef.current.contains(event.target) || event.target === containerRef.current;
|
|
421
|
+
if ((isDragging || isTargetValid) && event.cancelable) {
|
|
422
|
+
event.preventDefault();
|
|
423
|
+
scrollByWheel(event);
|
|
424
|
+
}
|
|
425
|
+
}, // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
426
|
+
[
|
|
427
|
+
scrollByWheel
|
|
428
|
+
]);
|
|
429
|
+
useEffect(()=>{
|
|
430
|
+
const container = containerRef.current;
|
|
431
|
+
if (!container) return;
|
|
432
|
+
const controller = new AbortController();
|
|
433
|
+
const { signal } = controller;
|
|
434
|
+
const opts = {
|
|
435
|
+
signal,
|
|
436
|
+
passive: false
|
|
437
|
+
};
|
|
438
|
+
container.addEventListener("touchstart", handleDragStartEvent, opts);
|
|
439
|
+
container.addEventListener("touchend", handleDragEndEvent, opts);
|
|
440
|
+
container.addEventListener("wheel", handleWheelEvent, opts);
|
|
441
|
+
document.addEventListener("mousedown", handleDragStartEvent, opts);
|
|
442
|
+
document.addEventListener("mouseup", handleDragEndEvent, opts);
|
|
443
|
+
return ()=>{
|
|
444
|
+
console.log("cleanup");
|
|
445
|
+
controller.abort();
|
|
446
|
+
};
|
|
447
|
+
}, [
|
|
448
|
+
handleDragEndEvent,
|
|
449
|
+
handleDragStartEvent,
|
|
450
|
+
handleWheelEvent
|
|
451
|
+
]);
|
|
452
|
+
useEffect(()=>{
|
|
453
|
+
selectByValue(value);
|
|
454
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
455
|
+
}, [
|
|
456
|
+
value,
|
|
457
|
+
valueProp
|
|
458
|
+
]);
|
|
459
|
+
console.log("render WheelPicker", valueProp);
|
|
460
|
+
return /*#__PURE__*/ React.createElement("div", {
|
|
461
|
+
ref: containerRef,
|
|
462
|
+
"data-rwp": true,
|
|
463
|
+
style: {
|
|
464
|
+
height: containerHeight
|
|
465
|
+
}
|
|
466
|
+
}, /*#__PURE__*/ React.createElement("ul", {
|
|
467
|
+
ref: wheelItemsRef,
|
|
468
|
+
"data-rwp-options": true
|
|
469
|
+
}, renderWheelItems), /*#__PURE__*/ React.createElement("div", {
|
|
470
|
+
className: classNames == null ? void 0 : classNames.highlightWrapper,
|
|
471
|
+
"data-rwp-highlight-wrapper": true,
|
|
472
|
+
style: {
|
|
473
|
+
height: itemHeight,
|
|
474
|
+
lineHeight: itemHeight + "px"
|
|
475
|
+
}
|
|
476
|
+
}, /*#__PURE__*/ React.createElement("ul", {
|
|
477
|
+
ref: highlightListRef,
|
|
478
|
+
"data-rwp-highlight-list": true,
|
|
479
|
+
style: {
|
|
480
|
+
top: infiniteProp ? -28 : undefined
|
|
481
|
+
}
|
|
482
|
+
}, renderHighlightItems)));
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
export { WheelPicker, WheelPickerWrapper };
|
package/dist/style.css
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
[data-rwp-wrapper] {
|
|
2
|
+
display: flex;
|
|
3
|
+
position: relative;
|
|
4
|
+
width: 100%;
|
|
5
|
+
align-items: stretch;
|
|
6
|
+
justify-content: space-between;
|
|
7
|
+
overflow: hidden;
|
|
8
|
+
perspective: 2000px;
|
|
9
|
+
-webkit-user-select: none;
|
|
10
|
+
user-select: none;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
[data-rwp] {
|
|
14
|
+
position: relative;
|
|
15
|
+
flex: 1;
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
cursor: ns-resize;
|
|
18
|
+
text-align: center;
|
|
19
|
+
-webkit-mask-image: linear-gradient(
|
|
20
|
+
to bottom,
|
|
21
|
+
transparent 0%,
|
|
22
|
+
black 20%,
|
|
23
|
+
black 80%,
|
|
24
|
+
transparent 100%
|
|
25
|
+
);
|
|
26
|
+
mask-image: linear-gradient(
|
|
27
|
+
to bottom,
|
|
28
|
+
transparent 0%,
|
|
29
|
+
black 20%,
|
|
30
|
+
black 80%,
|
|
31
|
+
transparent 100%
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
[data-rwp-highlight-wrapper] {
|
|
36
|
+
position: absolute;
|
|
37
|
+
top: 50%;
|
|
38
|
+
width: 100%;
|
|
39
|
+
font-size: 1rem;
|
|
40
|
+
font-weight: 500;
|
|
41
|
+
overflow: hidden;
|
|
42
|
+
transform: translateY(-50%);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
[data-rwp-highlight-list] {
|
|
46
|
+
position: absolute;
|
|
47
|
+
width: 100%;
|
|
48
|
+
list-style: none;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
[data-rwp-options] {
|
|
52
|
+
display: block;
|
|
53
|
+
position: absolute;
|
|
54
|
+
top: 50%;
|
|
55
|
+
left: 0;
|
|
56
|
+
width: 100%;
|
|
57
|
+
height: 0;
|
|
58
|
+
margin-left: auto;
|
|
59
|
+
margin-right: auto;
|
|
60
|
+
-webkit-font-smoothing: subpixel-antialiased;
|
|
61
|
+
will-change: transform;
|
|
62
|
+
backface-visibility: hidden;
|
|
63
|
+
transform-style: preserve-3d;
|
|
64
|
+
list-style: none;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
[data-rwp-option] {
|
|
68
|
+
position: absolute;
|
|
69
|
+
top: 0;
|
|
70
|
+
left: 0;
|
|
71
|
+
width: 100%;
|
|
72
|
+
-webkit-font-smoothing: subpixel-antialiased;
|
|
73
|
+
will-change: visibility;
|
|
74
|
+
font-size: 0.875rem;
|
|
75
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ncdai/react-wheel-picker",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "iOS-like wheel picker for React with smooth inertia scrolling and infinite loop support.",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": {
|
|
13
|
+
"types": "./dist/index.d.mts",
|
|
14
|
+
"default": "./dist/index.mjs"
|
|
15
|
+
},
|
|
16
|
+
"require": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./dist/style.css": "./dist/style.css"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"dev": "bunchee --watch",
|
|
26
|
+
"build": "bunchee && pnpm copy-assets",
|
|
27
|
+
"type-check": "tsc --noEmit",
|
|
28
|
+
"copy-assets": "cp -r ./src/style.css ./dist/style.css",
|
|
29
|
+
"dev:website": "turbo run dev --filter=website...",
|
|
30
|
+
"lint": "eslint . --ext .ts,.tsx --ignore-pattern 'website/**'",
|
|
31
|
+
"format": "prettier --write .",
|
|
32
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [],
|
|
35
|
+
"author": "Nguyen Chanh Dai <dai@chanhdai.com>",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"homepage": "https://react-wheel-picker.chanhdai.com",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/ncdai/react-wheel-picker.git"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/ncdai/react-wheel-picker/issues"
|
|
44
|
+
},
|
|
45
|
+
"packageManager": "pnpm@10.7.0",
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^20",
|
|
48
|
+
"@types/react": "^19",
|
|
49
|
+
"bunchee": "^6.5.1",
|
|
50
|
+
"prettier": "3.5.3",
|
|
51
|
+
"react": "^19.0.0",
|
|
52
|
+
"turbo": "^2.5.3",
|
|
53
|
+
"typescript": "^5.8.3"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
|
57
|
+
}
|
|
58
|
+
}
|