orn-ui 0.2.2 → 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.
- 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/NavigationBar.js +4 -1
- package/lib/commonjs/organisms/NavigationBar.js.map +1 -1
- package/lib/commonjs/organisms/ReorderableList.js +143 -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/NavigationBar.js +4 -1
- package/lib/module/organisms/NavigationBar.js.map +1 -1
- package/lib/module/organisms/ReorderableList.js +137 -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/NavigationBar.d.ts.map +1 -1
- package/lib/typescript/organisms/ReorderableList.d.ts +29 -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 +31 -7
- package/registry/manifest.json +14 -0
- package/registry/modal.json +1 -1
- package/registry/navigation-bar.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/NavigationBar.tsx +4 -1
- package/src/organisms/ReorderableList.tsx +151 -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/registry/steps.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "molecules/Steps.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { ScrollView, Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\n\nexport interface StepItem {\n label: string;\n description?: string;\n}\n\nexport type StepStatus = 'completed' | 'current' | 'pending';\n\nexport interface StepsProps {\n steps: StepItem[];\n /** Índice del paso actual (0-based). Los anteriores quedan 'completed'. */\n current: number;\n /** @default 'horizontal' */\n orientation?: 'horizontal' | 'vertical';\n /**\n * Qué muestra el círculo de un paso ya completado: un check, o su número.\n * @default 'check'\n */\n completedIndicator?: 'check' | 'number';\n /** Si se pasa, cada paso es tocable y reporta su índice. */\n onStepPress?: (index: number) => void;\n style?: StyleProp<ViewStyle>;\n}\n\nconst CIRCLE = 32;\n/** Ancho mínimo de una columna horizontal: por debajo, las etiquetas se parten. */\nconst STEP_MIN_WIDTH = 76;\n\nconst useStyles = createStyles((theme) => ({\n rowContainer: { flexDirection: 'row', alignItems: 'flex-start' },\n columnContainer: { flexDirection: 'column' },\n\n // horizontal\n // ScrollView trae flexGrow: 1 de fábrica y se comía todo el alto disponible\n // del contenedor, dejando los pasos pegados arriba de una caja enorme.\n hScrollView: { flexGrow: 0, flexShrink: 0 },\n hScroll: { flexGrow: 1, flexDirection: 'row', alignItems: 'flex-start' },\n // `minWidth` es lo que evita que RN parta palabras a la mitad\n // (\"Almacé / n\"): antes que apretar la columna por debajo de una palabra\n // corriente, la fila se vuelve scrollable.\n hStep: { flex: 1, minWidth: STEP_MIN_WIDTH, alignItems: 'center', gap: theme.tokens.spacing.sm },\n hLabels: { alignItems: 'center', gap: 2, paddingHorizontal: 4 },\n // El conector toma una fracción chica del ancho: compitiendo de igual a\n // igual con los pasos (flex: 1) cada etiqueta se quedaba con 1/(2n-1) del\n // total.\n hConnector: { height: 2, flex: 0.3, minWidth: 12, marginTop: CIRCLE / 2 - 1 },\n hLabel: { fontSize: theme.tokens.fontSize.sm },\n hDescription: { fontSize: theme.tokens.fontSize.xs },\n\n // vertical\n vStep: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n vIndicatorColumn: { alignItems: 'center' },\n vConnector: { width: 2, flex: 1, minHeight: 20, marginVertical: 4 },\n vLabels: { flex: 1, gap: 2, paddingBottom: theme.tokens.spacing.lg },\n\n circle: {\n width: CIRCLE,\n height: CIRCLE,\n borderRadius: CIRCLE / 2,\n borderWidth: 2,\n alignItems: 'center',\n justifyContent: 'center',\n },\n circleNumber: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.semibold },\n label: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.text },\n labelCentered: { textAlign: 'center' },\n description: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight },\n descriptionCentered: { textAlign: 'center' },\n}));\n\nfunction statusOf(index: number, current: number): StepStatus {\n if (index < current) return 'completed';\n if (index === current) return 'current';\n return 'pending';\n}\n\n/**\n * Steps\n * Indicador de progreso multi-paso. Base visual de `Wizard`, pero utilizable\n * suelto para mostrar el avance de un flujo que se navega de otra forma.\n */\nexport const Steps = memo(\n ({ steps, current, orientation = 'horizontal', completedIndicator = 'check', onStepPress, style }: StepsProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isVertical = orientation === 'vertical';\n\n const colorsFor = (status: StepStatus) => {\n if (status === 'completed') return { bg: colors.primary, border: colors.primary, fg: colors.onPrimary };\n if (status === 'current') return { bg: 'transparent', border: colors.primary, fg: colors.primaryText };\n return { bg: 'transparent', border: colors.border, fg: colors.textLight };\n };\n\n const renderCircle = (index: number, status: StepStatus) => {\n const c = colorsFor(status);\n const showCheck = status === 'completed' && completedIndicator === 'check';\n return (\n <View style={[styles.circle, { backgroundColor: c.bg, borderColor: c.border }]}>\n {showCheck ? (\n <Icon name=\"check\" size={18} color={c.fg} />\n ) : (\n <Text allowFontScaling={allowFontScaling} style={[styles.circleNumber, { color: c.fg }]}>\n {index + 1}\n </Text>\n )}\n </View>\n );\n };\n\n const renderLabels = (step: StepItem, status: StepStatus, centered: boolean) => (\n <View style={centered ? styles.hLabels : styles.vLabels}>\n <Text\n allowFontScaling={allowFontScaling}\n style={[\n styles.label,\n centered && styles.labelCentered,\n centered && styles.hLabel,\n status === 'pending' && { color: colors.textLight },\n ]}\n >\n {step.label}\n </Text>\n {!!step.description && (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.description, centered && styles.descriptionCentered, centered && styles.hDescription]}\n >\n {step.description}\n </Text>\n )}\n </View>\n );\n\n const stepAccessibility = (index: number, step: StepItem, status: StepStatus) => ({\n accessibilityRole: (onStepPress ? 'button' : 'text') as 'button' | 'text',\n accessibilityLabel: `Step ${index + 1}: ${step.label}`,\n accessibilityState: { selected: status === 'current', disabled: false },\n });\n\n if (isVertical) {\n return (\n <View style={[styles.columnContainer, style]}>\n {steps.map((step, index) => {\n const status = statusOf(index, current);\n const isLast = index === steps.length - 1;\n const Wrapper
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { ScrollView, Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\n\nexport interface StepItem {\n label: string;\n description?: string;\n}\n\nexport type StepStatus = 'completed' | 'current' | 'pending';\n\nexport interface StepsProps {\n steps: StepItem[];\n /** Índice del paso actual (0-based). Los anteriores quedan 'completed'. */\n current: number;\n /** @default 'horizontal' */\n orientation?: 'horizontal' | 'vertical';\n /**\n * Qué muestra el círculo de un paso ya completado: un check, o su número.\n * @default 'check'\n */\n completedIndicator?: 'check' | 'number';\n /** Si se pasa, cada paso es tocable y reporta su índice. */\n onStepPress?: (index: number) => void;\n style?: StyleProp<ViewStyle>;\n}\n\nconst CIRCLE = 32;\n/** Ancho mínimo de una columna horizontal: por debajo, las etiquetas se parten. */\nconst STEP_MIN_WIDTH = 76;\n\nconst useStyles = createStyles((theme) => ({\n rowContainer: { flexDirection: 'row', alignItems: 'flex-start' },\n columnContainer: { flexDirection: 'column' },\n\n // horizontal\n // ScrollView trae flexGrow: 1 de fábrica y se comía todo el alto disponible\n // del contenedor, dejando los pasos pegados arriba de una caja enorme.\n hScrollView: { flexGrow: 0, flexShrink: 0 },\n hScroll: { flexGrow: 1, flexDirection: 'row', alignItems: 'flex-start' },\n // `minWidth` es lo que evita que RN parta palabras a la mitad\n // (\"Almacé / n\"): antes que apretar la columna por debajo de una palabra\n // corriente, la fila se vuelve scrollable.\n hStep: { flex: 1, minWidth: STEP_MIN_WIDTH, alignItems: 'center', gap: theme.tokens.spacing.sm },\n hLabels: { alignItems: 'center', gap: 2, paddingHorizontal: 4 },\n // El conector toma una fracción chica del ancho: compitiendo de igual a\n // igual con los pasos (flex: 1) cada etiqueta se quedaba con 1/(2n-1) del\n // total.\n hConnector: { height: 2, flex: 0.3, minWidth: 12, marginTop: CIRCLE / 2 - 1 },\n hLabel: { fontSize: theme.tokens.fontSize.sm },\n hDescription: { fontSize: theme.tokens.fontSize.xs },\n\n // vertical\n vStep: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n vIndicatorColumn: { alignItems: 'center' },\n vConnector: { width: 2, flex: 1, minHeight: 20, marginVertical: 4 },\n vLabels: { flex: 1, gap: 2, paddingBottom: theme.tokens.spacing.lg },\n\n circle: {\n width: CIRCLE,\n height: CIRCLE,\n borderRadius: CIRCLE / 2,\n borderWidth: 2,\n alignItems: 'center',\n justifyContent: 'center',\n },\n circleNumber: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.semibold },\n label: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.text },\n labelCentered: { textAlign: 'center' },\n description: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight },\n descriptionCentered: { textAlign: 'center' },\n}));\n\nfunction statusOf(index: number, current: number): StepStatus {\n if (index < current) return 'completed';\n if (index === current) return 'current';\n return 'pending';\n}\n\n/**\n * Steps\n * Indicador de progreso multi-paso. Base visual de `Wizard`, pero utilizable\n * suelto para mostrar el avance de un flujo que se navega de otra forma.\n */\nexport const Steps = memo(\n ({ steps, current, orientation = 'horizontal', completedIndicator = 'check', onStepPress, style }: StepsProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isVertical = orientation === 'vertical';\n\n const colorsFor = (status: StepStatus) => {\n if (status === 'completed') return { bg: colors.primary, border: colors.primary, fg: colors.onPrimary };\n if (status === 'current') return { bg: 'transparent', border: colors.primary, fg: colors.primaryText };\n return { bg: 'transparent', border: colors.border, fg: colors.textLight };\n };\n\n const renderCircle = (index: number, status: StepStatus) => {\n const c = colorsFor(status);\n const showCheck = status === 'completed' && completedIndicator === 'check';\n return (\n <View style={[styles.circle, { backgroundColor: c.bg, borderColor: c.border }]}>\n {showCheck ? (\n <Icon name=\"check\" size={18} color={c.fg} />\n ) : (\n <Text allowFontScaling={allowFontScaling} style={[styles.circleNumber, { color: c.fg }]}>\n {index + 1}\n </Text>\n )}\n </View>\n );\n };\n\n const renderLabels = (step: StepItem, status: StepStatus, centered: boolean) => (\n <View style={centered ? styles.hLabels : styles.vLabels}>\n <Text\n allowFontScaling={allowFontScaling}\n style={[\n styles.label,\n centered && styles.labelCentered,\n centered && styles.hLabel,\n status === 'pending' && { color: colors.textLight },\n ]}\n >\n {step.label}\n </Text>\n {!!step.description && (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.description, centered && styles.descriptionCentered, centered && styles.hDescription]}\n >\n {step.description}\n </Text>\n )}\n </View>\n );\n\n const stepAccessibility = (index: number, step: StepItem, status: StepStatus) => ({\n accessibilityRole: (onStepPress ? 'button' : 'text') as 'button' | 'text',\n accessibilityLabel: `Step ${index + 1}: ${step.label}`,\n accessibilityState: { selected: status === 'current', disabled: false },\n });\n\n if (isVertical) {\n return (\n <View style={[styles.columnContainer, style]}>\n {steps.map((step, index) => {\n const status = statusOf(index, current);\n const isLast = index === steps.length - 1;\n const Wrapper = onStepPress ? TouchableOpacity : View;\n // Sólo tocable si hay onStepPress: pasarle onPress/activeOpacity\n // a un View plano no crashea, pero los deja como ruido silencioso.\n const interactiveProps = onStepPress\n ? { onPress: () => onStepPress(index), activeOpacity: 0.7 }\n : undefined;\n return (\n <Wrapper\n key={`${step.label}-${index}`}\n style={styles.vStep}\n {...interactiveProps}\n {...stepAccessibility(index, step, status)}\n >\n <View style={styles.vIndicatorColumn}>\n {renderCircle(index, status)}\n {!isLast && (\n <View\n style={[\n styles.vConnector,\n { backgroundColor: index < current ? colors.primary : colors.border },\n ]}\n />\n )}\n </View>\n {renderLabels(step, status, false)}\n </Wrapper>\n );\n })}\n </View>\n );\n }\n\n return (\n <ScrollView\n testID=\"steps-row\"\n horizontal\n showsHorizontalScrollIndicator={false}\n // Con espacio de sobra los pasos se reparten la fila igual que antes\n // (flexGrow); cuando no entran, se desplaza en vez de apretar el texto.\n style={[styles.hScrollView, style]}\n contentContainerStyle={styles.hScroll}\n >\n {steps.map((step, index) => {\n const status = statusOf(index, current);\n const isLast = index === steps.length - 1;\n const Wrapper = onStepPress ? TouchableOpacity : View;\n const interactiveProps = onStepPress\n ? { onPress: () => onStepPress(index), activeOpacity: 0.7 }\n : undefined;\n return (\n <React.Fragment key={`${step.label}-${index}`}>\n <Wrapper\n style={styles.hStep}\n {...interactiveProps}\n {...stepAccessibility(index, step, status)}\n >\n {renderCircle(index, status)}\n {renderLabels(step, status, true)}\n </Wrapper>\n {!isLast && (\n <View\n style={[styles.hConnector, { backgroundColor: index < current ? colors.primary : colors.border }]}\n />\n )}\n </React.Fragment>\n );\n })}\n </ScrollView>\n );\n }\n);\nSteps.displayName = 'Steps';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "SymmetricGrid",
|
|
3
|
+
"slug": "symmetric-grid",
|
|
4
|
+
"type": "component",
|
|
5
|
+
"category": "molecules",
|
|
6
|
+
"exportedNames": [
|
|
7
|
+
"SymmetricGrid"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "molecules/SymmetricGrid.tsx",
|
|
12
|
+
"content": "import React, { useMemo } from 'react';\nimport { View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\n\nexport interface SymmetricGridProps<T> {\n data: T[];\n renderItem: (item: T, index: number) => React.ReactElement;\n keyExtractor: (item: T, index: number) => string;\n /** Columnas por fila. @default 2 */\n columns?: number;\n /** Separación entre celdas, en ambos ejes. @default theme.tokens.spacing.md */\n gap?: number;\n /**\n * Última fila incompleta: centrada (simétrica) en vez de pegada a la\n * izquierda con un hueco a la derecha. @default true\n */\n balanceLastRow?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n grid: { gap: theme.tokens.spacing.md },\n row: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n rowBalanced: { justifyContent: 'center' },\n rowFull: { justifyContent: 'flex-start' },\n cell: { flexGrow: 0, flexShrink: 0 },\n}));\n\nfunction chunk<T>(data: T[], size: number): T[][] {\n const rows: T[][] = [];\n for (let i = 0; i < data.length; i += size) rows.push(data.slice(i, i + size));\n return rows;\n}\n\n/**\n * SymmetricGrid\n * Ley de Prägnanz (buena forma): agrupa en filas parejas de N columnas.\n * La última fila, cuando queda incompleta, se centra por defecto en vez de\n * quedar pegada a un borde — eso es lo que la lee como \"fila corta\" y no\n * como un layout roto con un hueco a un lado.\n */\nexport function SymmetricGrid<T>({\n data,\n renderItem,\n keyExtractor,\n columns = 2,\n gap,\n balanceLastRow = true,\n style,\n}: SymmetricGridProps<T>) {\n const styles = useStyles();\n const rows = useMemo(() => chunk(data, columns), [data, columns]);\n const cellWidth = `${100 / columns}%` as const;\n\n return (\n <View style={[styles.grid, gap !== undefined && { gap }, style]}>\n {rows.map((row, rowIndex) => {\n const isLastRow = rowIndex === rows.length - 1;\n const isIncomplete = row.length < columns;\n const centered = isLastRow && isIncomplete && balanceLastRow;\n\n return (\n <View\n // `columns` en la key: si cambia, las filas no reusan nodos DOM\n // de una fila armada con otra cantidad de columnas.\n key={`row-${columns}-${rowIndex}`}\n style={[styles.row, centered ? styles.rowBalanced : styles.rowFull, gap !== undefined && { gap }]}\n >\n {row.map((item, colIndex) => {\n const index = rowIndex * columns + colIndex;\n return (\n <View key={keyExtractor(item, index)} style={[styles.cell, { width: cellWidth }]}>\n {renderItem(item, index)}\n </View>\n );\n })}\n </View>\n );\n })}\n </View>\n );\n}\n"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"core"
|
|
17
|
+
]
|
|
18
|
+
}
|
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
|
);
|
|
@@ -50,7 +50,10 @@ const useStyles = createStyles((theme) => ({
|
|
|
50
50
|
position: 'absolute',
|
|
51
51
|
top: theme.tokens.spacing.xs,
|
|
52
52
|
bottom: theme.tokens.spacing.xs,
|
|
53
|
-
|
|
53
|
+
// El indicador se posiciona contra el borde de la barra, no contra su caja
|
|
54
|
+
// interna: al `left` hay que sumarle el padding horizontal para que quede
|
|
55
|
+
// centrado sobre la pestaña y no corrido hacia la izquierda.
|
|
56
|
+
left: theme.tokens.spacing.xs + theme.tokens.spacing.sm,
|
|
54
57
|
borderRadius: theme.tokens.radius.lg,
|
|
55
58
|
backgroundColor: theme.colors.primarySoft,
|
|
56
59
|
},
|