orn-ui 0.2.3 → 0.2.5

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 +165 -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 +159 -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 +48 -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 +183 -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
@@ -149,13 +149,17 @@ export const Steps = memo(
149
149
  {steps.map((step, index) => {
150
150
  const status = statusOf(index, current);
151
151
  const isLast = index === steps.length - 1;
152
- const Wrapper: any = onStepPress ? TouchableOpacity : View;
152
+ const Wrapper = onStepPress ? TouchableOpacity : View;
153
+ // Sólo tocable si hay onStepPress: pasarle onPress/activeOpacity
154
+ // a un View plano no crashea, pero los deja como ruido silencioso.
155
+ const interactiveProps = onStepPress
156
+ ? { onPress: () => onStepPress(index), activeOpacity: 0.7 }
157
+ : undefined;
153
158
  return (
154
159
  <Wrapper
155
- key={step.label}
160
+ key={`${step.label}-${index}`}
156
161
  style={styles.vStep}
157
- onPress={onStepPress ? () => onStepPress(index) : undefined}
158
- activeOpacity={0.7}
162
+ {...interactiveProps}
159
163
  {...stepAccessibility(index, step, status)}
160
164
  >
161
165
  <View style={styles.vIndicatorColumn}>
@@ -190,13 +194,15 @@ export const Steps = memo(
190
194
  {steps.map((step, index) => {
191
195
  const status = statusOf(index, current);
192
196
  const isLast = index === steps.length - 1;
193
- const Wrapper: any = onStepPress ? TouchableOpacity : View;
197
+ const Wrapper = onStepPress ? TouchableOpacity : View;
198
+ const interactiveProps = onStepPress
199
+ ? { onPress: () => onStepPress(index), activeOpacity: 0.7 }
200
+ : undefined;
194
201
  return (
195
- <React.Fragment key={step.label}>
202
+ <React.Fragment key={`${step.label}-${index}`}>
196
203
  <Wrapper
197
204
  style={styles.hStep}
198
- onPress={onStepPress ? () => onStepPress(index) : undefined}
199
- activeOpacity={0.7}
205
+ {...interactiveProps}
200
206
  {...stepAccessibility(index, step, status)}
201
207
  >
202
208
  {renderCircle(index, status)}
@@ -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,183 @@
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
+ /**
14
+ * Empieza el arrastre de la fila `index`. Úsalo para apagar el scroll del
15
+ * contenedor (`scrollEnabled={false}`) mientras dura: en iOS el
16
+ * `UIScrollView` padre cancela los toques del contenido apenas su gesto
17
+ * vertical arranca, y sin eso el arrastre muere a los pocos píxeles.
18
+ */
19
+ onDragStart?: (index: number) => void;
20
+ /** Termina el arrastre — soltando o por cancelación. Siempre corre si corrió `onDragStart`. */
21
+ onDragEnd?: () => void;
22
+ disabled?: boolean;
23
+ style?: StyleProp<ViewStyle>;
24
+ }
25
+
26
+ /** Reordena moviendo el ítem de `from` a `to`, sin mutar `data`. Exportada aparte por ser testeable sin simular gestos. */
27
+ export function move<T>(data: T[], from: number, to: number): T[] {
28
+ const next = data.slice();
29
+ const [item] = next.splice(from, 1) as [T];
30
+ next.splice(to, 0, item);
31
+ return next;
32
+ }
33
+
34
+ const useStyles = createStyles(() => ({
35
+ row: { width: '100%' },
36
+ }));
37
+
38
+ /**
39
+ * ReorderableList
40
+ * Ley de destino común (common fate): al arrastrar una fila, las filas que
41
+ * tiene que atravesar se desplazan juntas en el mismo sentido — eso es lo
42
+ * que comunica "van a intercambiar lugar" antes de soltar el dedo, no un
43
+ * simple resaltado del ítem activo.
44
+ *
45
+ * Sin dependencias de gestos/animación externas (reanimated, gesture-handler):
46
+ * PanResponder + Animated alcanzan porque el cálculo de índice destino
47
+ * necesita el valor de arrastre en JS en cada movimiento (no algo que
48
+ * useNativeDriver pueda resolver solo).
49
+ *
50
+ * Dentro de un scroll vertical hay que cablear `onDragStart`/`onDragEnd` a
51
+ * `scrollEnabled`, o en iOS el arrastre no llega a empezar:
52
+ *
53
+ * ```tsx
54
+ * const [dragging, setDragging] = useState(false);
55
+ * <ScrollView scrollEnabled={!dragging}>
56
+ * <ReorderableList onDragStart={() => setDragging(true)} onDragEnd={() => setDragging(false)} … />
57
+ * </ScrollView>
58
+ * ```
59
+ */
60
+ export function ReorderableList<T>({
61
+ data,
62
+ keyExtractor,
63
+ itemHeight,
64
+ renderItem,
65
+ onReorder,
66
+ onDragStart,
67
+ onDragEnd,
68
+ disabled = false,
69
+ style,
70
+ }: ReorderableListProps<T>) {
71
+ const styles = useStyles();
72
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
73
+ const [targetIndex, setTargetIndex] = useState<number | null>(null);
74
+ const dy = useRef(new Animated.Value(0)).current;
75
+ const activeIndexRef = useRef<number | null>(null);
76
+ // El PanResponder de cada fila se crea una sola vez (se cachea en el Map de
77
+ // abajo) y su closure queda fijo al render de esa primera vez: leer
78
+ // `targetIndex` (state) directamente ahí siempre daría el valor del
79
+ // montaje. Un ref sí refleja la última escritura sin importar cuándo se
80
+ // creó la closure que lo lee.
81
+ const targetIndexRef = useRef<number | null>(null);
82
+ const dataRef = useRef(data);
83
+ dataRef.current = data;
84
+ // Igual razón que targetIndexRef: el responder de cada fila se crea una
85
+ // sola vez, así que leer `disabled` directo del closure congelaría el
86
+ // valor del momento en que esa fila se creó.
87
+ const disabledRef = useRef(disabled);
88
+ disabledRef.current = disabled;
89
+ // Misma razón: los callbacks se leen desde closures creadas una sola vez.
90
+ const onReorderRef = useRef(onReorder);
91
+ onReorderRef.current = onReorder;
92
+ const onDragStartRef = useRef(onDragStart);
93
+ onDragStartRef.current = onDragStart;
94
+ const onDragEndRef = useRef(onDragEnd);
95
+ onDragEndRef.current = onDragEnd;
96
+
97
+ const panResponders = useRef(new Map<number, ReturnType<typeof PanResponder.create>>()).current;
98
+ // Si `data` se achica, los responders de índices que ya no existen quedan
99
+ // colgados en el Map para siempre: se podan en cada render.
100
+ for (const index of panResponders.keys()) {
101
+ if (index >= data.length) panResponders.delete(index);
102
+ }
103
+
104
+ function panResponderFor(index: number) {
105
+ let responder = panResponders.get(index);
106
+ if (responder) return responder;
107
+
108
+ const endDrag = () => {
109
+ dy.setValue(0);
110
+ activeIndexRef.current = null;
111
+ targetIndexRef.current = null;
112
+ setActiveIndex(null);
113
+ setTargetIndex(null);
114
+ onDragEndRef.current?.();
115
+ };
116
+
117
+ responder = PanResponder.create({
118
+ onStartShouldSetPanResponder: () => !disabledRef.current,
119
+ onMoveShouldSetPanResponder: (_, gesture) => !disabledRef.current && Math.abs(gesture.dy) > 2,
120
+ // Un scroll padre pide el responder apenas su gesto vertical arranca —
121
+ // el mismo eje que el arrastre. Cederlo cancelaría el drag a mitad de
122
+ // camino, así que la fila se lo queda hasta soltar.
123
+ onPanResponderTerminationRequest: () => false,
124
+ onPanResponderGrant: () => {
125
+ activeIndexRef.current = index;
126
+ targetIndexRef.current = index;
127
+ setActiveIndex(index);
128
+ setTargetIndex(index);
129
+ dy.setValue(0);
130
+ onDragStartRef.current?.(index);
131
+ },
132
+ onPanResponderMove: (_, gesture) => {
133
+ dy.setValue(gesture.dy);
134
+ const from = activeIndexRef.current;
135
+ if (from === null) return;
136
+ const rawTarget = from + Math.round(gesture.dy / itemHeight);
137
+ const clamped = Math.max(0, Math.min(dataRef.current.length - 1, rawTarget));
138
+ targetIndexRef.current = clamped;
139
+ setTargetIndex((prev) => (prev === clamped ? prev : clamped));
140
+ },
141
+ onPanResponderRelease: () => {
142
+ const from = activeIndexRef.current;
143
+ const to = targetIndexRef.current;
144
+ endDrag();
145
+ if (from === null || to === null || from === to) return;
146
+ onReorderRef.current(move(dataRef.current, from, to));
147
+ },
148
+ onPanResponderTerminate: endDrag,
149
+ });
150
+ panResponders.set(index, responder);
151
+ return responder;
152
+ }
153
+
154
+ return (
155
+ <View style={style}>
156
+ {data.map((item, index) => {
157
+ const isActive = activeIndex === index;
158
+ // Filas entre el origen y el destino se corren un lugar entero en el
159
+ // sentido del arrastre: es el efecto de "grupo que se mueve junto".
160
+ let shift = 0;
161
+ if (activeIndex !== null && targetIndex !== null && !isActive) {
162
+ if (activeIndex < targetIndex && index > activeIndex && index <= targetIndex) shift = -itemHeight;
163
+ else if (activeIndex > targetIndex && index < activeIndex && index >= targetIndex) shift = itemHeight;
164
+ }
165
+
166
+ return (
167
+ <Animated.View
168
+ key={keyExtractor(item, index)}
169
+ style={[
170
+ styles.row,
171
+ isActive
172
+ ? { transform: [{ translateY: dy }], zIndex: 1, elevation: 4 }
173
+ : { transform: [{ translateY: shift }] },
174
+ ]}
175
+ {...panResponderFor(index).panHandlers}
176
+ >
177
+ {renderItem(item, index, isActive)}
178
+ </Animated.View>
179
+ );
180
+ })}
181
+ </View>
182
+ );
183
+ }
@@ -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';