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.
Files changed (103) hide show
  1. package/AGENTS.md +25 -3
  2. package/README.md +9 -8
  3. package/lib/commonjs/atoms/Gradient.js +111 -0
  4. package/lib/commonjs/atoms/Gradient.js.map +1 -0
  5. package/lib/commonjs/atoms/ShinyText.js +188 -0
  6. package/lib/commonjs/atoms/ShinyText.js.map +1 -0
  7. package/lib/commonjs/atoms/index.js +14 -0
  8. package/lib/commonjs/atoms/index.js.map +1 -1
  9. package/lib/commonjs/organisms/Chart.js +947 -0
  10. package/lib/commonjs/organisms/Chart.js.map +1 -0
  11. package/lib/commonjs/organisms/OptionWheel.js +408 -0
  12. package/lib/commonjs/organisms/OptionWheel.js.map +1 -0
  13. package/lib/commonjs/organisms/Slides.js +406 -0
  14. package/lib/commonjs/organisms/Slides.js.map +1 -0
  15. package/lib/commonjs/organisms/Timeline.js +420 -0
  16. package/lib/commonjs/organisms/Timeline.js.map +1 -0
  17. package/lib/commonjs/organisms/chart/frame.js +373 -0
  18. package/lib/commonjs/organisms/chart/frame.js.map +1 -0
  19. package/lib/commonjs/organisms/chart/marks.js +316 -0
  20. package/lib/commonjs/organisms/chart/marks.js.map +1 -0
  21. package/lib/commonjs/organisms/chart/math.js +468 -0
  22. package/lib/commonjs/organisms/chart/math.js.map +1 -0
  23. package/lib/commonjs/organisms/chart/palette.js +120 -0
  24. package/lib/commonjs/organisms/chart/palette.js.map +1 -0
  25. package/lib/commonjs/organisms/index.js +28 -0
  26. package/lib/commonjs/organisms/index.js.map +1 -1
  27. package/lib/commonjs/organisms/useSlides.js +186 -0
  28. package/lib/commonjs/organisms/useSlides.js.map +1 -0
  29. package/lib/module/atoms/Gradient.js +106 -0
  30. package/lib/module/atoms/Gradient.js.map +1 -0
  31. package/lib/module/atoms/ShinyText.js +183 -0
  32. package/lib/module/atoms/ShinyText.js.map +1 -0
  33. package/lib/module/atoms/index.js +2 -0
  34. package/lib/module/atoms/index.js.map +1 -1
  35. package/lib/module/organisms/Chart.js +942 -0
  36. package/lib/module/organisms/Chart.js.map +1 -0
  37. package/lib/module/organisms/OptionWheel.js +403 -0
  38. package/lib/module/organisms/OptionWheel.js.map +1 -0
  39. package/lib/module/organisms/Slides.js +401 -0
  40. package/lib/module/organisms/Slides.js.map +1 -0
  41. package/lib/module/organisms/Timeline.js +415 -0
  42. package/lib/module/organisms/Timeline.js.map +1 -0
  43. package/lib/module/organisms/chart/frame.js +367 -0
  44. package/lib/module/organisms/chart/frame.js.map +1 -0
  45. package/lib/module/organisms/chart/marks.js +310 -0
  46. package/lib/module/organisms/chart/marks.js.map +1 -0
  47. package/lib/module/organisms/chart/math.js +445 -0
  48. package/lib/module/organisms/chart/math.js.map +1 -0
  49. package/lib/module/organisms/chart/palette.js +114 -0
  50. package/lib/module/organisms/chart/palette.js.map +1 -0
  51. package/lib/module/organisms/index.js +4 -0
  52. package/lib/module/organisms/index.js.map +1 -1
  53. package/lib/module/organisms/useSlides.js +182 -0
  54. package/lib/module/organisms/useSlides.js.map +1 -0
  55. package/lib/typescript/atoms/Gradient.d.ts +37 -0
  56. package/lib/typescript/atoms/Gradient.d.ts.map +1 -0
  57. package/lib/typescript/atoms/ShinyText.d.ts +48 -0
  58. package/lib/typescript/atoms/ShinyText.d.ts.map +1 -0
  59. package/lib/typescript/atoms/index.d.ts +4 -0
  60. package/lib/typescript/atoms/index.d.ts.map +1 -1
  61. package/lib/typescript/organisms/Chart.d.ts +208 -0
  62. package/lib/typescript/organisms/Chart.d.ts.map +1 -0
  63. package/lib/typescript/organisms/OptionWheel.d.ts +78 -0
  64. package/lib/typescript/organisms/OptionWheel.d.ts.map +1 -0
  65. package/lib/typescript/organisms/Slides.d.ts +69 -0
  66. package/lib/typescript/organisms/Slides.d.ts.map +1 -0
  67. package/lib/typescript/organisms/Timeline.d.ts +61 -0
  68. package/lib/typescript/organisms/Timeline.d.ts.map +1 -0
  69. package/lib/typescript/organisms/chart/frame.d.ts +85 -0
  70. package/lib/typescript/organisms/chart/frame.d.ts.map +1 -0
  71. package/lib/typescript/organisms/chart/marks.d.ts +88 -0
  72. package/lib/typescript/organisms/chart/marks.d.ts.map +1 -0
  73. package/lib/typescript/organisms/chart/math.d.ts +161 -0
  74. package/lib/typescript/organisms/chart/math.d.ts.map +1 -0
  75. package/lib/typescript/organisms/chart/palette.d.ts +6 -0
  76. package/lib/typescript/organisms/chart/palette.d.ts.map +1 -0
  77. package/lib/typescript/organisms/index.d.ts +8 -0
  78. package/lib/typescript/organisms/index.d.ts.map +1 -1
  79. package/lib/typescript/organisms/useSlides.d.ts +52 -0
  80. package/lib/typescript/organisms/useSlides.d.ts.map +1 -0
  81. package/llms.txt +6 -5
  82. package/package.json +33 -2
  83. package/registry/chart.json +35 -0
  84. package/registry/gradient.json +18 -0
  85. package/registry/index.json +79 -0
  86. package/registry/manifest.json +42 -0
  87. package/registry/option-wheel.json +19 -0
  88. package/registry/shiny-text.json +19 -0
  89. package/registry/slides.json +24 -0
  90. package/registry/timeline.json +20 -0
  91. package/src/atoms/Gradient.tsx +99 -0
  92. package/src/atoms/ShinyText.tsx +226 -0
  93. package/src/atoms/index.ts +4 -0
  94. package/src/organisms/Chart.tsx +1223 -0
  95. package/src/organisms/OptionWheel.tsx +476 -0
  96. package/src/organisms/Slides.tsx +413 -0
  97. package/src/organisms/Timeline.tsx +467 -0
  98. package/src/organisms/chart/frame.tsx +349 -0
  99. package/src/organisms/chart/marks.tsx +295 -0
  100. package/src/organisms/chart/math.ts +475 -0
  101. package/src/organisms/chart/palette.ts +112 -0
  102. package/src/organisms/index.ts +22 -0
  103. package/src/organisms/useSlides.ts +220 -0
@@ -0,0 +1,413 @@
1
+ import React, { memo, useEffect, useRef } from 'react';
2
+ import {
3
+ Animated,
4
+ Easing,
5
+ ScrollView,
6
+ StyleSheet,
7
+ Text,
8
+ TouchableOpacity,
9
+ View,
10
+ type StyleProp,
11
+ type ViewStyle,
12
+ } from 'react-native';
13
+ import { createStyles } from '../theme/createStyles';
14
+ import { useAllowFontScaling, useColors, useTheme } from '../theme/UIProvider';
15
+ import { useReduceMotion } from '../atoms/Transition';
16
+ import { Gradient, type GradientDirection } from '../atoms/Gradient';
17
+ import { useSlides, type SlidesOrientation } from './useSlides';
18
+
19
+ export type { SlidesOrientation } from './useSlides';
20
+ export type SlidesIndicators = 'dots' | 'numbers' | 'none';
21
+ export type SlidesIndicatorPlacement = 'overlay' | 'outside';
22
+
23
+ /** Un color sólido, o dos o más para un degradado (ver `Gradient`). */
24
+ export type SlideBackground = string | string[];
25
+
26
+ export interface SlidesProps<T> {
27
+ data: T[];
28
+ keyExtractor: (item: T, index: number) => string;
29
+ /** Contenido de la slide: imágenes, botones, lo que sea. Se dibuja sobre `background`. */
30
+ renderItem: (item: T, index: number) => React.ReactNode;
31
+ /** Fondo por slide: `'#101014'` pinta plano, `['#004cef', '#00cae1']` degrada. */
32
+ background?: (item: T, index: number) => SlideBackground | undefined;
33
+ /** Dirección del degradado cuando `background` devuelve varios colores. @default 'vertical' */
34
+ gradientDirection?: GradientDirection;
35
+ /** @default 'horizontal' */
36
+ orientation?: SlidesOrientation;
37
+ /** Círculos, círculos numerados, o ninguno. @default 'dots' */
38
+ indicators?: SlidesIndicators;
39
+ /**
40
+ * 'overlay' los apoya sobre la slide, en blanco; 'outside' los saca fuera
41
+ * del área deslizable, con los colores del tema.
42
+ * @default 'overlay'
43
+ */
44
+ indicatorPlacement?: SlidesIndicatorPlacement;
45
+ /** Después de la última vuelve a la primera (y al revés). @default false */
46
+ loop?: boolean;
47
+ /** Avanza solo. Se pausa mientras el dedo arrastra. @default false */
48
+ autoPlay?: boolean;
49
+ /** ms entre avances de `autoPlay`. @default 4000 */
50
+ interval?: number;
51
+ /**
52
+ * Botón para frenar el avance automático. Sólo aparece con `autoPlay`, y va
53
+ * en `true` porque contenido que se mueve solo necesita una forma de
54
+ * pausarlo (WCAG 2.2.2).
55
+ * @default true
56
+ */
57
+ showAutoPlayToggle?: boolean;
58
+ /** Hueco entre slides, en px. En 0 quedan pegadas. @default 0 */
59
+ spacing?: number;
60
+ /** Índice controlado. Sin él, el componente maneja el suyo desde `defaultIndex`. */
61
+ index?: number;
62
+ /** @default 0 */
63
+ defaultIndex?: number;
64
+ onIndexChange?: (index: number) => void;
65
+ /** Alto del área deslizable. @default 240 */
66
+ height?: number;
67
+ /** @default true */
68
+ swipeEnabled?: boolean;
69
+ /** Estilos de la caja visible de cada slide — padding, alineación, radio. */
70
+ slideStyle?: StyleProp<ViewStyle>;
71
+ style?: StyleProp<ViewStyle>;
72
+ testID?: string;
73
+ }
74
+
75
+ const DOT = 8;
76
+ const DOT_ACTIVE = 20;
77
+ const NUMBER_CIRCLE = 24;
78
+ const TOGGLE = 32;
79
+ const DEFAULT_HEIGHT = 240;
80
+ const DEFAULT_INTERVAL = 4000;
81
+
82
+ const useStyles = createStyles((theme) => ({
83
+ // Deja redondear el bloque por `style` sin que el degradado se salga.
84
+ root: { overflow: 'hidden' },
85
+ viewport: { flex: 1 },
86
+ // La slide visible es la caja de adentro: con `spacing` la página es más
87
+ // ancha que ella, y el fondo tiene que recortarse acá y no en la página, o
88
+ // el degradado pinta también el hueco.
89
+ slide: { flex: 1, overflow: 'hidden' },
90
+ // Con indicadores fuera y orientación vertical el contenedor es una fila:
91
+ // sin flex la ventana no tiene ancho propio y colapsa a 0.
92
+ viewportInRow: { flex: 1 },
93
+ slideContent: { flex: 1 },
94
+
95
+ indicatorsRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },
96
+ indicatorsColumn: { flexDirection: 'column', alignItems: 'center', justifyContent: 'center' },
97
+ indicatorsOverlayHorizontal: { position: 'absolute', left: 0, right: 0, bottom: theme.tokens.spacing.md },
98
+ indicatorsOverlayVertical: { position: 'absolute', top: 0, bottom: 0, right: theme.tokens.spacing.md },
99
+ indicatorsOutsideHorizontal: { paddingTop: theme.tokens.spacing.md },
100
+ indicatorsOutsideVertical: { paddingLeft: theme.tokens.spacing.md },
101
+ indicatorSlot: { padding: theme.tokens.spacing.xs },
102
+
103
+ dot: { width: DOT, height: DOT, borderRadius: DOT / 2 },
104
+
105
+ autoPlayToggle: {
106
+ position: 'absolute',
107
+ top: theme.tokens.spacing.md,
108
+ right: theme.tokens.spacing.md,
109
+ width: TOGGLE,
110
+ height: TOGGLE,
111
+ borderRadius: TOGGLE / 2,
112
+ alignItems: 'center',
113
+ justifyContent: 'center',
114
+ flexDirection: 'row',
115
+ gap: 3,
116
+ },
117
+ pauseBar: { width: 3, height: 12, borderRadius: 1 },
118
+ // Triángulo con bordes: un "play" sin SVG ni set de íconos.
119
+ playTriangle: {
120
+ width: 0,
121
+ height: 0,
122
+ marginLeft: 2,
123
+ borderTopWidth: 6,
124
+ borderBottomWidth: 6,
125
+ borderLeftWidth: 10,
126
+ borderTopColor: 'transparent',
127
+ borderBottomColor: 'transparent',
128
+ },
129
+
130
+ numberCircle: {
131
+ minWidth: NUMBER_CIRCLE,
132
+ height: NUMBER_CIRCLE,
133
+ borderRadius: NUMBER_CIRCLE / 2,
134
+ borderWidth: 1,
135
+ paddingHorizontal: theme.tokens.spacing.xs,
136
+ alignItems: 'center',
137
+ justifyContent: 'center',
138
+ },
139
+ numberText: { fontSize: theme.tokens.fontSize.xs, fontWeight: theme.tokens.fontWeight.semibold },
140
+
141
+ outsideHorizontal: { flexDirection: 'column' },
142
+ outsideVertical: { flexDirection: 'row' },
143
+ }));
144
+
145
+ interface DotProps {
146
+ selected: boolean;
147
+ isHorizontal: boolean;
148
+ /** En overlay el contraste lo da la opacidad; afuera, el color. */
149
+ overlay: boolean;
150
+ activeColor: string;
151
+ inactiveColor: string;
152
+ testID?: string;
153
+ }
154
+
155
+ /**
156
+ * El punto activo se estira en vez de saltar: el cambio de golpe se lee como
157
+ * "apareció otro punto", no como "el mismo se movió". Ancho y color son props
158
+ * de layout/pintura, así que van por el driver de JS.
159
+ */
160
+ const Dot = memo(({ selected, isHorizontal, overlay, activeColor, inactiveColor, testID }: DotProps) => {
161
+ const styles = useStyles();
162
+ const theme = useTheme();
163
+ const reduceMotion = useReduceMotion();
164
+ const progress = useRef(new Animated.Value(selected ? 1 : 0)).current;
165
+
166
+ useEffect(() => {
167
+ Animated.timing(progress, {
168
+ toValue: selected ? 1 : 0,
169
+ duration: reduceMotion ? 0 : theme.tokens.duration.base,
170
+ easing: Easing.out(Easing.quad),
171
+ useNativeDriver: false,
172
+ }).start();
173
+ }, [progress, reduceMotion, selected, theme]);
174
+
175
+ const length = progress.interpolate({ inputRange: [0, 1], outputRange: [DOT, DOT_ACTIVE] });
176
+ const backgroundColor = progress.interpolate({ inputRange: [0, 1], outputRange: [inactiveColor, activeColor] });
177
+
178
+ return (
179
+ <Animated.View
180
+ testID={testID}
181
+ style={[
182
+ styles.dot,
183
+ isHorizontal ? { width: length } : { height: length },
184
+ { backgroundColor },
185
+ overlay && { opacity: progress.interpolate({ inputRange: [0, 1], outputRange: [0.45, 1] }) },
186
+ ]}
187
+ />
188
+ );
189
+ });
190
+ Dot.displayName = 'SlidesDot';
191
+
192
+ /**
193
+ * Slides
194
+ * Carrusel con fondo propio (color plano o degradado) y contenido libre:
195
+ * `renderItem` decide si adentro va una imagen, texto o botones. El estado
196
+ * vive en `useSlides`; acá sólo está el árbol.
197
+ *
198
+ * El deslizamiento es un `ScrollView` con `pagingEnabled` en vez de gestos a
199
+ * mano: es lo único que da snap nativo en iOS y Android sin
200
+ * `react-native-gesture-handler`, en todo el rango soportado (Expo SDK 54–57).
201
+ */
202
+ export function Slides<T>({
203
+ data,
204
+ keyExtractor,
205
+ renderItem,
206
+ background,
207
+ gradientDirection = 'vertical',
208
+ orientation = 'horizontal',
209
+ indicators = 'dots',
210
+ indicatorPlacement = 'overlay',
211
+ loop = false,
212
+ autoPlay = false,
213
+ interval = DEFAULT_INTERVAL,
214
+ showAutoPlayToggle = true,
215
+ spacing = 0,
216
+ index,
217
+ defaultIndex = 0,
218
+ onIndexChange,
219
+ height = DEFAULT_HEIGHT,
220
+ swipeEnabled = true,
221
+ slideStyle,
222
+ style,
223
+ testID,
224
+ }: SlidesProps<T>) {
225
+ const colors = useColors();
226
+ const styles = useStyles();
227
+ const allowFontScaling = useAllowFontScaling();
228
+
229
+ const total = data.length;
230
+ const {
231
+ active,
232
+ pages,
233
+ isHorizontal,
234
+ size,
235
+ pageSize,
236
+ scrollRef,
237
+ autoPlayPaused,
238
+ motionReduced,
239
+ goTo,
240
+ toggleAutoPlay,
241
+ onLayout,
242
+ onMomentumScrollEnd,
243
+ onScrollBeginDrag,
244
+ onScrollEndDrag,
245
+ } = useSlides({ total, orientation, loop, autoPlay, interval, index, defaultIndex, onIndexChange });
246
+
247
+ const isOverlay = indicatorPlacement === 'overlay';
248
+
249
+ const renderBackground = (item: T, real: number) => {
250
+ const value = background?.(item, real);
251
+ if (!value) return null;
252
+ if (Array.isArray(value)) {
253
+ return <Gradient colors={value} direction={gradientDirection} style={StyleSheet.absoluteFill} />;
254
+ }
255
+ return <View style={[StyleSheet.absoluteFill, { backgroundColor: value }]} pointerEvents="none" />;
256
+ };
257
+
258
+ const renderIndicator = (position: number) => {
259
+ const selected = position === active;
260
+
261
+ if (indicators === 'numbers') {
262
+ const numberColors = isOverlay
263
+ ? {
264
+ background: selected ? colors.white : 'transparent',
265
+ border: colors.white,
266
+ text: selected ? colors.black : colors.white,
267
+ }
268
+ : {
269
+ background: selected ? colors.primary : 'transparent',
270
+ border: selected ? colors.primary : colors.border,
271
+ text: selected ? colors.onPrimary : colors.textLight,
272
+ };
273
+ return (
274
+ <View
275
+ style={[
276
+ styles.numberCircle,
277
+ { backgroundColor: numberColors.background, borderColor: numberColors.border },
278
+ ]}
279
+ >
280
+ <Text allowFontScaling={allowFontScaling} style={[styles.numberText, { color: numberColors.text }]}>
281
+ {position + 1}
282
+ </Text>
283
+ </View>
284
+ );
285
+ }
286
+
287
+ return (
288
+ <Dot
289
+ testID={`${testID ?? 'slides'}-dot-${position}`}
290
+ selected={selected}
291
+ isHorizontal={isHorizontal}
292
+ overlay={isOverlay}
293
+ activeColor={isOverlay ? colors.white : colors.primary}
294
+ inactiveColor={isOverlay ? colors.white : colors.border}
295
+ />
296
+ );
297
+ };
298
+
299
+ // Con reduce-motion el autoPlay ya está apagado: no hay nada que pausar.
300
+ const renderAutoPlayToggle = () => {
301
+ if (!autoPlay || !showAutoPlayToggle || motionReduced || total < 2) return null;
302
+ return (
303
+ <TouchableOpacity
304
+ testID={`${testID ?? 'slides'}-autoplay-toggle`}
305
+ style={[styles.autoPlayToggle, { backgroundColor: colors.black, opacity: 0.55 }]}
306
+ activeOpacity={0.7}
307
+ onPress={toggleAutoPlay}
308
+ accessibilityRole="button"
309
+ accessibilityLabel={autoPlayPaused ? 'Resume automatic slides' : 'Pause automatic slides'}
310
+ accessibilityState={{ selected: !autoPlayPaused }}
311
+ >
312
+ {autoPlayPaused ? (
313
+ <View style={[styles.playTriangle, { borderLeftColor: colors.white }]} />
314
+ ) : (
315
+ <>
316
+ <View style={[styles.pauseBar, { backgroundColor: colors.white }]} />
317
+ <View style={[styles.pauseBar, { backgroundColor: colors.white }]} />
318
+ </>
319
+ )}
320
+ </TouchableOpacity>
321
+ );
322
+ };
323
+
324
+ const renderIndicators = () => {
325
+ if (indicators === 'none' || total < 2) return null;
326
+ return (
327
+ <View
328
+ testID={`${testID ?? 'slides'}-indicators`}
329
+ style={[
330
+ isHorizontal ? styles.indicatorsRow : styles.indicatorsColumn,
331
+ isOverlay
332
+ ? isHorizontal
333
+ ? styles.indicatorsOverlayHorizontal
334
+ : styles.indicatorsOverlayVertical
335
+ : isHorizontal
336
+ ? styles.indicatorsOutsideHorizontal
337
+ : styles.indicatorsOutsideVertical,
338
+ ]}
339
+ pointerEvents="box-none"
340
+ >
341
+ {data.map((item, position) => (
342
+ <TouchableOpacity
343
+ key={`indicator-${keyExtractor(item, position)}`}
344
+ testID={`${testID ?? 'slides'}-indicator-${position}`}
345
+ style={styles.indicatorSlot}
346
+ activeOpacity={0.7}
347
+ onPress={() => goTo(position)}
348
+ accessibilityRole="button"
349
+ accessibilityLabel={`Slide ${position + 1} of ${total}`}
350
+ accessibilityState={{ selected: position === active }}
351
+ >
352
+ {renderIndicator(position)}
353
+ </TouchableOpacity>
354
+ ))}
355
+ </View>
356
+ );
357
+ };
358
+
359
+ const viewport = (
360
+ <View
361
+ testID={testID}
362
+ style={[styles.root, { height }, !isOverlay && !isHorizontal && styles.viewportInRow, isOverlay && style]}
363
+ onLayout={onLayout}
364
+ >
365
+ <ScrollView
366
+ ref={scrollRef}
367
+ testID={`${testID ?? 'slides'}-scroll`}
368
+ style={styles.viewport}
369
+ horizontal={isHorizontal}
370
+ pagingEnabled
371
+ scrollEnabled={swipeEnabled && total > 1}
372
+ showsHorizontalScrollIndicator={false}
373
+ showsVerticalScrollIndicator={false}
374
+ onMomentumScrollEnd={onMomentumScrollEnd}
375
+ onScrollBeginDrag={onScrollBeginDrag}
376
+ onScrollEndDrag={onScrollEndDrag}
377
+ >
378
+ {pageSize > 0 &&
379
+ pages.map(({ real, clone }) => {
380
+ const item = data[real] as T;
381
+ return (
382
+ // El hueco se reparte a los dos lados de cada página: así la
383
+ // separación entre dos slides es `spacing` entero y la página
384
+ // sigue midiendo exactamente lo que pagina el ScrollView.
385
+ <View
386
+ key={clone ? `slides-clone-${clone}` : keyExtractor(item, real)}
387
+ style={[
388
+ { width: size.width, height: size.height },
389
+ isHorizontal ? { paddingHorizontal: spacing / 2 } : { paddingVertical: spacing / 2 },
390
+ ]}
391
+ >
392
+ <View style={[styles.slide, slideStyle]}>
393
+ {renderBackground(item, real)}
394
+ <View style={styles.slideContent}>{renderItem(item, real)}</View>
395
+ </View>
396
+ </View>
397
+ );
398
+ })}
399
+ </ScrollView>
400
+ {renderAutoPlayToggle()}
401
+ {isOverlay && renderIndicators()}
402
+ </View>
403
+ );
404
+
405
+ if (isOverlay) return viewport;
406
+
407
+ return (
408
+ <View style={[isHorizontal ? styles.outsideHorizontal : styles.outsideVertical, style]}>
409
+ {viewport}
410
+ {renderIndicators()}
411
+ </View>
412
+ );
413
+ }