@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.
- package/dist/cjs/Wheel/{WheelEdgeFade.cjs → NativeWheel.cjs} +5 -5
- package/dist/cjs/Wheel/NativeWheel.native.js +408 -0
- package/dist/cjs/Wheel/NativeWheel.native.js.map +1 -0
- package/dist/cjs/Wheel/haptic.cjs +3 -4
- package/dist/cjs/Wheel/haptic.native.js +4 -5
- package/dist/cjs/Wheel/haptic.native.js.map +1 -1
- package/dist/cjs/Wheel/index.cjs +62 -218
- package/dist/cjs/Wheel/index.native.js +54 -228
- package/dist/cjs/Wheel/index.native.js.map +1 -1
- package/dist/cjs/Wheel/wheelShared.cjs +156 -0
- package/dist/cjs/Wheel/wheelShared.native.js +169 -0
- package/dist/cjs/Wheel/wheelShared.native.js.map +1 -0
- package/dist/cjs/shared/TimeColumn.cjs +0 -9
- package/dist/cjs/shared/TimeColumn.native.js +0 -9
- package/dist/cjs/shared/TimeColumn.native.js.map +1 -1
- package/dist/esm/Wheel/NativeWheel.mjs +5 -0
- package/dist/esm/Wheel/NativeWheel.mjs.map +1 -0
- package/dist/esm/Wheel/NativeWheel.native.js +380 -0
- package/dist/esm/Wheel/NativeWheel.native.js.map +1 -0
- package/dist/esm/Wheel/haptic.mjs +3 -4
- package/dist/esm/Wheel/haptic.mjs.map +1 -1
- package/dist/esm/Wheel/haptic.native.js +4 -5
- package/dist/esm/Wheel/haptic.native.js.map +1 -1
- package/dist/esm/Wheel/index.mjs +49 -205
- package/dist/esm/Wheel/index.mjs.map +1 -1
- package/dist/esm/Wheel/index.native.js +41 -215
- package/dist/esm/Wheel/index.native.js.map +1 -1
- package/dist/esm/Wheel/wheelShared.mjs +119 -0
- package/dist/esm/Wheel/wheelShared.mjs.map +1 -0
- package/dist/esm/Wheel/wheelShared.native.js +129 -0
- package/dist/esm/Wheel/wheelShared.native.js.map +1 -0
- package/dist/esm/shared/TimeColumn.mjs +0 -9
- package/dist/esm/shared/TimeColumn.mjs.map +1 -1
- package/dist/esm/shared/TimeColumn.native.js +0 -9
- package/dist/esm/shared/TimeColumn.native.js.map +1 -1
- package/dist/jsx/Wheel/NativeWheel.mjs +5 -0
- package/dist/jsx/Wheel/NativeWheel.mjs.map +1 -0
- package/dist/jsx/Wheel/NativeWheel.native.js +408 -0
- package/dist/jsx/Wheel/NativeWheel.native.js.map +1 -0
- package/dist/jsx/Wheel/haptic.mjs +3 -4
- package/dist/jsx/Wheel/haptic.mjs.map +1 -1
- package/dist/jsx/Wheel/haptic.native.js +4 -5
- package/dist/jsx/Wheel/haptic.native.js.map +1 -1
- package/dist/jsx/Wheel/index.mjs +49 -205
- package/dist/jsx/Wheel/index.mjs.map +1 -1
- package/dist/jsx/Wheel/index.native.js +54 -228
- package/dist/jsx/Wheel/index.native.js.map +1 -1
- package/dist/jsx/Wheel/wheelShared.mjs +119 -0
- package/dist/jsx/Wheel/wheelShared.mjs.map +1 -0
- package/dist/jsx/Wheel/wheelShared.native.js +169 -0
- package/dist/jsx/Wheel/wheelShared.native.js.map +1 -0
- package/dist/jsx/shared/TimeColumn.mjs +0 -9
- package/dist/jsx/shared/TimeColumn.mjs.map +1 -1
- package/dist/jsx/shared/TimeColumn.native.js +0 -9
- package/dist/jsx/shared/TimeColumn.native.js.map +1 -1
- package/package.json +8 -8
- package/src/Wheel/NativeWheel.native.spec.tsx +181 -0
- package/src/Wheel/NativeWheel.native.tsx +430 -0
- package/src/Wheel/NativeWheel.tsx +6 -0
- package/src/Wheel/haptic.ts +9 -8
- package/src/Wheel/index.tsx +121 -363
- package/src/Wheel/wheelShared.spec.ts +53 -0
- package/src/Wheel/wheelShared.ts +222 -0
- package/src/shared/TimeColumn.tsx +0 -7
- package/types/Wheel/NativeWheel.d.ts +4 -0
- package/types/Wheel/NativeWheel.d.ts.map +1 -0
- package/types/Wheel/NativeWheel.native.d.ts +9 -0
- package/types/Wheel/NativeWheel.native.d.ts.map +1 -0
- package/types/Wheel/haptic.d.ts +4 -2
- package/types/Wheel/haptic.d.ts.map +1 -1
- package/types/Wheel/index.d.ts +4 -49
- package/types/Wheel/index.d.ts.map +1 -1
- package/types/Wheel/wheelShared.d.ts +87 -0
- package/types/Wheel/wheelShared.d.ts.map +1 -0
- package/types/shared/TimeColumn.d.ts.map +1 -1
- package/dist/cjs/Wheel/WheelEdgeFade.native.js +0 -116
- package/dist/cjs/Wheel/WheelEdgeFade.native.js.map +0 -1
- package/dist/esm/Wheel/WheelEdgeFade.mjs +0 -5
- package/dist/esm/Wheel/WheelEdgeFade.mjs.map +0 -1
- package/dist/esm/Wheel/WheelEdgeFade.native.js +0 -88
- package/dist/esm/Wheel/WheelEdgeFade.native.js.map +0 -1
- package/dist/jsx/Wheel/WheelEdgeFade.mjs +0 -5
- package/dist/jsx/Wheel/WheelEdgeFade.mjs.map +0 -1
- package/dist/jsx/Wheel/WheelEdgeFade.native.js +0 -116
- package/dist/jsx/Wheel/WheelEdgeFade.native.js.map +0 -1
- package/src/Wheel/WheelEdgeFade.native.tsx +0 -67
- package/src/Wheel/WheelEdgeFade.spec.tsx +0 -141
- package/src/Wheel/WheelEdgeFade.tsx +0 -7
- package/types/Wheel/WheelEdgeFade.d.ts +0 -8
- package/types/Wheel/WheelEdgeFade.d.ts.map +0 -1
- package/types/Wheel/WheelEdgeFade.native.d.ts +0 -8
- package/types/Wheel/WheelEdgeFade.native.d.ts.map +0 -1
package/src/Wheel/index.tsx
CHANGED
|
@@ -1,63 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { CSSProperties, ReactNode } from "react";
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
3
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import type { GetProps, SizeTokens } from "tamagui";
|
|
7
|
-
import { View, getTokens, isWeb, styled, Text } from "tamagui";
|
|
3
|
+
import type { GetProps } from "tamagui";
|
|
4
|
+
import { View, isWeb, styled, Text } from "tamagui";
|
|
8
5
|
import { t } from "../shared/t";
|
|
9
6
|
import { fireSelectionHaptic } from "./haptic";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
items: (T | WheelItem<T>)[];
|
|
25
|
-
/** Currently selected value */
|
|
26
|
-
value?: T;
|
|
27
|
-
/** Called when selection changes */
|
|
28
|
-
onChange?: (value: T) => void;
|
|
29
|
-
/** Number of visible items (should be odd for center alignment) */
|
|
30
|
-
visibleItems?: 3 | 5 | 7;
|
|
31
|
-
/** Height of each item */
|
|
32
|
-
itemHeight?: number;
|
|
33
|
-
/** Column width in px. Items are absolutely positioned, so this is the painted width. */
|
|
34
|
-
width?: number;
|
|
35
|
-
/** Custom render function for items */
|
|
36
|
-
renderItem?: (item: T, index: number, isSelected: boolean) => ReactNode;
|
|
37
|
-
/** Whether the wheel is disabled */
|
|
38
|
-
disabled?: boolean;
|
|
39
|
-
/** Size token for text */
|
|
40
|
-
size?: SizeTokens;
|
|
41
|
-
/**
|
|
42
|
-
* When true (and there are at least 2 items), scrolling wraps at the ends
|
|
43
|
-
* instead of rubber-banding. Uses virtual rows with modulo item indexing.
|
|
44
|
-
*/
|
|
45
|
-
loop?: boolean;
|
|
46
|
-
/**
|
|
47
|
-
* How the iOS selection fill rounds and insets. `"all"` is a standalone
|
|
48
|
-
* column; `"start"` / `"end"` / `"none"` join adjacent columns into one band.
|
|
49
|
-
*/
|
|
50
|
-
highlightEdges?: WheelHighlightEdges;
|
|
51
|
-
/** Stretch inside a row of columns (time picker). */
|
|
52
|
-
flex?: number;
|
|
53
|
-
/**
|
|
54
|
-
* Fires once each time a different item crosses the centre band (drag,
|
|
55
|
-
* flick, or snap). Native wires this to a selection haptic.
|
|
56
|
-
*/
|
|
57
|
-
onItemCross?: (value: T, index: number) => void;
|
|
58
|
-
/** Accessible name for the picker (web renders role="slider"). */
|
|
59
|
-
"aria-label"?: string;
|
|
60
|
-
}
|
|
7
|
+
import { NativeWheel } from "./NativeWheel";
|
|
8
|
+
import {
|
|
9
|
+
defaultColumnWidth,
|
|
10
|
+
drumPose,
|
|
11
|
+
drumReachRows,
|
|
12
|
+
getItemLabel,
|
|
13
|
+
getItemValue,
|
|
14
|
+
modIndex,
|
|
15
|
+
nearestRowForDataIndex,
|
|
16
|
+
useWheelLayout,
|
|
17
|
+
type WheelProps,
|
|
18
|
+
} from "./wheelShared";
|
|
19
|
+
|
|
20
|
+
export type { WheelHighlightEdges, WheelItem, WheelProps } from "./wheelShared";
|
|
61
21
|
|
|
62
22
|
// ── iOS Physics Constants ─────────────────────────────────────
|
|
63
23
|
// Based on Apple's UIScrollView deceleration behavior
|
|
@@ -74,22 +34,6 @@ type WheelPointer = {
|
|
|
74
34
|
locationY?: number;
|
|
75
35
|
};
|
|
76
36
|
|
|
77
|
-
/**
|
|
78
|
-
* Flutter CupertinoPicker / UIPickerView cylinder. diameterRatio 1.07 and
|
|
79
|
-
* squeeze 1.45 are the Cupertino defaults — a shallow drum, not a 70° fan.
|
|
80
|
-
*/
|
|
81
|
-
const diameterRatio = 1.07;
|
|
82
|
-
const squeeze = 1.45;
|
|
83
|
-
const perspectivePx = 400;
|
|
84
|
-
const defaultColumnWidth = 88;
|
|
85
|
-
|
|
86
|
-
/** Type scale for wheel numerals — iOS UIPickerView is ~21pt at default size. */
|
|
87
|
-
const wheelFontByStep: Record<-1 | 0 | 1, SizeTokens> = {
|
|
88
|
-
[-1]: "$6",
|
|
89
|
-
0: "$8",
|
|
90
|
-
1: "$9",
|
|
91
|
-
};
|
|
92
|
-
|
|
93
37
|
// The interactive wrapper is a raw <div>, so the non-removable focus-visible
|
|
94
38
|
// ring (W12 / DG-A11Y-01 / LC-71: 2px solid $outlineColor) has to come from
|
|
95
39
|
// an injected stylesheet — inline styles cannot express :focus-visible.
|
|
@@ -143,29 +87,6 @@ const SelectionIndicator = styled(View, {
|
|
|
143
87
|
|
|
144
88
|
// ── Helper functions ──────────────────────────────────────────
|
|
145
89
|
|
|
146
|
-
function normalizeItem<T>(item: T | WheelItem<T>): WheelItem<T> {
|
|
147
|
-
if (typeof item === "object" && item !== null && "value" in item) {
|
|
148
|
-
return item as WheelItem<T>;
|
|
149
|
-
}
|
|
150
|
-
return { value: item as T, label: String(item) };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function getItemLabel<T>(item: T | WheelItem<T>): string {
|
|
154
|
-
const normalized = normalizeItem(item);
|
|
155
|
-
return normalized.label ?? String(normalized.value);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function getItemValue<T>(item: T | WheelItem<T>): T {
|
|
159
|
-
const normalized = normalizeItem(item);
|
|
160
|
-
return normalized.value;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Non-negative modulo (JavaScript % can be negative for negative lhs). */
|
|
164
|
-
function modIndex(row: number, n: number): number {
|
|
165
|
-
if (n <= 0) return 0;
|
|
166
|
-
return ((row % n) + n) % n;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
90
|
/**
|
|
170
91
|
* Scroll offset that shows the same item as `targetOffset` (same row mod n), chosen so its
|
|
171
92
|
* value is closest to `referenceOffset` — shortest motion when animating from `referenceOffset`.
|
|
@@ -185,15 +106,6 @@ function equivalentOffsetNear(
|
|
|
185
106
|
return base + k * span;
|
|
186
107
|
}
|
|
187
108
|
|
|
188
|
-
/** Shortest steps on the item circle from current row to target data index. */
|
|
189
|
-
function nearestRowForDataIndex(currentRow: number, targetDataIndex: number, n: number): number {
|
|
190
|
-
const currentMod = modIndex(currentRow, n);
|
|
191
|
-
let delta = targetDataIndex - currentMod;
|
|
192
|
-
if (delta > n / 2) delta -= n;
|
|
193
|
-
if (delta < -n / 2) delta += n;
|
|
194
|
-
return currentRow + delta;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
109
|
// Rubber-band effect for over-scrolling (iOS style)
|
|
198
110
|
function rubberBand(offset: number, dimension: number): number {
|
|
199
111
|
const c = rubberBandConstant;
|
|
@@ -203,9 +115,9 @@ function rubberBand(offset: number, dimension: number): number {
|
|
|
203
115
|
}
|
|
204
116
|
|
|
205
117
|
/**
|
|
206
|
-
* Project a row onto the
|
|
207
|
-
*
|
|
208
|
-
*
|
|
118
|
+
* Project a row onto the UIPickerView drum (wheelShared drumPose). Row opacity
|
|
119
|
+
* stays 1 on web: the viewport mask fades the edges, so row text keeps the AA
|
|
120
|
+
* $color11 ramp.
|
|
209
121
|
*/
|
|
210
122
|
function getItemTransform(
|
|
211
123
|
index: number,
|
|
@@ -213,78 +125,39 @@ function getItemTransform(
|
|
|
213
125
|
itemHeight: number,
|
|
214
126
|
containerHeight: number,
|
|
215
127
|
): { translateY: number; rotateX: number; scale: number; opacity: number } {
|
|
216
|
-
const
|
|
217
|
-
const angle = (index * itemHeight - scrollOffset) / squeeze / Math.max(radius, 1);
|
|
218
|
-
const clamped = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, angle));
|
|
219
|
-
const centerTop = (containerHeight - itemHeight) / 2;
|
|
220
|
-
const translateY = centerTop + radius * Math.sin(clamped);
|
|
221
|
-
const rotateX = -(clamped * 180) / Math.PI;
|
|
222
|
-
const scale = Math.max(0.72, Math.min(1, Math.cos(clamped)));
|
|
128
|
+
const pose = drumPose(index - scrollOffset / itemHeight, itemHeight, containerHeight);
|
|
223
129
|
return {
|
|
224
|
-
translateY,
|
|
225
|
-
rotateX,
|
|
226
|
-
scale,
|
|
130
|
+
translateY: (containerHeight - itemHeight) / 2 + pose.offset,
|
|
131
|
+
rotateX: pose.rotateX,
|
|
132
|
+
scale: pose.scale,
|
|
227
133
|
opacity: 1,
|
|
228
134
|
};
|
|
229
135
|
}
|
|
230
136
|
|
|
231
|
-
|
|
232
|
-
if (typeof token === "number") return token;
|
|
233
|
-
const key = String(token).replace(/^\$/, "");
|
|
234
|
-
const val = (getTokens() as { [k: string]: Record<string, { val?: number }> })[scale]?.[key]?.val;
|
|
235
|
-
if (typeof val === "number") return val;
|
|
236
|
-
return scale === "radius" ? 9 : 8;
|
|
237
|
-
}
|
|
137
|
+
// ── Wheel Component ───────────────────────────────────────────
|
|
238
138
|
|
|
239
|
-
function
|
|
240
|
-
|
|
241
|
-
radiusToken: string | number,
|
|
242
|
-
pointy: boolean,
|
|
243
|
-
): CSSProperties {
|
|
244
|
-
const r = pointy ? 0 : tokenPx(radiusToken, "radius");
|
|
245
|
-
const inset = tokenPx("$2", "space");
|
|
246
|
-
const capStart = edges === "all" || edges === "start";
|
|
247
|
-
const capEnd = edges === "all" || edges === "end";
|
|
248
|
-
return {
|
|
249
|
-
borderStartStartRadius: capStart ? r : 0,
|
|
250
|
-
borderStartEndRadius: capEnd ? r : 0,
|
|
251
|
-
borderEndStartRadius: capStart ? r : 0,
|
|
252
|
-
borderEndEndRadius: capEnd ? r : 0,
|
|
253
|
-
marginInlineStart: capStart ? inset : 0,
|
|
254
|
-
marginInlineEnd: capEnd ? inset : 0,
|
|
255
|
-
};
|
|
139
|
+
export function Wheel<T = string | number>(props: WheelProps<T>) {
|
|
140
|
+
return isWeb ? <WebWheel {...props} /> : <NativeWheel {...props} />;
|
|
256
141
|
}
|
|
257
142
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
"aria-label": ariaLabel,
|
|
275
|
-
}: WheelProps<T>) {
|
|
276
|
-
const { knobProps } = useResolvedKnobs();
|
|
277
|
-
const size = sizeProp ?? wheelFontByStep[sizeTokenStep(knobProps.sizeToken)];
|
|
278
|
-
// Row geometry rides nestedControl (density-aware, 32px at medium — iOS
|
|
279
|
-
// UIPickerView item extent). Explicit itemHeight ejects.
|
|
280
|
-
const itemHeight = itemHeightProp ?? knobProps.nestedControl.px ?? 32;
|
|
143
|
+
function WebWheel<T = string | number>(props: WheelProps<T>) {
|
|
144
|
+
const {
|
|
145
|
+
items,
|
|
146
|
+
value,
|
|
147
|
+
onChange,
|
|
148
|
+
visibleItems = 5,
|
|
149
|
+
width: widthProp,
|
|
150
|
+
renderItem,
|
|
151
|
+
disabled = false,
|
|
152
|
+
loop = false,
|
|
153
|
+
flex,
|
|
154
|
+
onItemCross,
|
|
155
|
+
"aria-label": ariaLabel,
|
|
156
|
+
} = props;
|
|
157
|
+
const { knobProps, size, itemHeight, snapDurationMs, highlightStyle, highlightClassName } =
|
|
158
|
+
useWheelLayout(props);
|
|
281
159
|
const columnWidth = widthProp ?? defaultColumnWidth;
|
|
282
160
|
ensureWheelFocusStyles();
|
|
283
|
-
// LC-25: discrete snaps (keyboard / controlled value) tween; drag stays 1:1.
|
|
284
|
-
// knobProps.motion.control.transition is undefined at animation "none" (which also carries
|
|
285
|
-
// prefers-reduced-motion) — snaps must JUMP then, not fall back to a tween.
|
|
286
|
-
const motionTransition = knobProps.motion.control.transition as string | undefined;
|
|
287
|
-
const snapDurationMs = motionDurationMs(motionTransition);
|
|
288
161
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
289
162
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
290
163
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
@@ -640,51 +513,6 @@ export function Wheel<T = string | number>({
|
|
|
640
513
|
[snapToNearestRow, startMomentum, hitTestDataIndex, snapToIndex, disabled],
|
|
641
514
|
);
|
|
642
515
|
|
|
643
|
-
const disabledRef = useRef(disabled);
|
|
644
|
-
disabledRef.current = disabled;
|
|
645
|
-
const pointerDownRef = useRef(handlePointerDown);
|
|
646
|
-
pointerDownRef.current = handlePointerDown;
|
|
647
|
-
const pointerMoveRef = useRef(handlePointerMove);
|
|
648
|
-
pointerMoveRef.current = handlePointerMove;
|
|
649
|
-
const pointerUpRef = useRef(handlePointerUp);
|
|
650
|
-
pointerUpRef.current = handlePointerUp;
|
|
651
|
-
|
|
652
|
-
const panResponder = useMemo(() => {
|
|
653
|
-
if (isWeb) return { panHandlers: {} as Record<string, unknown> };
|
|
654
|
-
return PanResponder.create({
|
|
655
|
-
onStartShouldSetPanResponder: () => !disabledRef.current,
|
|
656
|
-
onMoveShouldSetPanResponder: (_, gesture) => Math.abs(gesture.dy) > 1,
|
|
657
|
-
onPanResponderTerminationRequest: () => false,
|
|
658
|
-
onShouldBlockNativeResponder: () => true,
|
|
659
|
-
onPanResponderGrant: (evt: GestureResponderEvent) => {
|
|
660
|
-
pointerDownRef.current({
|
|
661
|
-
clientY: evt.nativeEvent.pageY,
|
|
662
|
-
pointerId: 1,
|
|
663
|
-
});
|
|
664
|
-
},
|
|
665
|
-
onPanResponderMove: (evt: GestureResponderEvent) => {
|
|
666
|
-
pointerMoveRef.current({
|
|
667
|
-
clientY: evt.nativeEvent.pageY,
|
|
668
|
-
pointerId: 1,
|
|
669
|
-
});
|
|
670
|
-
},
|
|
671
|
-
onPanResponderRelease: (evt: GestureResponderEvent) => {
|
|
672
|
-
pointerUpRef.current({
|
|
673
|
-
clientY: evt.nativeEvent.pageY,
|
|
674
|
-
pointerId: 1,
|
|
675
|
-
locationY: evt.nativeEvent.locationY,
|
|
676
|
-
});
|
|
677
|
-
},
|
|
678
|
-
onPanResponderTerminate: (evt: GestureResponderEvent) => {
|
|
679
|
-
pointerUpRef.current({
|
|
680
|
-
clientY: evt.nativeEvent.pageY,
|
|
681
|
-
pointerId: 1,
|
|
682
|
-
locationY: evt.nativeEvent.locationY,
|
|
683
|
-
});
|
|
684
|
-
},
|
|
685
|
-
});
|
|
686
|
-
}, []);
|
|
687
|
-
|
|
688
516
|
// Handle mouse wheel
|
|
689
517
|
const handleWheel = useCallback(
|
|
690
518
|
(e: React.WheelEvent | WheelEvent) => {
|
|
@@ -797,36 +625,20 @@ export function Wheel<T = string | number>({
|
|
|
797
625
|
|
|
798
626
|
const renderRows = useMemo(() => {
|
|
799
627
|
if (n === 0) return [];
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
}
|
|
807
|
-
return list;
|
|
628
|
+
const span = Math.ceil(drumReachRows(itemHeight, containerHeight));
|
|
629
|
+
const centerRow = Math.round(scrollOffset / itemHeight);
|
|
630
|
+
const first = loopEnabled ? centerRow - span : Math.max(0, centerRow - span);
|
|
631
|
+
const last = loopEnabled ? centerRow + span : Math.min(n - 1, centerRow + span);
|
|
632
|
+
const list: { virtualRow: number; dataIndex: number }[] = [];
|
|
633
|
+
for (let vr = first; vr <= last; vr++) {
|
|
634
|
+
list.push({ virtualRow: vr, dataIndex: modIndex(vr, n) });
|
|
808
635
|
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
n - 1,
|
|
812
|
-
Math.ceil((scrollOffset + containerHeight) / itemHeight) + paddingItems + 1,
|
|
813
|
-
);
|
|
814
|
-
return Array.from({ length: end - start + 1 }, (_, i) => {
|
|
815
|
-
const virtualRow = start + i;
|
|
816
|
-
return { virtualRow, dataIndex: virtualRow };
|
|
817
|
-
});
|
|
818
|
-
}, [n, loopEnabled, scrollOffset, itemHeight, paddingItems, containerHeight]);
|
|
636
|
+
return list;
|
|
637
|
+
}, [n, loopEnabled, scrollOffset, itemHeight, containerHeight]);
|
|
819
638
|
|
|
820
639
|
const itemColor = (isSelected: boolean) =>
|
|
821
640
|
disabled ? "$color10" : isSelected ? "$color12" : "$color11";
|
|
822
641
|
|
|
823
|
-
const radiusToken = knobProps.borderRadius.borderRadius as string | number;
|
|
824
|
-
const highlightStyle = highlightBandStyle(highlightEdges, radiusToken, Boolean(knobProps.pointy));
|
|
825
|
-
const highlightSmoothClass =
|
|
826
|
-
"className" in knobProps.borderRadius && typeof knobProps.borderRadius.className === "string"
|
|
827
|
-
? knobProps.borderRadius.className
|
|
828
|
-
: undefined;
|
|
829
|
-
|
|
830
642
|
const renderItemNode = (dataIndex: number, isSelected: boolean) => {
|
|
831
643
|
const item = items[dataIndex];
|
|
832
644
|
const itemValue = getItemValue(item);
|
|
@@ -859,7 +671,7 @@ export function Wheel<T = string | number>({
|
|
|
859
671
|
>
|
|
860
672
|
{/* iOS 14+ selection fill — LC-71 selected=fill, never hairline borders */}
|
|
861
673
|
<SelectionIndicator
|
|
862
|
-
className={
|
|
674
|
+
className={highlightClassName}
|
|
863
675
|
backgroundColor="$color4"
|
|
864
676
|
top={paddingItems * itemHeight}
|
|
865
677
|
height={itemHeight}
|
|
@@ -868,128 +680,74 @@ export function Wheel<T = string | number>({
|
|
|
868
680
|
/>
|
|
869
681
|
|
|
870
682
|
{/* Interactive container */}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
>
|
|
940
|
-
{renderItemNode(dataIndex, isSelected)}
|
|
941
|
-
</div>
|
|
942
|
-
);
|
|
943
|
-
})}
|
|
944
|
-
</div>
|
|
945
|
-
) : (
|
|
946
|
-
<View
|
|
947
|
-
width="100%"
|
|
948
|
-
height={containerHeight}
|
|
949
|
-
position="relative"
|
|
950
|
-
overflow="hidden"
|
|
951
|
-
cursor={disabled ? "not-allowed" : "grab"}
|
|
952
|
-
data-testid="wheel-wrapper"
|
|
953
|
-
data-disabled={disabled ? true : undefined}
|
|
954
|
-
aria-disabled={disabled || undefined}
|
|
955
|
-
{...panResponder.panHandlers}
|
|
956
|
-
>
|
|
957
|
-
{renderRows.map(({ virtualRow, dataIndex }) => {
|
|
958
|
-
const isSelected = dataIndex === liveDataIndex;
|
|
959
|
-
const transform = getItemTransform(
|
|
960
|
-
virtualRow,
|
|
961
|
-
scrollOffset,
|
|
962
|
-
itemHeight,
|
|
963
|
-
containerHeight,
|
|
964
|
-
);
|
|
965
|
-
|
|
966
|
-
if (Math.abs(transform.rotateX) > 85) return null;
|
|
967
|
-
|
|
968
|
-
return (
|
|
969
|
-
<View
|
|
970
|
-
key={`${dataIndex}-${virtualRow}`}
|
|
971
|
-
position="absolute"
|
|
972
|
-
left={0}
|
|
973
|
-
right={0}
|
|
974
|
-
height={itemHeight}
|
|
975
|
-
alignItems="center"
|
|
976
|
-
justifyContent="center"
|
|
977
|
-
opacity={transform.opacity}
|
|
978
|
-
pointerEvents="none"
|
|
979
|
-
y={transform.translateY}
|
|
980
|
-
scale={transform.scale}
|
|
981
|
-
rotateX={`${transform.rotateX}deg`}
|
|
982
|
-
data-testid="wheel-item"
|
|
983
|
-
data-index={dataIndex}
|
|
984
|
-
data-selected={isSelected ? true : undefined}
|
|
985
|
-
>
|
|
986
|
-
{renderItemNode(dataIndex, isSelected)}
|
|
987
|
-
</View>
|
|
988
|
-
);
|
|
989
|
-
})}
|
|
990
|
-
<WheelEdgeFade height={paddingItems * itemHeight} />
|
|
991
|
-
</View>
|
|
992
|
-
)}
|
|
683
|
+
<div
|
|
684
|
+
ref={containerRef}
|
|
685
|
+
className="mp1-wheel-wrapper"
|
|
686
|
+
tabIndex={disabled ? -1 : 0}
|
|
687
|
+
role="slider"
|
|
688
|
+
aria-label={ariaLabel ?? t("Value picker")}
|
|
689
|
+
aria-orientation="vertical"
|
|
690
|
+
aria-valuemin={0}
|
|
691
|
+
aria-valuemax={Math.max(0, n - 1)}
|
|
692
|
+
aria-valuenow={selectedIndex}
|
|
693
|
+
aria-valuetext={
|
|
694
|
+
items[selectedIndex] !== undefined ? getItemLabel(items[selectedIndex]) : undefined
|
|
695
|
+
}
|
|
696
|
+
aria-disabled={disabled || undefined}
|
|
697
|
+
data-disabled={disabled ? true : undefined}
|
|
698
|
+
style={{
|
|
699
|
+
width: "100%",
|
|
700
|
+
height: containerHeight,
|
|
701
|
+
position: "relative",
|
|
702
|
+
perspective: `${containerHeight * 4}px`,
|
|
703
|
+
perspectiveOrigin: "center center",
|
|
704
|
+
overflow: "hidden",
|
|
705
|
+
userSelect: "none",
|
|
706
|
+
touchAction: "none",
|
|
707
|
+
cursor: disabled ? "not-allowed" : "grab",
|
|
708
|
+
...wheelMaskStyle,
|
|
709
|
+
}}
|
|
710
|
+
onPointerDown={handlePointerDown as any}
|
|
711
|
+
onPointerMove={handlePointerMove as any}
|
|
712
|
+
onPointerUp={handlePointerUp as any}
|
|
713
|
+
onPointerCancel={handlePointerUp as any}
|
|
714
|
+
onKeyDown={handleKeyDown as any}
|
|
715
|
+
data-testid="wheel-wrapper"
|
|
716
|
+
>
|
|
717
|
+
{renderRows.map(({ virtualRow, dataIndex }) => {
|
|
718
|
+
const isSelected = dataIndex === liveDataIndex;
|
|
719
|
+
const transform = getItemTransform(virtualRow, scrollOffset, itemHeight, containerHeight);
|
|
720
|
+
|
|
721
|
+
if (Math.abs(transform.rotateX) > 85) return null;
|
|
722
|
+
|
|
723
|
+
return (
|
|
724
|
+
<div
|
|
725
|
+
key={`${dataIndex}-${virtualRow}`}
|
|
726
|
+
data-testid="wheel-item"
|
|
727
|
+
data-index={dataIndex}
|
|
728
|
+
data-selected={isSelected ? true : undefined}
|
|
729
|
+
aria-hidden={isSelected ? undefined : true}
|
|
730
|
+
style={{
|
|
731
|
+
position: "absolute",
|
|
732
|
+
left: 0,
|
|
733
|
+
right: 0,
|
|
734
|
+
height: itemHeight,
|
|
735
|
+
display: "flex",
|
|
736
|
+
alignItems: "center",
|
|
737
|
+
justifyContent: "center",
|
|
738
|
+
transform: `translateY(${transform.translateY}px) rotateX(${transform.rotateX}deg) scale(${transform.scale})`,
|
|
739
|
+
opacity: transform.opacity,
|
|
740
|
+
pointerEvents: "none",
|
|
741
|
+
transformOrigin: "center center",
|
|
742
|
+
transformStyle: "preserve-3d",
|
|
743
|
+
backfaceVisibility: "hidden",
|
|
744
|
+
}}
|
|
745
|
+
>
|
|
746
|
+
{renderItemNode(dataIndex, isSelected)}
|
|
747
|
+
</div>
|
|
748
|
+
);
|
|
749
|
+
})}
|
|
750
|
+
</div>
|
|
993
751
|
</WheelContainer>
|
|
994
752
|
);
|
|
995
753
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { drumPose, drumReachRows } from "./wheelShared";
|
|
3
|
+
|
|
4
|
+
describe("UIPickerView drum geometry", () => {
|
|
5
|
+
const itemHeight = 40;
|
|
6
|
+
|
|
7
|
+
for (const visibleItems of [3, 5, 7]) {
|
|
8
|
+
const frameHeight = itemHeight * visibleItems;
|
|
9
|
+
|
|
10
|
+
it(`never overlaps neighbouring rows (${visibleItems} visible)`, () => {
|
|
11
|
+
const reach = Math.floor(drumReachRows(itemHeight, frameHeight));
|
|
12
|
+
for (let d = -reach; d < reach; d++) {
|
|
13
|
+
const a = drumPose(d, itemHeight, frameHeight);
|
|
14
|
+
const b = drumPose(d + 1, itemHeight, frameHeight);
|
|
15
|
+
const halfA = (itemHeight * Math.cos((a.rotateX * Math.PI) / 180) * a.scale) / 2;
|
|
16
|
+
const halfB = (itemHeight * Math.cos((b.rotateX * Math.PI) / 180) * b.scale) / 2;
|
|
17
|
+
expect(b.offset - a.offset).toBeGreaterThanOrEqual(halfA + halfB - 1e-6);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it(`puts the frame edge at the drum's 80° line (${visibleItems} visible)`, () => {
|
|
22
|
+
const reach = drumReachRows(itemHeight, frameHeight);
|
|
23
|
+
for (let d = -reach; d <= reach; d += 0.25) {
|
|
24
|
+
const pose = drumPose(d, itemHeight, frameHeight);
|
|
25
|
+
if (Math.abs(pose.rotateX) > 80) continue;
|
|
26
|
+
expect(Math.abs(pose.offset)).toBeLessThanOrEqual(frameHeight / 2 + 1e-9);
|
|
27
|
+
}
|
|
28
|
+
expect(drumPose(reach + 0.01, itemHeight, frameHeight).opacity).toBe(0);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it("seats the centred row flat, full size and opaque", () => {
|
|
33
|
+
expect(drumPose(0, itemHeight, 200)).toEqual({ offset: 0, rotateX: -0, scale: 1, opacity: 1 });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("bends, shrinks and fades rows away from the centre", () => {
|
|
37
|
+
const near = drumPose(1, itemHeight, 200);
|
|
38
|
+
const far = drumPose(3, itemHeight, 200);
|
|
39
|
+
expect(far.rotateX).toBeLessThan(near.rotateX);
|
|
40
|
+
expect(near.rotateX).toBeLessThan(0);
|
|
41
|
+
expect(far.scale).toBeLessThan(near.scale);
|
|
42
|
+
expect(far.opacity).toBeLessThan(near.opacity);
|
|
43
|
+
expect(near.opacity).toBeLessThan(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("is symmetric above and below the band", () => {
|
|
47
|
+
const above = drumPose(-2, itemHeight, 200);
|
|
48
|
+
const below = drumPose(2, itemHeight, 200);
|
|
49
|
+
expect(above.offset).toBeCloseTo(-below.offset);
|
|
50
|
+
expect(above.rotateX).toBeCloseTo(-below.rotateX);
|
|
51
|
+
expect(above.opacity).toBeCloseTo(below.opacity);
|
|
52
|
+
});
|
|
53
|
+
});
|