orn-ui 0.2.3 → 0.2.4

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 (83) hide show
  1. package/lib/commonjs/molecules/Steps.js +14 -6
  2. package/lib/commonjs/molecules/Steps.js.map +1 -1
  3. package/lib/commonjs/molecules/SymmetricGrid.js +84 -0
  4. package/lib/commonjs/molecules/SymmetricGrid.js.map +1 -0
  5. package/lib/commonjs/molecules/index.js +7 -0
  6. package/lib/commonjs/molecules/index.js.map +1 -1
  7. package/lib/commonjs/organisms/AlertProvider.js +12 -4
  8. package/lib/commonjs/organisms/AlertProvider.js.map +1 -1
  9. package/lib/commonjs/organisms/BottomSheet.js +58 -50
  10. package/lib/commonjs/organisms/BottomSheet.js.map +1 -1
  11. package/lib/commonjs/organisms/Modal.js +44 -42
  12. package/lib/commonjs/organisms/Modal.js.map +1 -1
  13. package/lib/commonjs/organisms/ReorderableList.js +143 -0
  14. package/lib/commonjs/organisms/ReorderableList.js.map +1 -0
  15. package/lib/commonjs/organisms/Screen.js +1 -2
  16. package/lib/commonjs/organisms/Screen.js.map +1 -1
  17. package/lib/commonjs/organisms/index.js +7 -0
  18. package/lib/commonjs/organisms/index.js.map +1 -1
  19. package/lib/commonjs/safe-area/SafeAreaUIProvider.js +38 -30
  20. package/lib/commonjs/safe-area/SafeAreaUIProvider.js.map +1 -1
  21. package/lib/commonjs/theme/UIProvider.js +35 -8
  22. package/lib/commonjs/theme/UIProvider.js.map +1 -1
  23. package/lib/module/molecules/Steps.js +14 -6
  24. package/lib/module/molecules/Steps.js.map +1 -1
  25. package/lib/module/molecules/SymmetricGrid.js +79 -0
  26. package/lib/module/molecules/SymmetricGrid.js.map +1 -0
  27. package/lib/module/molecules/index.js +1 -0
  28. package/lib/module/molecules/index.js.map +1 -1
  29. package/lib/module/organisms/AlertProvider.js +12 -4
  30. package/lib/module/organisms/AlertProvider.js.map +1 -1
  31. package/lib/module/organisms/BottomSheet.js +59 -51
  32. package/lib/module/organisms/BottomSheet.js.map +1 -1
  33. package/lib/module/organisms/Modal.js +45 -43
  34. package/lib/module/organisms/Modal.js.map +1 -1
  35. package/lib/module/organisms/ReorderableList.js +137 -0
  36. package/lib/module/organisms/ReorderableList.js.map +1 -0
  37. package/lib/module/organisms/Screen.js +1 -2
  38. package/lib/module/organisms/Screen.js.map +1 -1
  39. package/lib/module/organisms/index.js +1 -0
  40. package/lib/module/organisms/index.js.map +1 -1
  41. package/lib/module/safe-area/SafeAreaUIProvider.js +39 -31
  42. package/lib/module/safe-area/SafeAreaUIProvider.js.map +1 -1
  43. package/lib/module/theme/UIProvider.js +36 -10
  44. package/lib/module/theme/UIProvider.js.map +1 -1
  45. package/lib/typescript/molecules/Steps.d.ts.map +1 -1
  46. package/lib/typescript/molecules/SymmetricGrid.d.ts +26 -0
  47. package/lib/typescript/molecules/SymmetricGrid.d.ts.map +1 -0
  48. package/lib/typescript/molecules/index.d.ts +2 -0
  49. package/lib/typescript/molecules/index.d.ts.map +1 -1
  50. package/lib/typescript/organisms/AlertProvider.d.ts.map +1 -1
  51. package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
  52. package/lib/typescript/organisms/Modal.d.ts.map +1 -1
  53. package/lib/typescript/organisms/ReorderableList.d.ts +29 -0
  54. package/lib/typescript/organisms/ReorderableList.d.ts.map +1 -0
  55. package/lib/typescript/organisms/Screen.d.ts.map +1 -1
  56. package/lib/typescript/organisms/index.d.ts +2 -0
  57. package/lib/typescript/organisms/index.d.ts.map +1 -1
  58. package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts +0 -38
  59. package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts.map +1 -1
  60. package/lib/typescript/theme/UIProvider.d.ts +21 -1
  61. package/lib/typescript/theme/UIProvider.d.ts.map +1 -1
  62. package/package.json +11 -1
  63. package/registry/alert-provider.json +1 -1
  64. package/registry/bottom-sheet.json +1 -1
  65. package/registry/core.json +1 -1
  66. package/registry/index.json +30 -6
  67. package/registry/manifest.json +14 -0
  68. package/registry/modal.json +1 -1
  69. package/registry/reorderable-list.json +18 -0
  70. package/registry/screen.json +1 -1
  71. package/registry/steps.json +1 -1
  72. package/registry/symmetric-grid.json +18 -0
  73. package/src/molecules/Steps.tsx +14 -8
  74. package/src/molecules/SymmetricGrid.tsx +82 -0
  75. package/src/molecules/index.ts +2 -0
  76. package/src/organisms/AlertProvider.tsx +13 -5
  77. package/src/organisms/BottomSheet.tsx +58 -44
  78. package/src/organisms/Modal.tsx +63 -55
  79. package/src/organisms/ReorderableList.tsx +151 -0
  80. package/src/organisms/Screen.tsx +1 -2
  81. package/src/organisms/index.ts +2 -0
  82. package/src/safe-area/SafeAreaUIProvider.tsx +46 -41
  83. package/src/theme/UIProvider.tsx +50 -8
@@ -0,0 +1,82 @@
1
+ import React, { useMemo } from 'react';
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { createStyles } from '../theme/createStyles';
4
+
5
+ export interface SymmetricGridProps<T> {
6
+ data: T[];
7
+ renderItem: (item: T, index: number) => React.ReactElement;
8
+ keyExtractor: (item: T, index: number) => string;
9
+ /** Columnas por fila. @default 2 */
10
+ columns?: number;
11
+ /** Separación entre celdas, en ambos ejes. @default theme.tokens.spacing.md */
12
+ gap?: number;
13
+ /**
14
+ * Última fila incompleta: centrada (simétrica) en vez de pegada a la
15
+ * izquierda con un hueco a la derecha. @default true
16
+ */
17
+ balanceLastRow?: boolean;
18
+ style?: StyleProp<ViewStyle>;
19
+ }
20
+
21
+ const useStyles = createStyles((theme) => ({
22
+ grid: { gap: theme.tokens.spacing.md },
23
+ row: { flexDirection: 'row', gap: theme.tokens.spacing.md },
24
+ rowBalanced: { justifyContent: 'center' },
25
+ rowFull: { justifyContent: 'flex-start' },
26
+ cell: { flexGrow: 0, flexShrink: 0 },
27
+ }));
28
+
29
+ function chunk<T>(data: T[], size: number): T[][] {
30
+ const rows: T[][] = [];
31
+ for (let i = 0; i < data.length; i += size) rows.push(data.slice(i, i + size));
32
+ return rows;
33
+ }
34
+
35
+ /**
36
+ * SymmetricGrid
37
+ * Ley de Prägnanz (buena forma): agrupa en filas parejas de N columnas.
38
+ * La última fila, cuando queda incompleta, se centra por defecto en vez de
39
+ * quedar pegada a un borde — eso es lo que la lee como "fila corta" y no
40
+ * como un layout roto con un hueco a un lado.
41
+ */
42
+ export function SymmetricGrid<T>({
43
+ data,
44
+ renderItem,
45
+ keyExtractor,
46
+ columns = 2,
47
+ gap,
48
+ balanceLastRow = true,
49
+ style,
50
+ }: SymmetricGridProps<T>) {
51
+ const styles = useStyles();
52
+ const rows = useMemo(() => chunk(data, columns), [data, columns]);
53
+ const cellWidth = `${100 / columns}%` as const;
54
+
55
+ return (
56
+ <View style={[styles.grid, gap !== undefined && { gap }, style]}>
57
+ {rows.map((row, rowIndex) => {
58
+ const isLastRow = rowIndex === rows.length - 1;
59
+ const isIncomplete = row.length < columns;
60
+ const centered = isLastRow && isIncomplete && balanceLastRow;
61
+
62
+ return (
63
+ <View
64
+ // `columns` en la key: si cambia, las filas no reusan nodos DOM
65
+ // de una fila armada con otra cantidad de columnas.
66
+ key={`row-${columns}-${rowIndex}`}
67
+ style={[styles.row, centered ? styles.rowBalanced : styles.rowFull, gap !== undefined && { gap }]}
68
+ >
69
+ {row.map((item, colIndex) => {
70
+ const index = rowIndex * columns + colIndex;
71
+ return (
72
+ <View key={keyExtractor(item, index)} style={[styles.cell, { width: cellWidth }]}>
73
+ {renderItem(item, index)}
74
+ </View>
75
+ );
76
+ })}
77
+ </View>
78
+ );
79
+ })}
80
+ </View>
81
+ );
82
+ }
@@ -12,3 +12,5 @@ export { SegmentedControl } from './SegmentedControl';
12
12
  export type { SegmentedControlProps, SegmentedControlOption } from './SegmentedControl';
13
13
  export { Steps } from './Steps';
14
14
  export type { StepsProps, StepItem, StepStatus } from './Steps';
15
+ export { SymmetricGrid } from './SymmetricGrid';
16
+ export type { SymmetricGridProps } from './SymmetricGrid';
@@ -63,14 +63,21 @@ const HIDDEN: InternalState = { visible: false, title: '', type: 'info', buttons
63
63
  export function AlertProvider({ children }: { children: React.ReactNode }) {
64
64
  const [state, setState] = useState<InternalState>(HIDDEN);
65
65
  const resolverRef = useRef<((value: any) => void) | null>(null);
66
-
67
- const hide = useCallback(() => {
68
- setState(HIDDEN);
66
+ // Si alert()/confirm() se llaman de nuevo mientras ya hay uno abierto, el
67
+ // pedido anterior se resuelve (no se descarta) antes de mostrar el nuevo:
68
+ // evita que su promise quede colgada para siempre.
69
+ const settlePending = useCallback(() => {
69
70
  resolverRef.current?.(undefined);
70
71
  resolverRef.current = null;
71
72
  }, []);
72
73
 
74
+ const hide = useCallback(() => {
75
+ setState(HIDDEN);
76
+ settlePending();
77
+ }, [settlePending]);
78
+
73
79
  const alertFn = useCallback((options: AlertOptions): Promise<void> => {
80
+ settlePending();
74
81
  return new Promise((resolve) => {
75
82
  resolverRef.current = resolve;
76
83
  setState({
@@ -90,9 +97,10 @@ export function AlertProvider({ children }: { children: React.ReactNode }) {
90
97
  ],
91
98
  });
92
99
  });
93
- }, []);
100
+ }, [settlePending]);
94
101
 
95
102
  const confirmFn = useCallback((options: ConfirmOptions): Promise<boolean> => {
103
+ settlePending();
96
104
  return new Promise((resolve) => {
97
105
  resolverRef.current = resolve;
98
106
  setState({
@@ -124,7 +132,7 @@ export function AlertProvider({ children }: { children: React.ReactNode }) {
124
132
  ],
125
133
  });
126
134
  });
127
- }, []);
135
+ }, [settlePending]);
128
136
 
129
137
  const value = useMemo<AlertContextValue>(() => ({ alert: alertFn, confirm: confirmFn, hide }), [alertFn, confirmFn, hide]);
130
138
 
@@ -13,7 +13,7 @@ import {
13
13
  type ViewStyle,
14
14
  } from 'react-native';
15
15
  import { createStyles } from '../theme/createStyles';
16
- import { useColors, useInsets } from '../theme/UIProvider';
16
+ import { useColors, useModalSafeAreaBoundary } from '../theme/UIProvider';
17
17
  import { Title } from '../atoms/Text';
18
18
  import { IconButton } from '../atoms/IconButton';
19
19
  import { Transition } from '../atoms/Transition';
@@ -107,20 +107,28 @@ export const BottomSheet = memo(
107
107
  }: BottomSheetProps) => {
108
108
  const styles = useStyles();
109
109
  const colors = useColors();
110
- const insets = useInsets();
110
+ const ModalSafeAreaBoundary = useModalSafeAreaBoundary();
111
111
  const [mounted, setMounted] = useState(visible);
112
112
  const [keyboardOpen, setKeyboardOpen] = useState(false);
113
113
  const progress = useRef(new Animated.Value(0)).current;
114
+ // El PanResponder de más abajo se crea una sola vez; leer `onClose`
115
+ // directo del closure daría siempre el de ese primer render. El ref
116
+ // refleja la última prop sin importar cuándo se creó la closure.
117
+ const onCloseRef = useRef(onClose);
118
+ onCloseRef.current = onClose;
114
119
 
115
120
  useEffect(() => {
116
- if (visible) {
117
- setMounted(true);
118
- Animated.timing(progress, { toValue: 1, duration: 250, useNativeDriver: true }).start();
119
- } else {
120
- Animated.timing(progress, { toValue: 0, duration: 200, useNativeDriver: true }).start(({ finished }) => {
121
- if (finished) setMounted(false);
122
- });
123
- }
121
+ const animation = visible
122
+ ? Animated.timing(progress, { toValue: 1, duration: 250, useNativeDriver: true })
123
+ : Animated.timing(progress, { toValue: 0, duration: 200, useNativeDriver: true });
124
+ if (visible) setMounted(true);
125
+ animation.start(({ finished }) => {
126
+ if (!visible && finished) setMounted(false);
127
+ });
128
+ // Si el efecto vuelve a correr (o el componente se desmonta) antes de
129
+ // que termine, se corta: si no, un `finished` tardío podría llamar
130
+ // `setMounted` sobre un componente ya desmontado.
131
+ return () => animation.stop();
124
132
  }, [visible, progress]);
125
133
 
126
134
  useEffect(() => {
@@ -142,7 +150,7 @@ export const BottomSheet = memo(
142
150
  },
143
151
  onPanResponderRelease: (_, gesture) => {
144
152
  if (gesture.dy > DISMISS_THRESHOLD) {
145
- onClose();
153
+ onCloseRef.current();
146
154
  } else {
147
155
  Animated.timing(progress, { toValue: 1, duration: 150, useNativeDriver: true }).start();
148
156
  }
@@ -203,42 +211,48 @@ export const BottomSheet = memo(
203
211
  />
204
212
  </Transition>
205
213
 
206
- <Transition
207
- value={progress}
208
- preset="slide-up"
209
- distance={SCREEN_HEIGHT}
210
- testID={testID}
211
- style={[
212
- styles.sheet,
213
- { maxHeight: resolvedMaxHeight, paddingBottom: Math.max(insets.bottom, 20) },
214
- containerStyle,
215
- ]}
216
- >
217
- {draggable && (
218
- <View style={styles.handleArea} testID="bottom-sheet-handle" {...panResponder.panHandlers}>
219
- <View style={styles.handle} />
220
- </View>
221
- )}
214
+ {/* Insets medidos dentro de esta ventana nativa (el Modal nativo que
215
+ usa BottomSheet por debajo), no los de la raíz de la app. */}
216
+ <ModalSafeAreaBoundary>
217
+ {(insets) => (
218
+ <Transition
219
+ value={progress}
220
+ preset="slide-up"
221
+ distance={SCREEN_HEIGHT}
222
+ testID={testID}
223
+ style={[
224
+ styles.sheet,
225
+ { maxHeight: resolvedMaxHeight, paddingBottom: Math.max(insets.bottom, 20) },
226
+ containerStyle,
227
+ ]}
228
+ >
229
+ {draggable && (
230
+ <View style={styles.handleArea} testID="bottom-sheet-handle" {...panResponder.panHandlers}>
231
+ <View style={styles.handle} />
232
+ </View>
233
+ )}
222
234
 
223
- {hasHeader && (
224
- <View style={[styles.header, !draggable && styles.headerSpaced]}>
225
- <Title style={styles.title} numberOfLines={1}>
226
- {title}
227
- </Title>
228
- <IconButton
229
- iconName="close"
230
- size={24}
231
- color={colors.text}
232
- onPress={onClose}
233
- accessibilityLabel={closeAccessibilityLabel}
234
- />
235
- </View>
236
- )}
235
+ {hasHeader && (
236
+ <View style={[styles.header, !draggable && styles.headerSpaced]}>
237
+ <Title style={styles.title} numberOfLines={1}>
238
+ {title}
239
+ </Title>
240
+ <IconButton
241
+ iconName="close"
242
+ size={24}
243
+ color={colors.text}
244
+ onPress={onClose}
245
+ accessibilityLabel={closeAccessibilityLabel}
246
+ />
247
+ </View>
248
+ )}
237
249
 
238
- {body}
250
+ {body}
239
251
 
240
- {pinnedFooter}
241
- </Transition>
252
+ {pinnedFooter}
253
+ </Transition>
254
+ )}
255
+ </ModalSafeAreaBoundary>
242
256
  </KeyboardAvoidingView>
243
257
  </Modal>
244
258
  );
@@ -11,7 +11,8 @@ import {
11
11
  type ViewStyle,
12
12
  } from 'react-native';
13
13
  import { createStyles } from '../theme/createStyles';
14
- import { useColors, useInsets } from '../theme/UIProvider';
14
+ import { useColors, useModalSafeAreaBoundary } from '../theme/UIProvider';
15
+ import type { EdgeInsets } from '../theme/insets';
15
16
  import { Title } from '../atoms/Text';
16
17
  import { IconButton } from '../atoms/IconButton';
17
18
  import { Transition, type TransitionPreset } from '../atoms/Transition';
@@ -96,7 +97,7 @@ export const Modal = memo(
96
97
  const isOverlay = variant === 'overlay';
97
98
  const isFullScreen = variant === 'fullScreen';
98
99
  const isAnimatedVariant = isOverlay || isFull;
99
- const insets = useInsets();
100
+ const ModalSafeAreaBoundary = useModalSafeAreaBoundary();
100
101
  const colors = useColors();
101
102
  const styles = useStyles();
102
103
 
@@ -176,59 +177,66 @@ export const Modal = memo(
176
177
  hardwareAccelerated={hardwareAccelerated}
177
178
  >
178
179
  <KeyboardAvoidingView behavior={behavior} keyboardVerticalOffset={keyboardOffset} style={styles.flex1}>
179
- {isOverlay ? (
180
- <View style={styles.overlayContainer}>
181
- {/* Sin accessibilityLabel propio: el header ya expone un botón "Close"
182
- accesible; duplicar el label aquí generaría dos elementos con el
183
- mismo nombre para lectores de pantalla. Tocar afuera es un atajo
184
- puramente táctil. */}
185
- <Transition
186
- visible={visible}
187
- preset="fade"
188
- duration={200}
189
- keepMounted
190
- style={[{ ...StyleSheetAbsoluteFill }, styles.backdrop]}
191
- >
192
- <Pressable style={StyleSheetAbsoluteFill} onPress={onClose} testID="modal-backdrop" />
193
- </Transition>
194
-
195
- <Transition
196
- visible={visible}
197
- preset={cardPreset}
198
- distance={cardDistance}
199
- duration={250}
200
- onExited={() => setMounted(false)}
201
- style={[styles.overlayCard, containerStyle]}
202
- >
203
- {renderHeader()}
204
- {renderMainContent()}
205
- {renderFooter()}
206
- </Transition>
207
- </View>
208
- ) : isFull ? (
209
- <Transition
210
- visible={visible}
211
- preset={cardPreset}
212
- distance={cardDistance}
213
- duration={250}
214
- onExited={() => setMounted(false)}
215
- style={[styles.fullWrapper, containerStyle]}
216
- >
217
- <View style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
218
- {renderHeader()}
219
- {renderMainContent()}
220
- {renderFooter()}
221
- </View>
222
- </Transition>
223
- ) : (
224
- <View style={[styles.fullWrapper, containerStyle]}>
225
- <View style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
226
- {renderHeader()}
227
- {renderMainContent()}
228
- {renderFooter()}
229
- </View>
230
- </View>
231
- )}
180
+ {/* Insets medidos dentro de esta ventana nativa del Modal, no los de
181
+ la raíz de la app — pueden diferir (Android siempre abre una
182
+ ventana propia; iOS según `presentationStyle`). */}
183
+ <ModalSafeAreaBoundary>
184
+ {(insets: EdgeInsets) =>
185
+ isOverlay ? (
186
+ <View style={styles.overlayContainer}>
187
+ {/* Sin accessibilityLabel propio: el header ya expone un botón "Close"
188
+ accesible; duplicar el label aquí generaría dos elementos con el
189
+ mismo nombre para lectores de pantalla. Tocar afuera es un atajo
190
+ puramente táctil. */}
191
+ <Transition
192
+ visible={visible}
193
+ preset="fade"
194
+ duration={200}
195
+ keepMounted
196
+ style={[{ ...StyleSheetAbsoluteFill }, styles.backdrop]}
197
+ >
198
+ <Pressable style={StyleSheetAbsoluteFill} onPress={onClose} testID="modal-backdrop" />
199
+ </Transition>
200
+
201
+ <Transition
202
+ visible={visible}
203
+ preset={cardPreset}
204
+ distance={cardDistance}
205
+ duration={250}
206
+ onExited={() => setMounted(false)}
207
+ style={[styles.overlayCard, containerStyle]}
208
+ >
209
+ {renderHeader()}
210
+ {renderMainContent()}
211
+ {renderFooter()}
212
+ </Transition>
213
+ </View>
214
+ ) : isFull ? (
215
+ <Transition
216
+ visible={visible}
217
+ preset={cardPreset}
218
+ distance={cardDistance}
219
+ duration={250}
220
+ onExited={() => setMounted(false)}
221
+ style={[styles.fullWrapper, containerStyle]}
222
+ >
223
+ <View style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
224
+ {renderHeader()}
225
+ {renderMainContent()}
226
+ {renderFooter()}
227
+ </View>
228
+ </Transition>
229
+ ) : (
230
+ <View style={[styles.fullWrapper, containerStyle]}>
231
+ <View style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
232
+ {renderHeader()}
233
+ {renderMainContent()}
234
+ {renderFooter()}
235
+ </View>
236
+ </View>
237
+ )
238
+ }
239
+ </ModalSafeAreaBoundary>
232
240
  </KeyboardAvoidingView>
233
241
  </RNModal>
234
242
  );
@@ -0,0 +1,151 @@
1
+ import React, { useRef, useState } from 'react';
2
+ import { Animated, PanResponder, View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { createStyles } from '../theme/createStyles';
4
+
5
+ export interface ReorderableListProps<T> {
6
+ data: T[];
7
+ keyExtractor: (item: T, index: number) => string;
8
+ /** Alto fijo de cada fila: necesario para calcular el índice destino sin medir layout async. */
9
+ itemHeight: number;
10
+ renderItem: (item: T, index: number, dragging: boolean) => React.ReactElement;
11
+ /** Se llama al soltar, con el arreglo ya reordenado. No muta `data`. */
12
+ onReorder: (data: T[]) => void;
13
+ disabled?: boolean;
14
+ style?: StyleProp<ViewStyle>;
15
+ }
16
+
17
+ /** Reordena moviendo el ítem de `from` a `to`, sin mutar `data`. Exportada aparte por ser testeable sin simular gestos. */
18
+ export function move<T>(data: T[], from: number, to: number): T[] {
19
+ const next = data.slice();
20
+ const [item] = next.splice(from, 1) as [T];
21
+ next.splice(to, 0, item);
22
+ return next;
23
+ }
24
+
25
+ const useStyles = createStyles(() => ({
26
+ row: { width: '100%' },
27
+ }));
28
+
29
+ /**
30
+ * ReorderableList
31
+ * Ley de destino común (common fate): al arrastrar una fila, las filas que
32
+ * tiene que atravesar se desplazan juntas en el mismo sentido — eso es lo
33
+ * que comunica "van a intercambiar lugar" antes de soltar el dedo, no un
34
+ * simple resaltado del ítem activo.
35
+ *
36
+ * Sin dependencias de gestos/animación externas (reanimated, gesture-handler):
37
+ * PanResponder + Animated alcanzan porque el cálculo de índice destino
38
+ * necesita el valor de arrastre en JS en cada movimiento (no algo que
39
+ * useNativeDriver pueda resolver solo).
40
+ */
41
+ export function ReorderableList<T>({
42
+ data,
43
+ keyExtractor,
44
+ itemHeight,
45
+ renderItem,
46
+ onReorder,
47
+ disabled = false,
48
+ style,
49
+ }: ReorderableListProps<T>) {
50
+ const styles = useStyles();
51
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
52
+ const [targetIndex, setTargetIndex] = useState<number | null>(null);
53
+ const dy = useRef(new Animated.Value(0)).current;
54
+ const activeIndexRef = useRef<number | null>(null);
55
+ // El PanResponder de cada fila se crea una sola vez (se cachea en el Map de
56
+ // abajo) y su closure queda fijo al render de esa primera vez: leer
57
+ // `targetIndex` (state) directamente ahí siempre daría el valor del
58
+ // montaje. Un ref sí refleja la última escritura sin importar cuándo se
59
+ // creó la closure que lo lee.
60
+ const targetIndexRef = useRef<number | null>(null);
61
+ const dataRef = useRef(data);
62
+ dataRef.current = data;
63
+ // Igual razón que targetIndexRef: el responder de cada fila se crea una
64
+ // sola vez, así que leer `disabled` directo del closure congelaría el
65
+ // valor del momento en que esa fila se creó.
66
+ const disabledRef = useRef(disabled);
67
+ disabledRef.current = disabled;
68
+
69
+ const panResponders = useRef(new Map<number, ReturnType<typeof PanResponder.create>>()).current;
70
+ // Si `data` se achica, los responders de índices que ya no existen quedan
71
+ // colgados en el Map para siempre: se podan en cada render.
72
+ for (const index of panResponders.keys()) {
73
+ if (index >= data.length) panResponders.delete(index);
74
+ }
75
+
76
+ function panResponderFor(index: number) {
77
+ let responder = panResponders.get(index);
78
+ if (responder) return responder;
79
+
80
+ responder = PanResponder.create({
81
+ onStartShouldSetPanResponder: () => !disabledRef.current,
82
+ onMoveShouldSetPanResponder: (_, gesture) => !disabledRef.current && Math.abs(gesture.dy) > 2,
83
+ onPanResponderGrant: () => {
84
+ activeIndexRef.current = index;
85
+ targetIndexRef.current = index;
86
+ setActiveIndex(index);
87
+ setTargetIndex(index);
88
+ dy.setValue(0);
89
+ },
90
+ onPanResponderMove: (_, gesture) => {
91
+ dy.setValue(gesture.dy);
92
+ const from = activeIndexRef.current;
93
+ if (from === null) return;
94
+ const rawTarget = from + Math.round(gesture.dy / itemHeight);
95
+ const clamped = Math.max(0, Math.min(dataRef.current.length - 1, rawTarget));
96
+ targetIndexRef.current = clamped;
97
+ setTargetIndex((prev) => (prev === clamped ? prev : clamped));
98
+ },
99
+ onPanResponderRelease: () => {
100
+ const from = activeIndexRef.current;
101
+ const to = targetIndexRef.current;
102
+ dy.setValue(0);
103
+ activeIndexRef.current = null;
104
+ targetIndexRef.current = null;
105
+ setActiveIndex(null);
106
+ setTargetIndex(null);
107
+ if (from === null || to === null || from === to) return;
108
+ onReorder(move(dataRef.current, from, to));
109
+ },
110
+ onPanResponderTerminate: () => {
111
+ dy.setValue(0);
112
+ activeIndexRef.current = null;
113
+ targetIndexRef.current = null;
114
+ setActiveIndex(null);
115
+ setTargetIndex(null);
116
+ },
117
+ });
118
+ panResponders.set(index, responder);
119
+ return responder;
120
+ }
121
+
122
+ return (
123
+ <View style={style}>
124
+ {data.map((item, index) => {
125
+ const isActive = activeIndex === index;
126
+ // Filas entre el origen y el destino se corren un lugar entero en el
127
+ // sentido del arrastre: es el efecto de "grupo que se mueve junto".
128
+ let shift = 0;
129
+ if (activeIndex !== null && targetIndex !== null && !isActive) {
130
+ if (activeIndex < targetIndex && index > activeIndex && index <= targetIndex) shift = -itemHeight;
131
+ else if (activeIndex > targetIndex && index < activeIndex && index >= targetIndex) shift = itemHeight;
132
+ }
133
+
134
+ return (
135
+ <Animated.View
136
+ key={keyExtractor(item, index)}
137
+ style={[
138
+ styles.row,
139
+ isActive
140
+ ? { transform: [{ translateY: dy }], zIndex: 1, elevation: 4 }
141
+ : { transform: [{ translateY: shift }] },
142
+ ]}
143
+ {...panResponderFor(index).panHandlers}
144
+ >
145
+ {renderItem(item, index, isActive)}
146
+ </Animated.View>
147
+ );
148
+ })}
149
+ </View>
150
+ );
151
+ }
@@ -68,12 +68,11 @@ export const Screen = memo(({
68
68
  {scrollable ? (
69
69
  <ScrollView
70
70
  style={styles.container}
71
- contentInsetAdjustmentBehavior="automatic"
72
71
  automaticallyAdjustKeyboardInsets
73
72
  contentContainerStyle={[
74
73
  styles.contentContainer,
75
74
  { paddingTop: applyTop ? insets.top : 0 },
76
- Platform.OS === 'android' && applyBottom && { paddingBottom: insets.bottom },
75
+ applyBottom && { paddingBottom: insets.bottom },
77
76
  contentContainerStyle,
78
77
  ]}
79
78
  keyboardShouldPersistTaps="handled"
@@ -28,3 +28,5 @@ export { ThemeToggle } from './ThemeToggle';
28
28
  export type { ThemeToggleProps } from './ThemeToggle';
29
29
  export { NavigationBar } from './NavigationBar';
30
30
  export type { NavigationBarProps, NavigationBarItem } from './NavigationBar';
31
+ export { ReorderableList } from './ReorderableList';
32
+ export type { ReorderableListProps } from './ReorderableList';