orn-ui 0.2.7 → 0.2.8

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 (43) hide show
  1. package/AGENTS.md +21 -3
  2. package/README.md +7 -6
  3. package/lib/commonjs/atoms/Gradient.js +111 -0
  4. package/lib/commonjs/atoms/Gradient.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/Slides.js +406 -0
  8. package/lib/commonjs/organisms/Slides.js.map +1 -0
  9. package/lib/commonjs/organisms/index.js +7 -0
  10. package/lib/commonjs/organisms/index.js.map +1 -1
  11. package/lib/commonjs/organisms/useSlides.js +186 -0
  12. package/lib/commonjs/organisms/useSlides.js.map +1 -0
  13. package/lib/module/atoms/Gradient.js +106 -0
  14. package/lib/module/atoms/Gradient.js.map +1 -0
  15. package/lib/module/atoms/index.js +1 -0
  16. package/lib/module/atoms/index.js.map +1 -1
  17. package/lib/module/organisms/Slides.js +401 -0
  18. package/lib/module/organisms/Slides.js.map +1 -0
  19. package/lib/module/organisms/index.js +1 -0
  20. package/lib/module/organisms/index.js.map +1 -1
  21. package/lib/module/organisms/useSlides.js +182 -0
  22. package/lib/module/organisms/useSlides.js.map +1 -0
  23. package/lib/typescript/atoms/Gradient.d.ts +37 -0
  24. package/lib/typescript/atoms/Gradient.d.ts.map +1 -0
  25. package/lib/typescript/atoms/index.d.ts +2 -0
  26. package/lib/typescript/atoms/index.d.ts.map +1 -1
  27. package/lib/typescript/organisms/Slides.d.ts +69 -0
  28. package/lib/typescript/organisms/Slides.d.ts.map +1 -0
  29. package/lib/typescript/organisms/index.d.ts +2 -0
  30. package/lib/typescript/organisms/index.d.ts.map +1 -1
  31. package/lib/typescript/organisms/useSlides.d.ts +52 -0
  32. package/lib/typescript/organisms/useSlides.d.ts.map +1 -0
  33. package/llms.txt +6 -5
  34. package/package.json +12 -2
  35. package/registry/gradient.json +18 -0
  36. package/registry/index.json +26 -0
  37. package/registry/manifest.json +14 -0
  38. package/registry/slides.json +24 -0
  39. package/src/atoms/Gradient.tsx +99 -0
  40. package/src/atoms/index.ts +2 -0
  41. package/src/organisms/Slides.tsx +413 -0
  42. package/src/organisms/index.ts +8 -0
  43. 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
+ }
@@ -30,3 +30,11 @@ export { NavigationBar } from './NavigationBar';
30
30
  export type { NavigationBarProps, NavigationBarItem } from './NavigationBar';
31
31
  export { ReorderableList } from './ReorderableList';
32
32
  export type { ReorderableListProps } from './ReorderableList';
33
+ export { Slides } from './Slides';
34
+ export type {
35
+ SlidesProps,
36
+ SlidesOrientation,
37
+ SlidesIndicators,
38
+ SlidesIndicatorPlacement,
39
+ SlideBackground,
40
+ } from './Slides';
@@ -0,0 +1,220 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView } from 'react-native';
3
+ import { useReduceMotion } from '../atoms/Transition';
4
+
5
+ export type SlidesOrientation = 'horizontal' | 'vertical';
6
+
7
+ export interface UseSlidesOptions {
8
+ total: number;
9
+ orientation: SlidesOrientation;
10
+ loop: boolean;
11
+ autoPlay: boolean;
12
+ interval: number;
13
+ /** Índice controlado; sin él manda el estado interno. */
14
+ index?: number;
15
+ defaultIndex: number;
16
+ onIndexChange?: (index: number) => void;
17
+ }
18
+
19
+ /** Una página del scroll. Los clones del modo infinito repiten el `real` de un vecino. */
20
+ export interface SlidesPage {
21
+ real: number;
22
+ clone: 'head' | 'tail' | null;
23
+ }
24
+
25
+ export interface UseSlidesResult {
26
+ active: number;
27
+ pages: SlidesPage[];
28
+ isHorizontal: boolean;
29
+ isLoop: boolean;
30
+ size: { width: number; height: number };
31
+ /** Largo de una página sobre el eje del scroll. En 0 todavía no se midió. */
32
+ pageSize: number;
33
+ scrollRef: React.RefObject<ScrollView | null>;
34
+ autoPlayPaused: boolean;
35
+ /** El sistema pidió reducir movimiento: no hay autoPlay ni transiciones. */
36
+ motionReduced: boolean;
37
+ goTo: (index: number) => void;
38
+ toggleAutoPlay: () => void;
39
+ onLayout: (event: LayoutChangeEvent) => void;
40
+ onMomentumScrollEnd: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
41
+ onScrollBeginDrag: () => void;
42
+ onScrollEndDrag: () => void;
43
+ }
44
+
45
+ export function clampIndex(value: number, total: number): number {
46
+ if (total === 0) return 0;
47
+ if (value < 0) return 0;
48
+ if (value > total - 1) return total - 1;
49
+ return value;
50
+ }
51
+
52
+ /**
53
+ * useSlides
54
+ * Todo lo que en `Slides` no se ve: qué página está encuadrada, cuándo mover el
55
+ * scroll, cómo se mapean los clones del modo infinito a índices reales, y el
56
+ * reloj del avance automático.
57
+ *
58
+ * Trabaja sólo con índices, nunca con los ítems: así no necesita el genérico
59
+ * `T` del componente, y el UI resuelve `pages[i].real` contra su propio `data`.
60
+ */
61
+ export function useSlides({
62
+ total,
63
+ orientation,
64
+ loop,
65
+ autoPlay,
66
+ interval,
67
+ index,
68
+ defaultIndex,
69
+ onIndexChange,
70
+ }: UseSlidesOptions): UseSlidesResult {
71
+ const motionReduced = useReduceMotion();
72
+ const isHorizontal = orientation === 'horizontal';
73
+ // Un solo elemento no tiene a dónde ciclar: quedaría deslizable entre tres
74
+ // copias de lo mismo.
75
+ const isLoop = loop && total > 1;
76
+
77
+ const [uncontrolled, setUncontrolled] = useState(() => clampIndex(defaultIndex, total));
78
+ const active = clampIndex(index ?? uncontrolled, total);
79
+ const activeRef = useRef(active);
80
+ activeRef.current = active;
81
+
82
+ const [autoPlayPaused, setAutoPlayPaused] = useState(false);
83
+ const [size, setSize] = useState({ width: 0, height: 0 });
84
+ const pageSize = isHorizontal ? size.width : size.height;
85
+
86
+ const scrollRef = useRef<ScrollView>(null);
87
+ /** Página realmente visible (índice dentro de `pages`, clones incluidos). */
88
+ const offsetPageRef = useRef(isLoop ? 1 : 0);
89
+ const didInitRef = useRef(false);
90
+ const lastPageSizeRef = useRef(0);
91
+ const draggingRef = useRef(false);
92
+
93
+ const pages = useMemo<SlidesPage[]>(() => {
94
+ const real = Array.from({ length: total }, (_, i) => ({ real: i, clone: null }));
95
+ if (!isLoop) return real;
96
+ return [{ real: total - 1, clone: 'head' }, ...real, { real: 0, clone: 'tail' }];
97
+ }, [isLoop, total]);
98
+
99
+ const scrollToPage = useCallback(
100
+ (page: number, animated: boolean) => {
101
+ const offset = page * pageSize;
102
+ scrollRef.current?.scrollTo({
103
+ x: isHorizontal ? offset : 0,
104
+ y: isHorizontal ? 0 : offset,
105
+ animated,
106
+ });
107
+ },
108
+ [isHorizontal, pageSize]
109
+ );
110
+
111
+ const goTo = useCallback(
112
+ (next: number) => {
113
+ if (next === activeRef.current) return;
114
+ activeRef.current = next;
115
+ if (index == null) setUncontrolled(next);
116
+ onIndexChange?.(next);
117
+ },
118
+ [index, onIndexChange]
119
+ );
120
+
121
+ // Sólo actúa cuando el índice y la página visible discrepan: un cambio que
122
+ // vino del propio gesto no dispara un segundo scroll encima del que frenó.
123
+ useEffect(() => {
124
+ if (pageSize === 0 || total === 0) return;
125
+ const target = isLoop ? active + 1 : active;
126
+ const resized = lastPageSizeRef.current !== pageSize;
127
+ if (offsetPageRef.current === target && didInitRef.current && !resized) return;
128
+ // Ni el primer posicionamiento ni el reacomodo por rotación son un cambio
129
+ // de slide: animarlos sería un deslizamiento que nadie pidió.
130
+ const animated = didInitRef.current && !resized;
131
+ didInitRef.current = true;
132
+ lastPageSizeRef.current = pageSize;
133
+ offsetPageRef.current = target;
134
+ scrollToPage(target, animated);
135
+ }, [active, isLoop, pageSize, scrollToPage, total]);
136
+
137
+ const onLayout = useCallback((event: LayoutChangeEvent) => {
138
+ const { width, height } = event.nativeEvent.layout;
139
+ setSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));
140
+ }, []);
141
+
142
+ const onMomentumScrollEnd = useCallback(
143
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
144
+ draggingRef.current = false;
145
+ if (pageSize === 0) return;
146
+ const { contentOffset } = event.nativeEvent;
147
+ const page = Math.round((isHorizontal ? contentOffset.x : contentOffset.y) / pageSize);
148
+
149
+ if (isLoop) {
150
+ if (page === 0) {
151
+ offsetPageRef.current = total;
152
+ scrollToPage(total, false);
153
+ goTo(total - 1);
154
+ return;
155
+ }
156
+ if (page === total + 1) {
157
+ offsetPageRef.current = 1;
158
+ scrollToPage(1, false);
159
+ goTo(0);
160
+ return;
161
+ }
162
+ offsetPageRef.current = page;
163
+ goTo(page - 1);
164
+ return;
165
+ }
166
+
167
+ offsetPageRef.current = page;
168
+ goTo(clampIndex(page, total));
169
+ },
170
+ [goTo, isHorizontal, isLoop, pageSize, scrollToPage, total]
171
+ );
172
+
173
+ const onScrollBeginDrag = useCallback(() => {
174
+ draggingRef.current = true;
175
+ }, []);
176
+
177
+ const onScrollEndDrag = useCallback(() => {
178
+ // Un arrastre corto puede soltarse sin inercia y no emitir
179
+ // momentumScrollEnd: sin esto el autoPlay quedaría pausado para siempre.
180
+ draggingRef.current = false;
181
+ }, []);
182
+
183
+ const toggleAutoPlay = useCallback(() => setAutoPlayPaused((paused) => !paused), []);
184
+
185
+ useEffect(() => {
186
+ if (!autoPlay || autoPlayPaused || motionReduced || total < 2 || pageSize === 0) return;
187
+ const timer = setInterval(() => {
188
+ if (draggingRef.current) return;
189
+ const from = activeRef.current;
190
+ if (from < total - 1) {
191
+ goTo(from + 1);
192
+ return;
193
+ }
194
+ if (!isLoop) return;
195
+ // Última en modo infinito: avanza al clon de la primera en vez de
196
+ // rebobinar; el salto al original lo cierra momentumScrollEnd.
197
+ offsetPageRef.current = total + 1;
198
+ scrollToPage(total + 1, true);
199
+ }, interval);
200
+ return () => clearInterval(timer);
201
+ }, [autoPlay, autoPlayPaused, goTo, interval, isLoop, motionReduced, pageSize, scrollToPage, total]);
202
+
203
+ return {
204
+ active,
205
+ pages,
206
+ isHorizontal,
207
+ isLoop,
208
+ size,
209
+ pageSize,
210
+ scrollRef,
211
+ autoPlayPaused,
212
+ motionReduced,
213
+ goTo,
214
+ toggleAutoPlay,
215
+ onLayout,
216
+ onMomentumScrollEnd,
217
+ onScrollBeginDrag,
218
+ onScrollEndDrag,
219
+ };
220
+ }