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.
- package/lib/commonjs/molecules/Steps.js +14 -6
- package/lib/commonjs/molecules/Steps.js.map +1 -1
- package/lib/commonjs/molecules/SymmetricGrid.js +84 -0
- package/lib/commonjs/molecules/SymmetricGrid.js.map +1 -0
- package/lib/commonjs/molecules/index.js +7 -0
- package/lib/commonjs/molecules/index.js.map +1 -1
- package/lib/commonjs/organisms/AlertProvider.js +12 -4
- package/lib/commonjs/organisms/AlertProvider.js.map +1 -1
- package/lib/commonjs/organisms/BottomSheet.js +58 -50
- package/lib/commonjs/organisms/BottomSheet.js.map +1 -1
- package/lib/commonjs/organisms/Modal.js +44 -42
- package/lib/commonjs/organisms/Modal.js.map +1 -1
- package/lib/commonjs/organisms/ReorderableList.js +165 -0
- package/lib/commonjs/organisms/ReorderableList.js.map +1 -0
- package/lib/commonjs/organisms/Screen.js +1 -2
- package/lib/commonjs/organisms/Screen.js.map +1 -1
- package/lib/commonjs/organisms/index.js +7 -0
- package/lib/commonjs/organisms/index.js.map +1 -1
- package/lib/commonjs/safe-area/SafeAreaUIProvider.js +38 -30
- package/lib/commonjs/safe-area/SafeAreaUIProvider.js.map +1 -1
- package/lib/commonjs/theme/UIProvider.js +35 -8
- package/lib/commonjs/theme/UIProvider.js.map +1 -1
- package/lib/module/molecules/Steps.js +14 -6
- package/lib/module/molecules/Steps.js.map +1 -1
- package/lib/module/molecules/SymmetricGrid.js +79 -0
- package/lib/module/molecules/SymmetricGrid.js.map +1 -0
- package/lib/module/molecules/index.js +1 -0
- package/lib/module/molecules/index.js.map +1 -1
- package/lib/module/organisms/AlertProvider.js +12 -4
- package/lib/module/organisms/AlertProvider.js.map +1 -1
- package/lib/module/organisms/BottomSheet.js +59 -51
- package/lib/module/organisms/BottomSheet.js.map +1 -1
- package/lib/module/organisms/Modal.js +45 -43
- package/lib/module/organisms/Modal.js.map +1 -1
- package/lib/module/organisms/ReorderableList.js +159 -0
- package/lib/module/organisms/ReorderableList.js.map +1 -0
- package/lib/module/organisms/Screen.js +1 -2
- package/lib/module/organisms/Screen.js.map +1 -1
- package/lib/module/organisms/index.js +1 -0
- package/lib/module/organisms/index.js.map +1 -1
- package/lib/module/safe-area/SafeAreaUIProvider.js +39 -31
- package/lib/module/safe-area/SafeAreaUIProvider.js.map +1 -1
- package/lib/module/theme/UIProvider.js +36 -10
- package/lib/module/theme/UIProvider.js.map +1 -1
- package/lib/typescript/molecules/Steps.d.ts.map +1 -1
- package/lib/typescript/molecules/SymmetricGrid.d.ts +26 -0
- package/lib/typescript/molecules/SymmetricGrid.d.ts.map +1 -0
- package/lib/typescript/molecules/index.d.ts +2 -0
- package/lib/typescript/molecules/index.d.ts.map +1 -1
- package/lib/typescript/organisms/AlertProvider.d.ts.map +1 -1
- package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
- package/lib/typescript/organisms/Modal.d.ts.map +1 -1
- package/lib/typescript/organisms/ReorderableList.d.ts +48 -0
- package/lib/typescript/organisms/ReorderableList.d.ts.map +1 -0
- package/lib/typescript/organisms/Screen.d.ts.map +1 -1
- package/lib/typescript/organisms/index.d.ts +2 -0
- package/lib/typescript/organisms/index.d.ts.map +1 -1
- package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts +0 -38
- package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts.map +1 -1
- package/lib/typescript/theme/UIProvider.d.ts +21 -1
- package/lib/typescript/theme/UIProvider.d.ts.map +1 -1
- package/package.json +11 -1
- package/registry/alert-provider.json +1 -1
- package/registry/bottom-sheet.json +1 -1
- package/registry/core.json +1 -1
- package/registry/index.json +30 -6
- package/registry/manifest.json +14 -0
- package/registry/modal.json +1 -1
- package/registry/reorderable-list.json +18 -0
- package/registry/screen.json +1 -1
- package/registry/steps.json +1 -1
- package/registry/symmetric-grid.json +18 -0
- package/src/molecules/Steps.tsx +14 -8
- package/src/molecules/SymmetricGrid.tsx +82 -0
- package/src/molecules/index.ts +2 -0
- package/src/organisms/AlertProvider.tsx +13 -5
- package/src/organisms/BottomSheet.tsx +58 -44
- package/src/organisms/Modal.tsx +63 -55
- package/src/organisms/ReorderableList.tsx +183 -0
- package/src/organisms/Screen.tsx +1 -2
- package/src/organisms/index.ts +2 -0
- package/src/safe-area/SafeAreaUIProvider.tsx +46 -41
- package/src/theme/UIProvider.tsx +50 -8
package/src/molecules/Steps.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/molecules/index.ts
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
Animated.timing(progress, { toValue:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
250
|
+
{body}
|
|
239
251
|
|
|
240
|
-
|
|
241
|
-
|
|
252
|
+
{pinnedFooter}
|
|
253
|
+
</Transition>
|
|
254
|
+
)}
|
|
255
|
+
</ModalSafeAreaBoundary>
|
|
242
256
|
</KeyboardAvoidingView>
|
|
243
257
|
</Modal>
|
|
244
258
|
);
|
package/src/organisms/Modal.tsx
CHANGED
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
type ViewStyle,
|
|
12
12
|
} from 'react-native';
|
|
13
13
|
import { createStyles } from '../theme/createStyles';
|
|
14
|
-
import { useColors,
|
|
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
|
|
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
|
-
{
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
+
}
|
package/src/organisms/Screen.tsx
CHANGED
|
@@ -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
|
-
|
|
75
|
+
applyBottom && { paddingBottom: insets.bottom },
|
|
77
76
|
contentContainerStyle,
|
|
78
77
|
]}
|
|
79
78
|
keyboardShouldPersistTaps="handled"
|
package/src/organisms/index.ts
CHANGED
|
@@ -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';
|