@elik56a/react-native-pickers 0.0.0-stage → 0.1.1

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.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +288 -2
  3. package/lib/date/Calendar.d.ts +89 -0
  4. package/lib/date/Calendar.d.ts.map +1 -0
  5. package/lib/date/Calendar.js +250 -0
  6. package/lib/date/Calendar.js.map +1 -0
  7. package/lib/date/DatePicker.d.ts +14 -0
  8. package/lib/date/DatePicker.d.ts.map +1 -0
  9. package/lib/date/DatePicker.js +17 -0
  10. package/lib/date/DatePicker.js.map +1 -0
  11. package/lib/date/DateWheelPicker.d.ts +29 -0
  12. package/lib/date/DateWheelPicker.d.ts.map +1 -0
  13. package/lib/date/DateWheelPicker.js +83 -0
  14. package/lib/date/DateWheelPicker.js.map +1 -0
  15. package/lib/emoji/EmojiPicker.d.ts +47 -0
  16. package/lib/emoji/EmojiPicker.d.ts.map +1 -0
  17. package/lib/emoji/EmojiPicker.js +161 -0
  18. package/lib/emoji/EmojiPicker.js.map +1 -0
  19. package/lib/emoji/data.d.ts +2 -0
  20. package/lib/emoji/data.d.ts.map +1 -0
  21. package/lib/emoji/data.js +4 -0
  22. package/lib/emoji/data.js.map +1 -0
  23. package/lib/emoji/emojis.d.ts +23 -0
  24. package/lib/emoji/emojis.d.ts.map +1 -0
  25. package/lib/emoji/emojis.js +100 -0
  26. package/lib/emoji/emojis.js.map +1 -0
  27. package/lib/index.d.ts +17 -0
  28. package/lib/index.d.ts.map +1 -0
  29. package/lib/index.js +17 -0
  30. package/lib/index.js.map +1 -0
  31. package/lib/select/SelectList.d.ts +60 -0
  32. package/lib/select/SelectList.d.ts.map +1 -0
  33. package/lib/select/SelectList.js +91 -0
  34. package/lib/select/SelectList.js.map +1 -0
  35. package/lib/select/SelectPicker.d.ts +35 -0
  36. package/lib/select/SelectPicker.d.ts.map +1 -0
  37. package/lib/select/SelectPicker.js +64 -0
  38. package/lib/select/SelectPicker.js.map +1 -0
  39. package/lib/select/filter.d.ts +3 -0
  40. package/lib/select/filter.d.ts.map +1 -0
  41. package/lib/select/filter.js +13 -0
  42. package/lib/select/filter.js.map +1 -0
  43. package/lib/sheet/BottomSheet.d.ts +29 -0
  44. package/lib/sheet/BottomSheet.d.ts.map +1 -0
  45. package/lib/sheet/BottomSheet.js +89 -0
  46. package/lib/sheet/BottomSheet.js.map +1 -0
  47. package/lib/sheet/PickerSheet.d.ts +26 -0
  48. package/lib/sheet/PickerSheet.d.ts.map +1 -0
  49. package/lib/sheet/PickerSheet.js +27 -0
  50. package/lib/sheet/PickerSheet.js.map +1 -0
  51. package/lib/sheet/sheets.d.ts +48 -0
  52. package/lib/sheet/sheets.d.ts.map +1 -0
  53. package/lib/sheet/sheets.js +58 -0
  54. package/lib/sheet/sheets.js.map +1 -0
  55. package/lib/theme.d.ts +81 -0
  56. package/lib/theme.d.ts.map +1 -0
  57. package/lib/theme.js +96 -0
  58. package/lib/theme.js.map +1 -0
  59. package/lib/time/TimePicker.d.ts +41 -0
  60. package/lib/time/TimePicker.d.ts.map +1 -0
  61. package/lib/time/TimePicker.js +105 -0
  62. package/lib/time/TimePicker.js.map +1 -0
  63. package/lib/types.d.ts +9 -0
  64. package/lib/types.d.ts.map +1 -0
  65. package/lib/types.js +2 -0
  66. package/lib/types.js.map +1 -0
  67. package/lib/utils/OwnScroll.d.ts +10 -0
  68. package/lib/utils/OwnScroll.d.ts.map +1 -0
  69. package/lib/utils/OwnScroll.js +13 -0
  70. package/lib/utils/OwnScroll.js.map +1 -0
  71. package/lib/utils/date.d.ts +30 -0
  72. package/lib/utils/date.d.ts.map +1 -0
  73. package/lib/utils/date.js +115 -0
  74. package/lib/utils/date.js.map +1 -0
  75. package/lib/wheel/MultiWheelPicker.d.ts +26 -0
  76. package/lib/wheel/MultiWheelPicker.d.ts.map +1 -0
  77. package/lib/wheel/MultiWheelPicker.js +28 -0
  78. package/lib/wheel/MultiWheelPicker.js.map +1 -0
  79. package/lib/wheel/WheelPicker.d.ts +59 -0
  80. package/lib/wheel/WheelPicker.d.ts.map +1 -0
  81. package/lib/wheel/WheelPicker.js +257 -0
  82. package/lib/wheel/WheelPicker.js.map +1 -0
  83. package/lib/wheel/typeToSelect.d.ts +8 -0
  84. package/lib/wheel/typeToSelect.d.ts.map +1 -0
  85. package/lib/wheel/typeToSelect.js +10 -0
  86. package/lib/wheel/typeToSelect.js.map +1 -0
  87. package/package.json +81 -4
  88. package/src/date/Calendar.tsx +586 -0
  89. package/src/date/DatePicker.tsx +22 -0
  90. package/src/date/DateWheelPicker.tsx +149 -0
  91. package/src/emoji/EmojiPicker.tsx +410 -0
  92. package/src/emoji/data.ts +3 -0
  93. package/src/emoji/emojis.ts +127 -0
  94. package/src/index.ts +16 -0
  95. package/src/select/SelectList.tsx +266 -0
  96. package/src/select/SelectPicker.tsx +203 -0
  97. package/src/select/filter.ts +17 -0
  98. package/src/sheet/BottomSheet.tsx +199 -0
  99. package/src/sheet/PickerSheet.tsx +86 -0
  100. package/src/sheet/sheets.tsx +218 -0
  101. package/src/theme.tsx +160 -0
  102. package/src/time/TimePicker.tsx +199 -0
  103. package/src/types.ts +10 -0
  104. package/src/utils/OwnScroll.tsx +14 -0
  105. package/src/utils/date.ts +131 -0
  106. package/src/wheel/MultiWheelPicker.tsx +96 -0
  107. package/src/wheel/WheelPicker.tsx +534 -0
  108. package/src/wheel/typeToSelect.ts +12 -0
@@ -0,0 +1,534 @@
1
+ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ Animated,
4
+ FlatList,
5
+ Keyboard,
6
+ Platform,
7
+ Pressable,
8
+ StyleSheet,
9
+ Text,
10
+ TextInput,
11
+ View,
12
+ } from 'react-native';
13
+ import type { AccessibilityActionEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle } from 'react-native';
14
+ import { textStyle, usePickerTheme } from '../theme';
15
+ import type { PickerTheme } from '../theme';
16
+ import type { SlotStyles, ThemableProps } from '../types';
17
+ import { OwnScroll } from '../utils/OwnScroll';
18
+ import { canExtend, matchLabel } from './typeToSelect';
19
+
20
+ export interface WheelItem<T> {
21
+ label: string;
22
+ value: T;
23
+ disabled?: boolean;
24
+ key?: string;
25
+ }
26
+
27
+ export type WheelItemInput<T> = WheelItem<T> | (T extends string | number ? T : never);
28
+
29
+ export type WheelPickerSlot = 'container' | 'item' | 'itemText' | 'selectedItemText' | 'disabledItemText' | 'indicator';
30
+
31
+ export interface WheelRenderItemInfo<T> {
32
+ item: WheelItem<T>;
33
+ index: number;
34
+ selected: boolean;
35
+ theme: PickerTheme;
36
+ }
37
+
38
+ export interface WheelPickerProps<T> extends ThemableProps {
39
+ /** Items: primitives (`[1, 2, 3]`) or objects (`{ label, value, disabled }`) */
40
+ items: ReadonlyArray<WheelItemInput<T>>;
41
+ /** Selected value (controlled). */
42
+ value?: T;
43
+ /** Initial value when uncontrolled. */
44
+ defaultValue?: T;
45
+ /** Fires when the wheel settles on a new value. */
46
+ onChange?: (value: T, index: number) => void;
47
+ /** Fires on every item crossing while scrolling. Hook haptics here. */
48
+ onValueChanging?: (value: T, index: number) => void;
49
+ /** Row height. Default: 40 */
50
+ itemHeight?: number;
51
+ /** Visible rows (odd number). Default: 5 */
52
+ visibleItems?: number;
53
+ /** Infinite wheel. Default: false */
54
+ loop?: boolean;
55
+ /** Tap the selected row to type a value; the wheel jumps to the first matching label. Default: true */
56
+ keyboardInput?: boolean;
57
+ /** '3d' cylinder or 'flat' list. Default: '3d' */
58
+ variant?: '3d' | 'flat';
59
+ /** Degrees between rows on the 3d cylinder. Default: 20 */
60
+ rotation?: number;
61
+ /** Opacity of the farthest visible rows. Default: 0.3 */
62
+ fadeTo?: number;
63
+ /** Custom row renderer. */
64
+ renderItem?: (info: WheelRenderItemInfo<T>) => React.ReactNode;
65
+ /** Show the selection band. Default: true */
66
+ showIndicator?: boolean;
67
+ renderIndicator?: (theme: PickerTheme) => React.ReactNode;
68
+ textAlign?: TextStyle['textAlign'];
69
+ disabled?: boolean;
70
+ /** Custom equality for object values. Default: Object.is */
71
+ isEqual?: (a: T, b: T) => boolean;
72
+ style?: StyleProp<ViewStyle>;
73
+ styles?: SlotStyles<WheelPickerSlot>;
74
+ accessibilityLabel?: string;
75
+ testID?: string;
76
+ }
77
+
78
+ function normalize<T>(input: WheelItemInput<T>): WheelItem<T> {
79
+ if (typeof input === 'object' && input !== null && 'value' in (input as object)) return input as WheelItem<T>;
80
+ return { label: String(input), value: input as unknown as T };
81
+ }
82
+
83
+ const native = Platform.OS !== 'web';
84
+
85
+ export function WheelPicker<T>(props: WheelPickerProps<T>) {
86
+ const {
87
+ items: rawItems,
88
+ value,
89
+ defaultValue,
90
+ onChange,
91
+ onValueChanging,
92
+ itemHeight: h = 40,
93
+ visibleItems = 5,
94
+ loop = false,
95
+ keyboardInput = true,
96
+ variant = '3d',
97
+ rotation = 20,
98
+ fadeTo = 0.3,
99
+ renderItem,
100
+ showIndicator = true,
101
+ renderIndicator,
102
+ textAlign = 'center',
103
+ disabled = false,
104
+ isEqual = Object.is,
105
+ style,
106
+ styles: slots,
107
+ theme: themeOverride,
108
+ accessibilityLabel,
109
+ testID,
110
+ } = props;
111
+ const theme = usePickerTheme(themeOverride);
112
+ const items = useMemo(() => rawItems.map(normalize), [rawItems]);
113
+ const n = items.length;
114
+ const visible = visibleItems % 2 === 0 ? visibleItems + 1 : visibleItems;
115
+ const pad = ((visible - 1) / 2) * h;
116
+
117
+ // Loop: repeat the list so the user can keep scrolling; we silently recentre on settle.
118
+ const copies = loop && n > 0 ? Math.max(7, 2 * Math.ceil(240 / n) + 1) : 1;
119
+ const middle = Math.floor(copies / 2) * n;
120
+ const total = n * copies;
121
+
122
+ const indexOfValue = useCallback(
123
+ (v: T | undefined) => {
124
+ if (v === undefined) return -1;
125
+ return items.findIndex((it) => isEqual(it.value, v));
126
+ },
127
+ [items, isEqual]
128
+ );
129
+
130
+ const initialLogical = Math.max(0, indexOfValue(value !== undefined ? value : defaultValue));
131
+ const initialData = (loop ? middle : 0) + firstEnabled(items, initialLogical);
132
+
133
+ const listRef = useRef<FlatList<number>>(null);
134
+ const scrollY = useRef(new Animated.Value(initialData * h)).current;
135
+ const [active, setActive] = useState(initialData);
136
+ const st = useRef({
137
+ tick: initialData,
138
+ committed: initialData % Math.max(n, 1),
139
+ y: initialData * h,
140
+ dragging: false,
141
+ momentum: false,
142
+ timer: undefined as ReturnType<typeof setTimeout> | undefined,
143
+ mounted: false,
144
+ }).current;
145
+
146
+ const cbs = useRef({ onChange, onValueChanging });
147
+ cbs.current = { onChange, onValueChanging };
148
+
149
+ const logical = useCallback((dataIndex: number) => ((dataIndex % n) + n) % n, [n]);
150
+
151
+ const scrollToData = useCallback(
152
+ (dataIndex: number, animated: boolean) => {
153
+ listRef.current?.scrollToOffset({ offset: dataIndex * h, animated });
154
+ if (!animated) {
155
+ st.y = dataIndex * h;
156
+ st.tick = dataIndex;
157
+ setActive(dataIndex);
158
+ }
159
+ },
160
+ [h, st]
161
+ );
162
+
163
+ const settle = useCallback(() => {
164
+ if (n === 0 || st.dragging || st.momentum) return;
165
+ let idx = Math.min(total - 1, Math.max(0, Math.round(st.y / h)));
166
+ const li = logical(idx);
167
+ if (items[li]?.disabled) {
168
+ const target = nearestEnabled(items, li);
169
+ if (target !== li) {
170
+ scrollToData(idx + (target - li), true);
171
+ return;
172
+ }
173
+ }
174
+ if (Math.abs(st.y - idx * h) > 1) {
175
+ // Not aligned (web or interrupted momentum) – snap, settle will run again.
176
+ scrollToData(idx, true);
177
+ return;
178
+ }
179
+ if (li !== st.committed) {
180
+ st.committed = li;
181
+ const it = items[li];
182
+ if (it) cbs.current.onChange?.(it.value, li);
183
+ }
184
+ if (loop && (idx < n || idx >= total - n)) {
185
+ idx = middle + li;
186
+ scrollToData(idx, false);
187
+ }
188
+ }, [h, items, logical, loop, middle, n, scrollToData, st, total]);
189
+
190
+ const scheduleSettle = useCallback(
191
+ (delay: number) => {
192
+ if (st.timer) clearTimeout(st.timer);
193
+ st.timer = setTimeout(settle, delay);
194
+ },
195
+ [settle, st]
196
+ );
197
+
198
+ useEffect(
199
+ () => () => {
200
+ if (st.timer) clearTimeout(st.timer);
201
+ },
202
+ [st]
203
+ );
204
+
205
+ const onScroll = useMemo(
206
+ () =>
207
+ Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
208
+ useNativeDriver: native,
209
+ listener: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
210
+ const y = e.nativeEvent.contentOffset.y;
211
+ st.y = y;
212
+ const idx = Math.min(total - 1, Math.max(0, Math.round(y / h)));
213
+ if (idx !== st.tick) {
214
+ st.tick = idx;
215
+ setActive(idx);
216
+ const it = items[logical(idx)];
217
+ if (it && !it.disabled) cbs.current.onValueChanging?.(it.value, logical(idx));
218
+ }
219
+ scheduleSettle(native ? 140 : 110);
220
+ },
221
+ }),
222
+ [h, items, logical, scheduleSettle, scrollY, st, total]
223
+ );
224
+
225
+ // Controlled value sync
226
+ useEffect(() => {
227
+ if (value === undefined || n === 0) return;
228
+ const li = indexOfValue(value);
229
+ if (li < 0 || li === logical(st.tick) || st.dragging || st.momentum) {
230
+ if (li >= 0) st.committed = li;
231
+ return;
232
+ }
233
+ st.committed = li;
234
+ const base = st.tick - logical(st.tick);
235
+ scrollToData(loop ? base + li : li, st.mounted);
236
+ // eslint-disable-next-line react-hooks/exhaustive-deps
237
+ }, [value, items]);
238
+
239
+ // Initial position (contentOffset is not honoured everywhere)
240
+ const onLayout = useCallback(() => {
241
+ if (st.mounted) return;
242
+ st.mounted = true;
243
+ requestAnimationFrame(() => listRef.current?.scrollToOffset({ offset: initialData * h, animated: false }));
244
+ }, [h, initialData, st]);
245
+
246
+ const data = useMemo(() => Array.from({ length: total }, (_, i) => i), [total]);
247
+
248
+ const move = useCallback(
249
+ (delta: number) => {
250
+ const target = st.tick + delta;
251
+ if (!loop && (target < 0 || target >= n)) return;
252
+ scrollToData(target, true);
253
+ },
254
+ [loop, n, scrollToData, st]
255
+ );
256
+
257
+ // Typing only scrolls; settle() commits the value like a drag would.
258
+ const [typed, setTyped] = useState<string | null>(null);
259
+ const typing = typed !== null;
260
+ const numeric = useMemo(() => n > 0 && items.every((it) => /^\d+$/.test(it.label)), [items, n]);
261
+ const stopTyping = useCallback(() => setTyped(null), []);
262
+
263
+ const onType = useCallback(
264
+ (text: string) => {
265
+ const q = text.trim().toLowerCase();
266
+ if (!q) return setTyped('');
267
+ const li = matchLabel(items, q, numeric);
268
+ if (li < 0) return;
269
+ scrollToData(loop ? st.tick - logical(st.tick) + li : li, true);
270
+ setTyped(canExtend(items, q) ? text : null);
271
+ },
272
+ [items, logical, loop, numeric, scrollToData, st]
273
+ );
274
+
275
+ useEffect(() => {
276
+ if (!typing) return;
277
+ const sub = Keyboard.addListener('keyboardDidHide', stopTyping);
278
+ return () => sub.remove();
279
+ }, [typing, stopTyping]);
280
+
281
+ const onRowPress = useCallback(
282
+ (dataIndex: number) => {
283
+ if (keyboardInput && dataIndex === st.tick) setTyped((t) => (t === null ? '' : null));
284
+ else scrollToData(dataIndex, true);
285
+ },
286
+ [keyboardInput, scrollToData, st]
287
+ );
288
+
289
+ const onA11yAction = useCallback(
290
+ (e: AccessibilityActionEvent) => {
291
+ if (e.nativeEvent.actionName === 'increment') move(1);
292
+ if (e.nativeEvent.actionName === 'decrement') move(-1);
293
+ },
294
+ [move]
295
+ );
296
+
297
+ const renderRow = useCallback(
298
+ ({ item: dataIndex }: { item: number }) => {
299
+ const li = logical(dataIndex);
300
+ return (
301
+ <WheelRow
302
+ dataIndex={dataIndex}
303
+ item={items[li]!}
304
+ logicalIndex={li}
305
+ selected={dataIndex === active}
306
+ h={h}
307
+ visible={visible}
308
+ scrollY={scrollY}
309
+ variant={variant}
310
+ rotation={rotation}
311
+ fadeTo={fadeTo}
312
+ theme={theme}
313
+ textAlign={textAlign}
314
+ slots={slots}
315
+ renderItem={renderItem}
316
+ onPress={disabled ? undefined : onRowPress}
317
+ />
318
+ );
319
+ },
320
+ [active, disabled, fadeTo, h, items, logical, onRowPress, renderItem, rotation, scrollY, slots, textAlign, theme, variant, visible]
321
+ );
322
+
323
+ const current = items[logical(active)];
324
+
325
+ return (
326
+ <View
327
+ style={[{ height: h * visible, overflow: 'hidden' }, style, slots?.container]}
328
+ onLayout={onLayout}
329
+ accessible
330
+ accessibilityRole="adjustable"
331
+ accessibilityLabel={accessibilityLabel}
332
+ accessibilityValue={{ text: current?.label }}
333
+ accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
334
+ onAccessibilityAction={onA11yAction}
335
+ testID={testID}
336
+ >
337
+ {showIndicator && (
338
+ <View pointerEvents="none" style={[StyleSheet.absoluteFill, { justifyContent: 'center' }]}>
339
+ {renderIndicator ? (
340
+ renderIndicator(theme)
341
+ ) : (
342
+ <View
343
+ style={[
344
+ { height: h, borderRadius: theme.radius.md, backgroundColor: theme.colors.indicator, marginHorizontal: theme.spacing.xs },
345
+ slots?.indicator,
346
+ ]}
347
+ />
348
+ )}
349
+ </View>
350
+ )}
351
+ <OwnScroll>
352
+ <Animated.FlatList
353
+ ref={listRef as React.Ref<FlatList<number>>}
354
+ data={data}
355
+ keyExtractor={(i: number) => String(i)}
356
+ renderItem={renderRow}
357
+ extraData={active}
358
+ getItemLayout={(_: unknown, index: number) => ({ length: h, offset: pad + h * index, index })}
359
+ contentContainerStyle={{ paddingVertical: pad }}
360
+ contentOffset={{ x: 0, y: initialData * h }}
361
+ // iOS applies contentOffset without a scroll event, so the list would only render rows from 0.
362
+ initialScrollIndex={Math.max(0, initialData - (visible - 1) / 2)}
363
+ showsVerticalScrollIndicator={false}
364
+ snapToInterval={h}
365
+ decelerationRate="fast"
366
+ scrollEnabled={!disabled}
367
+ onScroll={onScroll}
368
+ scrollEventThrottle={16}
369
+ onScrollBeginDrag={() => {
370
+ st.dragging = true;
371
+ stopTyping();
372
+ }}
373
+ onScrollEndDrag={() => {
374
+ st.dragging = false;
375
+ scheduleSettle(140);
376
+ }}
377
+ onMomentumScrollBegin={() => {
378
+ st.momentum = true;
379
+ }}
380
+ onMomentumScrollEnd={() => {
381
+ st.momentum = false;
382
+ scheduleSettle(16);
383
+ }}
384
+ initialNumToRender={visible + 4}
385
+ windowSize={5}
386
+ maxToRenderPerBatch={visible * 2}
387
+ removeClippedSubviews={false}
388
+ nestedScrollEnabled
389
+ />
390
+ </OwnScroll>
391
+ {typing && (
392
+ <>
393
+ <View pointerEvents="none" style={[StyleSheet.absoluteFill, { justifyContent: 'center' }]}>
394
+ <View style={[s.typingRing, { height: h, borderRadius: theme.radius.md, borderColor: theme.colors.primary, marginHorizontal: theme.spacing.xs }]} />
395
+ </View>
396
+ <TextInput
397
+ autoFocus
398
+ value={typed ?? ''}
399
+ onChangeText={onType}
400
+ onSubmitEditing={stopTyping}
401
+ onBlur={stopTyping}
402
+ keyboardType={numeric ? (Platform.OS === 'ios' ? 'numbers-and-punctuation' : 'number-pad') : 'default'}
403
+ returnKeyType="done"
404
+ autoCorrect={false}
405
+ autoCapitalize="none"
406
+ caretHidden
407
+ style={s.hiddenInput}
408
+ accessibilityLabel={accessibilityLabel}
409
+ />
410
+ </>
411
+ )}
412
+ </View>
413
+ );
414
+ }
415
+
416
+ const s = StyleSheet.create({
417
+ hiddenInput: { position: 'absolute', width: 1, height: 1, opacity: 0 },
418
+ typingRing: { borderWidth: 2 },
419
+ });
420
+
421
+ interface RowProps<T> {
422
+ dataIndex: number;
423
+ logicalIndex: number;
424
+ item: WheelItem<T>;
425
+ selected: boolean;
426
+ h: number;
427
+ visible: number;
428
+ scrollY: Animated.Value;
429
+ variant: '3d' | 'flat';
430
+ rotation: number;
431
+ fadeTo: number;
432
+ theme: PickerTheme;
433
+ textAlign: TextStyle['textAlign'];
434
+ slots?: SlotStyles<WheelPickerSlot>;
435
+ renderItem?: (info: WheelRenderItemInfo<T>) => React.ReactNode;
436
+ onPress?: (dataIndex: number) => void;
437
+ }
438
+
439
+ function WheelRowImpl<T>({
440
+ dataIndex,
441
+ logicalIndex,
442
+ item,
443
+ selected,
444
+ h,
445
+ visible,
446
+ scrollY,
447
+ variant,
448
+ rotation,
449
+ fadeTo,
450
+ theme,
451
+ textAlign,
452
+ slots,
453
+ renderItem,
454
+ onPress,
455
+ }: RowProps<T>) {
456
+ const animatedStyle = useMemo(() => {
457
+ const half = (visible - 1) / 2;
458
+ const range = half + 1;
459
+ const center = dataIndex * h;
460
+ const ks: number[] = [];
461
+ for (let k = range; k >= -range; k--) ks.push(k); // y ascending ⇒ k descending
462
+ const inputRange = ks.map((k) => center - k * h);
463
+ const fade = (k: number) => {
464
+ const t = Math.min(1, Math.abs(k) / range);
465
+ return 1 - (1 - fadeTo) * t;
466
+ };
467
+ const opacity = scrollY.interpolate({ inputRange, outputRange: ks.map(fade), extrapolate: 'clamp' });
468
+ if (variant === 'flat') {
469
+ const scale = scrollY.interpolate({ inputRange, outputRange: ks.map((k) => (k === 0 ? 1 : 0.94)), extrapolate: 'clamp' });
470
+ return { opacity, transform: [{ scale }] };
471
+ }
472
+ const step = (rotation * Math.PI) / 180;
473
+ const radius = h / step;
474
+ const clampAngle = (k: number) => Math.max(-Math.PI / 2 + 0.05, Math.min(Math.PI / 2 - 0.05, k * step));
475
+ // Project row onto a cylinder: rows bunch up toward the edges.
476
+ const translateY = scrollY.interpolate({
477
+ inputRange,
478
+ outputRange: ks.map((k) => radius * Math.sin(clampAngle(k)) - k * h),
479
+ extrapolate: 'clamp',
480
+ });
481
+ const rotateX = scrollY.interpolate({
482
+ inputRange,
483
+ outputRange: ks.map((k) => `${(-clampAngle(k) * 180) / Math.PI}deg`),
484
+ extrapolate: 'clamp',
485
+ });
486
+ const scale = scrollY.interpolate({
487
+ inputRange,
488
+ outputRange: ks.map((k) => 0.9 + 0.1 * Math.cos(clampAngle(k))),
489
+ extrapolate: 'clamp',
490
+ });
491
+ return { opacity, transform: [{ perspective: 1000 }, { translateY }, { rotateX }, { scale }] };
492
+ }, [dataIndex, fadeTo, h, rotation, scrollY, variant, visible]);
493
+
494
+ const content = renderItem ? (
495
+ renderItem({ item, index: logicalIndex, selected, theme })
496
+ ) : (
497
+ <Text
498
+ numberOfLines={1}
499
+ style={[
500
+ textStyle(theme, 'lg', selected ? 'bold' : 'regular'),
501
+ { textAlign, color: item.disabled ? theme.colors.textDisabled : selected ? theme.colors.text : theme.colors.textSecondary },
502
+ slots?.itemText,
503
+ selected && slots?.selectedItemText,
504
+ item.disabled && slots?.disabledItemText,
505
+ ]}
506
+ >
507
+ {item.label}
508
+ </Text>
509
+ );
510
+
511
+ return (
512
+ <Pressable onPress={onPress && !item.disabled ? () => onPress(dataIndex) : undefined} accessible={false}>
513
+ <Animated.View
514
+ style={[{ height: h, justifyContent: 'center', paddingHorizontal: theme.spacing.sm }, animatedStyle, slots?.item]}
515
+ >
516
+ {content}
517
+ </Animated.View>
518
+ </Pressable>
519
+ );
520
+ }
521
+
522
+ const WheelRow = memo(WheelRowImpl) as typeof WheelRowImpl;
523
+
524
+ function firstEnabled<T>(items: WheelItem<T>[], from: number) {
525
+ return items[from]?.disabled ? nearestEnabled(items, from) : from;
526
+ }
527
+
528
+ function nearestEnabled<T>(items: WheelItem<T>[], from: number) {
529
+ for (let d = 1; d < items.length; d++) {
530
+ if (items[from + d] && !items[from + d]!.disabled) return from + d;
531
+ if (items[from - d] && !items[from - d]!.disabled) return from - d;
532
+ }
533
+ return from;
534
+ }
@@ -0,0 +1,12 @@
1
+ type LabeledItem = { label: string; disabled?: boolean };
2
+
3
+ export function matchLabel(items: ReadonlyArray<LabeledItem>, query: string, numeric: boolean) {
4
+ const enabled = items.map((_, i) => i).filter((i) => !items[i]!.disabled);
5
+ // "5" should find "05" on padded numeric wheels
6
+ const exact = numeric ? enabled.find((i) => Number(items[i]!.label) === Number(query)) : undefined;
7
+ return exact ?? enabled.find((i) => items[i]!.label.toLowerCase().startsWith(query)) ?? -1;
8
+ }
9
+
10
+ export function canExtend(items: ReadonlyArray<LabeledItem>, query: string) {
11
+ return items.some((it) => !it.disabled && it.label.length > query.length && it.label.toLowerCase().startsWith(query));
12
+ }