orn-ui 0.2.8 → 0.2.10

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 (80) hide show
  1. package/AGENTS.md +6 -2
  2. package/README.md +6 -6
  3. package/lib/commonjs/atoms/ShinyText.js +188 -0
  4. package/lib/commonjs/atoms/ShinyText.js.map +1 -0
  5. package/lib/commonjs/atoms/index.js +7 -0
  6. package/lib/commonjs/atoms/index.js.map +1 -1
  7. package/lib/commonjs/organisms/Chart.js +968 -0
  8. package/lib/commonjs/organisms/Chart.js.map +1 -0
  9. package/lib/commonjs/organisms/OptionWheel.js +408 -0
  10. package/lib/commonjs/organisms/OptionWheel.js.map +1 -0
  11. package/lib/commonjs/organisms/Timeline.js +420 -0
  12. package/lib/commonjs/organisms/Timeline.js.map +1 -0
  13. package/lib/commonjs/organisms/chart/frame.js +373 -0
  14. package/lib/commonjs/organisms/chart/frame.js.map +1 -0
  15. package/lib/commonjs/organisms/chart/marks.js +316 -0
  16. package/lib/commonjs/organisms/chart/marks.js.map +1 -0
  17. package/lib/commonjs/organisms/chart/math.js +468 -0
  18. package/lib/commonjs/organisms/chart/math.js.map +1 -0
  19. package/lib/commonjs/organisms/chart/palette.js +120 -0
  20. package/lib/commonjs/organisms/chart/palette.js.map +1 -0
  21. package/lib/commonjs/organisms/index.js +21 -0
  22. package/lib/commonjs/organisms/index.js.map +1 -1
  23. package/lib/module/atoms/ShinyText.js +183 -0
  24. package/lib/module/atoms/ShinyText.js.map +1 -0
  25. package/lib/module/atoms/index.js +1 -0
  26. package/lib/module/atoms/index.js.map +1 -1
  27. package/lib/module/organisms/Chart.js +963 -0
  28. package/lib/module/organisms/Chart.js.map +1 -0
  29. package/lib/module/organisms/OptionWheel.js +403 -0
  30. package/lib/module/organisms/OptionWheel.js.map +1 -0
  31. package/lib/module/organisms/Timeline.js +415 -0
  32. package/lib/module/organisms/Timeline.js.map +1 -0
  33. package/lib/module/organisms/chart/frame.js +367 -0
  34. package/lib/module/organisms/chart/frame.js.map +1 -0
  35. package/lib/module/organisms/chart/marks.js +310 -0
  36. package/lib/module/organisms/chart/marks.js.map +1 -0
  37. package/lib/module/organisms/chart/math.js +445 -0
  38. package/lib/module/organisms/chart/math.js.map +1 -0
  39. package/lib/module/organisms/chart/palette.js +114 -0
  40. package/lib/module/organisms/chart/palette.js.map +1 -0
  41. package/lib/module/organisms/index.js +3 -0
  42. package/lib/module/organisms/index.js.map +1 -1
  43. package/lib/typescript/atoms/ShinyText.d.ts +48 -0
  44. package/lib/typescript/atoms/ShinyText.d.ts.map +1 -0
  45. package/lib/typescript/atoms/index.d.ts +2 -0
  46. package/lib/typescript/atoms/index.d.ts.map +1 -1
  47. package/lib/typescript/organisms/Chart.d.ts +208 -0
  48. package/lib/typescript/organisms/Chart.d.ts.map +1 -0
  49. package/lib/typescript/organisms/OptionWheel.d.ts +78 -0
  50. package/lib/typescript/organisms/OptionWheel.d.ts.map +1 -0
  51. package/lib/typescript/organisms/Timeline.d.ts +61 -0
  52. package/lib/typescript/organisms/Timeline.d.ts.map +1 -0
  53. package/lib/typescript/organisms/chart/frame.d.ts +85 -0
  54. package/lib/typescript/organisms/chart/frame.d.ts.map +1 -0
  55. package/lib/typescript/organisms/chart/marks.d.ts +88 -0
  56. package/lib/typescript/organisms/chart/marks.d.ts.map +1 -0
  57. package/lib/typescript/organisms/chart/math.d.ts +161 -0
  58. package/lib/typescript/organisms/chart/math.d.ts.map +1 -0
  59. package/lib/typescript/organisms/chart/palette.d.ts +6 -0
  60. package/lib/typescript/organisms/chart/palette.d.ts.map +1 -0
  61. package/lib/typescript/organisms/index.d.ts +6 -0
  62. package/lib/typescript/organisms/index.d.ts.map +1 -1
  63. package/llms.txt +3 -3
  64. package/package.json +23 -2
  65. package/registry/chart.json +35 -0
  66. package/registry/index.json +53 -0
  67. package/registry/manifest.json +28 -0
  68. package/registry/option-wheel.json +19 -0
  69. package/registry/shiny-text.json +19 -0
  70. package/registry/timeline.json +20 -0
  71. package/src/atoms/ShinyText.tsx +226 -0
  72. package/src/atoms/index.ts +2 -0
  73. package/src/organisms/Chart.tsx +1252 -0
  74. package/src/organisms/OptionWheel.tsx +476 -0
  75. package/src/organisms/Timeline.tsx +467 -0
  76. package/src/organisms/chart/frame.tsx +349 -0
  77. package/src/organisms/chart/marks.tsx +295 -0
  78. package/src/organisms/chart/math.ts +475 -0
  79. package/src/organisms/chart/palette.ts +112 -0
  80. package/src/organisms/index.ts +14 -0
@@ -0,0 +1,476 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
2
+ import {
3
+ Animated,
4
+ ScrollView,
5
+ Text,
6
+ View,
7
+ type LayoutChangeEvent,
8
+ type NativeScrollEvent,
9
+ type NativeSyntheticEvent,
10
+ type StyleProp,
11
+ type ViewStyle,
12
+ } from 'react-native';
13
+ import { createStyles } from '../theme/createStyles';
14
+ import { useAllowFontScaling, useTheme } from '../theme/UIProvider';
15
+ import { useReduceMotion } from '../atoms/Transition';
16
+
17
+ export interface OptionWheelOption<T> {
18
+ label: string;
19
+ value: T;
20
+ /** The wheel refuses to settle here and slides to the nearest enabled row. */
21
+ disabled?: boolean;
22
+ }
23
+
24
+ export type OptionWheelVariant = 'window' | 'spotlight';
25
+
26
+ export interface OptionWheelProps<T> {
27
+ options: OptionWheelOption<T>[];
28
+ selectedValue: T | undefined;
29
+ onSelect: (value: T) => void;
30
+ /** Title above the wheel. */
31
+ label?: string;
32
+ /** Required when there's no `label`: the wheel as a whole needs a name. */
33
+ accessibilityLabel?: string;
34
+ /**
35
+ * Rows on screen. Forced to the next odd number so one of them is the
36
+ * centre. @default 5
37
+ */
38
+ visibleCount?: number;
39
+ /** Height of each row. @default 44 */
40
+ itemHeight?: number;
41
+ /** Fixed text to the right of the centre row ('kg', 'min'). */
42
+ unit?: string;
43
+ /** Tilts the far rows to suggest a cylinder. @default true */
44
+ perspective?: boolean;
45
+ /**
46
+ * 'window' marks the pick with a band behind the centre row. 'spotlight'
47
+ * drops the band and lets the far rows fall away and blur instead, which
48
+ * needs a quiet surface under it to read.
49
+ * @default 'window'
50
+ */
51
+ variant?: OptionWheelVariant;
52
+ /**
53
+ * Colour of the option labels. The surface the wheel sits on is whoever
54
+ * composes it to paint — this is how the text keeps contrast against it.
55
+ * @default theme.colors.text
56
+ */
57
+ textColor?: string;
58
+ /**
59
+ * Radius, in pixels, of the wheel the rows sit on. Its centre is off to the
60
+ * side, so the rows bow inwards as they get further from the middle and tilt
61
+ * with the tangent — the wheel reads as a rim seen edge-on instead of a flat
62
+ * list. Smaller radius, rounder arc. Undefined leaves the rows flat.
63
+ */
64
+ curveRadius?: number;
65
+ /** Which side the wheel's centre is on. @default 'left' */
66
+ curveFrom?: 'left' | 'right';
67
+ disabled?: boolean;
68
+ style?: StyleProp<ViewStyle>;
69
+ testID?: string;
70
+ }
71
+
72
+ const DEFAULT_VISIBLE_COUNT = 5;
73
+ /** 44pt: el mínimo táctil de iOS, y alcanza para una línea `lg` con aire. */
74
+ const DEFAULT_ITEM_HEIGHT = 44;
75
+ /**
76
+ * Filas hacia cada lado que la interpolación distingue; más lejos, todas
77
+ * iguales (`extrapolate: 'clamp'`). Los tres rangos son simétricos respecto
78
+ * del centro: la fila de arriba y la de abajo se ven igual, sólo que la de
79
+ * arriba se inclina hacia atrás y la de abajo hacia adelante.
80
+ */
81
+ const FALLOFF = 2;
82
+
83
+ /**
84
+ * Cada variante es un juego de rangos. 'spotlight' cae mucho más rápido y se
85
+ * inclina más porque no tiene banda que señale el centro: todo el trabajo de
86
+ * decir cuál está elegida lo hacen el contraste y la nitidez.
87
+ */
88
+ const RANGES: Record<OptionWheelVariant, { opacity: number[]; scale: number[]; rotation: string[] }> = {
89
+ window: {
90
+ opacity: [0.22, 0.55, 1, 0.55, 0.22],
91
+ scale: [0.8, 0.9, 1, 0.9, 0.8],
92
+ rotation: ['60deg', '32deg', '0deg', '-32deg', '-60deg'],
93
+ },
94
+ spotlight: {
95
+ opacity: [0.16, 0.42, 1, 0.42, 0.16],
96
+ scale: [0.72, 0.87, 1, 0.87, 0.72],
97
+ rotation: ['72deg', '40deg', '0deg', '-40deg', '-72deg'],
98
+ },
99
+ };
100
+
101
+ /**
102
+ * React Native no tiene desenfoque sin dependencia nativa, así que en
103
+ * 'spotlight' cada fila que no está en el centro se dibuja además dos veces
104
+ * corrida y a media tinta. La superposición emborrona el trazo: no es un blur
105
+ * gaussiano, pero a ese tamaño y con esa opacidad lee igual, y sigue siendo
106
+ * `opacity` y `transform` sobre el mismo scroll, o sea el hilo nativo.
107
+ */
108
+ /**
109
+ * Dónde cae cada punto del `inputRange` sobre el arco. `Animated` no sabe
110
+ * calcular raíces ni arcosenos, pero no hace falta: las cinco distancias al
111
+ * centro son fijas por fila, así que la flecha del arco y el ángulo de la
112
+ * tangente se resuelven una vez acá y entran como `outputRange`.
113
+ */
114
+ function arcOffsets(radius: number, itemHeight: number, sign: number) {
115
+ const shift: number[] = [];
116
+ const angle: string[] = [];
117
+ for (let step = -FALLOFF; step <= FALLOFF; step += 1) {
118
+ // Más allá del radio la fila ya dio la vuelta: se corta ahí en vez de
119
+ // sacarle la raíz a un negativo.
120
+ const dy = Math.min(Math.abs(step * itemHeight), radius);
121
+ // Flecha del arco: cuánto se mete la fila hacia el centro de la rueda.
122
+ shift.push(sign * (radius - Math.sqrt(radius * radius - dy * dy)));
123
+ // La tangente del arco, para que la fila siga la curva en vez de cruzarla.
124
+ const degrees = Math.asin(dy / radius) * (180 / Math.PI);
125
+ angle.push(`${-sign * Math.sign(step) * degrees}deg`);
126
+ }
127
+ return { shift, angle };
128
+ }
129
+
130
+ const GHOST_SHIFTS = [-1, 1];
131
+ const GHOST_SPREAD = 3;
132
+ const GHOST_OPACITY_RANGE = [0.55, 0.3, 0, 0.3, 0.55];
133
+
134
+ const useStyles = createStyles((theme) => ({
135
+ root: { gap: theme.tokens.spacing.sm },
136
+ label: {
137
+ fontWeight: theme.tokens.fontWeight.bold,
138
+ fontSize: theme.tokens.fontSize.md,
139
+ color: theme.colors.text,
140
+ },
141
+ viewport: { overflow: 'hidden' },
142
+ disabled: { opacity: 0.5 },
143
+ // La ventana central: figura contra el fondo de opciones que pasan.
144
+ window: {
145
+ position: 'absolute',
146
+ left: 0,
147
+ right: 0,
148
+ borderTopWidth: 1,
149
+ borderBottomWidth: 1,
150
+ borderColor: theme.colors.border,
151
+ backgroundColor: theme.colors.primarySoft,
152
+ },
153
+ row: { justifyContent: 'center', alignItems: 'center' },
154
+ ghost: {
155
+ position: 'absolute',
156
+ top: 0,
157
+ left: 0,
158
+ right: 0,
159
+ bottom: 0,
160
+ justifyContent: 'center',
161
+ alignItems: 'center',
162
+ },
163
+ rowContent: { flexDirection: 'row', alignItems: 'center', gap: theme.tokens.spacing.xs },
164
+ text: { fontSize: theme.tokens.fontSize.lg, color: theme.colors.text },
165
+ textDisabled: { color: theme.colors.textLight },
166
+ unit: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight },
167
+ }));
168
+
169
+ /** El índice habilitado más cercano a `index`, o -1 si no hay ninguno. */
170
+ function nearestEnabled<T>(options: OptionWheelOption<T>[], index: number): number {
171
+ if (options.length === 0) return -1;
172
+ for (let step = 0; step < options.length; step += 1) {
173
+ const before = index - step;
174
+ const after = index + step;
175
+ if (after < options.length && !options[after]?.disabled) return after;
176
+ if (before >= 0 && !options[before]?.disabled) return before;
177
+ }
178
+ return -1;
179
+ }
180
+
181
+ /**
182
+ * OptionWheel
183
+ * Selector de rueda: las opciones pasan por una ventana fija y la que queda en
184
+ * el centro es la elegida. El scroll hace snap a la altura de fila, así que
185
+ * soltar el dedo en cualquier lado siempre deja una opción centrada y no media.
186
+ *
187
+ * Gestalt, figura y fondo: lo que marca la selección no es un estilo sobre la
188
+ * fila —que viajaría con ella— sino una ventana quieta encima de la lista. La
189
+ * figura se queda, el fondo se mueve, y por eso la rueda se lee sin leer: el
190
+ * lugar señala, no el color. Y destino común: todas las filas se achican y se
191
+ * inclinan con la misma ley respecto del centro, de modo que se perciben como
192
+ * una superficie rígida que gira y no como N filas animándose por su cuenta.
193
+ *
194
+ * La escala, la opacidad y la inclinación salen de interpolar el
195
+ * `contentOffset` del scroll, no del estado de React: se calculan en el hilo
196
+ * nativo (`useNativeDriver`) y el hilo de JS sólo participa cuando la rueda ya
197
+ * frenó, para avisar qué quedó elegido. Con "reducir movimiento" activo se
198
+ * apaga la inclinación y queda el tamaño, que es información, no decoración.
199
+ *
200
+ * `disabled` en una opción no puede impedir que el snap la centre, así que la
201
+ * rueda la centra y se corre sola a la habilitada más cercana.
202
+ */
203
+ export function OptionWheel<T extends string | number>({
204
+ options,
205
+ selectedValue,
206
+ onSelect,
207
+ label,
208
+ accessibilityLabel,
209
+ visibleCount = DEFAULT_VISIBLE_COUNT,
210
+ itemHeight = DEFAULT_ITEM_HEIGHT,
211
+ unit,
212
+ perspective = true,
213
+ variant = 'window',
214
+ textColor,
215
+ curveRadius,
216
+ curveFrom = 'left',
217
+ disabled = false,
218
+ style,
219
+ testID,
220
+ }: OptionWheelProps<T>) {
221
+ const theme = useTheme();
222
+ const styles = useStyles();
223
+ const allowFontScaling = useAllowFontScaling();
224
+ const reduceMotion = useReduceMotion();
225
+
226
+ const scrollRef = useRef<ScrollView>(null);
227
+ const scrollY = useRef(new Animated.Value(0)).current;
228
+ // El primer posicionamiento no se anima: la rueda tiene que aparecer ya
229
+ // puesta en su valor, no viajando hasta él.
230
+ const settled = useRef(false);
231
+
232
+ // Impar siempre: con un número par no hay fila central que alinear con la
233
+ // ventana, y el snap dejaría la selección entre dos filas.
234
+ const rows = visibleCount % 2 === 0 ? visibleCount + 1 : visibleCount;
235
+ const padding = ((rows - 1) / 2) * itemHeight;
236
+ const height = rows * itemHeight;
237
+
238
+ const selectedIndex = useMemo(
239
+ () => options.findIndex((option) => option.value === selectedValue),
240
+ [options, selectedValue]
241
+ );
242
+
243
+ const scrollToIndex = useCallback(
244
+ (index: number, animated: boolean) => {
245
+ const y = index * itemHeight;
246
+ scrollRef.current?.scrollTo({ y, animated });
247
+ // `scrollY` sólo lo alimenta `onScroll`, y un salto sin animar no
248
+ // siempre lo emite: sin esto el valor se queda en 0, la interpolación
249
+ // cree que la fila centrada es la primera y apaga la que de verdad está
250
+ // en la ventana. El scroll animado sí emite en todo el recorrido, así
251
+ // que ahí forzarlo pelearía con la animación.
252
+ if (!animated) scrollY.setValue(y);
253
+ },
254
+ [itemHeight, scrollY]
255
+ );
256
+
257
+ useEffect(() => {
258
+ if (selectedIndex < 0) return;
259
+ scrollToIndex(selectedIndex, settled.current && !reduceMotion);
260
+ settled.current = true;
261
+ }, [selectedIndex, scrollToIndex, reduceMotion]);
262
+
263
+ /**
264
+ * Corre tanto al final del arrastre como al final de la inercia: en Android
265
+ * un arrastre corto sin impulso no dispara `onMomentumScrollEnd`, y la rueda
266
+ * se quedaría con la selección vieja. Reentrar con el mismo índice es inocuo
267
+ * porque sólo avisa cuando cambia.
268
+ */
269
+ const commit = useCallback(
270
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
271
+ const raw = Math.round(event.nativeEvent.contentOffset.y / itemHeight);
272
+ const clamped = Math.min(Math.max(raw, 0), options.length - 1);
273
+ const landed = options[clamped];
274
+ if (!landed) return;
275
+ const target = landed.disabled ? nearestEnabled(options, clamped) : clamped;
276
+ const option = options[target];
277
+ // Sin opciones habilitadas no hay dónde parar: la rueda se queda donde
278
+ // la dejaron y no avisa nada.
279
+ if (!option) return;
280
+ if (target !== clamped) scrollToIndex(target, true);
281
+ if (option.value !== selectedValue) onSelect(option.value);
282
+ },
283
+ [options, itemHeight, selectedValue, onSelect, scrollToIndex]
284
+ );
285
+
286
+ /** VoiceOver/TalkBack recorren la rueda con increment/decrement, no con el dedo. */
287
+ const step = useCallback(
288
+ (delta: number) => {
289
+ if (disabled || selectedIndex < 0) return;
290
+ const next = nearestEnabled(options, selectedIndex + delta);
291
+ const option = options[next];
292
+ // En el borde, `nearestEnabled` devuelve la fila donde ya estamos.
293
+ if (!option || next === selectedIndex) return;
294
+ onSelect(option.value);
295
+ },
296
+ [disabled, options, selectedIndex, onSelect]
297
+ );
298
+
299
+ const onLayout = useCallback(
300
+ (_: LayoutChangeEvent) => {
301
+ if (selectedIndex >= 0) scrollToIndex(selectedIndex, false);
302
+ },
303
+ [selectedIndex, scrollToIndex]
304
+ );
305
+
306
+ const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined;
307
+ const ranges = RANGES[variant];
308
+ // El desenfoque simulado triplica los nodos de texto: sólo va donde aporta,
309
+ // y con "reducir movimiento" no va, porque ahí la nitidez es lo único que
310
+ // queda para distinguir la fila elegida.
311
+ const smeared = variant === 'spotlight' && !reduceMotion;
312
+ const arc = useMemo(
313
+ () =>
314
+ curveRadius && curveRadius > 0 && !reduceMotion
315
+ ? arcOffsets(curveRadius, itemHeight, curveFrom === 'left' ? -1 : 1)
316
+ : undefined,
317
+ [curveRadius, itemHeight, curveFrom, reduceMotion]
318
+ );
319
+
320
+ return (
321
+ <View style={[styles.root, disabled && styles.disabled, style]} testID={testID}>
322
+ {!!label && (
323
+ <Text allowFontScaling={allowFontScaling} style={styles.label}>
324
+ {label}
325
+ </Text>
326
+ )}
327
+
328
+ <View
329
+ accessible
330
+ accessibilityRole="adjustable"
331
+ accessibilityLabel={label ?? accessibilityLabel}
332
+ accessibilityValue={{ text: selected?.label }}
333
+ accessibilityState={{ disabled }}
334
+ accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
335
+ onAccessibilityAction={(event) => step(event.nativeEvent.actionName === 'increment' ? 1 : -1)}
336
+ style={[styles.viewport, { height }]}
337
+ testID={testID && `${testID}-viewport`}
338
+ >
339
+ {variant === 'window' && (
340
+ <View
341
+ pointerEvents="none"
342
+ style={[styles.window, { top: padding, height: itemHeight, borderRadius: theme.tokens.radius.sm }]}
343
+ />
344
+ )}
345
+
346
+ <Animated.ScrollView
347
+ ref={scrollRef as never}
348
+ onLayout={onLayout}
349
+ scrollEnabled={!disabled}
350
+ showsVerticalScrollIndicator={false}
351
+ snapToInterval={itemHeight}
352
+ decelerationRate="fast"
353
+ scrollEventThrottle={16}
354
+ contentContainerStyle={{ paddingVertical: padding }}
355
+ onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
356
+ useNativeDriver: true,
357
+ })}
358
+ onScrollEndDrag={commit}
359
+ onMomentumScrollEnd={commit}
360
+ testID={testID && `${testID}-scroll`}
361
+ >
362
+ {options.map((option, index) => {
363
+ const centre = index * itemHeight;
364
+ const inputRange = [
365
+ centre - FALLOFF * itemHeight,
366
+ centre - itemHeight,
367
+ centre,
368
+ centre + itemHeight,
369
+ centre + FALLOFF * itemHeight,
370
+ ];
371
+ type Interpolated = Animated.AnimatedInterpolation<string | number>;
372
+ const transform: Array<
373
+ | { perspective: number }
374
+ | { scale: Interpolated }
375
+ | { rotateX: Interpolated }
376
+ | { rotateZ: Interpolated }
377
+ | { translateX: Interpolated }
378
+ > = [
379
+ { scale: scrollY.interpolate({ inputRange, outputRange: ranges.scale, extrapolate: 'clamp' }) },
380
+ ];
381
+ if (perspective && !reduceMotion) {
382
+ // El perspective va primero: aplicado después de la rotación no
383
+ // deforma nada y la fila se ve plana aunque esté rotada.
384
+ transform.unshift({ perspective: height * 2 });
385
+ transform.push({
386
+ rotateX: scrollY.interpolate({ inputRange, outputRange: ranges.rotation, extrapolate: 'clamp' }),
387
+ });
388
+ }
389
+
390
+ if (arc) {
391
+ transform.push(
392
+ { translateX: scrollY.interpolate({ inputRange, outputRange: arc.shift, extrapolate: 'clamp' }) },
393
+ { rotateZ: scrollY.interpolate({ inputRange, outputRange: arc.angle, extrapolate: 'clamp' }) }
394
+ );
395
+ }
396
+
397
+ const label = (
398
+ <Text
399
+ allowFontScaling={allowFontScaling}
400
+ style={[styles.text, option.disabled && styles.textDisabled, !!textColor && { color: textColor }]}
401
+ >
402
+ {option.label}
403
+ </Text>
404
+ );
405
+
406
+ return (
407
+ <Animated.View
408
+ key={String(option.value)}
409
+ testID={testID && `${testID}-item-${index}`}
410
+ style={[
411
+ styles.row,
412
+ {
413
+ height: itemHeight,
414
+ opacity: scrollY.interpolate({
415
+ inputRange,
416
+ outputRange: ranges.opacity,
417
+ extrapolate: 'clamp',
418
+ }),
419
+ transform,
420
+ },
421
+ ]}
422
+ >
423
+ {smeared &&
424
+ GHOST_SHIFTS.map((direction) => (
425
+ <Animated.View
426
+ key={direction}
427
+ pointerEvents="none"
428
+ // `accessibilityElementsHidden` no alcanza: el lector
429
+ // leería la fila tres veces igual, porque la rueda entera
430
+ // ya es un solo elemento y esto vive adentro.
431
+ style={[
432
+ styles.ghost,
433
+ {
434
+ opacity: scrollY.interpolate({
435
+ inputRange,
436
+ outputRange: GHOST_OPACITY_RANGE,
437
+ extrapolate: 'clamp',
438
+ }),
439
+ transform: [
440
+ {
441
+ translateX: scrollY.interpolate({
442
+ inputRange,
443
+ outputRange: [
444
+ direction * GHOST_SPREAD,
445
+ direction * (GHOST_SPREAD / 2),
446
+ 0,
447
+ direction * (GHOST_SPREAD / 2),
448
+ direction * GHOST_SPREAD,
449
+ ],
450
+ extrapolate: 'clamp',
451
+ }),
452
+ },
453
+ ],
454
+ },
455
+ ]}
456
+ >
457
+ {label}
458
+ </Animated.View>
459
+ ))}
460
+
461
+ <View style={styles.rowContent}>
462
+ {label}
463
+ {!!unit && index === selectedIndex && (
464
+ <Text allowFontScaling={allowFontScaling} style={styles.unit}>
465
+ {unit}
466
+ </Text>
467
+ )}
468
+ </View>
469
+ </Animated.View>
470
+ );
471
+ })}
472
+ </Animated.ScrollView>
473
+ </View>
474
+ </View>
475
+ );
476
+ }