orn-ui 0.2.7 → 0.2.9
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/AGENTS.md +25 -3
- package/README.md +9 -8
- package/lib/commonjs/atoms/Gradient.js +111 -0
- package/lib/commonjs/atoms/Gradient.js.map +1 -0
- package/lib/commonjs/atoms/ShinyText.js +188 -0
- package/lib/commonjs/atoms/ShinyText.js.map +1 -0
- package/lib/commonjs/atoms/index.js +14 -0
- package/lib/commonjs/atoms/index.js.map +1 -1
- package/lib/commonjs/organisms/Chart.js +947 -0
- package/lib/commonjs/organisms/Chart.js.map +1 -0
- package/lib/commonjs/organisms/OptionWheel.js +408 -0
- package/lib/commonjs/organisms/OptionWheel.js.map +1 -0
- package/lib/commonjs/organisms/Slides.js +406 -0
- package/lib/commonjs/organisms/Slides.js.map +1 -0
- package/lib/commonjs/organisms/Timeline.js +420 -0
- package/lib/commonjs/organisms/Timeline.js.map +1 -0
- package/lib/commonjs/organisms/chart/frame.js +373 -0
- package/lib/commonjs/organisms/chart/frame.js.map +1 -0
- package/lib/commonjs/organisms/chart/marks.js +316 -0
- package/lib/commonjs/organisms/chart/marks.js.map +1 -0
- package/lib/commonjs/organisms/chart/math.js +468 -0
- package/lib/commonjs/organisms/chart/math.js.map +1 -0
- package/lib/commonjs/organisms/chart/palette.js +120 -0
- package/lib/commonjs/organisms/chart/palette.js.map +1 -0
- package/lib/commonjs/organisms/index.js +28 -0
- package/lib/commonjs/organisms/index.js.map +1 -1
- package/lib/commonjs/organisms/useSlides.js +186 -0
- package/lib/commonjs/organisms/useSlides.js.map +1 -0
- package/lib/module/atoms/Gradient.js +106 -0
- package/lib/module/atoms/Gradient.js.map +1 -0
- package/lib/module/atoms/ShinyText.js +183 -0
- package/lib/module/atoms/ShinyText.js.map +1 -0
- package/lib/module/atoms/index.js +2 -0
- package/lib/module/atoms/index.js.map +1 -1
- package/lib/module/organisms/Chart.js +942 -0
- package/lib/module/organisms/Chart.js.map +1 -0
- package/lib/module/organisms/OptionWheel.js +403 -0
- package/lib/module/organisms/OptionWheel.js.map +1 -0
- package/lib/module/organisms/Slides.js +401 -0
- package/lib/module/organisms/Slides.js.map +1 -0
- package/lib/module/organisms/Timeline.js +415 -0
- package/lib/module/organisms/Timeline.js.map +1 -0
- package/lib/module/organisms/chart/frame.js +367 -0
- package/lib/module/organisms/chart/frame.js.map +1 -0
- package/lib/module/organisms/chart/marks.js +310 -0
- package/lib/module/organisms/chart/marks.js.map +1 -0
- package/lib/module/organisms/chart/math.js +445 -0
- package/lib/module/organisms/chart/math.js.map +1 -0
- package/lib/module/organisms/chart/palette.js +114 -0
- package/lib/module/organisms/chart/palette.js.map +1 -0
- package/lib/module/organisms/index.js +4 -0
- package/lib/module/organisms/index.js.map +1 -1
- package/lib/module/organisms/useSlides.js +182 -0
- package/lib/module/organisms/useSlides.js.map +1 -0
- package/lib/typescript/atoms/Gradient.d.ts +37 -0
- package/lib/typescript/atoms/Gradient.d.ts.map +1 -0
- package/lib/typescript/atoms/ShinyText.d.ts +48 -0
- package/lib/typescript/atoms/ShinyText.d.ts.map +1 -0
- package/lib/typescript/atoms/index.d.ts +4 -0
- package/lib/typescript/atoms/index.d.ts.map +1 -1
- package/lib/typescript/organisms/Chart.d.ts +208 -0
- package/lib/typescript/organisms/Chart.d.ts.map +1 -0
- package/lib/typescript/organisms/OptionWheel.d.ts +78 -0
- package/lib/typescript/organisms/OptionWheel.d.ts.map +1 -0
- package/lib/typescript/organisms/Slides.d.ts +69 -0
- package/lib/typescript/organisms/Slides.d.ts.map +1 -0
- package/lib/typescript/organisms/Timeline.d.ts +61 -0
- package/lib/typescript/organisms/Timeline.d.ts.map +1 -0
- package/lib/typescript/organisms/chart/frame.d.ts +85 -0
- package/lib/typescript/organisms/chart/frame.d.ts.map +1 -0
- package/lib/typescript/organisms/chart/marks.d.ts +88 -0
- package/lib/typescript/organisms/chart/marks.d.ts.map +1 -0
- package/lib/typescript/organisms/chart/math.d.ts +161 -0
- package/lib/typescript/organisms/chart/math.d.ts.map +1 -0
- package/lib/typescript/organisms/chart/palette.d.ts +6 -0
- package/lib/typescript/organisms/chart/palette.d.ts.map +1 -0
- package/lib/typescript/organisms/index.d.ts +8 -0
- package/lib/typescript/organisms/index.d.ts.map +1 -1
- package/lib/typescript/organisms/useSlides.d.ts +52 -0
- package/lib/typescript/organisms/useSlides.d.ts.map +1 -0
- package/llms.txt +6 -5
- package/package.json +33 -2
- package/registry/chart.json +35 -0
- package/registry/gradient.json +18 -0
- package/registry/index.json +79 -0
- package/registry/manifest.json +42 -0
- package/registry/option-wheel.json +19 -0
- package/registry/shiny-text.json +19 -0
- package/registry/slides.json +24 -0
- package/registry/timeline.json +20 -0
- package/src/atoms/Gradient.tsx +99 -0
- package/src/atoms/ShinyText.tsx +226 -0
- package/src/atoms/index.ts +4 -0
- package/src/organisms/Chart.tsx +1223 -0
- package/src/organisms/OptionWheel.tsx +476 -0
- package/src/organisms/Slides.tsx +413 -0
- package/src/organisms/Timeline.tsx +467 -0
- package/src/organisms/chart/frame.tsx +349 -0
- package/src/organisms/chart/marks.tsx +295 -0
- package/src/organisms/chart/math.ts +475 -0
- package/src/organisms/chart/palette.ts +112 -0
- package/src/organisms/index.ts +22 -0
- package/src/organisms/useSlides.ts +220 -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
|
+
}
|