orn-ui 0.2.0 → 0.2.2

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 (54) hide show
  1. package/README.md +16 -5
  2. package/lib/commonjs/molecules/Steps.js +36 -6
  3. package/lib/commonjs/molecules/Steps.js.map +1 -1
  4. package/lib/commonjs/organisms/DateField.js +46 -15
  5. package/lib/commonjs/organisms/DateField.js.map +1 -1
  6. package/lib/commonjs/organisms/DatePicker.js +129 -50
  7. package/lib/commonjs/organisms/DatePicker.js.map +1 -1
  8. package/lib/commonjs/organisms/NavigationBar.js +6 -1
  9. package/lib/commonjs/organisms/NavigationBar.js.map +1 -1
  10. package/lib/commonjs/organisms/SearchList.js +1 -2
  11. package/lib/commonjs/organisms/SearchList.js.map +1 -1
  12. package/lib/commonjs/organisms/Wizard.js +1 -0
  13. package/lib/commonjs/organisms/Wizard.js.map +1 -1
  14. package/lib/commonjs/theme/labels.js +5 -1
  15. package/lib/commonjs/theme/labels.js.map +1 -1
  16. package/lib/module/molecules/Steps.js +37 -7
  17. package/lib/module/molecules/Steps.js.map +1 -1
  18. package/lib/module/organisms/DateField.js +49 -18
  19. package/lib/module/organisms/DateField.js.map +1 -1
  20. package/lib/module/organisms/DatePicker.js +131 -52
  21. package/lib/module/organisms/DatePicker.js.map +1 -1
  22. package/lib/module/organisms/NavigationBar.js +6 -1
  23. package/lib/module/organisms/NavigationBar.js.map +1 -1
  24. package/lib/module/organisms/SearchList.js +1 -2
  25. package/lib/module/organisms/SearchList.js.map +1 -1
  26. package/lib/module/organisms/Wizard.js +1 -0
  27. package/lib/module/organisms/Wizard.js.map +1 -1
  28. package/lib/module/theme/labels.js +5 -1
  29. package/lib/module/theme/labels.js.map +1 -1
  30. package/lib/typescript/molecules/Steps.d.ts.map +1 -1
  31. package/lib/typescript/organisms/DateField.d.ts +23 -5
  32. package/lib/typescript/organisms/DateField.d.ts.map +1 -1
  33. package/lib/typescript/organisms/DatePicker.d.ts +5 -1
  34. package/lib/typescript/organisms/DatePicker.d.ts.map +1 -1
  35. package/lib/typescript/organisms/NavigationBar.d.ts.map +1 -1
  36. package/lib/typescript/organisms/Wizard.d.ts.map +1 -1
  37. package/lib/typescript/theme/labels.d.ts +6 -0
  38. package/lib/typescript/theme/labels.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/registry/core.json +1 -1
  41. package/registry/date-field.json +1 -1
  42. package/registry/date-picker.json +1 -1
  43. package/registry/index.json +7 -7
  44. package/registry/navigation-bar.json +1 -1
  45. package/registry/search-list.json +1 -1
  46. package/registry/steps.json +1 -1
  47. package/registry/wizard.json +1 -1
  48. package/src/molecules/Steps.tsx +27 -8
  49. package/src/organisms/DateField.tsx +79 -19
  50. package/src/organisms/DatePicker.tsx +198 -80
  51. package/src/organisms/NavigationBar.tsx +3 -0
  52. package/src/organisms/SearchList.tsx +1 -1
  53. package/src/organisms/Wizard.tsx +1 -0
  54. package/src/theme/labels.ts +13 -0
@@ -1 +1 @@
1
- {"version":3,"file":"NavigationBar.d.ts","sourceRoot":"","sources":["../../../src/organisms/NavigationBar.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4C,MAAM,OAAO,CAAC;AACjE,OAAO,EAOL,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAItB,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,2DAA2D;IAC3D,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,iBAAiB,EAAE,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,kEAAkE;IAClE,QAAQ,CAAC,EAAE,QAAQ,GAAG,KAAK,CAAC;IAC5B,oBAAoB;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA8CD;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,6GAUrB,kBAAkB,uBA0HtB,CAAC"}
1
+ {"version":3,"file":"NavigationBar.d.ts","sourceRoot":"","sources":["../../../src/organisms/NavigationBar.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4C,MAAM,OAAO,CAAC;AACjE,OAAO,EAOL,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAItB,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,2DAA2D;IAC3D,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,iBAAiB,EAAE,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,kEAAkE;IAClE,QAAQ,CAAC,EAAE,QAAQ,GAAG,KAAK,CAAC;IAC5B,oBAAoB;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA8CD;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,6GAUrB,kBAAkB,uBA6HtB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"Wizard.d.ts","sourceRoot":"","sources":["../../../src/organisms/Wizard.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAC;AACjF,OAAO,EAAoB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAEhF,OAAO,EAAS,KAAK,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAI1D,MAAM,WAAW,UAAW,SAAQ,QAAQ;IAC1C,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC;IACzB,0EAA0E;IAC1E,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,4BAA4B;IAC5B,WAAW,CAAC,EAAE,YAAY,GAAG,UAAU,CAAC;IACxC,kBAAkB,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IACxC,sFAAsF;IACtF,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAUD;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,EACrB,KAAK,EACL,OAAO,EAAE,iBAAiB,EAC1B,YAAY,EACZ,QAAQ,EACR,WAA0B,EAC1B,kBAA4B,EAC5B,mBAA0B,EAC1B,SAAkB,EAClB,SAAkB,EAClB,WAAsB,EACtB,iBAAwB,EACxB,QAAe,EACf,KAAK,GACN,EAAE,WAAW,qBAiGb"}
1
+ {"version":3,"file":"Wizard.d.ts","sourceRoot":"","sources":["../../../src/organisms/Wizard.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAC;AACjF,OAAO,EAAoB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAEhF,OAAO,EAAS,KAAK,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAI1D,MAAM,WAAW,UAAW,SAAQ,QAAQ;IAC1C,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC;IACzB,0EAA0E;IAC1E,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,4BAA4B;IAC5B,WAAW,CAAC,EAAE,YAAY,GAAG,UAAU,CAAC;IACxC,kBAAkB,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IACxC,sFAAsF;IACtF,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAUD;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,EACrB,KAAK,EACL,OAAO,EAAE,iBAAiB,EAC1B,YAAY,EACZ,QAAQ,EACR,WAA0B,EAC1B,kBAA4B,EAC5B,mBAA0B,EAC1B,SAAkB,EAClB,SAAkB,EAClB,WAAsB,EACtB,iBAAwB,EACxB,QAAe,EACf,KAAK,GACN,EAAE,WAAW,qBAkGb"}
@@ -8,6 +8,12 @@ export interface Labels {
8
8
  noResultsDescription: string;
9
9
  search: string;
10
10
  selectPlaceholder: string;
11
+ /** Nombres de los meses, de enero a diciembre. */
12
+ months: string[];
13
+ /** Iniciales de los días, índice 0 = domingo (alinea con `Date.getDay()`). */
14
+ weekdaysShort: string[];
15
+ selectDate: string;
16
+ clear: string;
11
17
  }
12
18
  export declare const defaultLabels: Labels;
13
19
  //# sourceMappingURL=labels.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"labels.d.ts","sourceRoot":"","sources":["../../../src/theme/labels.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,MAAM;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED,eAAO,MAAM,aAAa,EAAE,MAU3B,CAAC"}
1
+ {"version":3,"file":"labels.d.ts","sourceRoot":"","sources":["../../../src/theme/labels.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,MAAM;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,iBAAiB,EAAE,MAAM,CAAC;IAC1B,kDAAkD;IAClD,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,8EAA8E;IAC9E,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,aAAa,EAAE,MAiB3B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orn-ui",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  {
47
47
  "path": "theme/labels.ts",
48
- "content": "export interface Labels {\n close: string;\n cancel: string;\n confirm: string;\n loading: string;\n loadingMore: string;\n noResultsTitle: string;\n noResultsDescription: string;\n search: string;\n selectPlaceholder: string;\n}\n\nexport const defaultLabels: Labels = {\n close: 'Close',\n cancel: 'Cancel',\n confirm: 'OK',\n loading: 'Loading...',\n loadingMore: 'Loading more...',\n noResultsTitle: 'No results found',\n noResultsDescription: 'Try a different search term',\n search: 'Search...',\n selectPlaceholder: 'Select...',\n};\n"
48
+ "content": "export interface Labels {\n close: string;\n cancel: string;\n confirm: string;\n loading: string;\n loadingMore: string;\n noResultsTitle: string;\n noResultsDescription: string;\n search: string;\n selectPlaceholder: string;\n /** Nombres de los meses, de enero a diciembre. */\n months: string[];\n /** Iniciales de los días, índice 0 = domingo (alinea con `Date.getDay()`). */\n weekdaysShort: string[];\n selectDate: string;\n clear: string;\n}\n\nexport const defaultLabels: Labels = {\n close: 'Close',\n cancel: 'Cancel',\n confirm: 'OK',\n loading: 'Loading...',\n loadingMore: 'Loading more...',\n noResultsTitle: 'No results found',\n noResultsDescription: 'Try a different search term',\n search: 'Search...',\n selectPlaceholder: 'Select...',\n months: [\n 'January', 'February', 'March', 'April', 'May', 'June',\n 'July', 'August', 'September', 'October', 'November', 'December',\n ],\n weekdaysShort: ['S', 'M', 'T', 'W', 'T', 'F', 'S'],\n selectDate: 'Select date',\n clear: 'Clear',\n};\n"
49
49
  },
50
50
  {
51
51
  "path": "theme/palettes.ts",
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/DateField.tsx",
12
- "content": "import React, { memo, useState } from 'react';\nimport { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Modal } from './Modal';\nimport { DatePicker, DEFAULT_MONTH_NAMES, type DatePickerProps } from './DatePicker';\nimport { Button } from '../atoms/Button';\n\nexport interface DateFieldProps\n extends Pick<DatePickerProps, 'minDate' | 'maxDate' | 'monthNames' | 'weekdayNames' | 'firstDayOfWeek'> {\n label?: string;\n required?: boolean;\n value?: Date;\n onChange: (date: Date) => void;\n placeholder?: string;\n error?: string;\n disabled?: boolean;\n /** Formatea la fecha en el campo. @default \"D de Month de YYYY\" en inglés */\n format?: (date: Date) => string;\n /** Título del modal del calendario. @default 'Select date' */\n modalTitle?: string;\n clearLabel?: string;\n containerStyle?: StyleProp<ViewStyle>;\n}\n\nfunction defaultFormat(date: Date, monthNames: string[]): string {\n return `${monthNames[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { marginBottom: theme.tokens.spacing.lg, width: '100%', gap: theme.tokens.spacing.sm },\n label: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.medium, color: theme.colors.text },\n required: { color: theme.colors.errorText },\n field: {\n flexDirection: 'row',\n alignItems: 'center',\n justifyContent: 'space-between',\n backgroundColor: theme.colors.inputBackground,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 2,\n borderColor: theme.colors.border,\n minHeight: 50,\n paddingHorizontal: theme.tokens.spacing.lg,\n },\n fieldError: { borderColor: theme.colors.error },\n fieldText: { fontSize: theme.tokens.fontSize.lg, color: theme.colors.text },\n placeholder: { color: theme.colors.textLight },\n errorText: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.errorText },\n}));\n\n/**\n * DateField\n * Campo de fecha: se ve como un Input, abre un `DatePicker` en un Modal al\n * tocarlo. Sin picker nativo del sistema, así que se ve y se comporta igual\n * en iOS y Android.\n */\nexport const DateField = memo(\n ({\n label,\n required = false,\n value,\n onChange,\n placeholder = 'Select a date',\n error,\n disabled = false,\n format,\n modalTitle = 'Select date',\n clearLabel = 'Clear',\n containerStyle,\n monthNames = DEFAULT_MONTH_NAMES,\n ...pickerProps\n }: DateFieldProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const [open, setOpen] = useState(false);\n\n const display = value ? (format ? format(value) : defaultFormat(value, monthNames)) : placeholder;\n\n return (\n <View style={[styles.container, containerStyle]}>\n {!!label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n {required && <Text style={styles.required}> *</Text>}\n </Text>\n )}\n\n <TouchableOpacity\n style={[styles.field, !!error && styles.fieldError, disabled && { opacity: 0.5 }]}\n onPress={() => setOpen(true)}\n disabled={disabled}\n activeOpacity={0.7}\n accessibilityRole=\"button\"\n accessibilityLabel={label ?? placeholder}\n accessibilityState={{ disabled, expanded: open }}\n >\n <Text allowFontScaling={allowFontScaling} style={[styles.fieldText, !value && styles.placeholder]}>\n {display}\n </Text>\n <Text allowFontScaling={allowFontScaling} style={{ color: colors.textLight }}>\n {'▾'}\n </Text>\n </TouchableOpacity>\n\n {!!error && (\n <Text allowFontScaling={allowFontScaling} style={styles.errorText}>\n {error}\n </Text>\n )}\n\n <Modal\n visible={open}\n onClose={() => setOpen(false)}\n title={modalTitle}\n variant=\"overlay\"\n scrollable={false}\n footer={<Button title={clearLabel} variant=\"ghost\" onPress={() => setOpen(false)} />}\n >\n <DatePicker\n value={value}\n monthNames={monthNames}\n {...pickerProps}\n onChange={(date) => {\n onChange(date);\n setOpen(false);\n }}\n />\n </Modal>\n </View>\n );\n }\n);\nDateField.displayName = 'DateField';\n"
12
+ "content": "import React, { memo, useCallback, useState } from 'react';\nimport { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useLabels } from '../theme/UIProvider';\nimport { Modal } from './Modal';\nimport { DatePicker, type DatePickerProps, type DateRange } from './DatePicker';\nimport { Button } from '../atoms/Button';\n\nexport interface DateFieldProps\n extends Pick<DatePickerProps, 'minDate' | 'maxDate' | 'monthNames' | 'weekdayNames' | 'firstDayOfWeek'> {\n label?: string;\n required?: boolean;\n /**\n * 'single' elige una fecha; 'range' elige un intervalo con dos toques.\n * @default 'single'\n */\n mode?: 'single' | 'range';\n /** Fecha elegida en mode=\"single\". */\n value?: Date;\n /** Se llama en mode=\"single\". */\n onChange?: (date: Date) => void;\n /** Rango elegido en mode=\"range\". */\n range?: DateRange;\n /** Se llama en mode=\"range\", tanto al fijar el inicio como al cerrar el rango. */\n onRangeChange?: (range: DateRange) => void;\n placeholder?: string;\n error?: string;\n disabled?: boolean;\n /** Formatea la fecha en el campo. @default \"Month D, YYYY\" */\n format?: (date: Date) => string;\n /** Título del modal del calendario. @default el `selectDate` de `useLabels()` */\n modalTitle?: string;\n /**\n * Si se pasa, el modal muestra un botón que borra la selección y cierra.\n * Sin esto no hay botón: el \"Clear\" anterior sólo cerraba, sin limpiar nada.\n */\n onClear?: () => void;\n /** Texto de ese botón. @default el `clear` de `useLabels()` */\n clearLabel?: string;\n containerStyle?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nfunction defaultFormat(date: Date, monthNames: string[]): string {\n return `${monthNames[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { marginBottom: theme.tokens.spacing.lg, width: '100%', gap: theme.tokens.spacing.sm },\n label: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.medium, color: theme.colors.text },\n required: { color: theme.colors.errorText },\n field: {\n flexDirection: 'row',\n alignItems: 'center',\n justifyContent: 'space-between',\n backgroundColor: theme.colors.inputBackground,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 2,\n borderColor: theme.colors.border,\n minHeight: 50,\n paddingHorizontal: theme.tokens.spacing.lg,\n },\n fieldError: { borderColor: theme.colors.error },\n fieldText: { fontSize: theme.tokens.fontSize.lg, color: theme.colors.text },\n placeholder: { color: theme.colors.textLight },\n errorText: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.errorText },\n}));\n\n/**\n * DateField\n * Campo de fecha: se ve como un Input, abre un `DatePicker` en un Modal al\n * tocarlo. Sin picker nativo del sistema, así que se ve y se comporta igual\n * en iOS y Android.\n */\nexport const DateField = memo(\n ({\n label,\n required = false,\n mode = 'single',\n value,\n onChange,\n range,\n onRangeChange,\n placeholder = 'Select a date',\n error,\n disabled = false,\n format,\n modalTitle,\n onClear,\n clearLabel,\n containerStyle,\n monthNames,\n testID,\n ...pickerProps\n }: DateFieldProps) => {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const allowFontScaling = useAllowFontScaling();\n const [open, setOpen] = useState(false);\n\n const months = monthNames ?? labels.months;\n const isRange = mode === 'range';\n\n const formatDate = (date: Date) => (format ? format(date) : defaultFormat(date, months));\n\n // El calendario se cierra solo cuando ya no falta nada por elegir: en\n // rango, recién cuando el segundo toque cierra el intervalo.\n const handleChange = useCallback(\n (date: Date) => {\n onChange?.(date);\n setOpen(false);\n },\n [onChange]\n );\n\n const handleRangeChange = useCallback(\n (next: DateRange) => {\n onRangeChange?.(next);\n if (next.end) setOpen(false);\n },\n [onRangeChange]\n );\n\n const handleClear = useCallback(() => {\n onClear?.();\n setOpen(false);\n }, [onClear]);\n\n const hasValue = isRange ? !!range?.start : !!value;\n let display = placeholder;\n if (isRange && range?.start) {\n display = range.end ? `${formatDate(range.start)} — ${formatDate(range.end)}` : formatDate(range.start);\n } else if (!isRange && value) {\n display = formatDate(value);\n }\n\n return (\n <View style={[styles.container, containerStyle]} testID={testID}>\n {!!label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n {required && <Text style={styles.required}> *</Text>}\n </Text>\n )}\n\n <TouchableOpacity\n style={[styles.field, !!error && styles.fieldError, disabled && { opacity: 0.5 }]}\n onPress={() => setOpen(true)}\n disabled={disabled}\n activeOpacity={0.7}\n accessibilityRole=\"button\"\n accessibilityLabel={label ?? placeholder}\n accessibilityState={{ disabled, expanded: open }}\n >\n <Text allowFontScaling={allowFontScaling} style={[styles.fieldText, !hasValue && styles.placeholder]}>\n {display}\n </Text>\n <Text allowFontScaling={allowFontScaling} style={{ color: colors.textLight }}>\n {'▾'}\n </Text>\n </TouchableOpacity>\n\n {!!error && (\n <Text allowFontScaling={allowFontScaling} style={styles.errorText}>\n {error}\n </Text>\n )}\n\n <Modal\n visible={open}\n onClose={() => setOpen(false)}\n title={modalTitle ?? labels.selectDate}\n variant=\"overlay\"\n scrollable={false}\n footer={\n onClear ? <Button title={clearLabel ?? labels.clear} variant=\"ghost\" onPress={handleClear} /> : undefined\n }\n >\n <DatePicker\n mode={mode}\n value={value}\n range={range}\n monthNames={months}\n {...pickerProps}\n onChange={handleChange}\n onRangeChange={handleRangeChange}\n />\n </Modal>\n </View>\n );\n }\n);\nDateField.displayName = 'DateField';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -12,7 +12,7 @@
12
12
  "files": [
13
13
  {
14
14
  "path": "organisms/DatePicker.tsx",
15
- "content": "import React, { memo, useMemo, useState } from 'react';\nimport { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { IconButton } from '../atoms/IconButton';\n\nexport const DEFAULT_MONTH_NAMES = [\n 'January', 'February', 'March', 'April', 'May', 'June',\n 'July', 'August', 'September', 'October', 'November', 'December',\n];\n\n/** Índice 0 = domingo, para alinear con Date.getDay(). */\nexport const DEFAULT_WEEKDAY_NAMES = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];\n\nexport interface DateRange {\n start: Date;\n /** Falta mientras el rango está a medio elegir (sólo se tocó el inicio). */\n end?: Date;\n}\n\nexport interface DatePickerProps {\n /**\n * 'single' elige una fecha; 'range' elige un intervalo con dos toques.\n * @default 'single'\n */\n mode?: 'single' | 'range';\n /** Fecha elegida en mode=\"single\". */\n value?: Date;\n /** Se llama en mode=\"single\". */\n onChange?: (date: Date) => void;\n /** Rango elegido en mode=\"range\". */\n range?: DateRange;\n /** Se llama en mode=\"range\", tanto al fijar el inicio como al cerrar el rango. */\n onRangeChange?: (range: DateRange) => void;\n minDate?: Date;\n maxDate?: Date;\n /** Mes mostrado al abrir si no hay `value`. @default hoy */\n defaultMonth?: Date;\n monthNames?: string[];\n weekdayNames?: string[];\n /** 0 = domingo, 1 = lunes. @default 0 */\n firstDayOfWeek?: 0 | 1;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\n/** Compara sólo año/mes/día, ignorando hora — dos Date del mismo día son \"iguales\". */\nfunction isSameDay(a: Date, b: Date): boolean {\n return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();\n}\n\nfunction startOfDay(d: Date): Date {\n return new Date(d.getFullYear(), d.getMonth(), d.getDate());\n}\n\n/** Estrictamente entre start y end, sin incluir los extremos. */\nfunction isBetween(day: Date, start: Date, end: Date): boolean {\n const t = startOfDay(day).getTime();\n return t > startOfDay(start).getTime() && t < startOfDay(end).getTime();\n}\n\nfunction isOutOfRange(day: Date, minDate?: Date, maxDate?: Date): boolean {\n const t = startOfDay(day).getTime();\n if (minDate && t < startOfDay(minDate).getTime()) return true;\n if (maxDate && t > startOfDay(maxDate).getTime()) return true;\n return false;\n}\n\n/**\n * Devuelve la grilla del mes: 6 filas de 7 celdas. Las celdas fuera del mes\n * son null, así el layout no salta de alto entre meses (un mes puede\n * necesitar 4, 5 o 6 filas según en qué día caiga el 1).\n */\nexport function buildMonthGrid(year: number, month: number, firstDayOfWeek: 0 | 1): (Date | null)[][] {\n const first = new Date(year, month, 1);\n const daysInMonth = new Date(year, month + 1, 0).getDate();\n const leading = (first.getDay() - firstDayOfWeek + 7) % 7;\n\n const cells: (Date | null)[] = [];\n for (let i = 0; i < leading; i++) cells.push(null);\n for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(year, month, d));\n while (cells.length < 42) cells.push(null);\n\n const rows: (Date | null)[][] = [];\n for (let r = 0; r < 6; r++) rows.push(cells.slice(r * 7, r * 7 + 7));\n return rows;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: {\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 1,\n borderColor: theme.colors.border,\n padding: theme.tokens.spacing.md,\n },\n header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: theme.tokens.spacing.sm },\n monthLabel: { fontSize: theme.tokens.fontSize.lg, fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.text },\n weekRow: { flexDirection: 'row' },\n weekday: {\n flex: 1,\n textAlign: 'center',\n fontSize: theme.tokens.fontSize.xs,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.textLight,\n paddingVertical: theme.tokens.spacing.sm,\n },\n row: { flexDirection: 'row' },\n cell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center' },\n dayButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },\n dayText: { fontSize: theme.tokens.fontSize.md, color: theme.colors.text },\n daySelected: { backgroundColor: theme.colors.primary },\n dayTextSelected: { color: theme.colors.onPrimary, fontWeight: theme.tokens.fontWeight.bold },\n // El resaltado del tramo intermedio va en la celda (cuadrada, sin gaps) y\n // no en el botón redondo, así la barra del rango se ve continua.\n cellInRange: { backgroundColor: theme.colors.primarySoft },\n cellRangeStart: { borderTopLeftRadius: 18, borderBottomLeftRadius: 18 },\n cellRangeEnd: { borderTopRightRadius: 18, borderBottomRightRadius: 18 },\n dayTextInRange: { color: theme.colors.primaryText, fontWeight: theme.tokens.fontWeight.semibold },\n dayToday: { borderWidth: 1, borderColor: theme.colors.primary },\n dayTextDisabled: { color: theme.colors.textLight, opacity: 0.4 },\n}));\n\n/**\n * DatePicker\n * Calendario mensual dibujado con View/Text puros — sin\n * @react-native-community/datetimepicker ni ningún picker nativo, para\n * mantener la librería en cero dependencias y verse igual en iOS y Android.\n */\nexport const DatePicker = memo(\n ({\n mode = 'single',\n value,\n onChange,\n range,\n onRangeChange,\n minDate,\n maxDate,\n defaultMonth,\n monthNames = DEFAULT_MONTH_NAMES,\n weekdayNames = DEFAULT_WEEKDAY_NAMES,\n firstDayOfWeek = 0,\n style,\n testID,\n }: DatePickerProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isRange = mode === 'range';\n\n const handlePress = (day: Date) => {\n if (!isRange) {\n onChange?.(day);\n return;\n }\n // Primer toque (o rango ya cerrado, o toque antes del inicio): arranca\n // un rango nuevo. Segundo toque en adelante: lo cierra.\n if (!range?.start || range.end || startOfDay(day) < startOfDay(range.start)) {\n onRangeChange?.({ start: day });\n } else {\n onRangeChange?.({ start: range.start, end: day });\n }\n };\n\n const initial = value ?? range?.start ?? defaultMonth ?? new Date();\n const [visibleMonth, setVisibleMonth] = useState(() => new Date(initial.getFullYear(), initial.getMonth(), 1));\n\n const today = useMemo(() => new Date(), []);\n const grid = useMemo(\n () => buildMonthGrid(visibleMonth.getFullYear(), visibleMonth.getMonth(), firstDayOfWeek),\n [visibleMonth, firstDayOfWeek]\n );\n\n const orderedWeekdays = useMemo(\n () => (firstDayOfWeek === 0 ? weekdayNames : [...weekdayNames.slice(1), weekdayNames[0]!]),\n [weekdayNames, firstDayOfWeek]\n );\n\n const shiftMonth = (delta: number) =>\n setVisibleMonth((m) => new Date(m.getFullYear(), m.getMonth() + delta, 1));\n\n const monthLabel = `${monthNames[visibleMonth.getMonth()]} ${visibleMonth.getFullYear()}`;\n\n return (\n <View style={[styles.container, style]} testID={testID}>\n <View style={styles.header}>\n <IconButton iconName=\"chevron-left\" accessibilityLabel=\"Previous month\" onPress={() => shiftMonth(-1)} />\n <Text allowFontScaling={allowFontScaling} style={styles.monthLabel} accessibilityRole=\"header\">\n {monthLabel}\n </Text>\n <IconButton iconName=\"chevron-right\" accessibilityLabel=\"Next month\" onPress={() => shiftMonth(1)} />\n </View>\n\n <View style={styles.weekRow}>\n {orderedWeekdays.map((w, i) => (\n <Text allowFontScaling={allowFontScaling} key={`${w}-${i}`} style={styles.weekday}>\n {w}\n </Text>\n ))}\n </View>\n\n {grid.map((row, rowIndex) => (\n <View style={styles.row} key={rowIndex}>\n {row.map((day, cellIndex) => {\n if (!day) return <View style={styles.cell} key={cellIndex} />;\n\n const isStart = isRange && !!range?.start && isSameDay(day, range.start);\n const isEnd = isRange && !!range?.end && isSameDay(day, range.end);\n const inRange =\n isRange && !!range?.start && !!range.end && isBetween(day, range.start, range.end);\n const selected = isRange ? isStart || isEnd : !!value && isSameDay(day, value);\n\n const disabled = isOutOfRange(day, minDate, maxDate);\n const isToday = isSameDay(day, today);\n return (\n <View\n style={[\n styles.cell,\n (inRange || (isStart && !!range?.end) || isEnd) && styles.cellInRange,\n isStart && !!range?.end && styles.cellRangeStart,\n isEnd && styles.cellRangeEnd,\n ]}\n key={cellIndex}\n >\n <TouchableOpacity\n style={[styles.dayButton, selected && styles.daySelected, !selected && isToday && styles.dayToday]}\n onPress={() => handlePress(day)}\n disabled={disabled}\n activeOpacity={0.7}\n accessibilityRole=\"button\"\n accessibilityLabel={`${monthNames[day.getMonth()]} ${day.getDate()}, ${day.getFullYear()}`}\n accessibilityState={{ selected, disabled }}\n >\n <Text\n allowFontScaling={allowFontScaling}\n style={[\n styles.dayText,\n inRange && styles.dayTextInRange,\n selected && styles.dayTextSelected,\n disabled && styles.dayTextDisabled,\n ]}\n >\n {day.getDate()}\n </Text>\n </TouchableOpacity>\n </View>\n );\n })}\n </View>\n ))}\n </View>\n );\n }\n);\nDatePicker.displayName = 'DatePicker';\n"
15
+ "content": "import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useLabels } from '../theme/UIProvider';\nimport { IconButton } from '../atoms/IconButton';\n\nexport const DEFAULT_MONTH_NAMES = [\n 'January', 'February', 'March', 'April', 'May', 'June',\n 'July', 'August', 'September', 'October', 'November', 'December',\n];\n\n/** Índice 0 = domingo, para alinear con Date.getDay(). */\nexport const DEFAULT_WEEKDAY_NAMES = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];\n\nexport interface DateRange {\n start: Date;\n /** Falta mientras el rango está a medio elegir (sólo se tocó el inicio). */\n end?: Date;\n}\n\nexport interface DatePickerProps {\n /**\n * 'single' elige una fecha; 'range' elige un intervalo con dos toques.\n * @default 'single'\n */\n mode?: 'single' | 'range';\n /** Fecha elegida en mode=\"single\". */\n value?: Date;\n /** Se llama en mode=\"single\". */\n onChange?: (date: Date) => void;\n /** Rango elegido en mode=\"range\". */\n range?: DateRange;\n /** Se llama en mode=\"range\", tanto al fijar el inicio como al cerrar el rango. */\n onRangeChange?: (range: DateRange) => void;\n minDate?: Date;\n maxDate?: Date;\n /** Mes mostrado al abrir si no hay `value`. @default hoy */\n defaultMonth?: Date;\n /** Nombres de los meses. @default los de `useLabels()` */\n monthNames?: string[];\n /** Iniciales de los días, índice 0 = domingo. @default las de `useLabels()` */\n weekdayNames?: string[];\n /** 0 = domingo, 1 = lunes. @default 0 */\n firstDayOfWeek?: 0 | 1;\n /** Avisa qué mes quedó a la vista al navegar con las flechas. */\n onVisibleMonthChange?: (month: Date) => void;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nfunction startOfDay(d: Date): Date {\n return new Date(d.getFullYear(), d.getMonth(), d.getDate());\n}\n\n/** Timestamp del día, para comparar sin volver a crear Date en cada celda. */\nfunction dayTime(d: Date): number {\n return startOfDay(d).getTime();\n}\n\nfunction dayTimeOf(d: Date | undefined): number | undefined {\n return d ? dayTime(d) : undefined;\n}\n\n/** Milisegundos que faltan para la próxima medianoche local. */\nfunction msUntilNextMidnight(from: Date): number {\n const next = new Date(from.getFullYear(), from.getMonth(), from.getDate() + 1);\n return next.getTime() - from.getTime();\n}\n\n/** Número de mes absoluto (año * 12 + mes), para comparar meses con un `<`. */\nfunction monthIndex(d: Date): number {\n return d.getFullYear() * 12 + d.getMonth();\n}\n\n/**\n * Devuelve la grilla del mes: 6 filas de 7 celdas. Las celdas fuera del mes\n * son null, así el layout no salta de alto entre meses (un mes puede\n * necesitar 4, 5 o 6 filas según en qué día caiga el 1).\n */\nexport function buildMonthGrid(year: number, month: number, firstDayOfWeek: 0 | 1): (Date | null)[][] {\n const first = new Date(year, month, 1);\n const daysInMonth = new Date(year, month + 1, 0).getDate();\n const leading = (first.getDay() - firstDayOfWeek + 7) % 7;\n\n const cells: (Date | null)[] = [];\n for (let i = 0; i < leading; i++) cells.push(null);\n for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(year, month, d));\n while (cells.length < 42) cells.push(null);\n\n const rows: (Date | null)[][] = [];\n for (let r = 0; r < 6; r++) rows.push(cells.slice(r * 7, r * 7 + 7));\n return rows;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: {\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 1,\n borderColor: theme.colors.border,\n padding: theme.tokens.spacing.md,\n },\n header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: theme.tokens.spacing.sm },\n monthLabel: { fontSize: theme.tokens.fontSize.lg, fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.text },\n weekRow: { flexDirection: 'row' },\n weekday: {\n flex: 1,\n textAlign: 'center',\n fontSize: theme.tokens.fontSize.xs,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.textLight,\n paddingVertical: theme.tokens.spacing.sm,\n },\n row: { flexDirection: 'row' },\n cell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center' },\n dayButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },\n dayText: { fontSize: theme.tokens.fontSize.md, color: theme.colors.text },\n daySelected: { backgroundColor: theme.colors.primary },\n dayTextSelected: { color: theme.colors.onPrimary, fontWeight: theme.tokens.fontWeight.bold },\n // El resaltado del tramo intermedio va en la celda (cuadrada, sin gaps) y\n // no en el botón redondo, así la barra del rango se ve continua.\n cellInRange: { backgroundColor: theme.colors.primarySoft },\n cellRangeStart: { borderTopLeftRadius: 18, borderBottomLeftRadius: 18 },\n cellRangeEnd: { borderTopRightRadius: 18, borderBottomRightRadius: 18 },\n dayTextInRange: { color: theme.colors.primaryText, fontWeight: theme.tokens.fontWeight.semibold },\n dayToday: { borderWidth: 1, borderColor: theme.colors.primary },\n dayTextDisabled: { color: theme.colors.textLight, opacity: 0.4 },\n}));\n\ntype Styles = ReturnType<typeof useStyles>;\n\ninterface DayCellProps {\n day: Date;\n label: string;\n selected: boolean;\n inRange: boolean;\n isRangeStart: boolean;\n isRangeEnd: boolean;\n isToday: boolean;\n disabled: boolean;\n allowFontScaling: boolean;\n styles: Styles;\n onPress: (day: Date) => void;\n}\n\n/**\n * Una celda del calendario. Va memoizada aparte porque el mes entero son 42\n * celdas: sin esto, cualquier render del padre reconcilia las 42.\n */\nconst DayCell = memo(\n ({\n day,\n label,\n selected,\n inRange,\n isRangeStart,\n isRangeEnd,\n isToday,\n disabled,\n allowFontScaling,\n styles,\n onPress,\n }: DayCellProps) => (\n <View\n style={[\n styles.cell,\n (inRange || isRangeStart || isRangeEnd) && styles.cellInRange,\n isRangeStart && styles.cellRangeStart,\n isRangeEnd && styles.cellRangeEnd,\n ]}\n >\n <TouchableOpacity\n style={[styles.dayButton, selected && styles.daySelected, !selected && isToday && styles.dayToday]}\n onPress={() => onPress(day)}\n disabled={disabled}\n activeOpacity={0.7}\n accessibilityRole=\"button\"\n accessibilityLabel={label}\n accessibilityState={{ selected, disabled }}\n >\n <Text\n allowFontScaling={allowFontScaling}\n style={[\n styles.dayText,\n inRange && styles.dayTextInRange,\n selected && styles.dayTextSelected,\n disabled && styles.dayTextDisabled,\n ]}\n >\n {day.getDate()}\n </Text>\n </TouchableOpacity>\n </View>\n )\n);\nDayCell.displayName = 'DayCell';\n\n/**\n * DatePicker\n * Calendario mensual dibujado con View/Text puros — sin\n * @react-native-community/datetimepicker ni ningún picker nativo, para\n * mantener la librería en cero dependencias y verse igual en iOS y Android.\n */\nexport const DatePicker = memo(\n ({\n mode = 'single',\n value,\n onChange,\n range,\n onRangeChange,\n minDate,\n maxDate,\n defaultMonth,\n monthNames,\n weekdayNames,\n firstDayOfWeek = 0,\n onVisibleMonthChange,\n style,\n testID,\n }: DatePickerProps) => {\n const styles = useStyles();\n const labels = useLabels();\n const allowFontScaling = useAllowFontScaling();\n const isRange = mode === 'range';\n\n const months = monthNames ?? labels.months;\n const weekdays = weekdayNames ?? labels.weekdaysShort;\n\n const handlePress = useCallback(\n (day: Date) => {\n if (!isRange) {\n onChange?.(day);\n return;\n }\n // Primer toque (o rango ya cerrado, o toque antes del inicio): arranca\n // un rango nuevo. Segundo toque en adelante: lo cierra.\n if (!range?.start || range.end || dayTime(day) < dayTime(range.start)) {\n onRangeChange?.({ start: day });\n } else {\n onRangeChange?.({ start: range.start, end: day });\n }\n },\n [isRange, onChange, onRangeChange, range?.start, range?.end]\n );\n\n const anchor = value ?? range?.start ?? defaultMonth;\n const [visibleMonth, setVisibleMonth] = useState(\n () => new Date((anchor ?? new Date()).getFullYear(), (anchor ?? new Date()).getMonth(), 1)\n );\n\n // La fecha elegida puede cambiar desde afuera (uso controlado): sin esto,\n // el calendario se quedaba clavado en el mes con el que montó.\n const anchorMonth = anchor ? monthIndex(anchor) : undefined;\n const lastAnchorMonth = useRef(anchorMonth);\n useEffect(() => {\n if (anchorMonth === undefined || anchorMonth === lastAnchorMonth.current) return;\n lastAnchorMonth.current = anchorMonth;\n setVisibleMonth(new Date(Math.floor(anchorMonth / 12), anchorMonth % 12, 1));\n }, [anchorMonth]);\n\n // \"Hoy\" se recalcula en la próxima medianoche: una app que queda abierta\n // toda la noche marcaba el día anterior.\n const [todayTs, setTodayTs] = useState(() => dayTime(new Date()));\n useEffect(() => {\n const timer = setTimeout(() => setTodayTs(dayTime(new Date())), msUntilNextMidnight(new Date()));\n return () => clearTimeout(timer);\n }, [todayTs]);\n\n const grid = useMemo(\n () => buildMonthGrid(visibleMonth.getFullYear(), visibleMonth.getMonth(), firstDayOfWeek),\n [visibleMonth, firstDayOfWeek]\n );\n\n const orderedWeekdays = useMemo(\n () => (firstDayOfWeek === 0 ? weekdays : [...weekdays.slice(1), weekdays[0]!]),\n [weekdays, firstDayOfWeek]\n );\n\n // Los límites se resuelven una vez y no 42 veces por render: comparar\n // timestamps sale gratis, crear un Date por celda no.\n const bounds = useMemo(\n () => ({\n min: dayTimeOf(minDate),\n max: dayTimeOf(maxDate),\n start: dayTimeOf(range?.start),\n end: dayTimeOf(range?.end),\n selected: dayTimeOf(value),\n }),\n [minDate, maxDate, range?.start, range?.end, value]\n );\n\n const shiftMonth = (delta: number) =>\n setVisibleMonth((m) => {\n const next = new Date(m.getFullYear(), m.getMonth() + delta, 1);\n onVisibleMonthChange?.(next);\n return next;\n });\n\n // Un mes entero fuera de los límites no tiene nada que elegir: la flecha\n // que lleva ahí se apaga en vez de dejar navegar al vacío.\n const visibleIndex = monthIndex(visibleMonth);\n const canGoBack = !minDate || visibleIndex > monthIndex(minDate);\n const canGoForward = !maxDate || visibleIndex < monthIndex(maxDate);\n\n const monthLabel = `${months[visibleMonth.getMonth()]} ${visibleMonth.getFullYear()}`;\n\n return (\n <View style={[styles.container, style]} testID={testID}>\n <View style={styles.header}>\n <IconButton\n iconName=\"chevron-left\"\n accessibilityLabel=\"Previous month\"\n disabled={!canGoBack}\n onPress={() => shiftMonth(-1)}\n />\n <Text allowFontScaling={allowFontScaling} style={styles.monthLabel} accessibilityRole=\"header\">\n {monthLabel}\n </Text>\n <IconButton\n iconName=\"chevron-right\"\n accessibilityLabel=\"Next month\"\n disabled={!canGoForward}\n onPress={() => shiftMonth(1)}\n />\n </View>\n\n <View style={styles.weekRow}>\n {orderedWeekdays.map((w, i) => (\n <Text allowFontScaling={allowFontScaling} key={`${w}-${i}`} style={styles.weekday}>\n {w}\n </Text>\n ))}\n </View>\n\n {grid.map((row, rowIndex) => (\n <View style={styles.row} key={rowIndex}>\n {row.map((day, cellIndex) => {\n if (!day) return <View style={styles.cell} key={cellIndex} />;\n\n const t = dayTime(day);\n const disabled = (bounds.min !== undefined && t < bounds.min) || (bounds.max !== undefined && t > bounds.max);\n const isStart = isRange && bounds.start === t;\n const isEnd = isRange && bounds.end === t;\n const closed = isRange && bounds.start !== undefined && bounds.end !== undefined;\n const between = closed && t > bounds.start! && t < bounds.end!;\n const selected = isRange ? isStart || isEnd : bounds.selected === t;\n\n return (\n <DayCell\n key={cellIndex}\n day={day}\n label={`${months[day.getMonth()]} ${day.getDate()}, ${day.getFullYear()}`}\n selected={selected}\n // Un día fuera de los límites no se pinta como parte del\n // rango: se ve deshabilitado, y eso es lo que es.\n inRange={between && !disabled}\n isRangeStart={!!closed && isStart && !disabled}\n isRangeEnd={isEnd && !disabled}\n isToday={t === todayTs}\n disabled={disabled}\n allowFontScaling={allowFontScaling}\n styles={styles}\n onPress={handlePress}\n />\n );\n })}\n </View>\n ))}\n </View>\n );\n }\n);\nDatePicker.displayName = 'DatePicker';\n"
16
16
  }
17
17
  ],
18
18
  "registryDependencies": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "core": {
3
- "bytes": 35122
3
+ "bytes": 35592
4
4
  },
5
5
  "components": [
6
6
  {
@@ -301,7 +301,7 @@
301
301
  "registryDependencies": [
302
302
  "core"
303
303
  ],
304
- "bytes": 7314
304
+ "bytes": 8302
305
305
  },
306
306
  {
307
307
  "name": "Modal",
@@ -421,7 +421,7 @@
421
421
  "icon-button",
422
422
  "pressable-scale"
423
423
  ],
424
- "bytes": 9327
424
+ "bytes": 9285
425
425
  },
426
426
  {
427
427
  "name": "Toast",
@@ -469,7 +469,7 @@
469
469
  "icon-button",
470
470
  "pressable-scale"
471
471
  ],
472
- "bytes": 10098
472
+ "bytes": 13400
473
473
  },
474
474
  {
475
475
  "name": "DateField",
@@ -488,7 +488,7 @@
488
488
  "date-picker",
489
489
  "button"
490
490
  ],
491
- "bytes": 4553
491
+ "bytes": 6402
492
492
  },
493
493
  {
494
494
  "name": "Wizard",
@@ -503,7 +503,7 @@
503
503
  "button",
504
504
  "transition"
505
505
  ],
506
- "bytes": 5591
506
+ "bytes": 5625
507
507
  },
508
508
  {
509
509
  "name": "ThemeToggle",
@@ -528,7 +528,7 @@
528
528
  "registryDependencies": [
529
529
  "core"
530
530
  ],
531
- "bytes": 7378
531
+ "bytes": 7623
532
532
  }
533
533
  ]
534
534
  }
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/NavigationBar.tsx",
12
- "content": "import React, { memo, useEffect, useRef, useState } from 'react';\nimport {\n Animated,\n StyleSheet,\n Text,\n TouchableOpacity,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useInsets, useTheme } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface NavigationBarItem {\n key: string;\n label: string;\n iconName?: IconName;\n /** Contador sobre el ícono. `true` pinta sólo el punto. */\n badge?: number | string | true;\n}\n\nexport interface NavigationBarProps {\n items: NavigationBarItem[];\n activeKey: string;\n onChange: (key: string) => void;\n /** Borde de la pantalla donde vive la barra. @default 'bottom' */\n position?: 'bottom' | 'top';\n /** @default true */\n showLabels?: boolean;\n /** Suma el inset de safe area del borde donde vive la barra. @default true */\n safeArea?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n bar: {\n flexDirection: 'row',\n backgroundColor: theme.colors.surface,\n borderColor: theme.colors.border,\n paddingHorizontal: theme.tokens.spacing.xs,\n paddingVertical: theme.tokens.spacing.sm,\n },\n barBottom: { borderTopWidth: StyleSheet.hairlineWidth },\n barTop: { borderBottomWidth: StyleSheet.hairlineWidth },\n indicator: {\n position: 'absolute',\n top: theme.tokens.spacing.xs,\n bottom: theme.tokens.spacing.xs,\n left: theme.tokens.spacing.sm,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.primarySoft,\n },\n item: { flex: 1, paddingVertical: theme.tokens.spacing.xs },\n itemContent: { alignItems: 'center', justifyContent: 'center', gap: 2 },\n label: { fontSize: theme.tokens.fontSize.xs, fontWeight: theme.tokens.fontWeight.medium },\n badge: {\n position: 'absolute',\n top: -4,\n left: '50%',\n marginLeft: 4,\n minWidth: 16,\n height: 16,\n paddingHorizontal: 4,\n borderRadius: theme.tokens.radius.full,\n backgroundColor: theme.colors.error,\n alignItems: 'center',\n justifyContent: 'center',\n },\n badgeDot: { minWidth: 10, height: 10, top: -2, paddingHorizontal: 0 },\n badgeText: {\n fontSize: 10,\n fontWeight: theme.tokens.fontWeight.bold,\n color: theme.colors.onError,\n },\n}));\n\nconst ICON_SIZE = 22;\n\n/**\n * NavigationBar\n * Barra de navegación por pestañas con la misma apariencia y las mismas\n * transiciones en iOS y Android: el indicador se desliza hasta la pestaña\n * activa y su contenido crece y toma el color de acento con un cross-fade.\n * No navega por su cuenta — reporta la pestaña tocada y el consumidor decide.\n */\nexport const NavigationBar = memo(\n ({\n items,\n activeKey,\n onChange,\n position = 'bottom',\n showLabels = true,\n safeArea = true,\n style,\n testID,\n }: NavigationBarProps) => {\n const theme = useTheme();\n const colors = useColors();\n const styles = useStyles();\n const insets = useInsets();\n const allowFontScaling = useAllowFontScaling();\n\n const [barWidth, setBarWidth] = useState(0);\n const slide = useRef(new Animated.Value(0)).current;\n const placed = useRef(false);\n\n const activeIndex = Math.max(\n 0,\n items.findIndex((item) => item.key === activeKey)\n );\n const itemWidth = items.length > 0 ? barWidth / items.length : 0;\n\n useEffect(() => {\n if (itemWidth <= 0) return;\n const to = activeIndex * itemWidth;\n // En el primer layout el indicador aparece en su lugar; animarlo desde 0\n // sería un deslizamiento que el usuario no pidió.\n if (!placed.current) {\n placed.current = true;\n slide.setValue(to);\n return;\n }\n Animated.spring(slide, { toValue: to, useNativeDriver: true, friction: 9, tension: 90 }).start();\n }, [activeIndex, itemWidth, slide]);\n\n const onLayout = (e: LayoutChangeEvent) => {\n const next = e.nativeEvent.layout.width - theme.tokens.spacing.xs * 2;\n if (next !== barWidth) setBarWidth(next);\n };\n\n const renderContent = (item: NavigationBarItem, color: string) => (\n <>\n {!!item.iconName && <Icon name={item.iconName} size={ICON_SIZE} color={color} />}\n {showLabels && (\n <Text allowFontScaling={allowFontScaling} numberOfLines={1} style={[styles.label, { color }]}>\n {item.label}\n </Text>\n )}\n </>\n );\n\n return (\n <View\n testID={testID}\n onLayout={onLayout}\n accessibilityRole=\"tablist\"\n style={[\n styles.bar,\n position === 'bottom' ? styles.barBottom : styles.barTop,\n safeArea && position === 'bottom' && { paddingBottom: theme.tokens.spacing.sm + insets.bottom },\n safeArea && position === 'top' && { paddingTop: theme.tokens.spacing.sm + insets.top },\n style,\n ]}\n >\n {itemWidth > 0 && (\n <Animated.View\n pointerEvents=\"none\"\n style={[\n styles.indicator,\n {\n width: itemWidth - theme.tokens.spacing.sm * 2,\n transform: [{ translateX: slide }],\n },\n ]}\n />\n )}\n\n {items.map((item, index) => {\n const active = index === activeIndex;\n const range = [(index - 1) * itemWidth, index * itemWidth, (index + 1) * itemWidth];\n const scale =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [1, 1.08, 1], extrapolate: 'clamp' })\n : 1;\n const activeOpacity =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [0, 1, 0], extrapolate: 'clamp' })\n : 0;\n\n return (\n <TouchableOpacity\n key={item.key}\n style={styles.item}\n activeOpacity={0.7}\n onPress={() => onChange(item.key)}\n accessibilityRole=\"tab\"\n accessibilityLabel={item.label}\n accessibilityState={{ selected: active }}\n >\n <Animated.View style={[styles.itemContent, { transform: [{ scale }] }]}>\n {renderContent(item, colors.textLight)}\n {/* Copia decorativa: sin ocultarla, el lector lee el label dos veces. */}\n <Animated.View\n pointerEvents=\"none\"\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={[StyleSheet.absoluteFill, styles.itemContent, { opacity: activeOpacity }]}\n >\n {renderContent(item, colors.primaryText)}\n </Animated.View>\n\n {item.badge !== undefined && (\n <View style={[styles.badge, item.badge === true && styles.badgeDot]}>\n {item.badge !== true && (\n <Text allowFontScaling={false} style={styles.badgeText}>\n {item.badge}\n </Text>\n )}\n </View>\n )}\n </Animated.View>\n </TouchableOpacity>\n );\n })}\n </View>\n );\n }\n);\nNavigationBar.displayName = 'NavigationBar';\n"
12
+ "content": "import React, { memo, useEffect, useRef, useState } from 'react';\nimport {\n Animated,\n StyleSheet,\n Text,\n TouchableOpacity,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useInsets, useTheme } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface NavigationBarItem {\n key: string;\n label: string;\n iconName?: IconName;\n /** Contador sobre el ícono. `true` pinta sólo el punto. */\n badge?: number | string | true;\n}\n\nexport interface NavigationBarProps {\n items: NavigationBarItem[];\n activeKey: string;\n onChange: (key: string) => void;\n /** Borde de la pantalla donde vive la barra. @default 'bottom' */\n position?: 'bottom' | 'top';\n /** @default true */\n showLabels?: boolean;\n /** Suma el inset de safe area del borde donde vive la barra. @default true */\n safeArea?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n bar: {\n flexDirection: 'row',\n backgroundColor: theme.colors.surface,\n borderColor: theme.colors.border,\n paddingHorizontal: theme.tokens.spacing.xs,\n paddingVertical: theme.tokens.spacing.sm,\n },\n barBottom: { borderTopWidth: StyleSheet.hairlineWidth },\n barTop: { borderBottomWidth: StyleSheet.hairlineWidth },\n indicator: {\n position: 'absolute',\n top: theme.tokens.spacing.xs,\n bottom: theme.tokens.spacing.xs,\n left: theme.tokens.spacing.sm,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.primarySoft,\n },\n item: { flex: 1, paddingVertical: theme.tokens.spacing.xs },\n itemContent: { alignItems: 'center', justifyContent: 'center', gap: 2 },\n label: { fontSize: theme.tokens.fontSize.xs, fontWeight: theme.tokens.fontWeight.medium },\n badge: {\n position: 'absolute',\n top: -4,\n left: '50%',\n marginLeft: 4,\n minWidth: 16,\n height: 16,\n paddingHorizontal: 4,\n borderRadius: theme.tokens.radius.full,\n backgroundColor: theme.colors.error,\n alignItems: 'center',\n justifyContent: 'center',\n },\n badgeDot: { minWidth: 10, height: 10, top: -2, paddingHorizontal: 0 },\n badgeText: {\n fontSize: 10,\n fontWeight: theme.tokens.fontWeight.bold,\n color: theme.colors.onError,\n },\n}));\n\nconst ICON_SIZE = 22;\n\n/**\n * NavigationBar\n * Barra de navegación por pestañas con la misma apariencia y las mismas\n * transiciones en iOS y Android: el indicador se desliza hasta la pestaña\n * activa y su contenido crece y toma el color de acento con un cross-fade.\n * No navega por su cuenta — reporta la pestaña tocada y el consumidor decide.\n */\nexport const NavigationBar = memo(\n ({\n items,\n activeKey,\n onChange,\n position = 'bottom',\n showLabels = true,\n safeArea = true,\n style,\n testID,\n }: NavigationBarProps) => {\n const theme = useTheme();\n const colors = useColors();\n const styles = useStyles();\n const insets = useInsets();\n const allowFontScaling = useAllowFontScaling();\n\n const [barWidth, setBarWidth] = useState(0);\n const slide = useRef(new Animated.Value(0)).current;\n const placed = useRef(false);\n\n const activeIndex = Math.max(\n 0,\n items.findIndex((item) => item.key === activeKey)\n );\n const itemWidth = items.length > 0 ? barWidth / items.length : 0;\n\n useEffect(() => {\n if (itemWidth <= 0) return;\n const to = activeIndex * itemWidth;\n // En el primer layout el indicador aparece en su lugar; animarlo desde 0\n // sería un deslizamiento que el usuario no pidió.\n if (!placed.current) {\n placed.current = true;\n slide.setValue(to);\n return;\n }\n Animated.spring(slide, { toValue: to, useNativeDriver: true, friction: 9, tension: 90 }).start();\n }, [activeIndex, itemWidth, slide]);\n\n const onLayout = (e: LayoutChangeEvent) => {\n const next = e.nativeEvent.layout.width - theme.tokens.spacing.xs * 2;\n if (next !== barWidth) setBarWidth(next);\n };\n\n const renderContent = (item: NavigationBarItem, color: string) => (\n <>\n {!!item.iconName && <Icon name={item.iconName} size={ICON_SIZE} color={color} />}\n {showLabels && (\n <Text allowFontScaling={allowFontScaling} numberOfLines={1} style={[styles.label, { color }]}>\n {item.label}\n </Text>\n )}\n </>\n );\n\n return (\n <View\n testID={testID}\n onLayout={onLayout}\n accessibilityRole=\"tablist\"\n style={[\n styles.bar,\n position === 'bottom' ? styles.barBottom : styles.barTop,\n safeArea && position === 'bottom' && { paddingBottom: theme.tokens.spacing.sm + insets.bottom },\n safeArea && position === 'top' && { paddingTop: theme.tokens.spacing.sm + insets.top },\n style,\n ]}\n >\n {itemWidth > 0 && (\n <Animated.View\n testID=\"navigation-bar-indicator\"\n pointerEvents=\"none\"\n style={[\n styles.indicator,\n safeArea && position === 'bottom' && { bottom: theme.tokens.spacing.xs + insets.bottom },\n safeArea && position === 'top' && { top: theme.tokens.spacing.xs + insets.top },\n {\n width: itemWidth - theme.tokens.spacing.sm * 2,\n transform: [{ translateX: slide }],\n },\n ]}\n />\n )}\n\n {items.map((item, index) => {\n const active = index === activeIndex;\n const range = [(index - 1) * itemWidth, index * itemWidth, (index + 1) * itemWidth];\n const scale =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [1, 1.08, 1], extrapolate: 'clamp' })\n : 1;\n const activeOpacity =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [0, 1, 0], extrapolate: 'clamp' })\n : 0;\n\n return (\n <TouchableOpacity\n key={item.key}\n style={styles.item}\n activeOpacity={0.7}\n onPress={() => onChange(item.key)}\n accessibilityRole=\"tab\"\n accessibilityLabel={item.label}\n accessibilityState={{ selected: active }}\n >\n <Animated.View style={[styles.itemContent, { transform: [{ scale }] }]}>\n {renderContent(item, colors.textLight)}\n {/* Copia decorativa: sin ocultarla, el lector lee el label dos veces. */}\n <Animated.View\n pointerEvents=\"none\"\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={[StyleSheet.absoluteFill, styles.itemContent, { opacity: activeOpacity }]}\n >\n {renderContent(item, colors.primaryText)}\n </Animated.View>\n\n {item.badge !== undefined && (\n <View style={[styles.badge, item.badge === true && styles.badgeDot]}>\n {item.badge !== true && (\n <Text allowFontScaling={false} style={styles.badgeText}>\n {item.badge}\n </Text>\n )}\n </View>\n )}\n </Animated.View>\n </TouchableOpacity>\n );\n })}\n </View>\n );\n }\n);\nNavigationBar.displayName = 'NavigationBar';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/SearchList.tsx",
12
- "content": "import React, { useCallback } from 'react';\nimport {\n ActivityIndicator,\n FlatList,\n RefreshControl,\n Text,\n View,\n type FlatListProps,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { Input } from '../atoms/Input';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Skeleton } from '../atoms/Skeleton';\nimport { Spinner } from '../atoms/Spinner';\nimport { IconButton } from '../atoms/IconButton';\nimport type { IconName } from '../icons/types';\n\nexport interface SearchListProps<T> {\n /** Chrome opcional arriba del buscador (ej. back button + título). */\n header?: React.ReactNode;\n\n searchValue: string;\n onSearchChange: (text: string) => void;\n searchPlaceholder?: string;\n searchIsLoading?: boolean;\n onScanPress?: () => void;\n scanAccessibilityLabel?: string;\n extraActions?: React.ReactNode;\n\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n /** Carga inicial: mientras sea true y no haya datos, se muestran placeholders. */\n isLoading?: boolean;\n /** 'replace' cambia toda la lista por un spinner/skeleton; 'overlay' lo muestra encima manteniendo el layout. */\n loadingMode?: 'replace' | 'overlay';\n /** Recarga con datos ya en pantalla (pull-to-refresh). @default false */\n isRefreshing?: boolean;\n isLoadingMore?: boolean;\n /** Controlado por el consumidor: mientras es false, se muestra un spinner de pantalla completa. @default true */\n isReady?: boolean;\n loadingText?: string;\n loadingMoreText?: string;\n hasMore?: boolean;\n noMoreText?: string;\n\n /** Filas fantasma de la carga inicial. @default 6 */\n skeletonCount?: number;\n /** Placeholder propio, para que calce con la forma real del ítem. */\n renderSkeletonItem?: () => React.ReactElement;\n\n onLoadMore?: () => void;\n onEndReached?: () => void;\n onEndReachedThreshold?: number;\n onRefresh?: () => void;\n\n emptyTitle?: string;\n emptyDescription?: string;\n emptyIconName?: IconName;\n\n ListHeaderComponent?: FlatListProps<T>['ListHeaderComponent'];\n ListFooterComponent?: FlatListProps<T>['ListFooterComponent'];\n\n contentContainerStyle?: StyleProp<ViewStyle>;\n containerStyle?: ViewStyle;\n\n /** Componente de lista a usar (FlashList, etc). @default FlatList */\n ListComponent?: React.ComponentType<any>;\n listProps?: Record<string, unknown>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1, backgroundColor: theme.colors.background },\n searchRow: { flexDirection: 'row', alignItems: 'center', marginBottom: theme.tokens.spacing.lg - 1, gap: theme.tokens.spacing.sm + 2 },\n searchInput: { flex: 1, marginBottom: 0 },\n scanButton: { backgroundColor: theme.colors.primary, width: 50, height: 50, borderRadius: theme.tokens.radius.lg, ...theme.tokens.shadow.sm },\n listArea: { flex: 1 },\n listContent: { paddingBottom: theme.tokens.spacing.xl },\n loadingOverlay: {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n backgroundColor: theme.colors.scrim,\n zIndex: 10,\n justifyContent: 'center',\n alignItems: 'center',\n },\n footer: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center', gap: theme.tokens.spacing.md },\n footerText: { color: theme.colors.textLight, fontSize: theme.tokens.fontSize.md },\n skeletonRow: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n padding: theme.tokens.spacing.md,\n },\n skeletonText: { flex: 1, gap: theme.tokens.spacing.sm },\n}));\n\n/**\n * SearchList\n * Buscador + lista + estados de carga/paginación/vacío. El contenido de\n * cada ítem (renderItem) queda a cargo de quien la usa, ya que difiere por\n * completo entre casos de uso.\n */\nexport function SearchList<T>({\n header,\n searchValue,\n onSearchChange,\n searchPlaceholder,\n searchIsLoading = false,\n onScanPress,\n scanAccessibilityLabel = 'Scan',\n extraActions,\n data,\n keyExtractor,\n renderItem,\n isLoading = false,\n loadingMode = 'replace',\n isRefreshing = false,\n isLoadingMore = false,\n isReady = true,\n loadingText,\n loadingMoreText,\n hasMore = false,\n noMoreText,\n skeletonCount = 6,\n renderSkeletonItem,\n onLoadMore,\n onEndReached,\n onEndReachedThreshold = 0.3,\n onRefresh,\n emptyTitle,\n emptyDescription,\n emptyIconName,\n ListHeaderComponent,\n ListFooterComponent,\n contentContainerStyle,\n containerStyle,\n ListComponent = FlatList,\n listProps,\n}: SearchListProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const resolvedSearchPlaceholder = searchPlaceholder ?? labels.search;\n const resolvedLoadingText = loadingText ?? labels.loading;\n const resolvedLoadingMoreText = loadingMoreText ?? labels.loadingMore;\n\n const handleEndReached = onLoadMore ?? onEndReached;\n\n // El footer propio y el loader de paginación conviven: antes el primero\n // tapaba al segundo y la lista dejaba de avisar que estaba trayendo más.\n const renderFooter = useCallback(() => {\n const custom =\n typeof ListFooterComponent === 'function' ? (\n <ListFooterComponent />\n ) : (\n (ListFooterComponent as React.ReactElement | undefined) ?? null\n );\n\n return (\n <>\n {custom}\n {isLoadingMore && (\n <View style={styles.footer}>\n <Spinner text={resolvedLoadingMoreText} fullscreen={false} />\n </View>\n )}\n {noMoreText && !hasMore && data.length > 0 && (\n <View style={styles.footer}>\n <Text style={styles.footerText}>{noMoreText}</Text>\n </View>\n )}\n </>\n );\n }, [ListFooterComponent, isLoadingMore, resolvedLoadingMoreText, noMoreText, hasMore, data.length, styles.footer, styles.footerText]);\n\n // Vacío sólo cuando ya se sabe que no hay nada: durante la carga inicial se\n // muestran placeholders, no \"sin resultados\".\n const renderEmpty = useCallback(() => {\n if (isLoading || isRefreshing) return null;\n return <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} style={{ marginTop: 50 }} />;\n }, [isLoading, isRefreshing, emptyTitle, emptyDescription, emptyIconName]);\n\n if (!isReady) {\n return (\n <View style={[styles.container, containerStyle]}>\n <Spinner text={resolvedLoadingText} />\n </View>\n );\n }\n\n const isInitialLoading = isLoading && loadingMode === 'replace' && data.length === 0;\n const showOverlayLoading = isLoading && loadingMode === 'overlay' && data.length === 0;\n\n return (\n <View style={[styles.container, containerStyle]}>\n {header}\n\n <View style={styles.searchRow}>\n <Input\n placeholder={resolvedSearchPlaceholder}\n leftIconName=\"search\"\n value={searchValue}\n onChangeText={onSearchChange}\n rightIconName={searchValue.length > 0 ? 'close' : undefined}\n onRightIconPress={() => onSearchChange('')}\n isLoading={searchIsLoading}\n containerStyle={styles.searchInput}\n />\n {onScanPress && (\n <IconButton\n iconName=\"search\"\n onPress={onScanPress}\n color={colors.onPrimary}\n style={styles.scanButton}\n accessibilityLabel={scanAccessibilityLabel}\n />\n )}\n {extraActions}\n </View>\n\n <View style={styles.listArea}>\n {showOverlayLoading && (\n <View style={styles.loadingOverlay}>\n <ActivityIndicator size=\"large\" color={colors.primary} />\n <Text style={styles.footerText}>{resolvedLoadingText}</Text>\n </View>\n )}\n\n {isInitialLoading ? (\n <View testID=\"list-skeleton\" style={[styles.listContent, contentContainerStyle]}>\n {Array.from({ length: skeletonCount }, (_, i) => (\n <React.Fragment key={i}>\n {renderSkeletonItem?.() ?? (\n <View style={styles.skeletonRow}>\n <Skeleton variant=\"circle\" width={40} />\n <View style={styles.skeletonText}>\n <Skeleton width=\"60%\" height={14} />\n <Skeleton width=\"35%\" height={12} />\n </View>\n </View>\n )}\n </React.Fragment>\n ))}\n </View>\n ) : (\n <ListComponent\n data={data}\n keyExtractor={keyExtractor}\n keyboardDismissMode=\"on-drag\"\n contentContainerStyle={[styles.listContent, contentContainerStyle]}\n onEndReached={handleEndReached}\n onEndReachedThreshold={onEndReachedThreshold}\n ListHeaderComponent={ListHeaderComponent}\n ListFooterComponent={renderFooter}\n ListEmptyComponent={renderEmpty}\n renderItem={renderItem}\n refreshControl={\n onRefresh ? (\n <RefreshControl\n refreshing={isRefreshing}\n onRefresh={onRefresh}\n colors={[colors.primary]}\n tintColor={colors.primary}\n />\n ) : undefined\n }\n {...listProps}\n />\n )}\n </View>\n </View>\n );\n}\n\n"
12
+ "content": "import React, { useCallback } from 'react';\nimport {\n ActivityIndicator,\n FlatList,\n RefreshControl,\n Text,\n View,\n type FlatListProps,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { Input } from '../atoms/Input';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Skeleton } from '../atoms/Skeleton';\nimport { Spinner } from '../atoms/Spinner';\nimport { IconButton } from '../atoms/IconButton';\nimport type { IconName } from '../icons/types';\n\nexport interface SearchListProps<T> {\n /** Chrome opcional arriba del buscador (ej. back button + título). */\n header?: React.ReactNode;\n\n searchValue: string;\n onSearchChange: (text: string) => void;\n searchPlaceholder?: string;\n searchIsLoading?: boolean;\n onScanPress?: () => void;\n scanAccessibilityLabel?: string;\n extraActions?: React.ReactNode;\n\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n /** Carga inicial: mientras sea true y no haya datos, se muestran placeholders. */\n isLoading?: boolean;\n /** 'replace' cambia toda la lista por un spinner/skeleton; 'overlay' lo muestra encima manteniendo el layout. */\n loadingMode?: 'replace' | 'overlay';\n /** Recarga con datos ya en pantalla (pull-to-refresh). @default false */\n isRefreshing?: boolean;\n isLoadingMore?: boolean;\n /** Controlado por el consumidor: mientras es false, se muestra un spinner de pantalla completa. @default true */\n isReady?: boolean;\n loadingText?: string;\n loadingMoreText?: string;\n hasMore?: boolean;\n noMoreText?: string;\n\n /** Filas fantasma de la carga inicial. @default 6 */\n skeletonCount?: number;\n /** Placeholder propio, para que calce con la forma real del ítem. */\n renderSkeletonItem?: () => React.ReactElement;\n\n onLoadMore?: () => void;\n onEndReached?: () => void;\n onEndReachedThreshold?: number;\n onRefresh?: () => void;\n\n emptyTitle?: string;\n emptyDescription?: string;\n emptyIconName?: IconName;\n\n ListHeaderComponent?: FlatListProps<T>['ListHeaderComponent'];\n ListFooterComponent?: FlatListProps<T>['ListFooterComponent'];\n\n contentContainerStyle?: StyleProp<ViewStyle>;\n containerStyle?: ViewStyle;\n\n /** Componente de lista a usar (FlashList, etc). @default FlatList */\n ListComponent?: React.ComponentType<any>;\n listProps?: Record<string, unknown>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1 },\n searchRow: { flexDirection: 'row', alignItems: 'center', marginBottom: theme.tokens.spacing.lg - 1, gap: theme.tokens.spacing.sm + 2 },\n searchInput: { flex: 1, marginBottom: 0 },\n scanButton: { backgroundColor: theme.colors.primary, width: 50, height: 50, borderRadius: theme.tokens.radius.lg, ...theme.tokens.shadow.sm },\n listArea: { flex: 1 },\n listContent: { paddingBottom: theme.tokens.spacing.xl },\n loadingOverlay: {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n backgroundColor: theme.colors.scrim,\n zIndex: 10,\n justifyContent: 'center',\n alignItems: 'center',\n },\n footer: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center', gap: theme.tokens.spacing.md },\n footerText: { color: theme.colors.textLight, fontSize: theme.tokens.fontSize.md },\n skeletonRow: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n padding: theme.tokens.spacing.md,\n },\n skeletonText: { flex: 1, gap: theme.tokens.spacing.sm },\n}));\n\n/**\n * SearchList\n * Buscador + lista + estados de carga/paginación/vacío. El contenido de\n * cada ítem (renderItem) queda a cargo de quien la usa, ya que difiere por\n * completo entre casos de uso.\n */\nexport function SearchList<T>({\n header,\n searchValue,\n onSearchChange,\n searchPlaceholder,\n searchIsLoading = false,\n onScanPress,\n scanAccessibilityLabel = 'Scan',\n extraActions,\n data,\n keyExtractor,\n renderItem,\n isLoading = false,\n loadingMode = 'replace',\n isRefreshing = false,\n isLoadingMore = false,\n isReady = true,\n loadingText,\n loadingMoreText,\n hasMore = false,\n noMoreText,\n skeletonCount = 6,\n renderSkeletonItem,\n onLoadMore,\n onEndReached,\n onEndReachedThreshold = 0.3,\n onRefresh,\n emptyTitle,\n emptyDescription,\n emptyIconName,\n ListHeaderComponent,\n ListFooterComponent,\n contentContainerStyle,\n containerStyle,\n ListComponent = FlatList,\n listProps,\n}: SearchListProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const resolvedSearchPlaceholder = searchPlaceholder ?? labels.search;\n const resolvedLoadingText = loadingText ?? labels.loading;\n const resolvedLoadingMoreText = loadingMoreText ?? labels.loadingMore;\n\n const handleEndReached = onLoadMore ?? onEndReached;\n\n // El footer propio y el loader de paginación conviven: antes el primero\n // tapaba al segundo y la lista dejaba de avisar que estaba trayendo más.\n const renderFooter = useCallback(() => {\n const custom =\n typeof ListFooterComponent === 'function' ? (\n <ListFooterComponent />\n ) : (\n (ListFooterComponent as React.ReactElement | undefined) ?? null\n );\n\n return (\n <>\n {custom}\n {isLoadingMore && (\n <View style={styles.footer}>\n <Spinner text={resolvedLoadingMoreText} fullscreen={false} />\n </View>\n )}\n {noMoreText && !hasMore && data.length > 0 && (\n <View style={styles.footer}>\n <Text style={styles.footerText}>{noMoreText}</Text>\n </View>\n )}\n </>\n );\n }, [ListFooterComponent, isLoadingMore, resolvedLoadingMoreText, noMoreText, hasMore, data.length, styles.footer, styles.footerText]);\n\n // Vacío sólo cuando ya se sabe que no hay nada: durante la carga inicial se\n // muestran placeholders, no \"sin resultados\".\n const renderEmpty = useCallback(() => {\n if (isLoading || isRefreshing) return null;\n return <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} style={{ marginTop: 50 }} />;\n }, [isLoading, isRefreshing, emptyTitle, emptyDescription, emptyIconName]);\n\n if (!isReady) {\n return (\n <View style={[styles.container, containerStyle]}>\n <Spinner text={resolvedLoadingText} />\n </View>\n );\n }\n\n const isInitialLoading = isLoading && loadingMode === 'replace' && data.length === 0;\n const showOverlayLoading = isLoading && loadingMode === 'overlay' && data.length === 0;\n\n return (\n <View style={[styles.container, containerStyle]}>\n {header}\n\n <View style={styles.searchRow}>\n <Input\n placeholder={resolvedSearchPlaceholder}\n leftIconName=\"search\"\n value={searchValue}\n onChangeText={onSearchChange}\n rightIconName={searchValue.length > 0 ? 'close' : undefined}\n onRightIconPress={() => onSearchChange('')}\n isLoading={searchIsLoading}\n containerStyle={styles.searchInput}\n />\n {onScanPress && (\n <IconButton\n iconName=\"search\"\n onPress={onScanPress}\n color={colors.onPrimary}\n style={styles.scanButton}\n accessibilityLabel={scanAccessibilityLabel}\n />\n )}\n {extraActions}\n </View>\n\n <View style={styles.listArea}>\n {showOverlayLoading && (\n <View style={styles.loadingOverlay}>\n <ActivityIndicator size=\"large\" color={colors.primary} />\n <Text style={styles.footerText}>{resolvedLoadingText}</Text>\n </View>\n )}\n\n {isInitialLoading ? (\n <View testID=\"list-skeleton\" style={[styles.listContent, contentContainerStyle]}>\n {Array.from({ length: skeletonCount }, (_, i) => (\n <React.Fragment key={i}>\n {renderSkeletonItem?.() ?? (\n <View style={styles.skeletonRow}>\n <Skeleton variant=\"circle\" width={40} />\n <View style={styles.skeletonText}>\n <Skeleton width=\"60%\" height={14} />\n <Skeleton width=\"35%\" height={12} />\n </View>\n </View>\n )}\n </React.Fragment>\n ))}\n </View>\n ) : (\n <ListComponent\n data={data}\n keyExtractor={keyExtractor}\n keyboardDismissMode=\"on-drag\"\n contentContainerStyle={[styles.listContent, contentContainerStyle]}\n onEndReached={handleEndReached}\n onEndReachedThreshold={onEndReachedThreshold}\n ListHeaderComponent={ListHeaderComponent}\n ListFooterComponent={renderFooter}\n ListEmptyComponent={renderEmpty}\n renderItem={renderItem}\n refreshControl={\n onRefresh ? (\n <RefreshControl\n refreshing={isRefreshing}\n onRefresh={onRefresh}\n colors={[colors.primary]}\n tintColor={colors.primary}\n />\n ) : undefined\n }\n {...listProps}\n />\n )}\n </View>\n </View>\n );\n}\n\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 { 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\nconst useStyles = createStyles((theme) => ({\n rowContainer: { flexDirection: 'row', alignItems: 'flex-start' },\n columnContainer: { flexDirection: 'column' },\n\n // horizontal\n hStep: { flex: 1, 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 y RN partía palabras a la mitad (\"Paymen / t\"). Con 0.3 un flujo de\n // 4 pasos deja ~68px por etiqueta, suficiente para una palabra corriente.\n hConnector: { height: 2, flex: 0.3, marginTop: CIRCLE / 2 - 1 },\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 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]}\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 <View style={[styles.rowContainer, 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 <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 </View>\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: 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"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "organisms/Wizard.tsx",
12
- "content": "import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { ScrollView, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { Steps, type StepItem } from '../molecules/Steps';\nimport { Button } from '../atoms/Button';\nimport { Transition } from '../atoms/Transition';\n\nexport interface WizardStep extends StepItem {\n content: React.ReactNode;\n /** false bloquea el avance desde este paso (validación). @default true */\n canGoNext?: boolean;\n}\n\nexport interface WizardProps {\n steps: WizardStep[];\n /** Paso actual (controlado). Si se omite, el Wizard maneja su propio estado. */\n current?: number;\n onStepChange?: (index: number) => void;\n onFinish?: () => void;\n /** @default 'horizontal' */\n orientation?: 'horizontal' | 'vertical';\n completedIndicator?: 'check' | 'number';\n /** Permite volver a un paso ya completado tocándolo en el indicador. @default true */\n allowStepNavigation?: boolean;\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n /**\n * El contenido del paso scrollea cuando no entra. Ponelo en false sólo si\n * un paso renderiza su propia lista virtualizada (anidar una\n * VirtualizedList dentro de un ScrollView rompe la virtualización).\n * @default true\n */\n scrollableContent?: boolean;\n /**\n * El contenido del paso entra deslizándose en el sentido de la navegación.\n * Sólo opacity y transform, así que la animación corre en el hilo nativo.\n * @default true\n */\n animated?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1, gap: theme.tokens.spacing.xl },\n content: { flex: 1 },\n contentInner: { flexGrow: 1, paddingBottom: theme.tokens.spacing.sm },\n footer: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n footerButton: { flex: 1 },\n}));\n\n/**\n * Wizard\n * Flujo multi-paso: indicador (Steps) + contenido del paso actual + barra de\n * navegación. Funciona controlado (`current` + `onStepChange`) o por su\n * cuenta. `canGoNext: false` en un paso deshabilita el avance, que es donde\n * se engancha la validación del formulario de ese paso.\n */\nexport function Wizard({\n steps,\n current: controlledCurrent,\n onStepChange,\n onFinish,\n orientation = 'horizontal',\n completedIndicator = 'check',\n allowStepNavigation = true,\n backLabel = 'Back',\n nextLabel = 'Next',\n finishLabel = 'Finish',\n scrollableContent = true,\n animated = true,\n style,\n}: WizardProps) {\n const styles = useStyles();\n const [uncontrolled, setUncontrolled] = useState(0);\n const isControlled = controlledCurrent !== undefined;\n const current = isControlled ? controlledCurrent : uncontrolled;\n\n const goTo = useCallback(\n (index: number) => {\n const clamped = Math.max(0, Math.min(index, steps.length - 1));\n if (!isControlled) setUncontrolled(clamped);\n onStepChange?.(clamped);\n },\n [isControlled, onStepChange, steps.length]\n );\n\n const stepItems = useMemo(\n () => steps.map(({ label, description }) => ({ label, description })),\n [steps]\n );\n\n // El ref todavía guarda el paso anterior durante el render que trae el nuevo.\n const previous = useRef(current);\n const forward = current >= previous.current;\n const entered = useRef(false);\n useEffect(() => {\n previous.current = current;\n entered.current = true;\n }, [current]);\n\n const activeStep = steps[current];\n const isFirst = current === 0;\n const isLast = current === steps.length - 1;\n const canGoNext = activeStep?.canGoNext !== false;\n\n const handleNext = () => {\n if (isLast) onFinish?.();\n else goTo(current + 1);\n };\n\n // El paso saliente no se anima: mantener los dos montados a la vez duplicaría\n // los formularios y su estado.\n const renderContent = () =>\n animated ? (\n <Transition\n key={current}\n testID=\"wizard-step\"\n preset={['fade', forward ? 'slide-left' : 'slide-right']}\n appear={entered.current}\n >\n {activeStep?.content}\n </Transition>\n ) : (\n activeStep?.content\n );\n\n return (\n <View style={[styles.container, style]}>\n <Steps\n steps={stepItems}\n current={current}\n orientation={orientation}\n completedIndicator={completedIndicator}\n // Sólo se puede volver atrás tocando el indicador; saltar hacia\n // adelante se saltearía la validación de los pasos intermedios.\n onStepPress={allowStepNavigation ? (index) => index < current && goTo(index) : undefined}\n />\n\n {/* El contenido scrollea en vez de desbordar: con el teclado abierto un\n form largo se comía el footer y los botones quedaban pintados encima\n de los campos. */}\n {scrollableContent ? (\n <ScrollView\n style={styles.content}\n contentContainerStyle={styles.contentInner}\n keyboardShouldPersistTaps=\"handled\"\n automaticallyAdjustKeyboardInsets\n showsVerticalScrollIndicator={false}\n >\n {renderContent()}\n </ScrollView>\n ) : (\n <View style={styles.content}>{renderContent()}</View>\n )}\n\n <View style={styles.footer}>\n {!isFirst && (\n <Button title={backLabel} variant=\"outline\" onPress={() => goTo(current - 1)} style={styles.footerButton} />\n )}\n <Button\n title={isLast ? finishLabel : nextLabel}\n onPress={handleNext}\n disabled={!canGoNext}\n style={styles.footerButton}\n />\n </View>\n </View>\n );\n}\n"
12
+ "content": "import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { ScrollView, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { Steps, type StepItem } from '../molecules/Steps';\nimport { Button } from '../atoms/Button';\nimport { Transition } from '../atoms/Transition';\n\nexport interface WizardStep extends StepItem {\n content: React.ReactNode;\n /** false bloquea el avance desde este paso (validación). @default true */\n canGoNext?: boolean;\n}\n\nexport interface WizardProps {\n steps: WizardStep[];\n /** Paso actual (controlado). Si se omite, el Wizard maneja su propio estado. */\n current?: number;\n onStepChange?: (index: number) => void;\n onFinish?: () => void;\n /** @default 'horizontal' */\n orientation?: 'horizontal' | 'vertical';\n completedIndicator?: 'check' | 'number';\n /** Permite volver a un paso ya completado tocándolo en el indicador. @default true */\n allowStepNavigation?: boolean;\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n /**\n * El contenido del paso scrollea cuando no entra. Ponelo en false sólo si\n * un paso renderiza su propia lista virtualizada (anidar una\n * VirtualizedList dentro de un ScrollView rompe la virtualización).\n * @default true\n */\n scrollableContent?: boolean;\n /**\n * El contenido del paso entra deslizándose en el sentido de la navegación.\n * Sólo opacity y transform, así que la animación corre en el hilo nativo.\n * @default true\n */\n animated?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1, gap: theme.tokens.spacing.xl },\n content: { flex: 1 },\n contentInner: { flexGrow: 1, paddingBottom: theme.tokens.spacing.sm },\n footer: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n footerButton: { flex: 1 },\n}));\n\n/**\n * Wizard\n * Flujo multi-paso: indicador (Steps) + contenido del paso actual + barra de\n * navegación. Funciona controlado (`current` + `onStepChange`) o por su\n * cuenta. `canGoNext: false` en un paso deshabilita el avance, que es donde\n * se engancha la validación del formulario de ese paso.\n */\nexport function Wizard({\n steps,\n current: controlledCurrent,\n onStepChange,\n onFinish,\n orientation = 'horizontal',\n completedIndicator = 'check',\n allowStepNavigation = true,\n backLabel = 'Back',\n nextLabel = 'Next',\n finishLabel = 'Finish',\n scrollableContent = true,\n animated = true,\n style,\n}: WizardProps) {\n const styles = useStyles();\n const [uncontrolled, setUncontrolled] = useState(0);\n const isControlled = controlledCurrent !== undefined;\n const current = isControlled ? controlledCurrent : uncontrolled;\n\n const goTo = useCallback(\n (index: number) => {\n const clamped = Math.max(0, Math.min(index, steps.length - 1));\n if (!isControlled) setUncontrolled(clamped);\n onStepChange?.(clamped);\n },\n [isControlled, onStepChange, steps.length]\n );\n\n const stepItems = useMemo(\n () => steps.map(({ label, description }) => ({ label, description })),\n [steps]\n );\n\n // El ref todavía guarda el paso anterior durante el render que trae el nuevo.\n const previous = useRef(current);\n const forward = current >= previous.current;\n const entered = useRef(false);\n useEffect(() => {\n previous.current = current;\n entered.current = true;\n }, [current]);\n\n const activeStep = steps[current];\n const isFirst = current === 0;\n const isLast = current === steps.length - 1;\n const canGoNext = activeStep?.canGoNext !== false;\n\n const handleNext = () => {\n if (isLast) onFinish?.();\n else goTo(current + 1);\n };\n\n // El paso saliente no se anima: mantener los dos montados a la vez duplicaría\n // los formularios y su estado.\n const renderContent = () =>\n animated ? (\n <Transition\n key={current}\n testID=\"wizard-step\"\n preset={['fade', forward ? 'slide-left' : 'slide-right']}\n appear={entered.current}\n >\n {activeStep?.content}\n </Transition>\n ) : (\n activeStep?.content\n );\n\n return (\n <View style={[styles.container, style]}>\n <Steps\n steps={stepItems}\n current={current}\n orientation={orientation}\n completedIndicator={completedIndicator}\n // Sólo se puede volver atrás tocando el indicador; saltar hacia\n // adelante se saltearía la validación de los pasos intermedios.\n onStepPress={allowStepNavigation ? (index) => index < current && goTo(index) : undefined}\n />\n\n {/* El contenido scrollea en vez de desbordar: con el teclado abierto un\n form largo se comía el footer y los botones quedaban pintados encima\n de los campos. */}\n {scrollableContent ? (\n <ScrollView\n testID=\"wizard-content\"\n style={styles.content}\n contentContainerStyle={styles.contentInner}\n keyboardShouldPersistTaps=\"handled\"\n automaticallyAdjustKeyboardInsets\n showsVerticalScrollIndicator={false}\n >\n {renderContent()}\n </ScrollView>\n ) : (\n <View style={styles.content}>{renderContent()}</View>\n )}\n\n <View style={styles.footer}>\n {!isFirst && (\n <Button title={backLabel} variant=\"outline\" onPress={() => goTo(current - 1)} style={styles.footerButton} />\n )}\n <Button\n title={isLast ? finishLabel : nextLabel}\n onPress={handleNext}\n disabled={!canGoNext}\n style={styles.footerButton}\n />\n </View>\n </View>\n );\n}\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
@@ -1,5 +1,5 @@
1
1
  import React, { memo } from 'react';
2
- import { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { ScrollView, Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';
3
3
  import { createStyles } from '../theme/createStyles';
4
4
  import { useAllowFontScaling, useColors } from '../theme/UIProvider';
5
5
  import { Icon } from '../icons/Icon';
@@ -28,19 +28,29 @@ export interface StepsProps {
28
28
  }
29
29
 
30
30
  const CIRCLE = 32;
31
+ /** Ancho mínimo de una columna horizontal: por debajo, las etiquetas se parten. */
32
+ const STEP_MIN_WIDTH = 76;
31
33
 
32
34
  const useStyles = createStyles((theme) => ({
33
35
  rowContainer: { flexDirection: 'row', alignItems: 'flex-start' },
34
36
  columnContainer: { flexDirection: 'column' },
35
37
 
36
38
  // horizontal
37
- hStep: { flex: 1, alignItems: 'center', gap: theme.tokens.spacing.sm },
39
+ // ScrollView trae flexGrow: 1 de fábrica y se comía todo el alto disponible
40
+ // del contenedor, dejando los pasos pegados arriba de una caja enorme.
41
+ hScrollView: { flexGrow: 0, flexShrink: 0 },
42
+ hScroll: { flexGrow: 1, flexDirection: 'row', alignItems: 'flex-start' },
43
+ // `minWidth` es lo que evita que RN parta palabras a la mitad
44
+ // ("Almacé / n"): antes que apretar la columna por debajo de una palabra
45
+ // corriente, la fila se vuelve scrollable.
46
+ hStep: { flex: 1, minWidth: STEP_MIN_WIDTH, alignItems: 'center', gap: theme.tokens.spacing.sm },
38
47
  hLabels: { alignItems: 'center', gap: 2, paddingHorizontal: 4 },
39
48
  // El conector toma una fracción chica del ancho: compitiendo de igual a
40
49
  // igual con los pasos (flex: 1) cada etiqueta se quedaba con 1/(2n-1) del
41
- // total y RN partía palabras a la mitad ("Paymen / t"). Con 0.3 un flujo de
42
- // 4 pasos deja ~68px por etiqueta, suficiente para una palabra corriente.
43
- hConnector: { height: 2, flex: 0.3, marginTop: CIRCLE / 2 - 1 },
50
+ // total.
51
+ hConnector: { height: 2, flex: 0.3, minWidth: 12, marginTop: CIRCLE / 2 - 1 },
52
+ hLabel: { fontSize: theme.tokens.fontSize.sm },
53
+ hDescription: { fontSize: theme.tokens.fontSize.xs },
44
54
 
45
55
  // vertical
46
56
  vStep: { flexDirection: 'row', gap: theme.tokens.spacing.md },
@@ -110,6 +120,7 @@ export const Steps = memo(
110
120
  style={[
111
121
  styles.label,
112
122
  centered && styles.labelCentered,
123
+ centered && styles.hLabel,
113
124
  status === 'pending' && { color: colors.textLight },
114
125
  ]}
115
126
  >
@@ -118,7 +129,7 @@ export const Steps = memo(
118
129
  {!!step.description && (
119
130
  <Text
120
131
  allowFontScaling={allowFontScaling}
121
- style={[styles.description, centered && styles.descriptionCentered]}
132
+ style={[styles.description, centered && styles.descriptionCentered, centered && styles.hDescription]}
122
133
  >
123
134
  {step.description}
124
135
  </Text>
@@ -167,7 +178,15 @@ export const Steps = memo(
167
178
  }
168
179
 
169
180
  return (
170
- <View style={[styles.rowContainer, style]}>
181
+ <ScrollView
182
+ testID="steps-row"
183
+ horizontal
184
+ showsHorizontalScrollIndicator={false}
185
+ // Con espacio de sobra los pasos se reparten la fila igual que antes
186
+ // (flexGrow); cuando no entran, se desplaza en vez de apretar el texto.
187
+ style={[styles.hScrollView, style]}
188
+ contentContainerStyle={styles.hScroll}
189
+ >
171
190
  {steps.map((step, index) => {
172
191
  const status = statusOf(index, current);
173
192
  const isLast = index === steps.length - 1;
@@ -191,7 +210,7 @@ export const Steps = memo(
191
210
  </React.Fragment>
192
211
  );
193
212
  })}
194
- </View>
213
+ </ScrollView>
195
214
  );
196
215
  }
197
216
  );