orn-ui 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/lib/commonjs/molecules/Steps.js +14 -6
  2. package/lib/commonjs/molecules/Steps.js.map +1 -1
  3. package/lib/commonjs/molecules/SymmetricGrid.js +84 -0
  4. package/lib/commonjs/molecules/SymmetricGrid.js.map +1 -0
  5. package/lib/commonjs/molecules/index.js +7 -0
  6. package/lib/commonjs/molecules/index.js.map +1 -1
  7. package/lib/commonjs/organisms/AlertProvider.js +12 -4
  8. package/lib/commonjs/organisms/AlertProvider.js.map +1 -1
  9. package/lib/commonjs/organisms/BottomSheet.js +58 -50
  10. package/lib/commonjs/organisms/BottomSheet.js.map +1 -1
  11. package/lib/commonjs/organisms/Modal.js +44 -42
  12. package/lib/commonjs/organisms/Modal.js.map +1 -1
  13. package/lib/commonjs/organisms/ReorderableList.js +143 -0
  14. package/lib/commonjs/organisms/ReorderableList.js.map +1 -0
  15. package/lib/commonjs/organisms/Screen.js +1 -2
  16. package/lib/commonjs/organisms/Screen.js.map +1 -1
  17. package/lib/commonjs/organisms/index.js +7 -0
  18. package/lib/commonjs/organisms/index.js.map +1 -1
  19. package/lib/commonjs/safe-area/SafeAreaUIProvider.js +38 -30
  20. package/lib/commonjs/safe-area/SafeAreaUIProvider.js.map +1 -1
  21. package/lib/commonjs/theme/UIProvider.js +35 -8
  22. package/lib/commonjs/theme/UIProvider.js.map +1 -1
  23. package/lib/module/molecules/Steps.js +14 -6
  24. package/lib/module/molecules/Steps.js.map +1 -1
  25. package/lib/module/molecules/SymmetricGrid.js +79 -0
  26. package/lib/module/molecules/SymmetricGrid.js.map +1 -0
  27. package/lib/module/molecules/index.js +1 -0
  28. package/lib/module/molecules/index.js.map +1 -1
  29. package/lib/module/organisms/AlertProvider.js +12 -4
  30. package/lib/module/organisms/AlertProvider.js.map +1 -1
  31. package/lib/module/organisms/BottomSheet.js +59 -51
  32. package/lib/module/organisms/BottomSheet.js.map +1 -1
  33. package/lib/module/organisms/Modal.js +45 -43
  34. package/lib/module/organisms/Modal.js.map +1 -1
  35. package/lib/module/organisms/ReorderableList.js +137 -0
  36. package/lib/module/organisms/ReorderableList.js.map +1 -0
  37. package/lib/module/organisms/Screen.js +1 -2
  38. package/lib/module/organisms/Screen.js.map +1 -1
  39. package/lib/module/organisms/index.js +1 -0
  40. package/lib/module/organisms/index.js.map +1 -1
  41. package/lib/module/safe-area/SafeAreaUIProvider.js +39 -31
  42. package/lib/module/safe-area/SafeAreaUIProvider.js.map +1 -1
  43. package/lib/module/theme/UIProvider.js +36 -10
  44. package/lib/module/theme/UIProvider.js.map +1 -1
  45. package/lib/typescript/molecules/Steps.d.ts.map +1 -1
  46. package/lib/typescript/molecules/SymmetricGrid.d.ts +26 -0
  47. package/lib/typescript/molecules/SymmetricGrid.d.ts.map +1 -0
  48. package/lib/typescript/molecules/index.d.ts +2 -0
  49. package/lib/typescript/molecules/index.d.ts.map +1 -1
  50. package/lib/typescript/organisms/AlertProvider.d.ts.map +1 -1
  51. package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
  52. package/lib/typescript/organisms/Modal.d.ts.map +1 -1
  53. package/lib/typescript/organisms/ReorderableList.d.ts +29 -0
  54. package/lib/typescript/organisms/ReorderableList.d.ts.map +1 -0
  55. package/lib/typescript/organisms/Screen.d.ts.map +1 -1
  56. package/lib/typescript/organisms/index.d.ts +2 -0
  57. package/lib/typescript/organisms/index.d.ts.map +1 -1
  58. package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts +0 -38
  59. package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts.map +1 -1
  60. package/lib/typescript/theme/UIProvider.d.ts +21 -1
  61. package/lib/typescript/theme/UIProvider.d.ts.map +1 -1
  62. package/package.json +11 -1
  63. package/registry/alert-provider.json +1 -1
  64. package/registry/bottom-sheet.json +1 -1
  65. package/registry/core.json +1 -1
  66. package/registry/index.json +30 -6
  67. package/registry/manifest.json +14 -0
  68. package/registry/modal.json +1 -1
  69. package/registry/reorderable-list.json +18 -0
  70. package/registry/screen.json +1 -1
  71. package/registry/steps.json +1 -1
  72. package/registry/symmetric-grid.json +18 -0
  73. package/src/molecules/Steps.tsx +14 -8
  74. package/src/molecules/SymmetricGrid.tsx +82 -0
  75. package/src/molecules/index.ts +2 -0
  76. package/src/organisms/AlertProvider.tsx +13 -5
  77. package/src/organisms/BottomSheet.tsx +58 -44
  78. package/src/organisms/Modal.tsx +63 -55
  79. package/src/organisms/ReorderableList.tsx +151 -0
  80. package/src/organisms/Screen.tsx +1 -2
  81. package/src/organisms/index.ts +2 -0
  82. package/src/safe-area/SafeAreaUIProvider.tsx +46 -41
  83. package/src/theme/UIProvider.tsx +50 -8
@@ -5,6 +5,18 @@ import { type EdgeInsets } from './insets';
5
5
  import type { Theme, ThemePair } from './types';
6
6
  import type { IconRenderer } from '../icons/types';
7
7
  export type ThemeMode = 'system' | 'light' | 'dark';
8
+ /**
9
+ * Un `<Modal>`/`<BottomSheet>` nativo abre su propia ventana (en Android
10
+ * siempre; en iOS según `presentationStyle`), así que los insets medidos en
11
+ * la raíz de la app pueden no valer ahí adentro. Este boundary es el punto de
12
+ * inyección: el default sólo repite los insets ambientes (comportamiento de
13
+ * siempre, cero deps), pero `SafeAreaUIProvider` lo reemplaza por uno que
14
+ * monta un `<SafeAreaProvider>` nuevo y remide dentro de esa ventana.
15
+ */
16
+ export interface ModalSafeAreaBoundaryProps {
17
+ children: (insets: EdgeInsets) => React.ReactNode;
18
+ }
19
+ export type ModalSafeAreaBoundaryComponent = React.ComponentType<ModalSafeAreaBoundaryProps>;
8
20
  export interface UIProviderProps {
9
21
  children: React.ReactNode;
10
22
  /** Par light/dark. Por defecto el theme de marca de la librería (créalo con createTheme()). */
@@ -21,6 +33,12 @@ export interface UIProviderProps {
21
33
  labels?: Partial<Labels>;
22
34
  /** @default false, para paridad con el resto de la librería (accesibilidad primero). */
23
35
  allowFontScaling?: boolean;
36
+ /**
37
+ * Fuente de insets que usan `Modal`/`BottomSheet` para su propia ventana
38
+ * nativa. No la pases a mano — `SafeAreaUIProvider` la cablea sola.
39
+ * @default repite los insets ambientes (comportamiento sin safe-area-context)
40
+ */
41
+ ModalSafeAreaBoundary?: ModalSafeAreaBoundaryComponent;
24
42
  }
25
43
  /**
26
44
  * UIProvider
@@ -29,7 +47,7 @@ export interface UIProviderProps {
29
47
  * árbol. No persiste el modo — si el consumidor quiere recordar la preferencia
30
48
  * entre sesiones, controla `mode`/`onModeChange` y persiste donde prefiera.
31
49
  */
32
- export declare function UIProvider({ children, theme, defaultMode, mode: controlledMode, onModeChange, icons, insets, labels: labelOverrides, allowFontScaling, }: UIProviderProps): React.JSX.Element;
50
+ export declare function UIProvider({ children, theme, defaultMode, mode: controlledMode, onModeChange, icons, insets, labels: labelOverrides, allowFontScaling, ModalSafeAreaBoundary, }: UIProviderProps): React.JSX.Element;
33
51
  export declare function useTheme(): Theme;
34
52
  export declare function useColors(): Theme['colors'];
35
53
  export declare function useThemeMode(): {
@@ -40,6 +58,8 @@ export declare function useIcons(): IconRenderer;
40
58
  export declare function useInsets(): EdgeInsets;
41
59
  export declare function useLabels(): Labels;
42
60
  export declare function useAllowFontScaling(): boolean;
61
+ /** Interno: lo consumen `Modal`/`BottomSheet` para remedir insets dentro de su propia ventana nativa. */
62
+ export declare function useModalSafeAreaBoundary(): ModalSafeAreaBoundaryComponent;
43
63
  /** Re-exportado para que tests/consumidores puedan simular cambios de sistema sin RN real. */
44
64
  export declare const __testUtils: {
45
65
  Appearance: typeof Appearance;
@@ -1 +1 @@
1
- {"version":3,"file":"UIProvider.d.ts","sourceRoot":"","sources":["../../../src/theme/UIProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAoE,MAAM,OAAO,CAAC;AACzF,OAAO,EAAE,UAAU,EAAkB,MAAM,cAAc,CAAC;AAE1D,OAAO,EAAiB,KAAK,MAAM,EAAE,MAAM,UAAU,CAAC;AACtD,OAAO,EAAc,KAAK,UAAU,EAAE,MAAM,UAAU,CAAC;AACvD,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEhD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAC;AAiBpD,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,+FAA+F;IAC/F,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,2EAA2E;IAC3E,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB,+FAA+F;IAC/F,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,IAAI,CAAC;IACzC,yEAAyE;IACzE,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,MAAM,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IACzB,wFAAwF;IACxF,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,KAAoB,EACpB,WAAsB,EACtB,IAAI,EAAE,cAAc,EACpB,YAAY,EACZ,KAAyB,EACzB,MAAmB,EACnB,MAAM,EAAE,cAAc,EACtB,gBAAwB,GACzB,EAAE,eAAe,qBA0CjB;AAUD,wBAAgB,QAAQ,IAAI,KAAK,CAEhC;AAED,wBAAgB,SAAS,IAAI,KAAK,CAAC,QAAQ,CAAC,CAE3C;AAED,wBAAgB,YAAY;;oBA3GV,SAAS,KAAK,IAAI;EA8GnC;AAED,wBAAgB,QAAQ,IAAI,YAAY,CAEvC;AAED,wBAAgB,SAAS,IAAI,UAAU,CAEtC;AAED,wBAAgB,SAAS,IAAI,MAAM,CAElC;AAED,wBAAgB,mBAAmB,IAAI,OAAO,CAE7C;AAED,8FAA8F;AAC9F,eAAO,MAAM,WAAW;;CAAiB,CAAC"}
1
+ {"version":3,"file":"UIProvider.d.ts","sourceRoot":"","sources":["../../../src/theme/UIProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4E,MAAM,OAAO,CAAC;AACjG,OAAO,EAAE,UAAU,EAAkB,MAAM,cAAc,CAAC;AAE1D,OAAO,EAAiB,KAAK,MAAM,EAAE,MAAM,UAAU,CAAC;AACtD,OAAO,EAAc,KAAK,UAAU,EAAE,MAAM,UAAU,CAAC;AACvD,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEhD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAC;AAEpD;;;;;;;GAOG;AACH,MAAM,WAAW,0BAA0B;IACzC,QAAQ,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,KAAK,CAAC,SAAS,CAAC;CACnD;AACD,MAAM,MAAM,8BAA8B,GAAG,KAAK,CAAC,aAAa,CAAC,0BAA0B,CAAC,CAAC;AAoB7F,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,+FAA+F;IAC/F,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,2EAA2E;IAC3E,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB,+FAA+F;IAC/F,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,IAAI,CAAC;IACzC,yEAAyE;IACzE,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,MAAM,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IACzB,wFAAwF;IACxF,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,8BAA8B,CAAC;CACxD;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,KAAoB,EACpB,WAAsB,EACtB,IAAI,EAAE,cAAc,EACpB,YAAY,EACZ,KAAyB,EACzB,MAAmB,EACnB,MAAM,EAAE,cAAc,EACtB,gBAAwB,EACxB,qBAAoD,GACrD,EAAE,eAAe,qBAwDjB;AAUD,wBAAgB,QAAQ,IAAI,KAAK,CAEhC;AAED,wBAAgB,SAAS,IAAI,KAAK,CAAC,QAAQ,CAAC,CAE3C;AAED,wBAAgB,YAAY;;oBA9HV,SAAS,KAAK,IAAI;EAiInC;AAED,wBAAgB,QAAQ,IAAI,YAAY,CAEvC;AAED,wBAAgB,SAAS,IAAI,UAAU,CAEtC;AAED,wBAAgB,SAAS,IAAI,MAAM,CAElC;AAED,wBAAgB,mBAAmB,IAAI,OAAO,CAE7C;AAED,yGAAyG;AACzG,wBAAgB,wBAAwB,IAAI,8BAA8B,CAEzE;AAED,8FAA8F;AAC9F,eAAO,MAAM,WAAW;;CAAiB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orn-ui",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Fast, tree-shakeable, atomic-design component library for React Native. Zero runtime dependencies.",
5
5
  "author": "David Trujillo <davot098@gmail.com>",
6
6
  "main": "lib/commonjs/index.js",
@@ -176,6 +176,11 @@
176
176
  "react-native": "./src/molecules/Steps.tsx",
177
177
  "default": "./lib/module/molecules/Steps.js"
178
178
  },
179
+ "./symmetric-grid": {
180
+ "types": "./lib/typescript/molecules/SymmetricGrid.d.ts",
181
+ "react-native": "./src/molecules/SymmetricGrid.tsx",
182
+ "default": "./lib/module/molecules/SymmetricGrid.js"
183
+ },
179
184
  "./modal": {
180
185
  "types": "./lib/typescript/organisms/Modal.d.ts",
181
186
  "react-native": "./src/organisms/Modal.tsx",
@@ -306,6 +311,11 @@
306
311
  "react-native": "./src/organisms/NavigationBar.tsx",
307
312
  "default": "./lib/module/organisms/NavigationBar.js"
308
313
  },
314
+ "./reorderable-list": {
315
+ "types": "./lib/typescript/organisms/ReorderableList.d.ts",
316
+ "react-native": "./src/organisms/ReorderableList.tsx",
317
+ "default": "./lib/module/organisms/ReorderableList.js"
318
+ },
309
319
  "./theme": {
310
320
  "types": "./lib/typescript/theme/index.d.ts",
311
321
  "react-native": "./src/theme/index.ts",
@@ -13,7 +13,7 @@
13
13
  "files": [
14
14
  {
15
15
  "path": "organisms/AlertProvider.tsx",
16
- "content": "import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport { Alert, type AlertButton, type AlertType } from './Alert';\nimport { createImperativeBridge } from './imperativeBridge';\n\nexport interface AlertOptions {\n title: string;\n message?: string;\n type?: AlertType;\n confirmText?: string;\n cancelText?: string;\n}\n\nexport interface ConfirmOptions extends AlertOptions {\n /** Botón de confirmar en rojo/destructivo (para \"eliminar\", etc). */\n destructive?: boolean;\n}\n\ninterface AlertContextValue {\n /** Muestra un alert de un solo botón. Resuelve cuando se cierra. */\n alert: (options: AlertOptions) => Promise<void>;\n /** Muestra confirmar/cancelar. Resuelve true si se confirmó, false si se canceló. */\n confirm: (options: ConfirmOptions) => Promise<boolean>;\n hide: () => void;\n}\n\nconst AlertContext = createContext<AlertContextValue | undefined>(undefined);\nconst bridge = createImperativeBridge<AlertContextValue>('AlertProvider');\n\n/**\n * Abre un alert desde fuera del árbol de React —un servicio, un interceptor—\n * sin pasar por el hook. Sin <AlertProvider> montado resuelve de inmediato, así\n * que un `await` nunca queda colgado.\n */\nexport function showAlert(options: AlertOptions): Promise<void> {\n return bridge.get()?.alert(options) ?? Promise.resolve();\n}\n\n/** Igual que showAlert, pero resuelve true/false. Sin provider, false. */\nexport function showConfirm(options: ConfirmOptions): Promise<boolean> {\n return bridge.get()?.confirm(options) ?? Promise.resolve(false);\n}\n\nexport function hideAlert(): void {\n bridge.get()?.hide();\n}\n\ninterface InternalState {\n visible: boolean;\n title: string;\n message?: string;\n type: AlertType;\n buttons: AlertButton[];\n}\n\nconst HIDDEN: InternalState = { visible: false, title: '', type: 'info', buttons: [] };\n\n/**\n * AlertProvider\n * Monta un único <Alert> controlado imperativamente. Colocar una vez cerca\n * de la raíz (dentro de UIProvider). Los handlers async pueden hacer\n * `const ok = await confirm({ title: 'Eliminar', destructive: true })`.\n */\nexport function AlertProvider({ children }: { children: React.ReactNode }) {\n const [state, setState] = useState<InternalState>(HIDDEN);\n const resolverRef = useRef<((value: any) => void) | null>(null);\n\n const hide = useCallback(() => {\n setState(HIDDEN);\n resolverRef.current?.(undefined);\n resolverRef.current = null;\n }, []);\n\n const alertFn = useCallback((options: AlertOptions): Promise<void> => {\n return new Promise((resolve) => {\n resolverRef.current = resolve;\n setState({\n visible: true,\n title: options.title,\n message: options.message,\n type: options.type ?? 'info',\n buttons: [\n {\n text: options.confirmText ?? 'OK',\n onPress: () => {\n setState(HIDDEN);\n resolve();\n resolverRef.current = null;\n },\n },\n ],\n });\n });\n }, []);\n\n const confirmFn = useCallback((options: ConfirmOptions): Promise<boolean> => {\n return new Promise((resolve) => {\n resolverRef.current = resolve;\n setState({\n visible: true,\n title: options.title,\n message: options.message,\n type: options.type ?? 'question',\n buttons: [\n {\n text: options.cancelText ?? 'Cancel',\n style: 'cancel',\n variant: 'outline',\n onPress: () => {\n setState(HIDDEN);\n resolve(false);\n resolverRef.current = null;\n },\n },\n {\n text: options.confirmText ?? 'OK',\n variant: options.destructive ? 'destructive' : 'primary',\n style: options.destructive ? 'destructive' : 'default',\n onPress: () => {\n setState(HIDDEN);\n resolve(true);\n resolverRef.current = null;\n },\n },\n ],\n });\n });\n }, []);\n\n const value = useMemo<AlertContextValue>(() => ({ alert: alertFn, confirm: confirmFn, hide }), [alertFn, confirmFn, hide]);\n\n useEffect(() => {\n bridge.set(value);\n return () => bridge.set(null);\n }, [value]);\n\n return (\n <AlertContext.Provider value={value}>\n {children}\n <Alert visible={state.visible} title={state.title} message={state.message} type={state.type} buttons={state.buttons} onClose={hide} />\n </AlertContext.Provider>\n );\n}\n\nexport function useAlert(): AlertContextValue {\n const ctx = useContext(AlertContext);\n if (!ctx) {\n throw new Error('orn-ui: useAlert must be used within an <AlertProvider>.');\n }\n return ctx;\n}\n"
16
+ "content": "import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport { Alert, type AlertButton, type AlertType } from './Alert';\nimport { createImperativeBridge } from './imperativeBridge';\n\nexport interface AlertOptions {\n title: string;\n message?: string;\n type?: AlertType;\n confirmText?: string;\n cancelText?: string;\n}\n\nexport interface ConfirmOptions extends AlertOptions {\n /** Botón de confirmar en rojo/destructivo (para \"eliminar\", etc). */\n destructive?: boolean;\n}\n\ninterface AlertContextValue {\n /** Muestra un alert de un solo botón. Resuelve cuando se cierra. */\n alert: (options: AlertOptions) => Promise<void>;\n /** Muestra confirmar/cancelar. Resuelve true si se confirmó, false si se canceló. */\n confirm: (options: ConfirmOptions) => Promise<boolean>;\n hide: () => void;\n}\n\nconst AlertContext = createContext<AlertContextValue | undefined>(undefined);\nconst bridge = createImperativeBridge<AlertContextValue>('AlertProvider');\n\n/**\n * Abre un alert desde fuera del árbol de React —un servicio, un interceptor—\n * sin pasar por el hook. Sin <AlertProvider> montado resuelve de inmediato, así\n * que un `await` nunca queda colgado.\n */\nexport function showAlert(options: AlertOptions): Promise<void> {\n return bridge.get()?.alert(options) ?? Promise.resolve();\n}\n\n/** Igual que showAlert, pero resuelve true/false. Sin provider, false. */\nexport function showConfirm(options: ConfirmOptions): Promise<boolean> {\n return bridge.get()?.confirm(options) ?? Promise.resolve(false);\n}\n\nexport function hideAlert(): void {\n bridge.get()?.hide();\n}\n\ninterface InternalState {\n visible: boolean;\n title: string;\n message?: string;\n type: AlertType;\n buttons: AlertButton[];\n}\n\nconst HIDDEN: InternalState = { visible: false, title: '', type: 'info', buttons: [] };\n\n/**\n * AlertProvider\n * Monta un único <Alert> controlado imperativamente. Colocar una vez cerca\n * de la raíz (dentro de UIProvider). Los handlers async pueden hacer\n * `const ok = await confirm({ title: 'Eliminar', destructive: true })`.\n */\nexport function AlertProvider({ children }: { children: React.ReactNode }) {\n const [state, setState] = useState<InternalState>(HIDDEN);\n const resolverRef = useRef<((value: any) => void) | null>(null);\n // Si alert()/confirm() se llaman de nuevo mientras ya hay uno abierto, el\n // pedido anterior se resuelve (no se descarta) antes de mostrar el nuevo:\n // evita que su promise quede colgada para siempre.\n const settlePending = useCallback(() => {\n resolverRef.current?.(undefined);\n resolverRef.current = null;\n }, []);\n\n const hide = useCallback(() => {\n setState(HIDDEN);\n settlePending();\n }, [settlePending]);\n\n const alertFn = useCallback((options: AlertOptions): Promise<void> => {\n settlePending();\n return new Promise((resolve) => {\n resolverRef.current = resolve;\n setState({\n visible: true,\n title: options.title,\n message: options.message,\n type: options.type ?? 'info',\n buttons: [\n {\n text: options.confirmText ?? 'OK',\n onPress: () => {\n setState(HIDDEN);\n resolve();\n resolverRef.current = null;\n },\n },\n ],\n });\n });\n }, [settlePending]);\n\n const confirmFn = useCallback((options: ConfirmOptions): Promise<boolean> => {\n settlePending();\n return new Promise((resolve) => {\n resolverRef.current = resolve;\n setState({\n visible: true,\n title: options.title,\n message: options.message,\n type: options.type ?? 'question',\n buttons: [\n {\n text: options.cancelText ?? 'Cancel',\n style: 'cancel',\n variant: 'outline',\n onPress: () => {\n setState(HIDDEN);\n resolve(false);\n resolverRef.current = null;\n },\n },\n {\n text: options.confirmText ?? 'OK',\n variant: options.destructive ? 'destructive' : 'primary',\n style: options.destructive ? 'destructive' : 'default',\n onPress: () => {\n setState(HIDDEN);\n resolve(true);\n resolverRef.current = null;\n },\n },\n ],\n });\n });\n }, [settlePending]);\n\n const value = useMemo<AlertContextValue>(() => ({ alert: alertFn, confirm: confirmFn, hide }), [alertFn, confirmFn, hide]);\n\n useEffect(() => {\n bridge.set(value);\n return () => bridge.set(null);\n }, [value]);\n\n return (\n <AlertContext.Provider value={value}>\n {children}\n <Alert visible={state.visible} title={state.title} message={state.message} type={state.type} buttons={state.buttons} onClose={hide} />\n </AlertContext.Provider>\n );\n}\n\nexport function useAlert(): AlertContextValue {\n const ctx = useContext(AlertContext);\n if (!ctx) {\n throw new Error('orn-ui: useAlert must be used within an <AlertProvider>.');\n }\n return ctx;\n}\n"
17
17
  },
18
18
  {
19
19
  "path": "organisms/imperativeBridge.ts",
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/BottomSheet.tsx",
12
- "content": "import React, { memo, useEffect, useRef, useState } from 'react';\nimport {\n Animated,\n Dimensions,\n Keyboard,\n KeyboardAvoidingView,\n Modal,\n PanResponder,\n Platform,\n Pressable,\n ScrollView,\n View,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useInsets } from '../theme/UIProvider';\nimport { Title } from '../atoms/Text';\nimport { IconButton } from '../atoms/IconButton';\nimport { Transition } from '../atoms/Transition';\n\nconst { height: SCREEN_HEIGHT } = Dimensions.get('window');\nconst DISMISS_THRESHOLD = 100;\n\nexport type FooterPlacement = 'fixed' | 'scroll' | 'hide-with-keyboard';\n\nexport interface BottomSheetProps {\n visible: boolean;\n onClose: () => void;\n children: React.ReactNode;\n /** Con título aparece el header, con su botón de cerrar. */\n title?: string;\n /** Acciones de la hoja. Dónde vive lo decide `footerPlacement`. */\n footer?: React.ReactNode;\n /**\n * 'fixed' lo ancla al pie de la hoja; 'scroll' lo deja al final del\n * contenido; 'hide-with-keyboard' lo esconde mientras se escribe, que en un\n * formulario largo le devuelve esa franja al campo enfocado. @default 'fixed'\n */\n footerPlacement?: FooterPlacement;\n /** El contenido scrollea cuando no entra. @default true */\n scrollable?: boolean;\n /** Arrastrar la hoja hacia abajo la cierra. @default true */\n draggable?: boolean;\n /** Fracción de la pantalla (0-1) o píxeles. @default 0.9 */\n maxHeight?: number;\n closeAccessibilityLabel?: string;\n containerStyle?: ViewStyle;\n contentStyle?: ViewStyle;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1, justifyContent: 'flex-end' },\n backdrop: { backgroundColor: theme.colors.overlay },\n sheet: {\n backgroundColor: theme.colors.surface,\n borderTopLeftRadius: theme.tokens.radius.xxl,\n borderTopRightRadius: theme.tokens.radius.xxl,\n ...theme.tokens.shadow.lg,\n },\n handleArea: { paddingVertical: theme.tokens.spacing.md, alignItems: 'center' },\n handle: { width: 40, height: 5, borderRadius: 3, backgroundColor: theme.colors.border },\n header: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n paddingHorizontal: theme.tokens.spacing.xl,\n paddingBottom: theme.tokens.spacing.md,\n },\n headerSpaced: { paddingTop: theme.tokens.spacing.xl },\n title: { flex: 1, fontSize: theme.tokens.fontSize.xl, fontWeight: theme.tokens.fontWeight.bold },\n content: { paddingHorizontal: theme.tokens.spacing.xl },\n scroll: { flexShrink: 1 },\n footer: {\n paddingHorizontal: theme.tokens.spacing.xl,\n paddingTop: theme.tokens.spacing.md,\n borderTopWidth: 1,\n borderTopColor: theme.colors.border,\n },\n footerInline: { paddingTop: theme.tokens.spacing.xl },\n}));\n\nconst absoluteFill: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 };\n\n/**\n * BottomSheet\n * Hoja que entra desde abajo, con arrastre para cerrar (PanResponder, no un\n * gesture-handler externo: la librería es cero dependencias). Header, footer y\n * scroll son opcionales, así que cubre tanto la hoja desnuda como el formulario\n * completo.\n */\nexport const BottomSheet = memo(\n ({\n visible,\n onClose,\n children,\n title,\n footer,\n footerPlacement = 'fixed',\n scrollable = true,\n draggable = true,\n maxHeight = 0.9,\n closeAccessibilityLabel = 'Close',\n containerStyle,\n contentStyle,\n testID,\n }: BottomSheetProps) => {\n const styles = useStyles();\n const colors = useColors();\n const insets = useInsets();\n const [mounted, setMounted] = useState(visible);\n const [keyboardOpen, setKeyboardOpen] = useState(false);\n const progress = useRef(new Animated.Value(0)).current;\n\n useEffect(() => {\n if (visible) {\n setMounted(true);\n Animated.timing(progress, { toValue: 1, duration: 250, useNativeDriver: true }).start();\n } else {\n Animated.timing(progress, { toValue: 0, duration: 200, useNativeDriver: true }).start(({ finished }) => {\n if (finished) setMounted(false);\n });\n }\n }, [visible, progress]);\n\n useEffect(() => {\n if (footerPlacement !== 'hide-with-keyboard') return;\n const [show, hide] =\n Platform.OS === 'ios' ? ['keyboardWillShow', 'keyboardWillHide'] : ['keyboardDidShow', 'keyboardDidHide'];\n const subs = [\n Keyboard.addListener(show as 'keyboardDidShow', () => setKeyboardOpen(true)),\n Keyboard.addListener(hide as 'keyboardDidHide', () => setKeyboardOpen(false)),\n ];\n return () => subs.forEach((sub) => sub.remove());\n }, [footerPlacement]);\n\n const panResponder = useRef(\n PanResponder.create({\n onMoveShouldSetPanResponder: (_, gesture) => gesture.dy > 5 && Math.abs(gesture.dy) > Math.abs(gesture.dx),\n onPanResponderMove: (_, gesture) => {\n if (gesture.dy > 0) progress.setValue(1 - Math.min(gesture.dy, SCREEN_HEIGHT) / SCREEN_HEIGHT);\n },\n onPanResponderRelease: (_, gesture) => {\n if (gesture.dy > DISMISS_THRESHOLD) {\n onClose();\n } else {\n Animated.timing(progress, { toValue: 1, duration: 150, useNativeDriver: true }).start();\n }\n },\n })\n ).current;\n\n if (!mounted) return null;\n\n // Porcentaje y no píxeles: se mide contra el contenedor, que con el teclado\n // abierto es más bajo, así que la hoja entra siempre entera.\n const resolvedMaxHeight: number | `${number}%` = maxHeight <= 1 ? `${maxHeight * 100}%` : maxHeight;\n const hasHeader = !!title;\n\n const scrolledFooter = !!footer && footerPlacement === 'scroll' && (\n <View style={styles.footerInline}>{footer}</View>\n );\n const pinnedFooter =\n !!footer && footerPlacement !== 'scroll' && !(footerPlacement === 'hide-with-keyboard' && keyboardOpen) ? (\n <View style={styles.footer}>{footer}</View>\n ) : null;\n\n const body = scrollable ? (\n <ScrollView\n style={styles.scroll}\n contentContainerStyle={[styles.content, contentStyle]}\n keyboardShouldPersistTaps=\"handled\"\n showsVerticalScrollIndicator={false}\n >\n {children}\n {scrolledFooter}\n </ScrollView>\n ) : (\n <View style={[styles.content, contentStyle]}>\n {children}\n {scrolledFooter}\n </View>\n );\n\n return (\n <Modal visible transparent animationType=\"none\" statusBarTranslucent onRequestClose={onClose}>\n {/* La hoja está anclada abajo, así que el teclado la tapa entera: hay\n que empujarla, no ajustar insets adentro del scroll. Una sola\n compensación, la del contenedor. */}\n <KeyboardAvoidingView\n style={styles.container}\n behavior={Platform.OS === 'ios' ? 'padding' : 'height'}\n >\n <Transition value={progress} preset=\"fade\" style={[absoluteFill, styles.backdrop]}>\n {/* Con header, el botón de cerrar ya expone la acción: repetir el\n label acá deja dos elementos con el mismo nombre. */}\n <Pressable\n style={absoluteFill}\n onPress={onClose}\n testID=\"bottom-sheet-backdrop\"\n accessibilityRole={hasHeader ? undefined : 'button'}\n accessibilityLabel={hasHeader ? undefined : closeAccessibilityLabel}\n />\n </Transition>\n\n <Transition\n value={progress}\n preset=\"slide-up\"\n distance={SCREEN_HEIGHT}\n testID={testID}\n style={[\n styles.sheet,\n { maxHeight: resolvedMaxHeight, paddingBottom: Math.max(insets.bottom, 20) },\n containerStyle,\n ]}\n >\n {draggable && (\n <View style={styles.handleArea} testID=\"bottom-sheet-handle\" {...panResponder.panHandlers}>\n <View style={styles.handle} />\n </View>\n )}\n\n {hasHeader && (\n <View style={[styles.header, !draggable && styles.headerSpaced]}>\n <Title style={styles.title} numberOfLines={1}>\n {title}\n </Title>\n <IconButton\n iconName=\"close\"\n size={24}\n color={colors.text}\n onPress={onClose}\n accessibilityLabel={closeAccessibilityLabel}\n />\n </View>\n )}\n\n {body}\n\n {pinnedFooter}\n </Transition>\n </KeyboardAvoidingView>\n </Modal>\n );\n }\n);\nBottomSheet.displayName = 'BottomSheet';\n"
12
+ "content": "import React, { memo, useEffect, useRef, useState } from 'react';\nimport {\n Animated,\n Dimensions,\n Keyboard,\n KeyboardAvoidingView,\n Modal,\n PanResponder,\n Platform,\n Pressable,\n ScrollView,\n View,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useModalSafeAreaBoundary } from '../theme/UIProvider';\nimport { Title } from '../atoms/Text';\nimport { IconButton } from '../atoms/IconButton';\nimport { Transition } from '../atoms/Transition';\n\nconst { height: SCREEN_HEIGHT } = Dimensions.get('window');\nconst DISMISS_THRESHOLD = 100;\n\nexport type FooterPlacement = 'fixed' | 'scroll' | 'hide-with-keyboard';\n\nexport interface BottomSheetProps {\n visible: boolean;\n onClose: () => void;\n children: React.ReactNode;\n /** Con título aparece el header, con su botón de cerrar. */\n title?: string;\n /** Acciones de la hoja. Dónde vive lo decide `footerPlacement`. */\n footer?: React.ReactNode;\n /**\n * 'fixed' lo ancla al pie de la hoja; 'scroll' lo deja al final del\n * contenido; 'hide-with-keyboard' lo esconde mientras se escribe, que en un\n * formulario largo le devuelve esa franja al campo enfocado. @default 'fixed'\n */\n footerPlacement?: FooterPlacement;\n /** El contenido scrollea cuando no entra. @default true */\n scrollable?: boolean;\n /** Arrastrar la hoja hacia abajo la cierra. @default true */\n draggable?: boolean;\n /** Fracción de la pantalla (0-1) o píxeles. @default 0.9 */\n maxHeight?: number;\n closeAccessibilityLabel?: string;\n containerStyle?: ViewStyle;\n contentStyle?: ViewStyle;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1, justifyContent: 'flex-end' },\n backdrop: { backgroundColor: theme.colors.overlay },\n sheet: {\n backgroundColor: theme.colors.surface,\n borderTopLeftRadius: theme.tokens.radius.xxl,\n borderTopRightRadius: theme.tokens.radius.xxl,\n ...theme.tokens.shadow.lg,\n },\n handleArea: { paddingVertical: theme.tokens.spacing.md, alignItems: 'center' },\n handle: { width: 40, height: 5, borderRadius: 3, backgroundColor: theme.colors.border },\n header: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n paddingHorizontal: theme.tokens.spacing.xl,\n paddingBottom: theme.tokens.spacing.md,\n },\n headerSpaced: { paddingTop: theme.tokens.spacing.xl },\n title: { flex: 1, fontSize: theme.tokens.fontSize.xl, fontWeight: theme.tokens.fontWeight.bold },\n content: { paddingHorizontal: theme.tokens.spacing.xl },\n scroll: { flexShrink: 1 },\n footer: {\n paddingHorizontal: theme.tokens.spacing.xl,\n paddingTop: theme.tokens.spacing.md,\n borderTopWidth: 1,\n borderTopColor: theme.colors.border,\n },\n footerInline: { paddingTop: theme.tokens.spacing.xl },\n}));\n\nconst absoluteFill: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 };\n\n/**\n * BottomSheet\n * Hoja que entra desde abajo, con arrastre para cerrar (PanResponder, no un\n * gesture-handler externo: la librería es cero dependencias). Header, footer y\n * scroll son opcionales, así que cubre tanto la hoja desnuda como el formulario\n * completo.\n */\nexport const BottomSheet = memo(\n ({\n visible,\n onClose,\n children,\n title,\n footer,\n footerPlacement = 'fixed',\n scrollable = true,\n draggable = true,\n maxHeight = 0.9,\n closeAccessibilityLabel = 'Close',\n containerStyle,\n contentStyle,\n testID,\n }: BottomSheetProps) => {\n const styles = useStyles();\n const colors = useColors();\n const ModalSafeAreaBoundary = useModalSafeAreaBoundary();\n const [mounted, setMounted] = useState(visible);\n const [keyboardOpen, setKeyboardOpen] = useState(false);\n const progress = useRef(new Animated.Value(0)).current;\n // El PanResponder de más abajo se crea una sola vez; leer `onClose`\n // directo del closure daría siempre el de ese primer render. El ref\n // refleja la última prop sin importar cuándo se creó la closure.\n const onCloseRef = useRef(onClose);\n onCloseRef.current = onClose;\n\n useEffect(() => {\n const animation = visible\n ? Animated.timing(progress, { toValue: 1, duration: 250, useNativeDriver: true })\n : Animated.timing(progress, { toValue: 0, duration: 200, useNativeDriver: true });\n if (visible) setMounted(true);\n animation.start(({ finished }) => {\n if (!visible && finished) setMounted(false);\n });\n // Si el efecto vuelve a correr (o el componente se desmonta) antes de\n // que termine, se corta: si no, un `finished` tardío podría llamar\n // `setMounted` sobre un componente ya desmontado.\n return () => animation.stop();\n }, [visible, progress]);\n\n useEffect(() => {\n if (footerPlacement !== 'hide-with-keyboard') return;\n const [show, hide] =\n Platform.OS === 'ios' ? ['keyboardWillShow', 'keyboardWillHide'] : ['keyboardDidShow', 'keyboardDidHide'];\n const subs = [\n Keyboard.addListener(show as 'keyboardDidShow', () => setKeyboardOpen(true)),\n Keyboard.addListener(hide as 'keyboardDidHide', () => setKeyboardOpen(false)),\n ];\n return () => subs.forEach((sub) => sub.remove());\n }, [footerPlacement]);\n\n const panResponder = useRef(\n PanResponder.create({\n onMoveShouldSetPanResponder: (_, gesture) => gesture.dy > 5 && Math.abs(gesture.dy) > Math.abs(gesture.dx),\n onPanResponderMove: (_, gesture) => {\n if (gesture.dy > 0) progress.setValue(1 - Math.min(gesture.dy, SCREEN_HEIGHT) / SCREEN_HEIGHT);\n },\n onPanResponderRelease: (_, gesture) => {\n if (gesture.dy > DISMISS_THRESHOLD) {\n onCloseRef.current();\n } else {\n Animated.timing(progress, { toValue: 1, duration: 150, useNativeDriver: true }).start();\n }\n },\n })\n ).current;\n\n if (!mounted) return null;\n\n // Porcentaje y no píxeles: se mide contra el contenedor, que con el teclado\n // abierto es más bajo, así que la hoja entra siempre entera.\n const resolvedMaxHeight: number | `${number}%` = maxHeight <= 1 ? `${maxHeight * 100}%` : maxHeight;\n const hasHeader = !!title;\n\n const scrolledFooter = !!footer && footerPlacement === 'scroll' && (\n <View style={styles.footerInline}>{footer}</View>\n );\n const pinnedFooter =\n !!footer && footerPlacement !== 'scroll' && !(footerPlacement === 'hide-with-keyboard' && keyboardOpen) ? (\n <View style={styles.footer}>{footer}</View>\n ) : null;\n\n const body = scrollable ? (\n <ScrollView\n style={styles.scroll}\n contentContainerStyle={[styles.content, contentStyle]}\n keyboardShouldPersistTaps=\"handled\"\n showsVerticalScrollIndicator={false}\n >\n {children}\n {scrolledFooter}\n </ScrollView>\n ) : (\n <View style={[styles.content, contentStyle]}>\n {children}\n {scrolledFooter}\n </View>\n );\n\n return (\n <Modal visible transparent animationType=\"none\" statusBarTranslucent onRequestClose={onClose}>\n {/* La hoja está anclada abajo, así que el teclado la tapa entera: hay\n que empujarla, no ajustar insets adentro del scroll. Una sola\n compensación, la del contenedor. */}\n <KeyboardAvoidingView\n style={styles.container}\n behavior={Platform.OS === 'ios' ? 'padding' : 'height'}\n >\n <Transition value={progress} preset=\"fade\" style={[absoluteFill, styles.backdrop]}>\n {/* Con header, el botón de cerrar ya expone la acción: repetir el\n label acá deja dos elementos con el mismo nombre. */}\n <Pressable\n style={absoluteFill}\n onPress={onClose}\n testID=\"bottom-sheet-backdrop\"\n accessibilityRole={hasHeader ? undefined : 'button'}\n accessibilityLabel={hasHeader ? undefined : closeAccessibilityLabel}\n />\n </Transition>\n\n {/* Insets medidos dentro de esta ventana nativa (el Modal nativo que\n usa BottomSheet por debajo), no los de la raíz de la app. */}\n <ModalSafeAreaBoundary>\n {(insets) => (\n <Transition\n value={progress}\n preset=\"slide-up\"\n distance={SCREEN_HEIGHT}\n testID={testID}\n style={[\n styles.sheet,\n { maxHeight: resolvedMaxHeight, paddingBottom: Math.max(insets.bottom, 20) },\n containerStyle,\n ]}\n >\n {draggable && (\n <View style={styles.handleArea} testID=\"bottom-sheet-handle\" {...panResponder.panHandlers}>\n <View style={styles.handle} />\n </View>\n )}\n\n {hasHeader && (\n <View style={[styles.header, !draggable && styles.headerSpaced]}>\n <Title style={styles.title} numberOfLines={1}>\n {title}\n </Title>\n <IconButton\n iconName=\"close\"\n size={24}\n color={colors.text}\n onPress={onClose}\n accessibilityLabel={closeAccessibilityLabel}\n />\n </View>\n )}\n\n {body}\n\n {pinnedFooter}\n </Transition>\n )}\n </ModalSafeAreaBoundary>\n </KeyboardAvoidingView>\n </Modal>\n );\n }\n);\nBottomSheet.displayName = 'BottomSheet';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -21,7 +21,7 @@
21
21
  },
22
22
  {
23
23
  "path": "theme/UIProvider.tsx",
24
- "content": "import React, { createContext, useContext, useMemo, useState, useCallback } from 'react';\nimport { Appearance, useColorScheme } from 'react-native';\nimport { defaultTheme } from './createTheme';\nimport { defaultLabels, type Labels } from './labels';\nimport { zeroInsets, type EdgeInsets } from './insets';\nimport type { Theme, ThemePair } from './types';\nimport { renderDefaultIcon } from '../icons/defaultIcons';\nimport type { IconRenderer } from '../icons/types';\n\nexport type ThemeMode = 'system' | 'light' | 'dark';\n\ninterface UIContextValue {\n theme: Theme;\n mode: ThemeMode;\n setMode: (mode: ThemeMode) => void;\n icons: IconRenderer;\n insets: EdgeInsets;\n labels: Labels;\n allowFontScaling: boolean;\n}\n\nconst UIContext = createContext<UIContextValue | undefined>(undefined);\n\n/** Una sola vez por sesión: en un re-render el aviso no aporta nada nuevo. */\nlet warnedAboutInsets = false;\n\nexport interface UIProviderProps {\n children: React.ReactNode;\n /** Par light/dark. Por defecto el theme de marca de la librería (créalo con createTheme()). */\n theme?: ThemePair;\n /** Modo inicial (no controlado) si no se pasa `mode`. @default 'system' */\n defaultMode?: ThemeMode;\n /** Modo controlado. Si se pasa junto a onModeChange, el provider no gestiona estado propio. */\n mode?: ThemeMode;\n onModeChange?: (mode: ThemeMode) => void;\n /** Renderer de íconos. Por defecto usa los glifos zero-dep incluidos. */\n icons?: IconRenderer;\n /** Insets de safe area (de useSafeAreaInsets()). Por defecto {0,0,0,0}. */\n insets?: EdgeInsets;\n labels?: Partial<Labels>;\n /** @default false, para paridad con el resto de la librería (accesibilidad primero). */\n allowFontScaling?: boolean;\n}\n\n/**\n * UIProvider\n * Único punto de entrada de la librería: resuelve el theme activo (sistema u\n * override manual), e inyecta íconos, insets y textos por defecto a todo el\n * árbol. No persiste el modo — si el consumidor quiere recordar la preferencia\n * entre sesiones, controla `mode`/`onModeChange` y persiste donde prefiera.\n */\nexport function UIProvider({\n children,\n theme = defaultTheme,\n defaultMode = 'system',\n mode: controlledMode,\n onModeChange,\n icons = renderDefaultIcon,\n insets = zeroInsets,\n labels: labelOverrides,\n allowFontScaling = false,\n}: UIProviderProps) {\n const systemScheme = useColorScheme();\n const [uncontrolledMode, setUncontrolledMode] = useState<ThemeMode>(defaultMode);\n const isControlled = controlledMode !== undefined;\n const mode = isControlled ? controlledMode : uncontrolledMode;\n\n const setMode = useCallback(\n (next: ThemeMode) => {\n if (!isControlled) setUncontrolledMode(next);\n onModeChange?.(next);\n },\n [isControlled, onModeChange]\n );\n\n // Olvidar `insets` no rompe nada visible al montar: el default de ceros\n // recién se nota cuando abrís un Modal `full` (se mete abajo del notch) o un\n // BottomSheet (queda pegado a la barra de gestos). El aviso llega antes.\n if (__DEV__ && insets === zeroInsets && !warnedAboutInsets) {\n warnedAboutInsets = true;\n console.warn(\n 'orn-ui: <UIProvider> sin `insets`, se usan {top:0,bottom:0,left:0,right:0}. ' +\n 'Los modales y el Screen quedan sin safe area. Pasá `insets={useSafeAreaInsets()}` ' +\n \"o usá <SafeAreaUIProvider> de 'orn-ui/safe-area', que lo cablea solo.\"\n );\n }\n\n const scheme: 'light' | 'dark' =\n mode === 'system' ? (systemScheme === 'dark' ? 'dark' : 'light') : mode;\n\n const resolvedTheme = theme[scheme];\n\n const labels = useMemo(\n () => (labelOverrides ? { ...defaultLabels, ...labelOverrides } : defaultLabels),\n [labelOverrides]\n );\n\n const value = useMemo<UIContextValue>(\n () => ({ theme: resolvedTheme, mode, setMode, icons, insets, labels, allowFontScaling }),\n [resolvedTheme, mode, setMode, icons, insets, labels, allowFontScaling]\n );\n\n return <UIContext.Provider value={value}>{children}</UIContext.Provider>;\n}\n\nfunction useUIContext(): UIContextValue {\n const ctx = useContext(UIContext);\n if (!ctx) {\n throw new Error('orn-ui: this hook must be used within a <UIProvider>.');\n }\n return ctx;\n}\n\nexport function useTheme(): Theme {\n return useUIContext().theme;\n}\n\nexport function useColors(): Theme['colors'] {\n return useUIContext().theme.colors;\n}\n\nexport function useThemeMode() {\n const { mode, setMode } = useUIContext();\n return { mode, setMode };\n}\n\nexport function useIcons(): IconRenderer {\n return useUIContext().icons;\n}\n\nexport function useInsets(): EdgeInsets {\n return useUIContext().insets;\n}\n\nexport function useLabels(): Labels {\n return useUIContext().labels;\n}\n\nexport function useAllowFontScaling(): boolean {\n return useUIContext().allowFontScaling;\n}\n\n/** Re-exportado para que tests/consumidores puedan simular cambios de sistema sin RN real. */\nexport const __testUtils = { Appearance };\n"
24
+ "content": "import React, { createContext, useContext, useMemo, useState, useCallback, useRef } from 'react';\nimport { Appearance, useColorScheme } from 'react-native';\nimport { defaultTheme } from './createTheme';\nimport { defaultLabels, type Labels } from './labels';\nimport { zeroInsets, type EdgeInsets } from './insets';\nimport type { Theme, ThemePair } from './types';\nimport { renderDefaultIcon } from '../icons/defaultIcons';\nimport type { IconRenderer } from '../icons/types';\n\nexport type ThemeMode = 'system' | 'light' | 'dark';\n\n/**\n * Un `<Modal>`/`<BottomSheet>` nativo abre su propia ventana (en Android\n * siempre; en iOS según `presentationStyle`), así que los insets medidos en\n * la raíz de la app pueden no valer ahí adentro. Este boundary es el punto de\n * inyección: el default sólo repite los insets ambientes (comportamiento de\n * siempre, cero deps), pero `SafeAreaUIProvider` lo reemplaza por uno que\n * monta un `<SafeAreaProvider>` nuevo y remide dentro de esa ventana.\n */\nexport interface ModalSafeAreaBoundaryProps {\n children: (insets: EdgeInsets) => React.ReactNode;\n}\nexport type ModalSafeAreaBoundaryComponent = React.ComponentType<ModalSafeAreaBoundaryProps>;\n\nfunction DefaultModalSafeAreaBoundary({ children }: ModalSafeAreaBoundaryProps) {\n const insets = useInsets();\n return <>{children(insets)}</>;\n}\n\ninterface UIContextValue {\n theme: Theme;\n mode: ThemeMode;\n setMode: (mode: ThemeMode) => void;\n icons: IconRenderer;\n insets: EdgeInsets;\n labels: Labels;\n allowFontScaling: boolean;\n ModalSafeAreaBoundary: ModalSafeAreaBoundaryComponent;\n}\n\nconst UIContext = createContext<UIContextValue | undefined>(undefined);\n\nexport interface UIProviderProps {\n children: React.ReactNode;\n /** Par light/dark. Por defecto el theme de marca de la librería (créalo con createTheme()). */\n theme?: ThemePair;\n /** Modo inicial (no controlado) si no se pasa `mode`. @default 'system' */\n defaultMode?: ThemeMode;\n /** Modo controlado. Si se pasa junto a onModeChange, el provider no gestiona estado propio. */\n mode?: ThemeMode;\n onModeChange?: (mode: ThemeMode) => void;\n /** Renderer de íconos. Por defecto usa los glifos zero-dep incluidos. */\n icons?: IconRenderer;\n /** Insets de safe area (de useSafeAreaInsets()). Por defecto {0,0,0,0}. */\n insets?: EdgeInsets;\n labels?: Partial<Labels>;\n /** @default false, para paridad con el resto de la librería (accesibilidad primero). */\n allowFontScaling?: boolean;\n /**\n * Fuente de insets que usan `Modal`/`BottomSheet` para su propia ventana\n * nativa. No la pases a mano — `SafeAreaUIProvider` la cablea sola.\n * @default repite los insets ambientes (comportamiento sin safe-area-context)\n */\n ModalSafeAreaBoundary?: ModalSafeAreaBoundaryComponent;\n}\n\n/**\n * UIProvider\n * Único punto de entrada de la librería: resuelve el theme activo (sistema u\n * override manual), e inyecta íconos, insets y textos por defecto a todo el\n * árbol. No persiste el modo — si el consumidor quiere recordar la preferencia\n * entre sesiones, controla `mode`/`onModeChange` y persiste donde prefiera.\n */\nexport function UIProvider({\n children,\n theme = defaultTheme,\n defaultMode = 'system',\n mode: controlledMode,\n onModeChange,\n icons = renderDefaultIcon,\n insets = zeroInsets,\n labels: labelOverrides,\n allowFontScaling = false,\n ModalSafeAreaBoundary = DefaultModalSafeAreaBoundary,\n}: UIProviderProps) {\n const systemScheme = useColorScheme();\n const [uncontrolledMode, setUncontrolledMode] = useState<ThemeMode>(defaultMode);\n const isControlled = controlledMode !== undefined;\n const mode = isControlled ? controlledMode : uncontrolledMode;\n\n const setMode = useCallback(\n (next: ThemeMode) => {\n if (!isControlled) setUncontrolledMode(next);\n onModeChange?.(next);\n },\n [isControlled, onModeChange]\n );\n\n // Por instancia, no por módulo: dos `<UIProvider>` en el mismo árbol (tests,\n // un modal con su propio provider) avisan cada uno si le faltan insets, en\n // vez de que el segundo se calle porque el primero ya avisó una vez.\n const warnedAboutInsets = useRef(false);\n\n // Olvidar `insets` no rompe nada visible al montar: el default de ceros\n // recién se nota cuando abrís un Modal `full` (se mete abajo del notch) o un\n // BottomSheet (queda pegado a la barra de gestos). El aviso llega antes.\n if (__DEV__ && insets === zeroInsets && !warnedAboutInsets.current) {\n warnedAboutInsets.current = true;\n console.warn(\n 'orn-ui: <UIProvider> sin `insets`, se usan {top:0,bottom:0,left:0,right:0}. ' +\n 'Los modales y el Screen quedan sin safe area. Pasá `insets={useSafeAreaInsets()}` ' +\n \"o usá <SafeAreaUIProvider> de 'orn-ui/safe-area', que lo cablea solo.\"\n );\n }\n\n const scheme: 'light' | 'dark' =\n mode === 'system' ? (systemScheme === 'dark' ? 'dark' : 'light') : mode;\n\n const resolvedTheme = theme[scheme];\n\n const labels = useMemo(\n () => (labelOverrides ? { ...defaultLabels, ...labelOverrides } : defaultLabels),\n [labelOverrides]\n );\n\n const value = useMemo<UIContextValue>(\n () => ({\n theme: resolvedTheme,\n mode,\n setMode,\n icons,\n insets,\n labels,\n allowFontScaling,\n ModalSafeAreaBoundary,\n }),\n [resolvedTheme, mode, setMode, icons, insets, labels, allowFontScaling, ModalSafeAreaBoundary]\n );\n\n return <UIContext.Provider value={value}>{children}</UIContext.Provider>;\n}\n\nfunction useUIContext(): UIContextValue {\n const ctx = useContext(UIContext);\n if (!ctx) {\n throw new Error('orn-ui: this hook must be used within a <UIProvider>.');\n }\n return ctx;\n}\n\nexport function useTheme(): Theme {\n return useUIContext().theme;\n}\n\nexport function useColors(): Theme['colors'] {\n return useUIContext().theme.colors;\n}\n\nexport function useThemeMode() {\n const { mode, setMode } = useUIContext();\n return { mode, setMode };\n}\n\nexport function useIcons(): IconRenderer {\n return useUIContext().icons;\n}\n\nexport function useInsets(): EdgeInsets {\n return useUIContext().insets;\n}\n\nexport function useLabels(): Labels {\n return useUIContext().labels;\n}\n\nexport function useAllowFontScaling(): boolean {\n return useUIContext().allowFontScaling;\n}\n\n/** Interno: lo consumen `Modal`/`BottomSheet` para remedir insets dentro de su propia ventana nativa. */\nexport function useModalSafeAreaBoundary(): ModalSafeAreaBoundaryComponent {\n return useUIContext().ModalSafeAreaBoundary;\n}\n\n/** Re-exportado para que tests/consumidores puedan simular cambios de sistema sin RN real. */\nexport const __testUtils = { Appearance };\n"
25
25
  },
26
26
  {
27
27
  "path": "theme/colors.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "core": {
3
- "bytes": 35592
3
+ "bytes": 37338
4
4
  },
5
5
  "components": [
6
6
  {
@@ -301,7 +301,19 @@
301
301
  "registryDependencies": [
302
302
  "core"
303
303
  ],
304
- "bytes": 8302
304
+ "bytes": 8623
305
+ },
306
+ {
307
+ "name": "SymmetricGrid",
308
+ "slug": "symmetric-grid",
309
+ "category": "molecules",
310
+ "exportedNames": [
311
+ "SymmetricGrid"
312
+ ],
313
+ "registryDependencies": [
314
+ "core"
315
+ ],
316
+ "bytes": 2789
305
317
  },
306
318
  {
307
319
  "name": "Modal",
@@ -317,7 +329,7 @@
317
329
  "pressable-scale",
318
330
  "transition"
319
331
  ],
320
- "bytes": 8647
332
+ "bytes": 9294
321
333
  },
322
334
  {
323
335
  "name": "BottomSheet",
@@ -333,7 +345,7 @@
333
345
  "pressable-scale",
334
346
  "transition"
335
347
  ],
336
- "bytes": 8662
348
+ "bytes": 9679
337
349
  },
338
350
  {
339
351
  "name": "Select",
@@ -376,7 +388,7 @@
376
388
  "alert",
377
389
  "button"
378
390
  ],
379
- "bytes": 5470
391
+ "bytes": 5834
380
392
  },
381
393
  {
382
394
  "name": "Screen",
@@ -388,7 +400,7 @@
388
400
  "registryDependencies": [
389
401
  "core"
390
402
  ],
391
- "bytes": 3321
403
+ "bytes": 3237
392
404
  },
393
405
  {
394
406
  "name": "List",
@@ -529,6 +541,18 @@
529
541
  "core"
530
542
  ],
531
543
  "bytes": 7875
544
+ },
545
+ {
546
+ "name": "ReorderableList",
547
+ "slug": "reorderable-list",
548
+ "category": "organisms",
549
+ "exportedNames": [
550
+ "ReorderableList"
551
+ ],
552
+ "registryDependencies": [
553
+ "core"
554
+ ],
555
+ "bytes": 5899
532
556
  }
533
557
  ]
534
558
  }
@@ -178,6 +178,13 @@
178
178
  "file": "molecules/Steps.tsx",
179
179
  "slug": "steps"
180
180
  },
181
+ {
182
+ "name": "SymmetricGrid",
183
+ "siblingNames": [],
184
+ "category": "molecules",
185
+ "file": "molecules/SymmetricGrid.tsx",
186
+ "slug": "symmetric-grid"
187
+ },
181
188
  {
182
189
  "name": "Modal",
183
190
  "siblingNames": [],
@@ -296,6 +303,13 @@
296
303
  "category": "organisms",
297
304
  "file": "organisms/NavigationBar.tsx",
298
305
  "slug": "navigation-bar"
306
+ },
307
+ {
308
+ "name": "ReorderableList",
309
+ "siblingNames": [],
310
+ "category": "organisms",
311
+ "file": "organisms/ReorderableList.tsx",
312
+ "slug": "reorderable-list"
299
313
  }
300
314
  ]
301
315
  }
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/Modal.tsx",
12
- "content": "import React, { memo, useEffect, useState } from 'react';\nimport {\n Dimensions,\n KeyboardAvoidingView,\n Modal as RNModal,\n Platform,\n Pressable,\n ScrollView,\n View,\n type ModalProps as RNModalProps,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useInsets } from '../theme/UIProvider';\nimport { Title } from '../atoms/Text';\nimport { IconButton } from '../atoms/IconButton';\nimport { Transition, type TransitionPreset } from '../atoms/Transition';\n\nconst { height: SCREEN_HEIGHT } = Dimensions.get('window');\n\nexport type ModalVariant = 'full' | 'overlay' | 'fullScreen';\n\nexport interface ModalProps\n extends Pick<RNModalProps, 'statusBarTranslucent' | 'onRequestClose' | 'hardwareAccelerated'> {\n visible: boolean;\n onClose?: () => void;\n title?: string;\n closeAccessibilityLabel?: string;\n children: React.ReactNode;\n footer?: React.ReactNode;\n containerStyle?: ViewStyle;\n contentStyle?: ViewStyle;\n variant?: ModalVariant;\n scrollable?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n flex1: { flex: 1 },\n fullWrapper: { flex: 1, backgroundColor: theme.colors.background },\n overlayContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: theme.tokens.spacing.xxl - 4 },\n backdrop: { backgroundColor: theme.colors.overlay },\n overlayCard: {\n width: '100%',\n maxHeight: '85%',\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.xxl,\n overflow: 'hidden',\n ...theme.tokens.shadow.lg,\n },\n header: {\n flexDirection: 'row',\n justifyContent: 'space-between',\n alignItems: 'center',\n padding: theme.tokens.spacing.xl,\n borderBottomWidth: 1,\n borderBottomColor: theme.colors.border,\n backgroundColor: theme.colors.surface,\n },\n title: { fontSize: theme.tokens.fontSize.xl, fontWeight: theme.tokens.fontWeight.bold, flex: 1 },\n closeButton: { marginLeft: theme.tokens.spacing.md },\n contentWrapper: { flexShrink: 1, backgroundColor: theme.colors.surface },\n scrollWrapper: { flexShrink: 1 },\n scrollContent: { padding: theme.tokens.spacing.xl, flexGrow: 1 },\n scrollContentFull: { padding: 0, flexGrow: 1 },\n footer: {\n padding: theme.tokens.spacing.xl,\n borderTopWidth: 1,\n borderTopColor: theme.colors.border,\n backgroundColor: theme.colors.surface,\n },\n}));\n\n/**\n * Modal\n * Un solo componente para todos los modales de la app: maneja scroll,\n * teclado, insets y layout según `variant`. Para una hoja desde abajo está\n * BottomSheet, que además trae arrastre para cerrar.\n */\nexport const Modal = memo(\n ({\n visible,\n onClose,\n title,\n closeAccessibilityLabel = 'Close',\n children,\n footer,\n containerStyle,\n contentStyle,\n variant = 'full',\n scrollable = true,\n statusBarTranslucent = true,\n onRequestClose,\n hardwareAccelerated,\n }: ModalProps) => {\n const isFull = variant === 'full';\n const isOverlay = variant === 'overlay';\n const isFullScreen = variant === 'fullScreen';\n const isAnimatedVariant = isOverlay || isFull;\n const insets = useInsets();\n const colors = useColors();\n const styles = useStyles();\n\n const [mounted, setMounted] = useState(!isAnimatedVariant && visible);\n\n useEffect(() => {\n if (!isAnimatedVariant) setMounted(visible);\n else if (visible) setMounted(true);\n }, [visible, isAnimatedVariant]);\n\n // La tarjeta nunca anima opacity: hacerlo dispara un bug de Android donde\n // los hijos con elevation parpadean. El fondo semitransparente sí.\n const cardPreset: TransitionPreset[] = isFull ? ['slide-up'] : ['slide-down', 'scale'];\n const cardDistance = isFull ? SCREEN_HEIGHT : 40;\n\n const behavior = Platform.OS === 'ios' ? (isFull ? undefined : 'padding') : 'height';\n const keyboardOffset = Platform.OS === 'ios' ? 0 : 20;\n\n const renderHeader = () => {\n if (!title && !onClose) return null;\n return (\n <View style={styles.header}>\n {!!title && (\n <Title style={styles.title} numberOfLines={1}>\n {title}\n </Title>\n )}\n {!title && <View style={styles.flex1} />}\n {!!onClose && (\n <IconButton iconName=\"close\" size={24} color={colors.text} onPress={onClose} accessibilityLabel={closeAccessibilityLabel} style={styles.closeButton} />\n )}\n </View>\n );\n };\n\n const renderMainContent = () => (\n <View style={[styles.contentWrapper, isFull && styles.flex1]}>\n {scrollable ? (\n <ScrollView\n style={[styles.scrollWrapper, isFull && styles.flex1]}\n contentContainerStyle={[!isFull ? styles.scrollContent : styles.scrollContentFull, contentStyle]}\n keyboardShouldPersistTaps=\"handled\"\n showsVerticalScrollIndicator={false}\n bounces={Platform.OS === 'ios'}\n automaticallyAdjustKeyboardInsets\n >\n {children}\n </ScrollView>\n ) : (\n <View style={[!isFull ? styles.scrollContent : styles.scrollContentFull, isFull && styles.flex1, contentStyle]}>\n {children}\n </View>\n )}\n </View>\n );\n\n const renderFooter = () => (footer ? <View style={styles.footer}>{footer}</View> : null);\n\n if (!mounted) return null;\n\n return (\n <RNModal\n visible\n onRequestClose={onRequestClose ?? onClose}\n animationType={isAnimatedVariant ? 'none' : 'slide'}\n // `full` anima su propia entrada (translateY) con animationType=\"none\"\n // y necesita `transparent` para que no se vea un fondo negro detrás\n // mientras desliza. RN sólo admite `transparent` junto a\n // 'overFullScreen' — con 'pageSheet' avisa que la combinación no está\n // soportada. Por eso `full` usa overFullScreen: la presentación nativa\n // de pageSheet sería redundante con nuestra animación de todos modos.\n presentationStyle={\n Platform.OS === 'ios' ? (isFullScreen ? 'fullScreen' : 'overFullScreen') : undefined\n }\n transparent={isOverlay || isFull}\n statusBarTranslucent={statusBarTranslucent}\n hardwareAccelerated={hardwareAccelerated}\n >\n <KeyboardAvoidingView behavior={behavior} keyboardVerticalOffset={keyboardOffset} style={styles.flex1}>\n {isOverlay ? (\n <View style={styles.overlayContainer}>\n {/* Sin accessibilityLabel propio: el header ya expone un botón \"Close\"\n accesible; duplicar el label aquí generaría dos elementos con el\n mismo nombre para lectores de pantalla. Tocar afuera es un atajo\n puramente táctil. */}\n <Transition\n visible={visible}\n preset=\"fade\"\n duration={200}\n keepMounted\n style={[{ ...StyleSheetAbsoluteFill }, styles.backdrop]}\n >\n <Pressable style={StyleSheetAbsoluteFill} onPress={onClose} testID=\"modal-backdrop\" />\n </Transition>\n\n <Transition\n visible={visible}\n preset={cardPreset}\n distance={cardDistance}\n duration={250}\n onExited={() => setMounted(false)}\n style={[styles.overlayCard, containerStyle]}\n >\n {renderHeader()}\n {renderMainContent()}\n {renderFooter()}\n </Transition>\n </View>\n ) : isFull ? (\n <Transition\n visible={visible}\n preset={cardPreset}\n distance={cardDistance}\n duration={250}\n onExited={() => setMounted(false)}\n style={[styles.fullWrapper, containerStyle]}\n >\n <View style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>\n {renderHeader()}\n {renderMainContent()}\n {renderFooter()}\n </View>\n </Transition>\n ) : (\n <View style={[styles.fullWrapper, containerStyle]}>\n <View style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>\n {renderHeader()}\n {renderMainContent()}\n {renderFooter()}\n </View>\n </View>\n )}\n </KeyboardAvoidingView>\n </RNModal>\n );\n }\n);\nModal.displayName = 'Modal';\n\nconst StyleSheetAbsoluteFill: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 };\n"
12
+ "content": "import React, { memo, useEffect, useState } from 'react';\nimport {\n Dimensions,\n KeyboardAvoidingView,\n Modal as RNModal,\n Platform,\n Pressable,\n ScrollView,\n View,\n type ModalProps as RNModalProps,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useModalSafeAreaBoundary } from '../theme/UIProvider';\nimport type { EdgeInsets } from '../theme/insets';\nimport { Title } from '../atoms/Text';\nimport { IconButton } from '../atoms/IconButton';\nimport { Transition, type TransitionPreset } from '../atoms/Transition';\n\nconst { height: SCREEN_HEIGHT } = Dimensions.get('window');\n\nexport type ModalVariant = 'full' | 'overlay' | 'fullScreen';\n\nexport interface ModalProps\n extends Pick<RNModalProps, 'statusBarTranslucent' | 'onRequestClose' | 'hardwareAccelerated'> {\n visible: boolean;\n onClose?: () => void;\n title?: string;\n closeAccessibilityLabel?: string;\n children: React.ReactNode;\n footer?: React.ReactNode;\n containerStyle?: ViewStyle;\n contentStyle?: ViewStyle;\n variant?: ModalVariant;\n scrollable?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n flex1: { flex: 1 },\n fullWrapper: { flex: 1, backgroundColor: theme.colors.background },\n overlayContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: theme.tokens.spacing.xxl - 4 },\n backdrop: { backgroundColor: theme.colors.overlay },\n overlayCard: {\n width: '100%',\n maxHeight: '85%',\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.xxl,\n overflow: 'hidden',\n ...theme.tokens.shadow.lg,\n },\n header: {\n flexDirection: 'row',\n justifyContent: 'space-between',\n alignItems: 'center',\n padding: theme.tokens.spacing.xl,\n borderBottomWidth: 1,\n borderBottomColor: theme.colors.border,\n backgroundColor: theme.colors.surface,\n },\n title: { fontSize: theme.tokens.fontSize.xl, fontWeight: theme.tokens.fontWeight.bold, flex: 1 },\n closeButton: { marginLeft: theme.tokens.spacing.md },\n contentWrapper: { flexShrink: 1, backgroundColor: theme.colors.surface },\n scrollWrapper: { flexShrink: 1 },\n scrollContent: { padding: theme.tokens.spacing.xl, flexGrow: 1 },\n scrollContentFull: { padding: 0, flexGrow: 1 },\n footer: {\n padding: theme.tokens.spacing.xl,\n borderTopWidth: 1,\n borderTopColor: theme.colors.border,\n backgroundColor: theme.colors.surface,\n },\n}));\n\n/**\n * Modal\n * Un solo componente para todos los modales de la app: maneja scroll,\n * teclado, insets y layout según `variant`. Para una hoja desde abajo está\n * BottomSheet, que además trae arrastre para cerrar.\n */\nexport const Modal = memo(\n ({\n visible,\n onClose,\n title,\n closeAccessibilityLabel = 'Close',\n children,\n footer,\n containerStyle,\n contentStyle,\n variant = 'full',\n scrollable = true,\n statusBarTranslucent = true,\n onRequestClose,\n hardwareAccelerated,\n }: ModalProps) => {\n const isFull = variant === 'full';\n const isOverlay = variant === 'overlay';\n const isFullScreen = variant === 'fullScreen';\n const isAnimatedVariant = isOverlay || isFull;\n const ModalSafeAreaBoundary = useModalSafeAreaBoundary();\n const colors = useColors();\n const styles = useStyles();\n\n const [mounted, setMounted] = useState(!isAnimatedVariant && visible);\n\n useEffect(() => {\n if (!isAnimatedVariant) setMounted(visible);\n else if (visible) setMounted(true);\n }, [visible, isAnimatedVariant]);\n\n // La tarjeta nunca anima opacity: hacerlo dispara un bug de Android donde\n // los hijos con elevation parpadean. El fondo semitransparente sí.\n const cardPreset: TransitionPreset[] = isFull ? ['slide-up'] : ['slide-down', 'scale'];\n const cardDistance = isFull ? SCREEN_HEIGHT : 40;\n\n const behavior = Platform.OS === 'ios' ? (isFull ? undefined : 'padding') : 'height';\n const keyboardOffset = Platform.OS === 'ios' ? 0 : 20;\n\n const renderHeader = () => {\n if (!title && !onClose) return null;\n return (\n <View style={styles.header}>\n {!!title && (\n <Title style={styles.title} numberOfLines={1}>\n {title}\n </Title>\n )}\n {!title && <View style={styles.flex1} />}\n {!!onClose && (\n <IconButton iconName=\"close\" size={24} color={colors.text} onPress={onClose} accessibilityLabel={closeAccessibilityLabel} style={styles.closeButton} />\n )}\n </View>\n );\n };\n\n const renderMainContent = () => (\n <View style={[styles.contentWrapper, isFull && styles.flex1]}>\n {scrollable ? (\n <ScrollView\n style={[styles.scrollWrapper, isFull && styles.flex1]}\n contentContainerStyle={[!isFull ? styles.scrollContent : styles.scrollContentFull, contentStyle]}\n keyboardShouldPersistTaps=\"handled\"\n showsVerticalScrollIndicator={false}\n bounces={Platform.OS === 'ios'}\n automaticallyAdjustKeyboardInsets\n >\n {children}\n </ScrollView>\n ) : (\n <View style={[!isFull ? styles.scrollContent : styles.scrollContentFull, isFull && styles.flex1, contentStyle]}>\n {children}\n </View>\n )}\n </View>\n );\n\n const renderFooter = () => (footer ? <View style={styles.footer}>{footer}</View> : null);\n\n if (!mounted) return null;\n\n return (\n <RNModal\n visible\n onRequestClose={onRequestClose ?? onClose}\n animationType={isAnimatedVariant ? 'none' : 'slide'}\n // `full` anima su propia entrada (translateY) con animationType=\"none\"\n // y necesita `transparent` para que no se vea un fondo negro detrás\n // mientras desliza. RN sólo admite `transparent` junto a\n // 'overFullScreen' — con 'pageSheet' avisa que la combinación no está\n // soportada. Por eso `full` usa overFullScreen: la presentación nativa\n // de pageSheet sería redundante con nuestra animación de todos modos.\n presentationStyle={\n Platform.OS === 'ios' ? (isFullScreen ? 'fullScreen' : 'overFullScreen') : undefined\n }\n transparent={isOverlay || isFull}\n statusBarTranslucent={statusBarTranslucent}\n hardwareAccelerated={hardwareAccelerated}\n >\n <KeyboardAvoidingView behavior={behavior} keyboardVerticalOffset={keyboardOffset} style={styles.flex1}>\n {/* Insets medidos dentro de esta ventana nativa del Modal, no los de\n la raíz de la app — pueden diferir (Android siempre abre una\n ventana propia; iOS según `presentationStyle`). */}\n <ModalSafeAreaBoundary>\n {(insets: EdgeInsets) =>\n isOverlay ? (\n <View style={styles.overlayContainer}>\n {/* Sin accessibilityLabel propio: el header ya expone un botón \"Close\"\n accesible; duplicar el label aquí generaría dos elementos con el\n mismo nombre para lectores de pantalla. Tocar afuera es un atajo\n puramente táctil. */}\n <Transition\n visible={visible}\n preset=\"fade\"\n duration={200}\n keepMounted\n style={[{ ...StyleSheetAbsoluteFill }, styles.backdrop]}\n >\n <Pressable style={StyleSheetAbsoluteFill} onPress={onClose} testID=\"modal-backdrop\" />\n </Transition>\n\n <Transition\n visible={visible}\n preset={cardPreset}\n distance={cardDistance}\n duration={250}\n onExited={() => setMounted(false)}\n style={[styles.overlayCard, containerStyle]}\n >\n {renderHeader()}\n {renderMainContent()}\n {renderFooter()}\n </Transition>\n </View>\n ) : isFull ? (\n <Transition\n visible={visible}\n preset={cardPreset}\n distance={cardDistance}\n duration={250}\n onExited={() => setMounted(false)}\n style={[styles.fullWrapper, containerStyle]}\n >\n <View style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>\n {renderHeader()}\n {renderMainContent()}\n {renderFooter()}\n </View>\n </Transition>\n ) : (\n <View style={[styles.fullWrapper, containerStyle]}>\n <View style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>\n {renderHeader()}\n {renderMainContent()}\n {renderFooter()}\n </View>\n </View>\n )\n }\n </ModalSafeAreaBoundary>\n </KeyboardAvoidingView>\n </RNModal>\n );\n }\n);\nModal.displayName = 'Modal';\n\nconst StyleSheetAbsoluteFill: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 };\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "ReorderableList",
3
+ "slug": "reorderable-list",
4
+ "type": "component",
5
+ "category": "organisms",
6
+ "exportedNames": [
7
+ "ReorderableList"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "organisms/ReorderableList.tsx",
12
+ "content": "import React, { useRef, useState } from 'react';\nimport { Animated, PanResponder, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\n\nexport interface ReorderableListProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n /** Alto fijo de cada fila: necesario para calcular el índice destino sin medir layout async. */\n itemHeight: number;\n renderItem: (item: T, index: number, dragging: boolean) => React.ReactElement;\n /** Se llama al soltar, con el arreglo ya reordenado. No muta `data`. */\n onReorder: (data: T[]) => void;\n disabled?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\n/** Reordena moviendo el ítem de `from` a `to`, sin mutar `data`. Exportada aparte por ser testeable sin simular gestos. */\nexport function move<T>(data: T[], from: number, to: number): T[] {\n const next = data.slice();\n const [item] = next.splice(from, 1) as [T];\n next.splice(to, 0, item);\n return next;\n}\n\nconst useStyles = createStyles(() => ({\n row: { width: '100%' },\n}));\n\n/**\n * ReorderableList\n * Ley de destino común (common fate): al arrastrar una fila, las filas que\n * tiene que atravesar se desplazan juntas en el mismo sentido — eso es lo\n * que comunica \"van a intercambiar lugar\" antes de soltar el dedo, no un\n * simple resaltado del ítem activo.\n *\n * Sin dependencias de gestos/animación externas (reanimated, gesture-handler):\n * PanResponder + Animated alcanzan porque el cálculo de índice destino\n * necesita el valor de arrastre en JS en cada movimiento (no algo que\n * useNativeDriver pueda resolver solo).\n */\nexport function ReorderableList<T>({\n data,\n keyExtractor,\n itemHeight,\n renderItem,\n onReorder,\n disabled = false,\n style,\n}: ReorderableListProps<T>) {\n const styles = useStyles();\n const [activeIndex, setActiveIndex] = useState<number | null>(null);\n const [targetIndex, setTargetIndex] = useState<number | null>(null);\n const dy = useRef(new Animated.Value(0)).current;\n const activeIndexRef = useRef<number | null>(null);\n // El PanResponder de cada fila se crea una sola vez (se cachea en el Map de\n // abajo) y su closure queda fijo al render de esa primera vez: leer\n // `targetIndex` (state) directamente ahí siempre daría el valor del\n // montaje. Un ref sí refleja la última escritura sin importar cuándo se\n // creó la closure que lo lee.\n const targetIndexRef = useRef<number | null>(null);\n const dataRef = useRef(data);\n dataRef.current = data;\n // Igual razón que targetIndexRef: el responder de cada fila se crea una\n // sola vez, así que leer `disabled` directo del closure congelaría el\n // valor del momento en que esa fila se creó.\n const disabledRef = useRef(disabled);\n disabledRef.current = disabled;\n\n const panResponders = useRef(new Map<number, ReturnType<typeof PanResponder.create>>()).current;\n // Si `data` se achica, los responders de índices que ya no existen quedan\n // colgados en el Map para siempre: se podan en cada render.\n for (const index of panResponders.keys()) {\n if (index >= data.length) panResponders.delete(index);\n }\n\n function panResponderFor(index: number) {\n let responder = panResponders.get(index);\n if (responder) return responder;\n\n responder = PanResponder.create({\n onStartShouldSetPanResponder: () => !disabledRef.current,\n onMoveShouldSetPanResponder: (_, gesture) => !disabledRef.current && Math.abs(gesture.dy) > 2,\n onPanResponderGrant: () => {\n activeIndexRef.current = index;\n targetIndexRef.current = index;\n setActiveIndex(index);\n setTargetIndex(index);\n dy.setValue(0);\n },\n onPanResponderMove: (_, gesture) => {\n dy.setValue(gesture.dy);\n const from = activeIndexRef.current;\n if (from === null) return;\n const rawTarget = from + Math.round(gesture.dy / itemHeight);\n const clamped = Math.max(0, Math.min(dataRef.current.length - 1, rawTarget));\n targetIndexRef.current = clamped;\n setTargetIndex((prev) => (prev === clamped ? prev : clamped));\n },\n onPanResponderRelease: () => {\n const from = activeIndexRef.current;\n const to = targetIndexRef.current;\n dy.setValue(0);\n activeIndexRef.current = null;\n targetIndexRef.current = null;\n setActiveIndex(null);\n setTargetIndex(null);\n if (from === null || to === null || from === to) return;\n onReorder(move(dataRef.current, from, to));\n },\n onPanResponderTerminate: () => {\n dy.setValue(0);\n activeIndexRef.current = null;\n targetIndexRef.current = null;\n setActiveIndex(null);\n setTargetIndex(null);\n },\n });\n panResponders.set(index, responder);\n return responder;\n }\n\n return (\n <View style={style}>\n {data.map((item, index) => {\n const isActive = activeIndex === index;\n // Filas entre el origen y el destino se corren un lugar entero en el\n // sentido del arrastre: es el efecto de \"grupo que se mueve junto\".\n let shift = 0;\n if (activeIndex !== null && targetIndex !== null && !isActive) {\n if (activeIndex < targetIndex && index > activeIndex && index <= targetIndex) shift = -itemHeight;\n else if (activeIndex > targetIndex && index < activeIndex && index >= targetIndex) shift = itemHeight;\n }\n\n return (\n <Animated.View\n key={keyExtractor(item, index)}\n style={[\n styles.row,\n isActive\n ? { transform: [{ translateY: dy }], zIndex: 1, elevation: 4 }\n : { transform: [{ translateY: shift }] },\n ]}\n {...panResponderFor(index).panHandlers}\n >\n {renderItem(item, index, isActive)}\n </Animated.View>\n );\n })}\n </View>\n );\n}\n"
13
+ }
14
+ ],
15
+ "registryDependencies": [
16
+ "core"
17
+ ]
18
+ }
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/Screen.tsx",
12
- "content": "import React, { memo } from 'react';\nimport { KeyboardAvoidingView, Platform, ScrollView, View, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useInsets } from '../theme/UIProvider';\n\nexport type ScreenEdge = 'top' | 'bottom';\n\nexport interface ScreenProps {\n children: React.ReactNode;\n style?: ViewStyle;\n scrollable?: boolean;\n contentContainerStyle?: ViewStyle;\n /** Qué insets de safe area aplicar. @default ['top', 'bottom'] */\n edges?: ScreenEdge[];\n /**\n * Compensa el teclado: en iOS con scroll lo hace el propio ScrollView, en el\n * resto de los casos con un KeyboardAvoidingView. Ponelo en false cuando\n * el contenido ya trae su propio scroller que maneja el teclado (Wizard,\n * List, SearchList): las dos compensaciones se suman y el campo enfocado\n * termina empujado fuera de la pantalla.\n * @default true\n */\n keyboardAvoiding?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n wrapper: { flex: 1, backgroundColor: theme.colors.background },\n container: { flex: 1 },\n contentContainer: { flexGrow: 1 },\n}));\n\n/**\n * Screen\n * Contenedor raíz de pantalla: fondo del theme, safe area (via insets del\n * UIProvider), teclado y scroll opcional resueltos de forma consistente.\n */\nexport const Screen = memo(({\n children,\n style,\n scrollable = true,\n contentContainerStyle,\n edges = ['top', 'bottom'],\n keyboardAvoiding = true,\n}: ScreenProps) => {\n const insets = useInsets();\n const styles = useStyles();\n const applyTop = edges.includes('top');\n const applyBottom = edges.includes('bottom');\n\n // En iOS el ScrollView de abajo ya sube el campo enfocado con\n // automaticallyAdjustKeyboardInsets; envolverlo además en un\n // KeyboardAvoidingView compensa el teclado dos veces y el input termina\n // empujado fuera del borde superior. Android no tiene ese prop, así que ahí\n // el KeyboardAvoidingView sigue siendo el único mecanismo.\n const avoidWithView = keyboardAvoiding && !(scrollable && Platform.OS === 'ios');\n\n const KeyboardWrapper: React.ComponentType<any> = avoidWithView ? KeyboardAvoidingView : View;\n const keyboardProps = avoidWithView\n ? {\n behavior: Platform.OS === 'ios' ? ('padding' as const) : ('height' as const),\n keyboardVerticalOffset: Platform.OS === 'ios' ? 0 : 20,\n }\n : {};\n\n return (\n <View style={[styles.wrapper, style]}>\n <KeyboardWrapper style={styles.container} {...keyboardProps}>\n {scrollable ? (\n <ScrollView\n style={styles.container}\n contentInsetAdjustmentBehavior=\"automatic\"\n automaticallyAdjustKeyboardInsets\n contentContainerStyle={[\n styles.contentContainer,\n { paddingTop: applyTop ? insets.top : 0 },\n Platform.OS === 'android' && applyBottom && { paddingBottom: insets.bottom },\n contentContainerStyle,\n ]}\n keyboardShouldPersistTaps=\"handled\"\n >\n {children}\n </ScrollView>\n ) : (\n <View style={[styles.container, { paddingTop: applyTop ? insets.top : 0, paddingBottom: applyBottom ? insets.bottom : 0 }]}>\n {children}\n </View>\n )}\n </KeyboardWrapper>\n </View>\n );\n});\nScreen.displayName = 'Screen';\n"
12
+ "content": "import React, { memo } from 'react';\nimport { KeyboardAvoidingView, Platform, ScrollView, View, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useInsets } from '../theme/UIProvider';\n\nexport type ScreenEdge = 'top' | 'bottom';\n\nexport interface ScreenProps {\n children: React.ReactNode;\n style?: ViewStyle;\n scrollable?: boolean;\n contentContainerStyle?: ViewStyle;\n /** Qué insets de safe area aplicar. @default ['top', 'bottom'] */\n edges?: ScreenEdge[];\n /**\n * Compensa el teclado: en iOS con scroll lo hace el propio ScrollView, en el\n * resto de los casos con un KeyboardAvoidingView. Ponelo en false cuando\n * el contenido ya trae su propio scroller que maneja el teclado (Wizard,\n * List, SearchList): las dos compensaciones se suman y el campo enfocado\n * termina empujado fuera de la pantalla.\n * @default true\n */\n keyboardAvoiding?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n wrapper: { flex: 1, backgroundColor: theme.colors.background },\n container: { flex: 1 },\n contentContainer: { flexGrow: 1 },\n}));\n\n/**\n * Screen\n * Contenedor raíz de pantalla: fondo del theme, safe area (via insets del\n * UIProvider), teclado y scroll opcional resueltos de forma consistente.\n */\nexport const Screen = memo(({\n children,\n style,\n scrollable = true,\n contentContainerStyle,\n edges = ['top', 'bottom'],\n keyboardAvoiding = true,\n}: ScreenProps) => {\n const insets = useInsets();\n const styles = useStyles();\n const applyTop = edges.includes('top');\n const applyBottom = edges.includes('bottom');\n\n // En iOS el ScrollView de abajo ya sube el campo enfocado con\n // automaticallyAdjustKeyboardInsets; envolverlo además en un\n // KeyboardAvoidingView compensa el teclado dos veces y el input termina\n // empujado fuera del borde superior. Android no tiene ese prop, así que ahí\n // el KeyboardAvoidingView sigue siendo el único mecanismo.\n const avoidWithView = keyboardAvoiding && !(scrollable && Platform.OS === 'ios');\n\n const KeyboardWrapper: React.ComponentType<any> = avoidWithView ? KeyboardAvoidingView : View;\n const keyboardProps = avoidWithView\n ? {\n behavior: Platform.OS === 'ios' ? ('padding' as const) : ('height' as const),\n keyboardVerticalOffset: Platform.OS === 'ios' ? 0 : 20,\n }\n : {};\n\n return (\n <View style={[styles.wrapper, style]}>\n <KeyboardWrapper style={styles.container} {...keyboardProps}>\n {scrollable ? (\n <ScrollView\n style={styles.container}\n automaticallyAdjustKeyboardInsets\n contentContainerStyle={[\n styles.contentContainer,\n { paddingTop: applyTop ? insets.top : 0 },\n applyBottom && { paddingBottom: insets.bottom },\n contentContainerStyle,\n ]}\n keyboardShouldPersistTaps=\"handled\"\n >\n {children}\n </ScrollView>\n ) : (\n <View style={[styles.container, { paddingTop: applyTop ? insets.top : 0, paddingBottom: applyBottom ? insets.bottom : 0 }]}>\n {children}\n </View>\n )}\n </KeyboardWrapper>\n </View>\n );\n});\nScreen.displayName = 'Screen';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -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)}