@multiplatform.one/forms 7.35.0 → 7.37.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.
Files changed (92) hide show
  1. package/dist/cjs/Wheel/{WheelEdgeFade.cjs → NativeWheel.cjs} +5 -5
  2. package/dist/cjs/Wheel/NativeWheel.native.js +408 -0
  3. package/dist/cjs/Wheel/NativeWheel.native.js.map +1 -0
  4. package/dist/cjs/Wheel/haptic.cjs +3 -4
  5. package/dist/cjs/Wheel/haptic.native.js +4 -5
  6. package/dist/cjs/Wheel/haptic.native.js.map +1 -1
  7. package/dist/cjs/Wheel/index.cjs +62 -218
  8. package/dist/cjs/Wheel/index.native.js +54 -228
  9. package/dist/cjs/Wheel/index.native.js.map +1 -1
  10. package/dist/cjs/Wheel/wheelShared.cjs +156 -0
  11. package/dist/cjs/Wheel/wheelShared.native.js +169 -0
  12. package/dist/cjs/Wheel/wheelShared.native.js.map +1 -0
  13. package/dist/cjs/shared/TimeColumn.cjs +0 -9
  14. package/dist/cjs/shared/TimeColumn.native.js +0 -9
  15. package/dist/cjs/shared/TimeColumn.native.js.map +1 -1
  16. package/dist/esm/Wheel/NativeWheel.mjs +5 -0
  17. package/dist/esm/Wheel/NativeWheel.mjs.map +1 -0
  18. package/dist/esm/Wheel/NativeWheel.native.js +380 -0
  19. package/dist/esm/Wheel/NativeWheel.native.js.map +1 -0
  20. package/dist/esm/Wheel/haptic.mjs +3 -4
  21. package/dist/esm/Wheel/haptic.mjs.map +1 -1
  22. package/dist/esm/Wheel/haptic.native.js +4 -5
  23. package/dist/esm/Wheel/haptic.native.js.map +1 -1
  24. package/dist/esm/Wheel/index.mjs +49 -205
  25. package/dist/esm/Wheel/index.mjs.map +1 -1
  26. package/dist/esm/Wheel/index.native.js +41 -215
  27. package/dist/esm/Wheel/index.native.js.map +1 -1
  28. package/dist/esm/Wheel/wheelShared.mjs +119 -0
  29. package/dist/esm/Wheel/wheelShared.mjs.map +1 -0
  30. package/dist/esm/Wheel/wheelShared.native.js +129 -0
  31. package/dist/esm/Wheel/wheelShared.native.js.map +1 -0
  32. package/dist/esm/shared/TimeColumn.mjs +0 -9
  33. package/dist/esm/shared/TimeColumn.mjs.map +1 -1
  34. package/dist/esm/shared/TimeColumn.native.js +0 -9
  35. package/dist/esm/shared/TimeColumn.native.js.map +1 -1
  36. package/dist/jsx/Wheel/NativeWheel.mjs +5 -0
  37. package/dist/jsx/Wheel/NativeWheel.mjs.map +1 -0
  38. package/dist/jsx/Wheel/NativeWheel.native.js +408 -0
  39. package/dist/jsx/Wheel/NativeWheel.native.js.map +1 -0
  40. package/dist/jsx/Wheel/haptic.mjs +3 -4
  41. package/dist/jsx/Wheel/haptic.mjs.map +1 -1
  42. package/dist/jsx/Wheel/haptic.native.js +4 -5
  43. package/dist/jsx/Wheel/haptic.native.js.map +1 -1
  44. package/dist/jsx/Wheel/index.mjs +49 -205
  45. package/dist/jsx/Wheel/index.mjs.map +1 -1
  46. package/dist/jsx/Wheel/index.native.js +54 -228
  47. package/dist/jsx/Wheel/index.native.js.map +1 -1
  48. package/dist/jsx/Wheel/wheelShared.mjs +119 -0
  49. package/dist/jsx/Wheel/wheelShared.mjs.map +1 -0
  50. package/dist/jsx/Wheel/wheelShared.native.js +169 -0
  51. package/dist/jsx/Wheel/wheelShared.native.js.map +1 -0
  52. package/dist/jsx/shared/TimeColumn.mjs +0 -9
  53. package/dist/jsx/shared/TimeColumn.mjs.map +1 -1
  54. package/dist/jsx/shared/TimeColumn.native.js +0 -9
  55. package/dist/jsx/shared/TimeColumn.native.js.map +1 -1
  56. package/package.json +8 -8
  57. package/src/Wheel/NativeWheel.native.spec.tsx +181 -0
  58. package/src/Wheel/NativeWheel.native.tsx +430 -0
  59. package/src/Wheel/NativeWheel.tsx +6 -0
  60. package/src/Wheel/haptic.ts +9 -8
  61. package/src/Wheel/index.tsx +121 -363
  62. package/src/Wheel/wheelShared.spec.ts +53 -0
  63. package/src/Wheel/wheelShared.ts +222 -0
  64. package/src/shared/TimeColumn.tsx +0 -7
  65. package/types/Wheel/NativeWheel.d.ts +4 -0
  66. package/types/Wheel/NativeWheel.d.ts.map +1 -0
  67. package/types/Wheel/NativeWheel.native.d.ts +9 -0
  68. package/types/Wheel/NativeWheel.native.d.ts.map +1 -0
  69. package/types/Wheel/haptic.d.ts +4 -2
  70. package/types/Wheel/haptic.d.ts.map +1 -1
  71. package/types/Wheel/index.d.ts +4 -49
  72. package/types/Wheel/index.d.ts.map +1 -1
  73. package/types/Wheel/wheelShared.d.ts +87 -0
  74. package/types/Wheel/wheelShared.d.ts.map +1 -0
  75. package/types/shared/TimeColumn.d.ts.map +1 -1
  76. package/dist/cjs/Wheel/WheelEdgeFade.native.js +0 -116
  77. package/dist/cjs/Wheel/WheelEdgeFade.native.js.map +0 -1
  78. package/dist/esm/Wheel/WheelEdgeFade.mjs +0 -5
  79. package/dist/esm/Wheel/WheelEdgeFade.mjs.map +0 -1
  80. package/dist/esm/Wheel/WheelEdgeFade.native.js +0 -88
  81. package/dist/esm/Wheel/WheelEdgeFade.native.js.map +0 -1
  82. package/dist/jsx/Wheel/WheelEdgeFade.mjs +0 -5
  83. package/dist/jsx/Wheel/WheelEdgeFade.mjs.map +0 -1
  84. package/dist/jsx/Wheel/WheelEdgeFade.native.js +0 -116
  85. package/dist/jsx/Wheel/WheelEdgeFade.native.js.map +0 -1
  86. package/src/Wheel/WheelEdgeFade.native.tsx +0 -67
  87. package/src/Wheel/WheelEdgeFade.spec.tsx +0 -141
  88. package/src/Wheel/WheelEdgeFade.tsx +0 -7
  89. package/types/Wheel/WheelEdgeFade.d.ts +0 -8
  90. package/types/Wheel/WheelEdgeFade.d.ts.map +0 -1
  91. package/types/Wheel/WheelEdgeFade.native.d.ts +0 -8
  92. package/types/Wheel/WheelEdgeFade.native.d.ts.map +0 -1
@@ -0,0 +1,181 @@
1
+ import { act, cleanup } from "@testing-library/react";
2
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
3
+ import type { ReactNode } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { NativeWheel } from "./NativeWheel.native";
6
+ import { fontLineHeightPx, tokenPx } from "./wheelShared";
7
+
8
+ type ScrollHandler = (...args: unknown[]) => void;
9
+ type ScrollProps = { children?: ReactNode } & Partial<Record<string, ScrollHandler | ReactNode>>;
10
+
11
+ const scroll = vi.hoisted(() => ({
12
+ props: null as ScrollProps | null,
13
+ scrollTo: vi.fn(),
14
+ }));
15
+
16
+ vi.mock("tamagui", async (importOriginal) => ({
17
+ ...(await importOriginal<typeof import("tamagui")>()),
18
+ isWeb: false,
19
+ }));
20
+
21
+ // The vitest config rewrites `from "react-native"` to react-native-web.
22
+ vi.mock("react-native-web", async (importOriginal) => {
23
+ const rn = await importOriginal<typeof import("react-native")>();
24
+ const { forwardRef, useImperativeHandle } = await import("react");
25
+ const ScrollView = forwardRef<unknown, ScrollProps>(function ScrollView(props, ref) {
26
+ scroll.props = props;
27
+ useImperativeHandle(ref, () => ({ scrollTo: scroll.scrollTo }));
28
+ return <div data-testid="scroll">{props.children as ReactNode}</div>;
29
+ });
30
+ return {
31
+ ...rn,
32
+ Animated: {
33
+ ...rn.Animated,
34
+ ScrollView,
35
+ event: (_mapping: unknown, config: { listener: (e: unknown) => void }) => config.listener,
36
+ },
37
+ };
38
+ });
39
+
40
+ afterEach(() => {
41
+ cleanup();
42
+ scroll.props = null;
43
+ scroll.scrollTo.mockReset();
44
+ });
45
+
46
+ const hours = Array.from({ length: 24 }, (_, h) => ({
47
+ value: h,
48
+ label: String(h).padStart(2, "0"),
49
+ }));
50
+ const fire = (name: string, arg?: unknown) =>
51
+ (scroll.props?.[name] as ScrollHandler | undefined)?.(arg);
52
+ const at = (y: number) => ({ nativeEvent: { contentOffset: { x: 0, y } } });
53
+ const rowHeight = () => (scroll.props as unknown as { snapToInterval: number }).snapToInterval;
54
+
55
+ describe("native Wheel on a UIScrollView", () => {
56
+ it("snaps to whole rows with the platform's own momentum", () => {
57
+ renderWithProviders(<NativeWheel items={hours} value={9} />);
58
+ const props = scroll.props as unknown as Record<string, unknown>;
59
+ expect(props.snapToInterval).toBeGreaterThan(0);
60
+ expect(props.decelerationRate).toBe("normal");
61
+ expect(props.contentOffset).toEqual({ x: 0, y: 9 * rowHeight() });
62
+ });
63
+
64
+ it("sizes rows from the font's line height plus the $1 space token", () => {
65
+ renderWithProviders(<NativeWheel items={hours} value={9} size="$8" />);
66
+ expect(rowHeight()).toBe(Math.ceil(fontLineHeightPx("$8", "$body") + tokenPx("$1", "space")));
67
+ expect(rowHeight()).toBeGreaterThan(fontLineHeightPx("$8", "$body"));
68
+ });
69
+
70
+ it("reports exactly the row centred when a fling comes to rest", () => {
71
+ const onChange = vi.fn();
72
+ renderWithProviders(<NativeWheel items={hours} value={9} onChange={onChange} />);
73
+ const h = rowHeight();
74
+ act(() => {
75
+ fire("onScrollBeginDrag");
76
+ fire("onScroll", at(11.4 * h));
77
+ fire("onScrollEndDrag");
78
+ fire("onMomentumScrollBegin");
79
+ fire("onScroll", at(14 * h));
80
+ fire("onMomentumScrollEnd", at(14 * h));
81
+ });
82
+ expect(onChange).toHaveBeenCalledExactlyOnceWith(14);
83
+ });
84
+
85
+ it("seats a row the snap left between two rows before reporting it", () => {
86
+ const onChange = vi.fn();
87
+ renderWithProviders(<NativeWheel items={hours} value={9} onChange={onChange} />);
88
+ const h = rowHeight();
89
+ act(() => {
90
+ fire("onMomentumScrollEnd", at(5.6 * h));
91
+ });
92
+ expect(scroll.scrollTo).toHaveBeenCalledWith({ y: 6 * h, animated: expect.any(Boolean) });
93
+ expect(onChange).toHaveBeenCalledExactlyOnceWith(6);
94
+ });
95
+
96
+ it("fires onItemCross for each row that crosses the band", () => {
97
+ const onItemCross = vi.fn();
98
+ renderWithProviders(<NativeWheel items={hours} value={0} onItemCross={onItemCross} />);
99
+ const h = rowHeight();
100
+ act(() => {
101
+ fire("onScroll", at(0.6 * h));
102
+ fire("onScroll", at(1.2 * h));
103
+ fire("onScroll", at(1.6 * h));
104
+ });
105
+ expect(onItemCross.mock.calls).toEqual([
106
+ [1, 1],
107
+ [2, 2],
108
+ ]);
109
+ });
110
+
111
+ it("loops: a looping column lays out copies and reports the wrapped value", () => {
112
+ const onChange = vi.fn();
113
+ renderWithProviders(<NativeWheel items={hours} value={23} loop onChange={onChange} />);
114
+ const h = rowHeight();
115
+ const start = (scroll.props as unknown as { contentOffset: { y: number } }).contentOffset.y;
116
+ act(() => {
117
+ fire("onMomentumScrollEnd", at(start + h));
118
+ });
119
+ expect(onChange).toHaveBeenCalledExactlyOnceWith(0);
120
+ });
121
+
122
+ it("is one adjustable element named for the column", () => {
123
+ const { getByTestId } = renderWithProviders(
124
+ <NativeWheel items={hours} value={9} aria-label="Hour" />,
125
+ );
126
+ const wheel = getByTestId("wheel");
127
+ expect(wheel.getAttribute("role")).toBe("slider");
128
+ expect(wheel.getAttribute("aria-label")).toBe("Hour");
129
+ });
130
+
131
+ it("taps a row on the drum to bring it to the band", () => {
132
+ const onChange = vi.fn();
133
+ renderWithProviders(<NativeWheel items={hours} value={0} onChange={onChange} />);
134
+ const h = rowHeight();
135
+ const frame = h * 5;
136
+ const radius = frame / 2 / Math.sin((80 * Math.PI) / 180);
137
+ const twoBelow = frame / 2 + radius * Math.sin((2 * h) / radius);
138
+ act(() => {
139
+ fire("onTouchStart", { nativeEvent: { pageY: 500, locationY: twoBelow } });
140
+ fire("onTouchEnd", { nativeEvent: { pageY: 501, locationY: twoBelow } });
141
+ });
142
+ expect(scroll.scrollTo).toHaveBeenCalledWith({ y: 2 * h, animated: expect.any(Boolean) });
143
+ expect(onChange).toHaveBeenCalledExactlyOnceWith(2);
144
+ });
145
+
146
+ it("seats and reports the row when a touch stops a fling (no momentum end on iOS)", () => {
147
+ vi.useFakeTimers();
148
+ try {
149
+ const onChange = vi.fn();
150
+ renderWithProviders(<NativeWheel items={hours} value={9} onChange={onChange} />);
151
+ const h = rowHeight();
152
+ act(() => {
153
+ fire("onScrollBeginDrag");
154
+ fire("onScrollEndDrag");
155
+ fire("onMomentumScrollBegin");
156
+ fire("onScroll", at(15.3 * h));
157
+ fire("onTouchStart", { nativeEvent: { pageY: 400, locationY: 0 } });
158
+ fire("onTouchEnd", { nativeEvent: { pageY: 400, locationY: 0 } });
159
+ vi.advanceTimersByTime(500);
160
+ });
161
+ expect(scroll.scrollTo).toHaveBeenCalledWith({ y: 15 * h, animated: expect.any(Boolean) });
162
+ expect(onChange).toHaveBeenCalledExactlyOnceWith(15);
163
+ } finally {
164
+ vi.useRealTimers();
165
+ }
166
+ });
167
+
168
+ it("reseats a value-less column when its items shrink under it", () => {
169
+ const onChange = vi.fn();
170
+ const { rerender } = renderWithProviders(
171
+ <NativeWheel items={hours} value={20} onChange={onChange} />,
172
+ );
173
+ rerender(<NativeWheel items={hours.slice(0, 10)} value={20} onChange={onChange} />);
174
+ expect(onChange).toHaveBeenCalledExactlyOnceWith(9);
175
+ });
176
+
177
+ it("does not scroll while disabled", () => {
178
+ renderWithProviders(<NativeWheel items={hours} value={9} disabled />);
179
+ expect((scroll.props as unknown as { scrollEnabled: boolean }).scrollEnabled).toBe(false);
180
+ });
181
+ });
@@ -0,0 +1,430 @@
1
+ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import {
4
+ Animated,
5
+ type AccessibilityActionEvent,
6
+ type GestureResponderEvent,
7
+ type NativeScrollEvent,
8
+ type NativeSyntheticEvent,
9
+ View as RNView,
10
+ } from "react-native";
11
+ import { Text, View } from "tamagui";
12
+ import { t } from "../shared/t";
13
+ import { fireSelectionHaptic } from "./haptic";
14
+ import {
15
+ defaultColumnWidth,
16
+ drumPose,
17
+ drumRadius,
18
+ drumReachRows,
19
+ getItemLabel,
20
+ getItemValue,
21
+ modIndex,
22
+ nearestRowForDataIndex,
23
+ useWheelLayout,
24
+ type WheelProps,
25
+ } from "./wheelShared";
26
+
27
+ /** Rows a looping wheel lays out, so a fling never reaches the ends before it recentres. */
28
+ const loopRowBudget = 1200;
29
+ const tapSlopPx = 8;
30
+ const tapMaxMs = 300;
31
+ const settleFallbackMs = 120;
32
+ const poseStep = 0.25;
33
+
34
+ interface DrumRowProps {
35
+ row: number;
36
+ scrollY: Animated.Value;
37
+ itemHeight: number;
38
+ frameHeight: number;
39
+ padTop: number;
40
+ reach: number;
41
+ children: ReactNode;
42
+ }
43
+
44
+ /**
45
+ * One row on the drum. Its pose is a native-driver interpolation of the
46
+ * scroll offset, so rotateX, scale and opacity track the finger on the UI
47
+ * thread with no JS frame in between.
48
+ */
49
+ const DrumRow = memo(function DrumRow({
50
+ row,
51
+ scrollY,
52
+ itemHeight,
53
+ frameHeight,
54
+ padTop,
55
+ reach,
56
+ children,
57
+ }: DrumRowProps) {
58
+ const style = useMemo(() => {
59
+ const span = 2 * (Math.ceil(reach) + 1);
60
+ const input: number[] = [];
61
+ const translate: number[] = [];
62
+ const rotate: string[] = [];
63
+ const scale: number[] = [];
64
+ const opacity: number[] = [];
65
+ for (let d = span; d >= -span - 1e-9; d -= poseStep) {
66
+ const pose = drumPose(d, itemHeight, frameHeight);
67
+ input.push((row - d) * itemHeight);
68
+ translate.push(pose.offset - d * itemHeight);
69
+ rotate.push(`${pose.rotateX}deg`);
70
+ scale.push(pose.scale);
71
+ opacity.push(pose.opacity);
72
+ }
73
+ const range = (output: number[] | string[]) =>
74
+ scrollY.interpolate({ inputRange: input, outputRange: output, extrapolate: "clamp" });
75
+ return {
76
+ position: "absolute" as const,
77
+ left: 0,
78
+ right: 0,
79
+ top: padTop + row * itemHeight,
80
+ height: itemHeight,
81
+ alignItems: "center" as const,
82
+ justifyContent: "center" as const,
83
+ opacity: range(opacity),
84
+ transform: [
85
+ { perspective: frameHeight * 4 },
86
+ { translateY: range(translate) },
87
+ { rotateX: range(rotate) },
88
+ { scale: range(scale) },
89
+ ],
90
+ };
91
+ }, [row, scrollY, itemHeight, frameHeight, padTop, reach]);
92
+ return (
93
+ <Animated.View pointerEvents="none" style={style}>
94
+ {children}
95
+ </Animated.View>
96
+ );
97
+ });
98
+
99
+ /**
100
+ * UIPickerView on a real UIScrollView: the platform's own momentum, rubber
101
+ * band and paging (snapToInterval) drive the wheel, and nested inside a
102
+ * screen's ScrollView the inner one owns the drag, so the column never drags
103
+ * its page along with it.
104
+ */
105
+ export function NativeWheel<T = string | number>(props: WheelProps<T>) {
106
+ const {
107
+ items,
108
+ value,
109
+ onChange,
110
+ width: widthProp,
111
+ renderItem,
112
+ disabled = false,
113
+ loop = false,
114
+ flex,
115
+ onItemCross,
116
+ "aria-label": ariaLabel,
117
+ } = props;
118
+ const layout = useWheelLayout(props);
119
+ const { itemHeight, frameHeight, size, knobProps, snapDurationMs } = layout;
120
+ const n = items.length;
121
+ const loopEnabled = Boolean(loop && n > 1);
122
+ const copies = loopEnabled ? Math.max(3, Math.ceil(loopRowBudget / n)) | 1 : 1;
123
+ const totalRows = n * copies;
124
+ const middleBase = loopEnabled ? Math.floor(copies / 2) * n : 0;
125
+ const padTop = (frameHeight - itemHeight) / 2;
126
+ const reach = drumReachRows(itemHeight, frameHeight);
127
+ const columnWidth = widthProp ?? defaultColumnWidth;
128
+
129
+ const indexOfValue = useCallback(
130
+ (v: T | undefined) => (v === undefined ? -1 : items.findIndex((it) => getItemValue(it) === v)),
131
+ [items],
132
+ );
133
+
134
+ const initialIndex = Math.max(0, indexOfValue(value));
135
+ const initialRow = middleBase + initialIndex;
136
+ const scrollY = useRef(new Animated.Value(initialRow * itemHeight)).current;
137
+ // iOS shifts the live offset by any change to the contentOffset prop, which
138
+ // would add to an in-flight scrollTo, so the prop is fixed at mount.
139
+ const [initialOffset] = useState(() => ({ x: 0, y: initialRow * itemHeight }));
140
+ const scrollRef = useRef<{ scrollTo: (o: { y: number; animated?: boolean }) => void } | null>(
141
+ null,
142
+ );
143
+ const [centerRow, setCenterRow] = useState(initialRow);
144
+ const centerRowRef = useRef(initialRow);
145
+ const yRef = useRef(initialRow * itemHeight);
146
+ const committedIndexRef = useRef(initialIndex);
147
+ const [committedIndex, setCommittedIndex] = useState(initialIndex);
148
+ const programmaticRef = useRef(false);
149
+ const draggingRef = useRef(false);
150
+ const momentumRef = useRef(false);
151
+ const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
152
+ const touchRef = useRef<{ pageY: number; time: number; stoppedMotion: boolean } | null>(null);
153
+ const dragBeganRef = useRef(false);
154
+ const onChangeRef = useRef(onChange);
155
+ onChangeRef.current = onChange;
156
+ const onItemCrossRef = useRef(onItemCross);
157
+ onItemCrossRef.current = onItemCross;
158
+
159
+ const scrollToRow = useCallback(
160
+ (row: number, animated: boolean) => {
161
+ const y = row * itemHeight;
162
+ programmaticRef.current = animated && Math.abs(y - yRef.current) > 0.5;
163
+ if (!animated) {
164
+ yRef.current = y;
165
+ scrollY.setValue(y);
166
+ centerRowRef.current = row;
167
+ setCenterRow(row);
168
+ }
169
+ scrollRef.current?.scrollTo({ y, animated });
170
+ },
171
+ [itemHeight, scrollY],
172
+ );
173
+
174
+ const commit = useCallback(
175
+ (dataIndex: number) => {
176
+ if (n === 0 || dataIndex === committedIndexRef.current) return;
177
+ committedIndexRef.current = dataIndex;
178
+ setCommittedIndex(dataIndex);
179
+ const item = items[dataIndex];
180
+ if (item !== undefined) onChangeRef.current?.(getItemValue(item));
181
+ },
182
+ [items, n],
183
+ );
184
+
185
+ /** Called once motion stops: seat the nearest row exactly, then report it. */
186
+ const settle = useCallback(() => {
187
+ if (n === 0 || draggingRef.current) return;
188
+ programmaticRef.current = false;
189
+ const y = yRef.current;
190
+ const row = Math.max(0, Math.min(totalRows - 1, Math.round(y / itemHeight)));
191
+ const dataIndex = modIndex(row, n);
192
+ if (Math.abs(y - row * itemHeight) > 0.5) {
193
+ scrollToRow(row, snapDurationMs > 0);
194
+ } else if (loopEnabled && Math.abs(row - (middleBase + dataIndex)) >= n) {
195
+ scrollToRow(middleBase + dataIndex, false);
196
+ }
197
+ commit(dataIndex);
198
+ }, [n, totalRows, itemHeight, scrollToRow, snapDurationMs, loopEnabled, middleBase, commit]);
199
+
200
+ const clearSettleTimer = () => {
201
+ if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
202
+ settleTimerRef.current = null;
203
+ };
204
+ useEffect(() => clearSettleTimer, []);
205
+
206
+ const onScroll = useMemo(
207
+ () =>
208
+ Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
209
+ useNativeDriver: true,
210
+ listener: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
211
+ const y = e.nativeEvent.contentOffset.y;
212
+ yRef.current = y;
213
+ const row = Math.round(y / itemHeight);
214
+ if (row === centerRowRef.current) return;
215
+ centerRowRef.current = row;
216
+ setCenterRow(row);
217
+ if (row < 0 || row >= totalRows || n === 0 || programmaticRef.current) return;
218
+ const dataIndex = modIndex(row, n);
219
+ const item = items[dataIndex];
220
+ if (item === undefined) return;
221
+ onItemCrossRef.current?.(getItemValue(item), dataIndex);
222
+ fireSelectionHaptic();
223
+ },
224
+ }),
225
+ [scrollY, itemHeight, totalRows, n, items],
226
+ );
227
+
228
+ useEffect(() => {
229
+ const index = indexOfValue(value);
230
+ if (index < 0 || draggingRef.current || momentumRef.current) return;
231
+ if (index === committedIndexRef.current && modIndex(centerRowRef.current, n) === index) return;
232
+ committedIndexRef.current = index;
233
+ setCommittedIndex(index);
234
+ const row = loopEnabled ? nearestRowForDataIndex(centerRowRef.current, index, n) : index;
235
+ scrollToRow(row, snapDurationMs > 0);
236
+ }, [value, indexOfValue, loopEnabled, n, scrollToRow, snapDurationMs]);
237
+
238
+ const seatedHeightRef = useRef(itemHeight);
239
+ useLayoutEffect(() => {
240
+ if (seatedHeightRef.current === itemHeight) return;
241
+ seatedHeightRef.current = itemHeight;
242
+ scrollToRow(centerRowRef.current, false);
243
+ }, [itemHeight, scrollToRow]);
244
+
245
+ const seatedCountRef = useRef(n);
246
+ useLayoutEffect(() => {
247
+ if (seatedCountRef.current === n || n === 0) return;
248
+ seatedCountRef.current = n;
249
+ const index = indexOfValue(value);
250
+ const dataIndex = index >= 0 ? index : Math.min(committedIndexRef.current, n - 1);
251
+ scrollToRow(middleBase + dataIndex, false);
252
+ commit(dataIndex);
253
+ }, [n, value, indexOfValue, middleBase, scrollToRow, commit]);
254
+
255
+ const step = useCallback(
256
+ (delta: number) => {
257
+ if (n === 0 || disabled) return;
258
+ const current = committedIndexRef.current;
259
+ const next = loopEnabled
260
+ ? modIndex(current + delta, n)
261
+ : Math.max(0, Math.min(n - 1, current + delta));
262
+ if (next === current) return;
263
+ const row = loopEnabled ? nearestRowForDataIndex(centerRowRef.current, next, n) : next;
264
+ scrollToRow(row, snapDurationMs > 0);
265
+ commit(next);
266
+ },
267
+ [n, disabled, loopEnabled, scrollToRow, snapDurationMs, commit],
268
+ );
269
+
270
+ const onAccessibilityAction = useCallback(
271
+ (e: AccessibilityActionEvent) => {
272
+ if (e.nativeEvent.actionName === "increment") step(1);
273
+ if (e.nativeEvent.actionName === "decrement") step(-1);
274
+ },
275
+ [step],
276
+ );
277
+
278
+ const onTouchStart = useCallback((e: GestureResponderEvent) => {
279
+ // A touch that stops a fling never gets onMomentumScrollEnd on iOS.
280
+ const stoppedMotion = momentumRef.current || programmaticRef.current;
281
+ momentumRef.current = false;
282
+ dragBeganRef.current = false;
283
+ clearSettleTimer();
284
+ touchRef.current = { pageY: e.nativeEvent.pageY, time: Date.now(), stoppedMotion };
285
+ }, []);
286
+
287
+ const onTouchEnd = useCallback(
288
+ (e: GestureResponderEvent) => {
289
+ const touch = touchRef.current;
290
+ touchRef.current = null;
291
+ if (!touch || disabled || n === 0 || dragBeganRef.current) return;
292
+ const isTap =
293
+ Math.abs(e.nativeEvent.pageY - touch.pageY) <= tapSlopPx &&
294
+ Date.now() - touch.time <= tapMaxMs;
295
+ if (touch.stoppedMotion || !isTap) {
296
+ settleTimerRef.current = setTimeout(() => {
297
+ if (!momentumRef.current && !draggingRef.current) settle();
298
+ }, settleFallbackMs);
299
+ return;
300
+ }
301
+ const radius = drumRadius(frameHeight);
302
+ // Rows ignore touches, so locationY is in the content view's space.
303
+ const fromCenter = e.nativeEvent.locationY - yRef.current - frameHeight / 2;
304
+ const theta = Math.asin(Math.max(-1, Math.min(1, fromCenter / radius)));
305
+ const rows = Math.round((theta * radius) / itemHeight);
306
+ if (rows === 0) return;
307
+ const row = Math.max(0, Math.min(totalRows - 1, centerRowRef.current + rows));
308
+ scrollToRow(row, snapDurationMs > 0);
309
+ commit(modIndex(row, n));
310
+ },
311
+ [disabled, n, frameHeight, itemHeight, totalRows, scrollToRow, snapDurationMs, commit, settle],
312
+ );
313
+
314
+ const span = Math.ceil(reach) + 1;
315
+ const rows: number[] = [];
316
+ for (let r = centerRow - span; r <= centerRow + span; r++) {
317
+ if (r >= 0 && r < totalRows) rows.push(r);
318
+ }
319
+
320
+ const committedItem = items[committedIndex];
321
+
322
+ return (
323
+ <RNView
324
+ style={{
325
+ height: frameHeight,
326
+ width: flex ? "100%" : columnWidth,
327
+ minWidth: flex ? 0 : columnWidth,
328
+ flex,
329
+ position: "relative",
330
+ overflow: "hidden",
331
+ }}
332
+ testID="wheel"
333
+ accessible
334
+ accessibilityRole="adjustable"
335
+ accessibilityLabel={ariaLabel ?? t("Value picker")}
336
+ accessibilityState={{ disabled }}
337
+ accessibilityValue={{
338
+ text: committedItem !== undefined ? getItemLabel(committedItem) : undefined,
339
+ }}
340
+ accessibilityActions={disabled ? [] : [{ name: "increment" }, { name: "decrement" }]}
341
+ onAccessibilityAction={onAccessibilityAction}
342
+ >
343
+ <View
344
+ className={layout.highlightClassName}
345
+ position="absolute"
346
+ left={0}
347
+ right={0}
348
+ top={padTop}
349
+ height={itemHeight}
350
+ backgroundColor="$color4"
351
+ pointerEvents="none"
352
+ style={layout.highlightStyle as never}
353
+ data-testid="wheel-selection"
354
+ />
355
+ <Animated.ScrollView
356
+ ref={scrollRef as never}
357
+ testID="wheel-wrapper"
358
+ style={{ flex: 1 }}
359
+ contentOffset={initialOffset}
360
+ showsVerticalScrollIndicator={false}
361
+ scrollEnabled={!disabled && n > 0}
362
+ nestedScrollEnabled
363
+ snapToInterval={itemHeight}
364
+ decelerationRate="normal"
365
+ scrollEventThrottle={16}
366
+ onScroll={onScroll}
367
+ onTouchStart={onTouchStart}
368
+ onTouchEnd={onTouchEnd}
369
+ onScrollBeginDrag={() => {
370
+ clearSettleTimer();
371
+ draggingRef.current = true;
372
+ dragBeganRef.current = true;
373
+ programmaticRef.current = false;
374
+ }}
375
+ onScrollEndDrag={() => {
376
+ draggingRef.current = false;
377
+ clearSettleTimer();
378
+ settleTimerRef.current = setTimeout(() => {
379
+ if (!momentumRef.current) settle();
380
+ }, settleFallbackMs);
381
+ }}
382
+ onMomentumScrollBegin={() => {
383
+ clearSettleTimer();
384
+ momentumRef.current = true;
385
+ }}
386
+ onMomentumScrollEnd={(e: NativeSyntheticEvent<NativeScrollEvent>) => {
387
+ momentumRef.current = false;
388
+ yRef.current = e.nativeEvent.contentOffset.y;
389
+ settle();
390
+ }}
391
+ >
392
+ <RNView style={{ height: totalRows * itemHeight + padTop * 2 }}>
393
+ {rows.map((r) => {
394
+ const dataIndex = modIndex(r, n);
395
+ const item = items[dataIndex];
396
+ const isSelected = r === centerRow;
397
+ return (
398
+ <DrumRow
399
+ key={r}
400
+ row={r}
401
+ scrollY={scrollY}
402
+ itemHeight={itemHeight}
403
+ frameHeight={frameHeight}
404
+ padTop={padTop}
405
+ reach={reach}
406
+ >
407
+ {renderItem ? (
408
+ renderItem(getItemValue(item), dataIndex, isSelected)
409
+ ) : (
410
+ <Text
411
+ {...knobProps.body}
412
+ fontSize={size}
413
+ color={disabled ? "$color10" : isSelected ? "$color12" : "$color11"}
414
+ numberOfLines={1}
415
+ paddingHorizontal="$2"
416
+ data-testid="wheel-item"
417
+ data-index={dataIndex}
418
+ data-selected={isSelected ? true : undefined}
419
+ >
420
+ {getItemLabel(item)}
421
+ </Text>
422
+ )}
423
+ </DrumRow>
424
+ );
425
+ })}
426
+ </RNView>
427
+ </Animated.ScrollView>
428
+ </RNView>
429
+ );
430
+ }
@@ -0,0 +1,6 @@
1
+ import type { WheelProps } from "./wheelShared";
2
+
3
+ /** Web paints the wheel itself (index.tsx); the scroll-view drum is native only. */
4
+ export function NativeWheel<T = string | number>(_props: WheelProps<T>) {
5
+ return null;
6
+ }
@@ -1,15 +1,16 @@
1
1
  import { isWeb } from "tamagui";
2
2
 
3
+ type HapticsModule = { selectionAsync?: () => Promise<void> };
4
+
3
5
  /**
4
- * iOS UIPickerView uses UISelectionFeedbackGenerator, not impact. Optional
5
- * so web / tests / apps without expo-haptics stay silent.
6
+ * iOS UIPickerView ticks with UISelectionFeedbackGenerator. The native module
7
+ * is looked up in the Expo registry instead of importing expo-haptics, so a
8
+ * binary built without it (the Kitchen Sink) stays silent and the wheel still
9
+ * ships over the air.
6
10
  */
7
11
  export function fireSelectionHaptic(): void {
8
12
  if (isWeb) return;
9
- try {
10
- const loaded = require("expo-haptics") as { selectionAsync?: () => Promise<void> };
11
- void loaded.selectionAsync?.();
12
- } catch {
13
- // optional peer
14
- }
13
+ const registry = (globalThis as { expo?: { modules?: Record<string, unknown> } }).expo?.modules;
14
+ const haptics = registry?.ExpoHaptics as HapticsModule | undefined;
15
+ void haptics?.selectionAsync?.()?.catch(() => {});
15
16
  }