orn-ui 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/atoms/Avatar.js +1 -1
- package/lib/commonjs/atoms/Avatar.js.map +1 -1
- package/lib/commonjs/atoms/Badge.js +10 -8
- package/lib/commonjs/atoms/Badge.js.map +1 -1
- package/lib/commonjs/atoms/Button.js +8 -6
- package/lib/commonjs/atoms/Button.js.map +1 -1
- package/lib/commonjs/atoms/Checkbox.js +4 -1
- package/lib/commonjs/atoms/Checkbox.js.map +1 -1
- package/lib/commonjs/atoms/Fab.js +1 -1
- package/lib/commonjs/atoms/Fab.js.map +1 -1
- package/lib/commonjs/atoms/Image.js +2 -2
- package/lib/commonjs/atoms/Image.js.map +1 -1
- package/lib/commonjs/atoms/Input.js +2 -2
- package/lib/commonjs/atoms/Input.js.map +1 -1
- package/lib/commonjs/molecules/AvatarHeader.js +2 -2
- package/lib/commonjs/molecules/AvatarHeader.js.map +1 -1
- package/lib/commonjs/molecules/OptionCard.js +2 -2
- package/lib/commonjs/molecules/OptionCard.js.map +1 -1
- package/lib/commonjs/molecules/Steps.js +2 -2
- package/lib/commonjs/molecules/Steps.js.map +1 -1
- package/lib/commonjs/molecules/ThemeToggle.js +1 -1
- package/lib/commonjs/molecules/ThemeToggle.js.map +1 -1
- package/lib/commonjs/organisms/Alert.js +6 -5
- package/lib/commonjs/organisms/Alert.js.map +1 -1
- package/lib/commonjs/organisms/DateField.js +2 -2
- package/lib/commonjs/organisms/DateField.js.map +1 -1
- package/lib/commonjs/organisms/DatePicker.js +3 -3
- package/lib/commonjs/organisms/DatePicker.js.map +1 -1
- package/lib/commonjs/organisms/List.js +1 -1
- package/lib/commonjs/organisms/List.js.map +1 -1
- package/lib/commonjs/organisms/SearchList.js +2 -2
- package/lib/commonjs/organisms/SearchList.js.map +1 -1
- package/lib/commonjs/organisms/Select.js +4 -4
- package/lib/commonjs/organisms/Select.js.map +1 -1
- package/lib/commonjs/organisms/Toast.js +12 -3
- package/lib/commonjs/organisms/Toast.js.map +1 -1
- package/lib/commonjs/safe-area/SafeAreaUIProvider.js +65 -0
- package/lib/commonjs/safe-area/SafeAreaUIProvider.js.map +1 -0
- package/lib/commonjs/safe-area/index.js +13 -0
- package/lib/commonjs/safe-area/index.js.map +1 -0
- package/lib/commonjs/theme/UIProvider.js +11 -0
- package/lib/commonjs/theme/UIProvider.js.map +1 -1
- package/lib/commonjs/theme/palettes.js +70 -11
- package/lib/commonjs/theme/palettes.js.map +1 -1
- package/lib/module/atoms/Avatar.js +1 -1
- package/lib/module/atoms/Avatar.js.map +1 -1
- package/lib/module/atoms/Badge.js +10 -8
- package/lib/module/atoms/Badge.js.map +1 -1
- package/lib/module/atoms/Button.js +8 -6
- package/lib/module/atoms/Button.js.map +1 -1
- package/lib/module/atoms/Checkbox.js +4 -1
- package/lib/module/atoms/Checkbox.js.map +1 -1
- package/lib/module/atoms/Fab.js +1 -1
- package/lib/module/atoms/Fab.js.map +1 -1
- package/lib/module/atoms/Image.js +2 -2
- package/lib/module/atoms/Image.js.map +1 -1
- package/lib/module/atoms/Input.js +2 -2
- package/lib/module/atoms/Input.js.map +1 -1
- package/lib/module/molecules/AvatarHeader.js +2 -2
- package/lib/module/molecules/AvatarHeader.js.map +1 -1
- package/lib/module/molecules/OptionCard.js +2 -2
- package/lib/module/molecules/OptionCard.js.map +1 -1
- package/lib/module/molecules/Steps.js +2 -2
- package/lib/module/molecules/Steps.js.map +1 -1
- package/lib/module/molecules/ThemeToggle.js +1 -1
- package/lib/module/molecules/ThemeToggle.js.map +1 -1
- package/lib/module/organisms/Alert.js +6 -5
- package/lib/module/organisms/Alert.js.map +1 -1
- package/lib/module/organisms/DateField.js +2 -2
- package/lib/module/organisms/DateField.js.map +1 -1
- package/lib/module/organisms/DatePicker.js +3 -3
- package/lib/module/organisms/DatePicker.js.map +1 -1
- package/lib/module/organisms/List.js +1 -1
- package/lib/module/organisms/List.js.map +1 -1
- package/lib/module/organisms/SearchList.js +2 -2
- package/lib/module/organisms/SearchList.js.map +1 -1
- package/lib/module/organisms/Select.js +4 -4
- package/lib/module/organisms/Select.js.map +1 -1
- package/lib/module/organisms/Toast.js +12 -3
- package/lib/module/organisms/Toast.js.map +1 -1
- package/lib/module/safe-area/SafeAreaUIProvider.js +60 -0
- package/lib/module/safe-area/SafeAreaUIProvider.js.map +1 -0
- package/lib/module/safe-area/index.js +4 -0
- package/lib/module/safe-area/index.js.map +1 -0
- package/lib/module/theme/UIProvider.js +11 -0
- package/lib/module/theme/UIProvider.js.map +1 -1
- package/lib/module/theme/palettes.js +70 -11
- package/lib/module/theme/palettes.js.map +1 -1
- package/lib/typescript/atoms/Badge.d.ts.map +1 -1
- package/lib/typescript/atoms/Button.d.ts.map +1 -1
- package/lib/typescript/atoms/Checkbox.d.ts.map +1 -1
- package/lib/typescript/organisms/Alert.d.ts.map +1 -1
- package/lib/typescript/organisms/Toast.d.ts.map +1 -1
- package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts +46 -0
- package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts.map +1 -0
- package/lib/typescript/safe-area/index.d.ts +3 -0
- package/lib/typescript/safe-area/index.d.ts.map +1 -0
- package/lib/typescript/theme/UIProvider.d.ts.map +1 -1
- package/lib/typescript/theme/palettes.d.ts +41 -3
- package/lib/typescript/theme/palettes.d.ts.map +1 -1
- package/package.json +36 -14
- package/registry/alert.json +1 -1
- package/registry/avatar-header.json +1 -1
- package/registry/avatar.json +1 -1
- package/registry/badge.json +1 -1
- package/registry/button.json +1 -1
- package/registry/checkbox.json +1 -1
- package/registry/core.json +2 -2
- package/registry/date-field.json +1 -1
- package/registry/date-picker.json +1 -1
- package/registry/fab.json +1 -1
- package/registry/image.json +1 -1
- package/registry/index.json +19 -19
- package/registry/input.json +1 -1
- package/registry/list.json +1 -1
- package/registry/option-card.json +1 -1
- package/registry/search-list.json +1 -1
- package/registry/select.json +1 -1
- package/registry/steps.json +1 -1
- package/registry/theme-toggle.json +1 -1
- package/registry/toast.json +1 -1
- package/src/atoms/Avatar.tsx +1 -1
- package/src/atoms/Badge.tsx +6 -4
- package/src/atoms/Button.tsx +8 -6
- package/src/atoms/Checkbox.tsx +4 -1
- package/src/atoms/Fab.tsx +1 -1
- package/src/atoms/Image.tsx +2 -2
- package/src/atoms/Input.tsx +2 -2
- package/src/molecules/AvatarHeader.tsx +2 -2
- package/src/molecules/OptionCard.tsx +2 -2
- package/src/molecules/Steps.tsx +2 -2
- package/src/molecules/ThemeToggle.tsx +1 -1
- package/src/organisms/Alert.tsx +6 -5
- package/src/organisms/DateField.tsx +2 -2
- package/src/organisms/DatePicker.tsx +3 -3
- package/src/organisms/List.tsx +1 -1
- package/src/organisms/SearchList.tsx +2 -2
- package/src/organisms/Select.tsx +4 -4
- package/src/organisms/Toast.tsx +11 -3
- package/src/safe-area/SafeAreaUIProvider.tsx +67 -0
- package/src/safe-area/index.ts +2 -0
- package/src/theme/UIProvider.tsx +15 -0
- package/src/theme/palettes.ts +105 -11
package/registry/core.json
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"path": "theme/UIProvider.tsx",
|
|
24
|
-
"content": "import React, { createContext, useContext, useMemo, useState, useCallback } from 'react';\nimport { Appearance, useColorScheme } from 'react-native';\nimport { defaultTheme } from './createTheme';\nimport { defaultLabels, type Labels } from './labels';\nimport { zeroInsets, type EdgeInsets } from './insets';\nimport type { Theme, ThemePair } from './types';\nimport { renderDefaultIcon } from '../icons/defaultIcons';\nimport type { IconRenderer } from '../icons/types';\n\nexport type ThemeMode = 'system' | 'light' | 'dark';\n\ninterface UIContextValue {\n theme: Theme;\n mode: ThemeMode;\n setMode: (mode: ThemeMode) => void;\n icons: IconRenderer;\n insets: EdgeInsets;\n labels: Labels;\n allowFontScaling: boolean;\n}\n\nconst UIContext = createContext<UIContextValue | undefined>(undefined);\n\nexport interface UIProviderProps {\n children: React.ReactNode;\n /** Par light/dark. Por defecto el theme de marca de la librería (créalo con createTheme()). */\n theme?: ThemePair;\n /** Modo inicial (no controlado) si no se pasa `mode`. @default 'system' */\n defaultMode?: ThemeMode;\n /** Modo controlado. Si se pasa junto a onModeChange, el provider no gestiona estado propio. */\n mode?: ThemeMode;\n onModeChange?: (mode: ThemeMode) => void;\n /** Renderer de íconos. Por defecto usa los glifos zero-dep incluidos. */\n icons?: IconRenderer;\n /** Insets de safe area (de useSafeAreaInsets()). Por defecto {0,0,0,0}. */\n insets?: EdgeInsets;\n labels?: Partial<Labels>;\n /** @default false, para paridad con el resto de la librería (accesibilidad primero). */\n allowFontScaling?: boolean;\n}\n\n/**\n * UIProvider\n * Único punto de entrada de la librería: resuelve el theme activo (sistema u\n * override manual), e inyecta íconos, insets y textos por defecto a todo el\n * árbol. No persiste el modo — si el consumidor quiere recordar la preferencia\n * entre sesiones, controla `mode`/`onModeChange` y persiste donde prefiera.\n */\nexport function UIProvider({\n children,\n theme = defaultTheme,\n defaultMode = 'system',\n mode: controlledMode,\n onModeChange,\n icons = renderDefaultIcon,\n insets = zeroInsets,\n labels: labelOverrides,\n allowFontScaling = false,\n}: UIProviderProps) {\n const systemScheme = useColorScheme();\n const [uncontrolledMode, setUncontrolledMode] = useState<ThemeMode>(defaultMode);\n const isControlled = controlledMode !== undefined;\n const mode = isControlled ? controlledMode : uncontrolledMode;\n\n const setMode = useCallback(\n (next: ThemeMode) => {\n if (!isControlled) setUncontrolledMode(next);\n onModeChange?.(next);\n },\n [isControlled, onModeChange]\n );\n\n const scheme: 'light' | 'dark' =\n mode === 'system' ? (systemScheme === 'dark' ? 'dark' : 'light') : mode;\n\n const resolvedTheme = theme[scheme];\n\n const labels = useMemo(\n () => (labelOverrides ? { ...defaultLabels, ...labelOverrides } : defaultLabels),\n [labelOverrides]\n );\n\n const value = useMemo<UIContextValue>(\n () => ({ theme: resolvedTheme, mode, setMode, icons, insets, labels, allowFontScaling }),\n [resolvedTheme, mode, setMode, icons, insets, labels, allowFontScaling]\n );\n\n return <UIContext.Provider value={value}>{children}</UIContext.Provider>;\n}\n\nfunction useUIContext(): UIContextValue {\n const ctx = useContext(UIContext);\n if (!ctx) {\n throw new Error('orn-ui: this hook must be used within a <UIProvider>.');\n }\n return ctx;\n}\n\nexport function useTheme(): Theme {\n return useUIContext().theme;\n}\n\nexport function useColors(): Theme['colors'] {\n return useUIContext().theme.colors;\n}\n\nexport function useThemeMode() {\n const { mode, setMode } = useUIContext();\n return { mode, setMode };\n}\n\nexport function useIcons(): IconRenderer {\n return useUIContext().icons;\n}\n\nexport function useInsets(): EdgeInsets {\n return useUIContext().insets;\n}\n\nexport function useLabels(): Labels {\n return useUIContext().labels;\n}\n\nexport function useAllowFontScaling(): boolean {\n return useUIContext().allowFontScaling;\n}\n\n/** Re-exportado para que tests/consumidores puedan simular cambios de sistema sin RN real. */\nexport const __testUtils = { Appearance };\n"
|
|
24
|
+
"content": "import React, { createContext, useContext, useMemo, useState, useCallback } from 'react';\nimport { Appearance, useColorScheme } from 'react-native';\nimport { defaultTheme } from './createTheme';\nimport { defaultLabels, type Labels } from './labels';\nimport { zeroInsets, type EdgeInsets } from './insets';\nimport type { Theme, ThemePair } from './types';\nimport { renderDefaultIcon } from '../icons/defaultIcons';\nimport type { IconRenderer } from '../icons/types';\n\nexport type ThemeMode = 'system' | 'light' | 'dark';\n\ninterface UIContextValue {\n theme: Theme;\n mode: ThemeMode;\n setMode: (mode: ThemeMode) => void;\n icons: IconRenderer;\n insets: EdgeInsets;\n labels: Labels;\n allowFontScaling: boolean;\n}\n\nconst UIContext = createContext<UIContextValue | undefined>(undefined);\n\n/** Una sola vez por sesión: en un re-render el aviso no aporta nada nuevo. */\nlet warnedAboutInsets = false;\n\nexport interface UIProviderProps {\n children: React.ReactNode;\n /** Par light/dark. Por defecto el theme de marca de la librería (créalo con createTheme()). */\n theme?: ThemePair;\n /** Modo inicial (no controlado) si no se pasa `mode`. @default 'system' */\n defaultMode?: ThemeMode;\n /** Modo controlado. Si se pasa junto a onModeChange, el provider no gestiona estado propio. */\n mode?: ThemeMode;\n onModeChange?: (mode: ThemeMode) => void;\n /** Renderer de íconos. Por defecto usa los glifos zero-dep incluidos. */\n icons?: IconRenderer;\n /** Insets de safe area (de useSafeAreaInsets()). Por defecto {0,0,0,0}. */\n insets?: EdgeInsets;\n labels?: Partial<Labels>;\n /** @default false, para paridad con el resto de la librería (accesibilidad primero). */\n allowFontScaling?: boolean;\n}\n\n/**\n * UIProvider\n * Único punto de entrada de la librería: resuelve el theme activo (sistema u\n * override manual), e inyecta íconos, insets y textos por defecto a todo el\n * árbol. No persiste el modo — si el consumidor quiere recordar la preferencia\n * entre sesiones, controla `mode`/`onModeChange` y persiste donde prefiera.\n */\nexport function UIProvider({\n children,\n theme = defaultTheme,\n defaultMode = 'system',\n mode: controlledMode,\n onModeChange,\n icons = renderDefaultIcon,\n insets = zeroInsets,\n labels: labelOverrides,\n allowFontScaling = false,\n}: UIProviderProps) {\n const systemScheme = useColorScheme();\n const [uncontrolledMode, setUncontrolledMode] = useState<ThemeMode>(defaultMode);\n const isControlled = controlledMode !== undefined;\n const mode = isControlled ? controlledMode : uncontrolledMode;\n\n const setMode = useCallback(\n (next: ThemeMode) => {\n if (!isControlled) setUncontrolledMode(next);\n onModeChange?.(next);\n },\n [isControlled, onModeChange]\n );\n\n // Olvidar `insets` no rompe nada visible al montar: el default de ceros\n // recién se nota cuando abrís un Modal `full` (se mete abajo del notch) o un\n // BottomSheet (queda pegado a la barra de gestos). El aviso llega antes.\n if (__DEV__ && insets === zeroInsets && !warnedAboutInsets) {\n warnedAboutInsets = true;\n console.warn(\n 'orn-ui: <UIProvider> sin `insets`, se usan {top:0,bottom:0,left:0,right:0}. ' +\n 'Los modales y el Screen quedan sin safe area. Pasá `insets={useSafeAreaInsets()}` ' +\n \"o usá <SafeAreaUIProvider> de 'orn-ui/safe-area', que lo cablea solo.\"\n );\n }\n\n const scheme: 'light' | 'dark' =\n mode === 'system' ? (systemScheme === 'dark' ? 'dark' : 'light') : mode;\n\n const resolvedTheme = theme[scheme];\n\n const labels = useMemo(\n () => (labelOverrides ? { ...defaultLabels, ...labelOverrides } : defaultLabels),\n [labelOverrides]\n );\n\n const value = useMemo<UIContextValue>(\n () => ({ theme: resolvedTheme, mode, setMode, icons, insets, labels, allowFontScaling }),\n [resolvedTheme, mode, setMode, icons, insets, labels, allowFontScaling]\n );\n\n return <UIContext.Provider value={value}>{children}</UIContext.Provider>;\n}\n\nfunction useUIContext(): UIContextValue {\n const ctx = useContext(UIContext);\n if (!ctx) {\n throw new Error('orn-ui: this hook must be used within a <UIProvider>.');\n }\n return ctx;\n}\n\nexport function useTheme(): Theme {\n return useUIContext().theme;\n}\n\nexport function useColors(): Theme['colors'] {\n return useUIContext().theme.colors;\n}\n\nexport function useThemeMode() {\n const { mode, setMode } = useUIContext();\n return { mode, setMode };\n}\n\nexport function useIcons(): IconRenderer {\n return useUIContext().icons;\n}\n\nexport function useInsets(): EdgeInsets {\n return useUIContext().insets;\n}\n\nexport function useLabels(): Labels {\n return useUIContext().labels;\n}\n\nexport function useAllowFontScaling(): boolean {\n return useUIContext().allowFontScaling;\n}\n\n/** Re-exportado para que tests/consumidores puedan simular cambios de sistema sin RN real. */\nexport const __testUtils = { Appearance };\n"
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
"path": "theme/createStyles.ts",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
"path": "theme/palettes.ts",
|
|
48
|
-
"content": "/**\n * Cada color es `string`, no un literal (`as const`): light y dark tienen\n * valores distintos para la misma clave, y createTheme() debe poder\n * aceptar cualquier hex/rgba en los overrides — literal typing lo impediría.\n */\nexport interface ThemeColors {\n primary: string;\n secondary: string;\n
|
|
48
|
+
"content": "/**\n * Cada color es `string`, no un literal (`as const`): light y dark tienen\n * valores distintos para la misma clave, y createTheme() debe poder\n * aceptar cualquier hex/rgba en los overrides — literal typing lo impediría.\n *\n * Cada acento viene en cuatro roles, y no son intercambiables:\n *\n * `primary` relleno sólido (fondo de botón, día seleccionado, FAB).\n * `onPrimary` texto/ícono *encima* de ese relleno.\n * `primarySoft` fondo teñido para estados seleccionados y pills.\n * `primaryText` texto/ícono del acento sobre `surface`, `background`\n * o sobre su propio `primarySoft`.\n *\n * El motivo es el contraste: un acento que se lee bien como relleno no se\n * lee como texto. Antes los componentes usaban `colors.primary` para las dos\n * cosas y lo teñían concatenando alpha al hex (`colors.primary + '15'`), lo\n * que en dark daba 2.6:1 sobre la superficie — muy por debajo del 4.5:1 de\n * WCAG AA. Todos los pares de esta paleta pasan AA en ambos esquemas.\n */\nexport interface ThemeColors {\n primary: string;\n onPrimary: string;\n primarySoft: string;\n primaryText: string;\n\n secondary: string;\n onSecondary: string;\n secondarySoft: string;\n secondaryText: string;\n\n success: string;\n onSuccess: string;\n successSoft: string;\n successText: string;\n\n error: string;\n onError: string;\n errorSoft: string;\n errorText: string;\n\n warning: string;\n onWarning: string;\n warningSoft: string;\n warningText: string;\n\n white: string;\n black: string;\n background: string;\n surface: string;\n text: string;\n textLight: string;\n border: string;\n inputBackground: string;\n overlay: string;\n /** Velo semitransparente sobre contenido propio (footer de carga de List). */\n scrim: string;\n /** Fondo de placeholder mientras carga una imagen. */\n skeleton: string;\n}\n\nconst neutral = {\n white: '#ffffff',\n black: '#000000',\n};\n\nexport const lightPalette: ThemeColors = {\n ...neutral,\n\n primary: '#004cef',\n onPrimary: '#ffffff',\n primarySoft: '#e6edfe',\n primaryText: '#00369f',\n\n secondary: '#00cae1',\n onSecondary: '#00363d',\n secondarySoft: '#dcf6f9',\n secondaryText: '#00707d',\n\n success: '#34c759',\n onSuccess: '#00320f',\n successSoft: '#e3f6e9',\n successText: '#116a30',\n\n error: '#ff3b30',\n onError: '#3d0300',\n errorSoft: '#fdeaea',\n errorText: '#c62222',\n\n warning: '#f59e0b',\n onWarning: '#3d2600',\n warningSoft: '#fdf1de',\n warningText: '#8a4b00',\n\n background: '#f8f9fa',\n surface: '#ffffff',\n text: '#333333',\n textLight: '#666666',\n border: '#e5e7eb',\n inputBackground: '#f5f7fa',\n overlay: 'rgba(0,0,0,0.5)',\n scrim: 'rgba(248,249,250,0.8)',\n skeleton: '#eceef1',\n};\n\n/**\n * Los acentos de dark NO son los de light: sobre `#1e1e1e` hay que subir la\n * luminosidad del acento y bajar la del texto que va encima (por eso\n * `onPrimary` es azul oscuro acá y blanco en light).\n */\nexport const darkPalette: ThemeColors = {\n ...neutral,\n\n primary: '#7ba4ff',\n onPrimary: '#00205c',\n primarySoft: '#1b2740',\n primaryText: '#a9c3ff',\n\n secondary: '#4dd8e8',\n onSecondary: '#00363d',\n secondarySoft: '#12333a',\n secondaryText: '#7fe6f2',\n\n success: '#5ddb84',\n onSuccess: '#003514',\n successSoft: '#13301e',\n successText: '#7fe6a1',\n\n error: '#ff7b72',\n onError: '#4d0500',\n errorSoft: '#3a1c1a',\n errorText: '#ffa9a3',\n\n warning: '#f0b458',\n onWarning: '#3d2600',\n warningSoft: '#33260f',\n warningText: '#f5c97e',\n\n background: '#121212',\n surface: '#1e1e1e',\n text: '#ecedee',\n textLight: '#9ba1a6',\n border: '#2a2a2e',\n inputBackground: '#232326',\n overlay: 'rgba(0,0,0,0.6)',\n scrim: 'rgba(18,18,18,0.8)',\n skeleton: '#26262a',\n};\n"
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"path": "theme/tokens.ts",
|
package/registry/date-field.json
CHANGED
|
@@ -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.
|
|
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"
|
|
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.white, 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.primary + '1f' },\n cellRangeStart: { borderTopLeftRadius: 18, borderBottomLeftRadius: 18 },\n cellRangeEnd: { borderTopRightRadius: 18, borderBottomRightRadius: 18 },\n dayTextInRange: { color: theme.colors.primary, 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, 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"
|
|
16
16
|
}
|
|
17
17
|
],
|
|
18
18
|
"registryDependencies": [
|
package/registry/fab.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "atoms/Fab.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { type GestureResponderEvent, type StyleProp, type ViewStyle } from 'react-native';\nimport { PressableScale } from './PressableScale';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors } from '../theme/UIProvider';\n\nexport interface FabProps {\n onPress: (e: GestureResponderEvent) => void;\n iconName?: IconName;\n icon?: React.ReactNode;\n size?: number;\n bottom?: number;\n right?: number;\n color?: string;\n style?: StyleProp<ViewStyle>;\n accessibilityLabel: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n fab: {\n position: 'absolute',\n justifyContent: 'center',\n alignItems: 'center',\n ...theme.tokens.shadow.lg,\n },\n}));\n\n/**\n * Fab\n * Botón flotante circular. `bottom`/`right` en vez del `tabBarHeight`\n * obligatorio de la versión original — el consumidor decide su propio\n * offset (con o sin tab bar).\n */\nexport const Fab = memo(\n ({ onPress, iconName = 'plus', icon, size = 60, bottom = 20, right = 20, color, style, accessibilityLabel }: FabProps) => {\n const styles = useStyles();\n const colors = useColors();\n return (\n <PressableScale\n onPress={onPress}\n style={[\n styles.fab,\n {\n width: size,\n height: size,\n borderRadius: size / 2,\n bottom,\n right,\n backgroundColor: color ?? colors.primary,\n },\n style,\n ]}\n accessibilityRole=\"button\"\n accessibilityLabel={accessibilityLabel}\n >\n {icon ?? <Icon name={iconName} size={size * 0.5} color={colors.
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { type GestureResponderEvent, type StyleProp, type ViewStyle } from 'react-native';\nimport { PressableScale } from './PressableScale';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors } from '../theme/UIProvider';\n\nexport interface FabProps {\n onPress: (e: GestureResponderEvent) => void;\n iconName?: IconName;\n icon?: React.ReactNode;\n size?: number;\n bottom?: number;\n right?: number;\n color?: string;\n style?: StyleProp<ViewStyle>;\n accessibilityLabel: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n fab: {\n position: 'absolute',\n justifyContent: 'center',\n alignItems: 'center',\n ...theme.tokens.shadow.lg,\n },\n}));\n\n/**\n * Fab\n * Botón flotante circular. `bottom`/`right` en vez del `tabBarHeight`\n * obligatorio de la versión original — el consumidor decide su propio\n * offset (con o sin tab bar).\n */\nexport const Fab = memo(\n ({ onPress, iconName = 'plus', icon, size = 60, bottom = 20, right = 20, color, style, accessibilityLabel }: FabProps) => {\n const styles = useStyles();\n const colors = useColors();\n return (\n <PressableScale\n onPress={onPress}\n style={[\n styles.fab,\n {\n width: size,\n height: size,\n borderRadius: size / 2,\n bottom,\n right,\n backgroundColor: color ?? colors.primary,\n },\n style,\n ]}\n accessibilityRole=\"button\"\n accessibilityLabel={accessibilityLabel}\n >\n {icon ?? <Icon name={iconName} size={size * 0.5} color={colors.onPrimary} />}\n </PressableScale>\n );\n }\n);\nFab.displayName = 'Fab';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/image.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
{
|
|
12
12
|
"path": "atoms/Image.tsx",
|
|
13
|
-
"content": "import React, { memo, useCallback, useEffect, useState } from 'react';\nimport {\n Image as RNImage,\n Platform,\n StyleSheet,\n View,\n type ImageErrorEventData,\n type ImageResizeMode,\n type ImageSourcePropType,\n type NativeSyntheticEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { Spinner } from './Spinner';\n\nexport interface ImageProps {\n source: ImageSourcePropType;\n width?: number;\n height?: number;\n /** @default 'cover' */\n resizeMode?: ImageResizeMode;\n /** Radio de las esquinas, mismo patrón que Avatar/Card. @default 0 */\n radius?: number;\n /** Qué mostrar mientras carga. `false` para no mostrar nada. @default true (spinner chico) */\n loading?: React.ReactNode | boolean;\n /** Qué mostrar si la carga falla. @default un placeholder gris, sin contenido */\n fallback?: React.ReactNode;\n /**\n * 'high' dispara Image.prefetch() al montar, antes de que <Image> arranque\n * su propio ciclo de carga — pensado para la imagen above-the-fold de una\n * pantalla (la primera que el usuario ve). Sin costo si ya está en caché.\n * @default 'normal'\n */\n priority?: 'low' | 'normal' | 'high';\n style?: StyleProp<ViewStyle>;\n onLoadEnd?: () => void;\n onError?: (error: NativeSyntheticEvent<ImageErrorEventData>) => void;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { overflow: 'hidden', backgroundColor: theme.colors.
|
|
13
|
+
"content": "import React, { memo, useCallback, useEffect, useState } from 'react';\nimport {\n Image as RNImage,\n Platform,\n StyleSheet,\n View,\n type ImageErrorEventData,\n type ImageResizeMode,\n type ImageSourcePropType,\n type NativeSyntheticEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { Spinner } from './Spinner';\n\nexport interface ImageProps {\n source: ImageSourcePropType;\n width?: number;\n height?: number;\n /** @default 'cover' */\n resizeMode?: ImageResizeMode;\n /** Radio de las esquinas, mismo patrón que Avatar/Card. @default 0 */\n radius?: number;\n /** Qué mostrar mientras carga. `false` para no mostrar nada. @default true (spinner chico) */\n loading?: React.ReactNode | boolean;\n /** Qué mostrar si la carga falla. @default un placeholder gris, sin contenido */\n fallback?: React.ReactNode;\n /**\n * 'high' dispara Image.prefetch() al montar, antes de que <Image> arranque\n * su propio ciclo de carga — pensado para la imagen above-the-fold de una\n * pantalla (la primera que el usuario ve). Sin costo si ya está en caché.\n * @default 'normal'\n */\n priority?: 'low' | 'normal' | 'high';\n style?: StyleProp<ViewStyle>;\n onLoadEnd?: () => void;\n onError?: (error: NativeSyntheticEvent<ImageErrorEventData>) => void;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { overflow: 'hidden', backgroundColor: theme.colors.skeleton },\n overlay: { alignItems: 'center', justifyContent: 'center' },\n fallback: { flex: 1, backgroundColor: theme.colors.skeleton },\n}));\n\nfunction sourceUri(source: ImageSourcePropType): string | undefined {\n return typeof source === 'object' && source !== null && 'uri' in source ? source.uri : undefined;\n}\n\n/**\n * Image\n * Wrapper cero-dependencias sobre <Image> de RN: agrega estado de carga y\n * error (con los mismos placeholders que el resto de la librería), y expone\n * `priority=\"high\"` para adelantar la descarga vía Image.prefetch(). El\n * cache real (disco/memoria) lo maneja el motor de imágenes nativo de RN —\n * esto no lo reimplementa, solo no lo deja apagado por accidente: en iOS\n * fuerza `cache=\"force-cache\"` (usa el disco aunque el header HTTP no lo\n * pida); en Android el cache de disco de la imagen nativa ya está siempre\n * activo, no hay knob equivalente que tocar.\n */\nexport const Image = memo(\n ({\n source,\n width,\n height,\n resizeMode = 'cover',\n radius = 0,\n loading = true,\n fallback,\n priority = 'normal',\n style,\n onLoadEnd,\n onError,\n testID,\n }: ImageProps) => {\n const styles = useStyles();\n const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>('loading');\n const uri = sourceUri(source);\n\n useEffect(() => {\n // Resetea el estado si cambia la imagen (misma instancia de Image,\n // source distinto: p.ej. avatar de un usuario a otro en la misma fila).\n setStatus('loading');\n }, [uri ?? source]);\n\n useEffect(() => {\n if (priority === 'high' && uri) {\n RNImage.prefetch(uri).catch(() => {});\n }\n }, [priority, uri]);\n\n const handleError = useCallback(\n (e: NativeSyntheticEvent<ImageErrorEventData>) => {\n setStatus('error');\n onError?.(e);\n },\n [onError]\n );\n\n const handleLoadEnd = useCallback(() => {\n setStatus('loaded');\n onLoadEnd?.();\n }, [onLoadEnd]);\n\n return (\n <View style={[styles.container, { width, height, borderRadius: radius }, style]} testID={testID}>\n {status !== 'error' && (\n <RNImage\n source={source}\n resizeMode={resizeMode}\n style={StyleSheet.absoluteFill}\n fadeDuration={Platform.OS === 'android' ? 150 : undefined}\n {...(Platform.OS === 'ios' ? { cache: 'force-cache' as const } : null)}\n onLoadEnd={handleLoadEnd}\n onError={handleError}\n />\n )}\n {status === 'loading' && loading !== false && (\n <View style={[StyleSheet.absoluteFill, styles.overlay]}>\n {loading === true ? <Spinner size=\"small\" fullscreen={false} /> : loading}\n </View>\n )}\n {status === 'error' && (fallback ?? <View style={styles.fallback} />)}\n </View>\n );\n }\n);\nImage.displayName = 'Image';\n\n/**\n * Precarga una URL antes de montar el componente (p.ej. al recibir la\n * respuesta de un listado, antes de navegar al detalle). Re-exporta\n * Image.prefetch de RN — no hay nada zero-dep que agregarle encima.\n */\nexport const prefetchImage = RNImage.prefetch;\n"
|
|
14
14
|
}
|
|
15
15
|
],
|
|
16
16
|
"registryDependencies": [
|
package/registry/index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"core": {
|
|
3
|
-
"bytes":
|
|
3
|
+
"bytes": 27455
|
|
4
4
|
},
|
|
5
5
|
"components": [
|
|
6
6
|
{
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"registryDependencies": [
|
|
29
29
|
"core"
|
|
30
30
|
],
|
|
31
|
-
"bytes":
|
|
31
|
+
"bytes": 6790
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
34
|
"name": "IconButton",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"registryDependencies": [
|
|
54
54
|
"core"
|
|
55
55
|
],
|
|
56
|
-
"bytes":
|
|
56
|
+
"bytes": 5447
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"name": "Checkbox",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"registryDependencies": [
|
|
66
66
|
"core"
|
|
67
67
|
],
|
|
68
|
-
"bytes":
|
|
68
|
+
"bytes": 2847
|
|
69
69
|
},
|
|
70
70
|
{
|
|
71
71
|
"name": "Badge",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"registryDependencies": [
|
|
78
78
|
"core"
|
|
79
79
|
],
|
|
80
|
-
"bytes":
|
|
80
|
+
"bytes": 2172
|
|
81
81
|
},
|
|
82
82
|
{
|
|
83
83
|
"name": "Card",
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"registryDependencies": [
|
|
114
114
|
"core"
|
|
115
115
|
],
|
|
116
|
-
"bytes":
|
|
116
|
+
"bytes": 1012
|
|
117
117
|
},
|
|
118
118
|
{
|
|
119
119
|
"name": "Image",
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
"core",
|
|
128
128
|
"spinner"
|
|
129
129
|
],
|
|
130
|
-
"bytes":
|
|
130
|
+
"bytes": 4610
|
|
131
131
|
},
|
|
132
132
|
{
|
|
133
133
|
"name": "Spinner",
|
|
@@ -176,7 +176,7 @@
|
|
|
176
176
|
"core",
|
|
177
177
|
"pressable-scale"
|
|
178
178
|
],
|
|
179
|
-
"bytes":
|
|
179
|
+
"bytes": 1788
|
|
180
180
|
},
|
|
181
181
|
{
|
|
182
182
|
"name": "PressableScale",
|
|
@@ -212,7 +212,7 @@
|
|
|
212
212
|
"registryDependencies": [
|
|
213
213
|
"core"
|
|
214
214
|
],
|
|
215
|
-
"bytes":
|
|
215
|
+
"bytes": 3006
|
|
216
216
|
},
|
|
217
217
|
{
|
|
218
218
|
"name": "InfoRow",
|
|
@@ -250,7 +250,7 @@
|
|
|
250
250
|
"registryDependencies": [
|
|
251
251
|
"core"
|
|
252
252
|
],
|
|
253
|
-
"bytes":
|
|
253
|
+
"bytes": 2487
|
|
254
254
|
},
|
|
255
255
|
{
|
|
256
256
|
"name": "ThemeToggle",
|
|
@@ -262,7 +262,7 @@
|
|
|
262
262
|
"registryDependencies": [
|
|
263
263
|
"core"
|
|
264
264
|
],
|
|
265
|
-
"bytes":
|
|
265
|
+
"bytes": 2444
|
|
266
266
|
},
|
|
267
267
|
{
|
|
268
268
|
"name": "Steps",
|
|
@@ -274,7 +274,7 @@
|
|
|
274
274
|
"registryDependencies": [
|
|
275
275
|
"core"
|
|
276
276
|
],
|
|
277
|
-
"bytes":
|
|
277
|
+
"bytes": 7314
|
|
278
278
|
},
|
|
279
279
|
{
|
|
280
280
|
"name": "Modal",
|
|
@@ -313,7 +313,7 @@
|
|
|
313
313
|
"registryDependencies": [
|
|
314
314
|
"core"
|
|
315
315
|
],
|
|
316
|
-
"bytes":
|
|
316
|
+
"bytes": 5357
|
|
317
317
|
},
|
|
318
318
|
{
|
|
319
319
|
"name": "Alert",
|
|
@@ -326,7 +326,7 @@
|
|
|
326
326
|
"core",
|
|
327
327
|
"button"
|
|
328
328
|
],
|
|
329
|
-
"bytes":
|
|
329
|
+
"bytes": 6240
|
|
330
330
|
},
|
|
331
331
|
{
|
|
332
332
|
"name": "AlertProvider",
|
|
@@ -367,7 +367,7 @@
|
|
|
367
367
|
"empty-state",
|
|
368
368
|
"spinner"
|
|
369
369
|
],
|
|
370
|
-
"bytes":
|
|
370
|
+
"bytes": 4767
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"name": "SearchList",
|
|
@@ -384,7 +384,7 @@
|
|
|
384
384
|
"icon-button",
|
|
385
385
|
"pressable-scale"
|
|
386
386
|
],
|
|
387
|
-
"bytes":
|
|
387
|
+
"bytes": 5856
|
|
388
388
|
},
|
|
389
389
|
{
|
|
390
390
|
"name": "Toast",
|
|
@@ -396,7 +396,7 @@
|
|
|
396
396
|
"registryDependencies": [
|
|
397
397
|
"core"
|
|
398
398
|
],
|
|
399
|
-
"bytes":
|
|
399
|
+
"bytes": 4737
|
|
400
400
|
},
|
|
401
401
|
{
|
|
402
402
|
"name": "ToastProvider",
|
|
@@ -427,7 +427,7 @@
|
|
|
427
427
|
"icon-button",
|
|
428
428
|
"pressable-scale"
|
|
429
429
|
],
|
|
430
|
-
"bytes":
|
|
430
|
+
"bytes": 10098
|
|
431
431
|
},
|
|
432
432
|
{
|
|
433
433
|
"name": "DateField",
|
|
@@ -445,7 +445,7 @@
|
|
|
445
445
|
"date-picker",
|
|
446
446
|
"button"
|
|
447
447
|
],
|
|
448
|
-
"bytes":
|
|
448
|
+
"bytes": 4553
|
|
449
449
|
},
|
|
450
450
|
{
|
|
451
451
|
"name": "Wizard",
|
package/registry/input.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "atoms/Input.tsx",
|
|
12
|
-
"content": "import React, { memo, useRef, useState } from 'react';\nimport {\n ActivityIndicator,\n Animated,\n Text,\n TextInput,\n TouchableOpacity,\n View,\n type StyleProp,\n type TextInputProps,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface InputProps extends Omit<TextInputProps, 'style'> {\n label?: string;\n /** Muestra un asterisco junto al label. Puramente visual, no valida nada. */\n required?: boolean;\n error?: string;\n containerStyle?: StyleProp<ViewStyle>;\n style?: TextInputProps['style'];\n leftIconName?: IconName;\n rightIconName?: IconName;\n onRightIconPress?: () => void;\n isPassword?: boolean;\n isLoading?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { marginBottom: theme.tokens.spacing.lg, width: '100%' },\n label: {\n marginBottom: theme.tokens.spacing.sm,\n fontSize: theme.tokens.fontSize.md,\n fontWeight: theme.tokens.fontWeight.medium,\n color: theme.colors.text,\n },\n required: { color: theme.colors.
|
|
12
|
+
"content": "import React, { memo, useRef, useState } from 'react';\nimport {\n ActivityIndicator,\n Animated,\n Text,\n TextInput,\n TouchableOpacity,\n View,\n type StyleProp,\n type TextInputProps,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface InputProps extends Omit<TextInputProps, 'style'> {\n label?: string;\n /** Muestra un asterisco junto al label. Puramente visual, no valida nada. */\n required?: boolean;\n error?: string;\n containerStyle?: StyleProp<ViewStyle>;\n style?: TextInputProps['style'];\n leftIconName?: IconName;\n rightIconName?: IconName;\n onRightIconPress?: () => void;\n isPassword?: boolean;\n isLoading?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { marginBottom: theme.tokens.spacing.lg, width: '100%' },\n label: {\n marginBottom: theme.tokens.spacing.sm,\n fontSize: theme.tokens.fontSize.md,\n fontWeight: theme.tokens.fontWeight.medium,\n color: theme.colors.text,\n },\n required: { color: theme.colors.errorText },\n inputWrapper: {\n flexDirection: 'row',\n alignItems: 'center',\n backgroundColor: theme.colors.inputBackground,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 2,\n minHeight: 50,\n },\n input: {\n flex: 1,\n paddingVertical: theme.tokens.spacing.md,\n paddingHorizontal: theme.tokens.spacing.lg,\n fontSize: theme.tokens.fontSize.lg,\n color: theme.colors.text,\n },\n leftIcon: { marginLeft: theme.tokens.spacing.lg, marginRight: -theme.tokens.spacing.xs },\n rightIcon: { padding: theme.tokens.spacing.sm + 2, marginRight: theme.tokens.spacing.xs },\n errorText: { marginTop: theme.tokens.spacing.xs, fontSize: theme.tokens.fontSize.sm, color: theme.colors.errorText },\n}));\n\n/**\n * Input\n * El borde anima color entre border/primary/error con Animated (no\n * useNativeDriver: los navegadores de color en RN sin Fabric solo son\n * confiables en el hilo de JS; el costo es despreciable, se anima un borde).\n */\nexport const Input = memo(\n ({\n label,\n required = false,\n error,\n containerStyle,\n leftIconName,\n rightIconName,\n onRightIconPress,\n isPassword = false,\n isLoading = false,\n style,\n onFocus,\n onBlur,\n testID,\n ...props\n }: InputProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const [isFocused, setIsFocused] = useState(false);\n const [showPassword, setShowPassword] = useState(false);\n const focusProgress = useRef(new Animated.Value(0)).current;\n\n const handleFocus: TextInputProps['onFocus'] = (e) => {\n setIsFocused(true);\n Animated.timing(focusProgress, { toValue: 1, duration: 200, useNativeDriver: false }).start();\n onFocus?.(e);\n };\n\n const handleBlur: TextInputProps['onBlur'] = (e) => {\n setIsFocused(false);\n Animated.timing(focusProgress, { toValue: 0, duration: 200, useNativeDriver: false }).start();\n onBlur?.(e);\n };\n\n const borderColor = error\n ? colors.error\n : focusProgress.interpolate({ outputRange: [colors.border, colors.primary], inputRange: [0, 1] });\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 <Animated.View style={[styles.inputWrapper, { borderColor }]}>\n {leftIconName && <Icon name={leftIconName} size={20} color={colors.textLight} style={styles.leftIcon} />}\n\n <TextInput\n style={[styles.input, style]}\n placeholderTextColor={colors.textLight}\n onFocus={handleFocus}\n onBlur={handleBlur}\n secureTextEntry={isPassword && !showPassword}\n allowFontScaling={allowFontScaling}\n textAlignVertical=\"center\"\n accessibilityLabel={label ?? props.placeholder}\n {...props}\n />\n\n {isLoading ? (\n <View style={styles.rightIcon}>\n <ActivityIndicator size=\"small\" color={colors.primary} />\n </View>\n ) : isPassword ? (\n <TouchableOpacity\n onPress={() => setShowPassword((v) => !v)}\n style={styles.rightIcon}\n accessibilityRole=\"button\"\n accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}\n >\n <Icon name={showPassword ? 'eye-off' : 'eye'} size={20} color={colors.textLight} />\n </TouchableOpacity>\n ) : rightIconName ? (\n <TouchableOpacity\n onPress={onRightIconPress}\n style={styles.rightIcon}\n disabled={!onRightIconPress}\n accessibilityRole=\"button\"\n accessibilityLabel=\"Clear\"\n >\n <Icon name={rightIconName} size={20} color={colors.textLight} />\n </TouchableOpacity>\n ) : null}\n </Animated.View>\n\n {!!error && (\n <Text allowFontScaling={allowFontScaling} style={styles.errorText}>\n {error}\n </Text>\n )}\n </View>\n );\n }\n);\nInput.displayName = 'Input';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/list.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/List.tsx",
|
|
12
|
-
"content": "import React, { memo, useCallback } from 'react';\nimport {\n ActivityIndicator,\n FlatList,\n Platform,\n RefreshControl,\n Text,\n View,\n type FlatListProps,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Spinner } from '../atoms/Spinner';\nimport type { IconName } from '../icons/types';\n\nexport interface ListProps<T> {\n data: T[];\n keyExtractor: (item: T) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n isLoading: 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\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?: 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 loadingOverlay: {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n backgroundColor: theme.colors.
|
|
12
|
+
"content": "import React, { memo, useCallback } from 'react';\nimport {\n ActivityIndicator,\n FlatList,\n Platform,\n RefreshControl,\n Text,\n View,\n type FlatListProps,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Spinner } from '../atoms/Spinner';\nimport type { IconName } from '../icons/types';\n\nexport interface ListProps<T> {\n data: T[];\n keyExtractor: (item: T) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n isLoading: 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\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?: 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 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 loadingText: { marginTop: theme.tokens.spacing.md, color: theme.colors.textLight, fontWeight: theme.tokens.fontWeight.medium },\n listContent: { padding: theme.tokens.spacing.md, paddingBottom: Platform.OS === 'android' ? 100 : 80 },\n footerLoader: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center' },\n}));\n\n/**\n * List\n * Lista con estados de carga/paginación/vacío resueltos, sin atarse a\n * expo-router ni a FlashList: `ListComponent` permite inyectar cualquiera\n * (por defecto FlatList del propio React Native).\n */\nexport function List<T>({\n data,\n keyExtractor,\n renderItem,\n isLoading,\n isLoadingMore = false,\n isReady = true,\n loadingText,\n loadingMoreText,\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}: ListProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const resolvedLoadingText = loadingText ?? labels.loading;\n const resolvedLoadingMoreText = loadingMoreText ?? labels.loadingMore;\n\n const renderFooter = useCallback(() => {\n if (ListFooterComponent) {\n return typeof ListFooterComponent === 'function' ? <ListFooterComponent /> : (ListFooterComponent as React.ReactElement);\n }\n if (!isLoadingMore) return null;\n return (\n <View style={styles.footerLoader}>\n <Spinner text={resolvedLoadingMoreText} fullscreen={false} />\n </View>\n );\n }, [isLoadingMore, resolvedLoadingMoreText, ListFooterComponent, styles.footerLoader]);\n\n const renderEmpty = useCallback(() => {\n if (isLoading) return null;\n return <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} style={{ marginTop: 50 }} />;\n }, [isLoading, emptyTitle, emptyDescription, emptyIconName]);\n\n if (!isReady) {\n return (\n <View style={styles.container}>\n <Spinner text={resolvedLoadingText} />\n </View>\n );\n }\n\n return (\n <View style={[styles.container, containerStyle]}>\n {isLoading && data.length === 0 && (\n <View style={styles.loadingOverlay} pointerEvents=\"none\">\n <ActivityIndicator size=\"large\" color={colors.primary} />\n <Text style={styles.loadingText}>{resolvedLoadingText}</Text>\n </View>\n )}\n\n <ListComponent\n data={data}\n keyExtractor={keyExtractor}\n renderItem={renderItem}\n contentContainerStyle={[styles.listContent, contentContainerStyle]}\n onEndReached={onEndReached}\n onEndReachedThreshold={onEndReachedThreshold}\n ListHeaderComponent={ListHeaderComponent}\n ListFooterComponent={renderFooter}\n ListEmptyComponent={renderEmpty}\n refreshControl={\n onRefresh ? (\n <RefreshControl refreshing={isLoading} onRefresh={onRefresh} colors={[colors.primary]} tintColor={colors.primary} />\n ) : undefined\n }\n {...listProps}\n />\n </View>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "molecules/OptionCard.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { Text, TouchableOpacity, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface OptionCardProps {\n label: string;\n iconName: IconName;\n isSelected: boolean;\n onPress: () => void;\n /** 'vertical': ícono arriba, texto abajo centrado. 'horizontal': ícono y texto en fila. */\n layout?: 'vertical' | 'horizontal';\n style?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n card: {\n alignItems: 'center',\n justifyContent: 'center',\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.md,\n borderWidth: 1,\n borderColor: theme.colors.border,\n ...theme.tokens.shadow.sm,\n },\n cardHorizontal: { flex: 1, flexDirection: 'row', padding: theme.tokens.spacing.md - 2, gap: theme.tokens.spacing.sm },\n cardVertical: { width: '31%', padding: theme.tokens.spacing.sm, gap: 5, minHeight: 100 },\n cardSelected: {\n borderColor: theme.colors.primary,\n backgroundColor: theme.colors.
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { Text, TouchableOpacity, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface OptionCardProps {\n label: string;\n iconName: IconName;\n isSelected: boolean;\n onPress: () => void;\n /** 'vertical': ícono arriba, texto abajo centrado. 'horizontal': ícono y texto en fila. */\n layout?: 'vertical' | 'horizontal';\n style?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n card: {\n alignItems: 'center',\n justifyContent: 'center',\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.md,\n borderWidth: 1,\n borderColor: theme.colors.border,\n ...theme.tokens.shadow.sm,\n },\n cardHorizontal: { flex: 1, flexDirection: 'row', padding: theme.tokens.spacing.md - 2, gap: theme.tokens.spacing.sm },\n cardVertical: { width: '31%', padding: theme.tokens.spacing.sm, gap: 5, minHeight: 100 },\n cardSelected: {\n borderColor: theme.colors.primary,\n backgroundColor: theme.colors.primarySoft,\n borderWidth: 1.5,\n elevation: 0,\n shadowOpacity: 0,\n },\n label: { color: theme.colors.textLight, fontWeight: theme.tokens.fontWeight.medium },\n labelVertical: { fontSize: theme.tokens.fontSize.xs, textAlign: 'center' },\n labelSelected: { color: theme.colors.primaryText },\n}));\n\n/**\n * OptionCard\n * Card con ícono+label que resalta con borde/fondo primary cuando\n * `isSelected`. Para elegir entre opciones excluyentes (forma de pago, tipo\n * de documento, etc).\n */\nexport const OptionCard = memo(\n ({ label, iconName, isSelected, onPress, layout = 'horizontal', style }: OptionCardProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n\n return (\n <TouchableOpacity\n // activeOpacity=1: esta tarjeta tiene elevation (sombra Android); animar su\n // propia opacity en el press dispara un bug de Android que redibuja la\n // sombra como un borde oscuro pegado al borde del componente.\n activeOpacity={1}\n style={[\n styles.card,\n layout === 'vertical' ? styles.cardVertical : styles.cardHorizontal,\n isSelected && styles.cardSelected,\n style,\n ]}\n onPress={onPress}\n accessibilityRole=\"button\"\n accessibilityState={{ selected: isSelected }}\n accessibilityLabel={label}\n >\n <Icon name={iconName} size={24} color={isSelected ? colors.primary : colors.textLight} />\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.label, layout === 'vertical' && styles.labelVertical, isSelected && styles.labelSelected]}\n >\n {label}\n </Text>\n </TouchableOpacity>\n );\n }\n);\nOptionCard.displayName = 'OptionCard';\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, { memo } from 'react';\nimport { ActivityIndicator, FlatList, Text, View, type FlatListProps, type StyleProp, type ViewStyle } 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 { 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) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n isLoading?: boolean;\n /** 'replace' cambia toda la lista por un spinner; 'overlay' lo muestra encima manteniendo el layout. */\n loadingMode?: 'replace' | 'overlay';\n loadingText?: string;\n\n onLoadMore?: () => void;\n isLoadingMore?: boolean;\n loadingMoreText?: string;\n hasMore?: boolean;\n noMoreText?: string;\n\n emptyTitle?: string;\n emptyDescription?: string;\n emptyIconName?: IconName;\n\n contentContainerStyle?: StyleProp<ViewStyle>;\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.
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { ActivityIndicator, FlatList, Text, View, type FlatListProps, type StyleProp, type ViewStyle } 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 { 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) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n isLoading?: boolean;\n /** 'replace' cambia toda la lista por un spinner; 'overlay' lo muestra encima manteniendo el layout. */\n loadingMode?: 'replace' | 'overlay';\n loadingText?: string;\n\n onLoadMore?: () => void;\n isLoadingMore?: boolean;\n loadingMoreText?: string;\n hasMore?: boolean;\n noMoreText?: string;\n\n emptyTitle?: string;\n emptyDescription?: string;\n emptyIconName?: IconName;\n\n contentContainerStyle?: StyleProp<ViewStyle>;\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}));\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 loadingText,\n onLoadMore,\n isLoadingMore = false,\n loadingMoreText,\n hasMore = false,\n noMoreText,\n emptyTitle,\n emptyDescription,\n emptyIconName,\n contentContainerStyle,\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 renderFooter = () => {\n if (isLoadingMore) {\n return (\n <View style={styles.footer}>\n <Spinner text={resolvedLoadingMoreText} fullscreen={false} />\n </View>\n );\n }\n if (noMoreText && !hasMore && data.length > 0) {\n return (\n <View style={styles.footer}>\n <Text style={styles.footerText}>{noMoreText}</Text>\n </View>\n );\n }\n return null;\n };\n\n const showReplaceLoading = isLoading && loadingMode === 'replace' && data.length === 0;\n const showOverlayLoading = isLoading && loadingMode === 'overlay' && data.length === 0;\n const showEmpty = !isLoading && data.length === 0;\n\n return (\n <View style={styles.container}>\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 {showReplaceLoading ? (\n <Spinner text={resolvedLoadingText} />\n ) : showEmpty ? (\n <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} />\n ) : (\n <ListComponent\n data={data}\n keyExtractor={keyExtractor}\n keyboardDismissMode=\"on-drag\"\n contentContainerStyle={contentContainerStyle ?? styles.listContent}\n onEndReached={onLoadMore}\n onEndReachedThreshold={0.2}\n ListFooterComponent={renderFooter}\n renderItem={renderItem}\n {...listProps}\n />\n )}\n </View>\n </View>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/select.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/Select.tsx",
|
|
12
|
-
"content": "import React, { memo, useState } from 'react';\nimport { ActivityIndicator, Pressable, Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useLabels } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\n\nexport interface SelectOption<T> {\n label: string;\n value: T;\n}\n\nexport interface SelectProps<T> {\n label?: string;\n required?: boolean;\n options: SelectOption<T>[];\n selectedValue: T | undefined;\n onSelect: (value: T) => void;\n placeholder?: string;\n error?: string;\n isLoading?: boolean;\n containerStyle?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { gap: theme.tokens.spacing.sm },\n containerOpen: { zIndex: 20, elevation: 20 },\n label: { fontWeight: theme.tokens.fontWeight.bold, fontSize: theme.tokens.fontSize.md, color: theme.colors.text },\n required: { color: theme.colors.
|
|
12
|
+
"content": "import React, { memo, useState } from 'react';\nimport { ActivityIndicator, Pressable, Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useLabels } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\n\nexport interface SelectOption<T> {\n label: string;\n value: T;\n}\n\nexport interface SelectProps<T> {\n label?: string;\n required?: boolean;\n options: SelectOption<T>[];\n selectedValue: T | undefined;\n onSelect: (value: T) => void;\n placeholder?: string;\n error?: string;\n isLoading?: boolean;\n containerStyle?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { gap: theme.tokens.spacing.sm },\n containerOpen: { zIndex: 20, elevation: 20 },\n label: { fontWeight: theme.tokens.fontWeight.bold, fontSize: theme.tokens.fontSize.md, color: theme.colors.text },\n required: { color: theme.colors.errorText },\n field: {\n flexDirection: 'row',\n alignItems: 'center',\n justifyContent: 'space-between',\n borderWidth: 2,\n borderColor: theme.colors.border,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.inputBackground,\n minHeight: 50,\n paddingHorizontal: theme.tokens.spacing.lg,\n },\n fieldText: { fontSize: theme.tokens.fontSize.lg, color: theme.colors.text },\n fieldPlaceholder: { color: theme.colors.textLight },\n optionsList: {\n position: 'absolute',\n top: '100%',\n left: 0,\n right: 0,\n marginTop: theme.tokens.spacing.sm,\n borderWidth: 2,\n borderColor: theme.colors.border,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.surface,\n overflow: 'hidden',\n zIndex: 20,\n elevation: 20,\n ...theme.tokens.shadow.md,\n },\n optionRow: {\n flexDirection: 'row',\n alignItems: 'center',\n justifyContent: 'space-between',\n paddingHorizontal: theme.tokens.spacing.lg,\n paddingVertical: theme.tokens.spacing.md + 2,\n borderBottomWidth: 1,\n borderBottomColor: theme.colors.border,\n },\n optionRowLast: { borderBottomWidth: 0 },\n optionRowSelected: { backgroundColor: theme.colors.primarySoft },\n optionText: { fontSize: theme.tokens.fontSize.lg - 1, color: theme.colors.text },\n optionTextSelected: { fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.primaryText },\n errorText: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.errorText },\n}));\n\n/**\n * Select\n * Dropdown que expande la lista de opciones flotando sobre el resto del\n * contenido (position: absolute), en vez de empujarlo hacia abajo o abrir\n * un picker nativo del sistema.\n */\nexport function Select<T extends string | number>({\n label,\n required = false,\n options,\n selectedValue,\n onSelect,\n placeholder,\n error,\n isLoading = false,\n containerStyle,\n}: SelectProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const allowFontScaling = useAllowFontScaling();\n const [open, setOpen] = useState(false);\n const selectedOption = options.find((o) => o.value === selectedValue);\n\n return (\n <View style={[styles.container, open && styles.containerOpen, containerStyle]}>\n {label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n {required && <Text style={styles.required}> *</Text>}\n </Text>\n )}\n\n <Pressable\n style={styles.field}\n onPress={() => !isLoading && setOpen((o) => !o)}\n disabled={isLoading}\n accessibilityRole=\"button\"\n accessibilityLabel={label ?? placeholder ?? labels.selectPlaceholder}\n accessibilityState={{ expanded: open, disabled: isLoading }}\n >\n <Text allowFontScaling={allowFontScaling} style={[styles.fieldText, !selectedOption && styles.fieldPlaceholder]}>\n {selectedOption?.label || placeholder || labels.selectPlaceholder}\n </Text>\n {isLoading ? (\n <ActivityIndicator size=\"small\" color={colors.primary} />\n ) : (\n <Icon name={open ? 'chevron-up' : 'chevron-down'} size={20} color={colors.textLight} />\n )}\n </Pressable>\n\n {open && !isLoading && (\n <View style={styles.optionsList}>\n {options.map((option, index) => {\n const isSelected = option.value === selectedValue;\n return (\n <Pressable\n key={String(option.value)}\n style={[styles.optionRow, index === options.length - 1 && styles.optionRowLast, isSelected && styles.optionRowSelected]}\n onPress={() => {\n onSelect(option.value);\n setOpen(false);\n }}\n accessibilityRole=\"menuitem\"\n accessibilityState={{ selected: isSelected }}\n >\n <Text allowFontScaling={allowFontScaling} style={[styles.optionText, isSelected && styles.optionTextSelected]}>\n {option.label}\n </Text>\n {isSelected && <Icon name=\"check\" size={18} color={colors.primary} />}\n </Pressable>\n );\n })}\n </View>\n )}\n\n {!!error && (\n <Text allowFontScaling={allowFontScaling} style={styles.errorText}>\n {error}\n </Text>\n )}\n </View>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/steps.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "molecules/Steps.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { 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.
|
|
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"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "molecules/ThemeToggle.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, useThemeMode, type ThemeMode } from '../theme/UIProvider';\n\nexport interface ThemeToggleProps {\n style?: StyleProp<ViewStyle>;\n labels?: Partial<Record<ThemeMode, string>>;\n}\n\nconst MODES: ThemeMode[] = ['system', 'light', 'dark'];\nconst DEFAULT_LABELS: Record<ThemeMode, string> = { system: 'Auto', light: 'Light', dark: 'Dark' };\n\nconst useStyles = createStyles((theme) => ({\n track: {\n flexDirection: 'row',\n backgroundColor: theme.colors.inputBackground,\n borderRadius: theme.tokens.radius.md,\n padding: 3,\n },\n segment: {\n flex: 1,\n paddingVertical: theme.tokens.spacing.sm,\n borderRadius: theme.tokens.radius.sm,\n alignItems: 'center',\n },\n segmentActive: { backgroundColor: theme.colors.surface, ...theme.tokens.shadow.sm },\n label: { fontSize: theme.tokens.fontSize.sm, fontWeight: theme.tokens.fontWeight.medium, color: theme.colors.textLight },\n labelActive: { color: theme.colors.
|
|
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, useThemeMode, type ThemeMode } from '../theme/UIProvider';\n\nexport interface ThemeToggleProps {\n style?: StyleProp<ViewStyle>;\n labels?: Partial<Record<ThemeMode, string>>;\n}\n\nconst MODES: ThemeMode[] = ['system', 'light', 'dark'];\nconst DEFAULT_LABELS: Record<ThemeMode, string> = { system: 'Auto', light: 'Light', dark: 'Dark' };\n\nconst useStyles = createStyles((theme) => ({\n track: {\n flexDirection: 'row',\n backgroundColor: theme.colors.inputBackground,\n borderRadius: theme.tokens.radius.md,\n padding: 3,\n },\n segment: {\n flex: 1,\n paddingVertical: theme.tokens.spacing.sm,\n borderRadius: theme.tokens.radius.sm,\n alignItems: 'center',\n },\n segmentActive: { backgroundColor: theme.colors.surface, ...theme.tokens.shadow.sm },\n label: { fontSize: theme.tokens.fontSize.sm, fontWeight: theme.tokens.fontWeight.medium, color: theme.colors.textLight },\n labelActive: { color: theme.colors.primaryText, fontWeight: theme.tokens.fontWeight.semibold },\n}));\n\n/**\n * ThemeToggle\n * Segmented control de 3 estados (system/light/dark) conectado a\n * UIProvider.setMode. Sin persistencia — el consumidor la resuelve vía\n * onModeChange en <UIProvider>.\n */\nexport const ThemeToggle = memo(({ style, labels: labelOverrides }: ThemeToggleProps) => {\n const { mode, setMode } = useThemeMode();\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const labels = { ...DEFAULT_LABELS, ...labelOverrides };\n\n return (\n <View style={[styles.track, style]} accessibilityRole=\"tablist\">\n {MODES.map((m) => {\n const active = mode === m;\n return (\n <TouchableOpacity\n key={m}\n style={[styles.segment, active && styles.segmentActive]}\n onPress={() => setMode(m)}\n accessibilityRole=\"tab\"\n accessibilityState={{ selected: active }}\n accessibilityLabel={labels[m]}\n >\n <Text allowFontScaling={allowFontScaling} style={[styles.label, active && styles.labelActive]}>\n {labels[m]}\n </Text>\n </TouchableOpacity>\n );\n })}\n </View>\n );\n});\nThemeToggle.displayName = 'ThemeToggle';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|