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.
Files changed (90) 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/NavigationBar.js +4 -1
  14. package/lib/commonjs/organisms/NavigationBar.js.map +1 -1
  15. package/lib/commonjs/organisms/ReorderableList.js +143 -0
  16. package/lib/commonjs/organisms/ReorderableList.js.map +1 -0
  17. package/lib/commonjs/organisms/Screen.js +1 -2
  18. package/lib/commonjs/organisms/Screen.js.map +1 -1
  19. package/lib/commonjs/organisms/index.js +7 -0
  20. package/lib/commonjs/organisms/index.js.map +1 -1
  21. package/lib/commonjs/safe-area/SafeAreaUIProvider.js +38 -30
  22. package/lib/commonjs/safe-area/SafeAreaUIProvider.js.map +1 -1
  23. package/lib/commonjs/theme/UIProvider.js +35 -8
  24. package/lib/commonjs/theme/UIProvider.js.map +1 -1
  25. package/lib/module/molecules/Steps.js +14 -6
  26. package/lib/module/molecules/Steps.js.map +1 -1
  27. package/lib/module/molecules/SymmetricGrid.js +79 -0
  28. package/lib/module/molecules/SymmetricGrid.js.map +1 -0
  29. package/lib/module/molecules/index.js +1 -0
  30. package/lib/module/molecules/index.js.map +1 -1
  31. package/lib/module/organisms/AlertProvider.js +12 -4
  32. package/lib/module/organisms/AlertProvider.js.map +1 -1
  33. package/lib/module/organisms/BottomSheet.js +59 -51
  34. package/lib/module/organisms/BottomSheet.js.map +1 -1
  35. package/lib/module/organisms/Modal.js +45 -43
  36. package/lib/module/organisms/Modal.js.map +1 -1
  37. package/lib/module/organisms/NavigationBar.js +4 -1
  38. package/lib/module/organisms/NavigationBar.js.map +1 -1
  39. package/lib/module/organisms/ReorderableList.js +137 -0
  40. package/lib/module/organisms/ReorderableList.js.map +1 -0
  41. package/lib/module/organisms/Screen.js +1 -2
  42. package/lib/module/organisms/Screen.js.map +1 -1
  43. package/lib/module/organisms/index.js +1 -0
  44. package/lib/module/organisms/index.js.map +1 -1
  45. package/lib/module/safe-area/SafeAreaUIProvider.js +39 -31
  46. package/lib/module/safe-area/SafeAreaUIProvider.js.map +1 -1
  47. package/lib/module/theme/UIProvider.js +36 -10
  48. package/lib/module/theme/UIProvider.js.map +1 -1
  49. package/lib/typescript/molecules/Steps.d.ts.map +1 -1
  50. package/lib/typescript/molecules/SymmetricGrid.d.ts +26 -0
  51. package/lib/typescript/molecules/SymmetricGrid.d.ts.map +1 -0
  52. package/lib/typescript/molecules/index.d.ts +2 -0
  53. package/lib/typescript/molecules/index.d.ts.map +1 -1
  54. package/lib/typescript/organisms/AlertProvider.d.ts.map +1 -1
  55. package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
  56. package/lib/typescript/organisms/Modal.d.ts.map +1 -1
  57. package/lib/typescript/organisms/NavigationBar.d.ts.map +1 -1
  58. package/lib/typescript/organisms/ReorderableList.d.ts +29 -0
  59. package/lib/typescript/organisms/ReorderableList.d.ts.map +1 -0
  60. package/lib/typescript/organisms/Screen.d.ts.map +1 -1
  61. package/lib/typescript/organisms/index.d.ts +2 -0
  62. package/lib/typescript/organisms/index.d.ts.map +1 -1
  63. package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts +0 -38
  64. package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts.map +1 -1
  65. package/lib/typescript/theme/UIProvider.d.ts +21 -1
  66. package/lib/typescript/theme/UIProvider.d.ts.map +1 -1
  67. package/package.json +11 -1
  68. package/registry/alert-provider.json +1 -1
  69. package/registry/bottom-sheet.json +1 -1
  70. package/registry/core.json +1 -1
  71. package/registry/index.json +31 -7
  72. package/registry/manifest.json +14 -0
  73. package/registry/modal.json +1 -1
  74. package/registry/navigation-bar.json +1 -1
  75. package/registry/reorderable-list.json +18 -0
  76. package/registry/screen.json +1 -1
  77. package/registry/steps.json +1 -1
  78. package/registry/symmetric-grid.json +18 -0
  79. package/src/molecules/Steps.tsx +14 -8
  80. package/src/molecules/SymmetricGrid.tsx +82 -0
  81. package/src/molecules/index.ts +2 -0
  82. package/src/organisms/AlertProvider.tsx +13 -5
  83. package/src/organisms/BottomSheet.tsx +58 -44
  84. package/src/organisms/Modal.tsx +63 -55
  85. package/src/organisms/NavigationBar.tsx +4 -1
  86. package/src/organisms/ReorderableList.tsx +151 -0
  87. package/src/organisms/Screen.tsx +1 -2
  88. package/src/organisms/index.ts +2 -0
  89. package/src/safe-area/SafeAreaUIProvider.tsx +46 -41
  90. package/src/theme/UIProvider.tsx +50 -8
@@ -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: any = onStepPress ? TouchableOpacity : View;\n return (\n <Wrapper\n key={step.label}\n style={styles.vStep}\n onPress={onStepPress ? () => onStepPress(index) : undefined}\n activeOpacity={0.7}\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: any = onStepPress ? TouchableOpacity : View;\n return (\n <React.Fragment key={step.label}>\n <Wrapper\n style={styles.hStep}\n onPress={onStepPress ? () => onStepPress(index) : undefined}\n activeOpacity={0.7}\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"
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
+ }
@@ -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
  );
@@ -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
- left: theme.tokens.spacing.sm,
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
  },