orn-ui 0.1.0 → 0.2.0
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/README.md +9 -2
- 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/atoms/PressableScale.js +9 -10
- package/lib/commonjs/atoms/PressableScale.js.map +1 -1
- package/lib/commonjs/atoms/Skeleton.js +96 -0
- package/lib/commonjs/atoms/Skeleton.js.map +1 -0
- package/lib/commonjs/atoms/Transition.js +171 -0
- package/lib/commonjs/atoms/Transition.js.map +1 -0
- package/lib/commonjs/atoms/index.js +20 -0
- package/lib/commonjs/atoms/index.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 +57 -24
- package/lib/commonjs/molecules/OptionCard.js.map +1 -1
- package/lib/commonjs/molecules/{ThemeToggle.js → SegmentedControl.js} +37 -37
- package/lib/commonjs/molecules/SegmentedControl.js.map +1 -0
- package/lib/commonjs/molecules/Stepper.js +124 -47
- package/lib/commonjs/molecules/Stepper.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/index.js +9 -3
- package/lib/commonjs/molecules/index.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/AlertProvider.js +26 -0
- package/lib/commonjs/organisms/AlertProvider.js.map +1 -1
- package/lib/commonjs/organisms/BottomSheet.js +118 -39
- package/lib/commonjs/organisms/BottomSheet.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 +66 -44
- package/lib/commonjs/organisms/List.js.map +1 -1
- package/lib/commonjs/organisms/Modal.js +42 -87
- package/lib/commonjs/organisms/Modal.js.map +1 -1
- package/lib/commonjs/organisms/NavigationBar.js +205 -0
- package/lib/commonjs/organisms/NavigationBar.js.map +1 -0
- package/lib/commonjs/organisms/Screen.js +9 -2
- package/lib/commonjs/organisms/Screen.js.map +1 -1
- package/lib/commonjs/organisms/SearchList.js +99 -30
- package/lib/commonjs/organisms/SearchList.js.map +1 -1
- package/lib/commonjs/organisms/Select.js +226 -54
- package/lib/commonjs/organisms/Select.js.map +1 -1
- package/lib/commonjs/organisms/ThemeToggle.js +52 -0
- package/lib/commonjs/organisms/ThemeToggle.js.map +1 -0
- package/lib/commonjs/organisms/Toast.js +20 -25
- package/lib/commonjs/organisms/Toast.js.map +1 -1
- package/lib/commonjs/organisms/ToastProvider.js +24 -0
- package/lib/commonjs/organisms/ToastProvider.js.map +1 -1
- package/lib/commonjs/organisms/Wizard.js +22 -2
- package/lib/commonjs/organisms/Wizard.js.map +1 -1
- package/lib/commonjs/organisms/imperativeBridge.js +28 -0
- package/lib/commonjs/organisms/imperativeBridge.js.map +1 -0
- package/lib/commonjs/organisms/index.js +50 -0
- package/lib/commonjs/organisms/index.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/colors.js +207 -0
- package/lib/commonjs/theme/colors.js.map +1 -0
- package/lib/commonjs/theme/createTheme.js +25 -2
- package/lib/commonjs/theme/createTheme.js.map +1 -1
- package/lib/commonjs/theme/index.js +25 -0
- package/lib/commonjs/theme/index.js.map +1 -1
- package/lib/commonjs/theme/palettes.js +73 -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/atoms/PressableScale.js +9 -10
- package/lib/module/atoms/PressableScale.js.map +1 -1
- package/lib/module/atoms/Skeleton.js +91 -0
- package/lib/module/atoms/Skeleton.js.map +1 -0
- package/lib/module/atoms/Transition.js +165 -0
- package/lib/module/atoms/Transition.js.map +1 -0
- package/lib/module/atoms/index.js +2 -0
- package/lib/module/atoms/index.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 +58 -25
- package/lib/module/molecules/OptionCard.js.map +1 -1
- package/lib/module/molecules/{ThemeToggle.js → SegmentedControl.js} +37 -37
- package/lib/module/molecules/SegmentedControl.js.map +1 -0
- package/lib/module/molecules/Stepper.js +125 -49
- package/lib/module/molecules/Stepper.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/index.js +2 -2
- package/lib/module/molecules/index.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/AlertProvider.js +24 -1
- package/lib/module/organisms/AlertProvider.js.map +1 -1
- package/lib/module/organisms/BottomSheet.js +120 -41
- package/lib/module/organisms/BottomSheet.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 +68 -46
- package/lib/module/organisms/List.js.map +1 -1
- package/lib/module/organisms/Modal.js +44 -89
- package/lib/module/organisms/Modal.js.map +1 -1
- package/lib/module/organisms/NavigationBar.js +200 -0
- package/lib/module/organisms/NavigationBar.js.map +1 -0
- package/lib/module/organisms/Screen.js +9 -2
- package/lib/module/organisms/Screen.js.map +1 -1
- package/lib/module/organisms/SearchList.js +100 -31
- package/lib/module/organisms/SearchList.js.map +1 -1
- package/lib/module/organisms/Select.js +228 -56
- package/lib/module/organisms/Select.js.map +1 -1
- package/lib/module/organisms/ThemeToggle.js +47 -0
- package/lib/module/organisms/ThemeToggle.js.map +1 -0
- package/lib/module/organisms/Toast.js +22 -27
- package/lib/module/organisms/Toast.js.map +1 -1
- package/lib/module/organisms/ToastProvider.js +21 -0
- package/lib/module/organisms/ToastProvider.js.map +1 -1
- package/lib/module/organisms/Wizard.js +23 -3
- package/lib/module/organisms/Wizard.js.map +1 -1
- package/lib/module/organisms/imperativeBridge.js +24 -0
- package/lib/module/organisms/imperativeBridge.js.map +1 -0
- package/lib/module/organisms/index.js +4 -2
- package/lib/module/organisms/index.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/colors.js +194 -0
- package/lib/module/theme/colors.js.map +1 -0
- package/lib/module/theme/createTheme.js +25 -2
- package/lib/module/theme/createTheme.js.map +1 -1
- package/lib/module/theme/index.js +1 -0
- package/lib/module/theme/index.js.map +1 -1
- package/lib/module/theme/palettes.js +73 -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/atoms/PressableScale.d.ts +3 -2
- package/lib/typescript/atoms/PressableScale.d.ts.map +1 -1
- package/lib/typescript/atoms/Skeleton.d.ts +29 -0
- package/lib/typescript/atoms/Skeleton.d.ts.map +1 -0
- package/lib/typescript/atoms/Transition.d.ts +51 -0
- package/lib/typescript/atoms/Transition.d.ts.map +1 -0
- package/lib/typescript/atoms/index.d.ts +4 -0
- package/lib/typescript/atoms/index.d.ts.map +1 -1
- package/lib/typescript/molecules/OptionCard.d.ts +11 -6
- package/lib/typescript/molecules/OptionCard.d.ts.map +1 -1
- package/lib/typescript/molecules/SegmentedControl.d.ts +24 -0
- package/lib/typescript/molecules/SegmentedControl.d.ts.map +1 -0
- package/lib/typescript/molecules/Stepper.d.ts +35 -3
- package/lib/typescript/molecules/Stepper.d.ts.map +1 -1
- package/lib/typescript/molecules/index.d.ts +4 -4
- package/lib/typescript/molecules/index.d.ts.map +1 -1
- package/lib/typescript/organisms/Alert.d.ts.map +1 -1
- package/lib/typescript/organisms/AlertProvider.d.ts +9 -0
- package/lib/typescript/organisms/AlertProvider.d.ts.map +1 -1
- package/lib/typescript/organisms/BottomSheet.d.ts +26 -4
- package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
- package/lib/typescript/organisms/List.d.ts +9 -2
- package/lib/typescript/organisms/List.d.ts.map +1 -1
- package/lib/typescript/organisms/Modal.d.ts +3 -5
- package/lib/typescript/organisms/Modal.d.ts.map +1 -1
- package/lib/typescript/organisms/NavigationBar.d.ts +32 -0
- package/lib/typescript/organisms/NavigationBar.d.ts.map +1 -0
- package/lib/typescript/organisms/Screen.d.ts +2 -1
- package/lib/typescript/organisms/Screen.d.ts.map +1 -1
- package/lib/typescript/organisms/SearchList.d.ts +21 -5
- package/lib/typescript/organisms/SearchList.d.ts.map +1 -1
- package/lib/typescript/organisms/Select.d.ts +19 -4
- package/lib/typescript/organisms/Select.d.ts.map +1 -1
- package/lib/typescript/{molecules → organisms}/ThemeToggle.d.ts +4 -4
- package/lib/typescript/organisms/ThemeToggle.d.ts.map +1 -0
- package/lib/typescript/organisms/Toast.d.ts.map +1 -1
- package/lib/typescript/organisms/ToastProvider.d.ts +8 -0
- package/lib/typescript/organisms/ToastProvider.d.ts.map +1 -1
- package/lib/typescript/organisms/Wizard.d.ts +7 -1
- package/lib/typescript/organisms/Wizard.d.ts.map +1 -1
- package/lib/typescript/organisms/imperativeBridge.d.ts +12 -0
- package/lib/typescript/organisms/imperativeBridge.d.ts.map +1 -0
- package/lib/typescript/organisms/index.d.ts +7 -3
- package/lib/typescript/organisms/index.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/colors.d.ts +44 -0
- package/lib/typescript/theme/colors.d.ts.map +1 -0
- package/lib/typescript/theme/createTheme.d.ts +3 -0
- package/lib/typescript/theme/createTheme.d.ts.map +1 -1
- package/lib/typescript/theme/index.d.ts +3 -1
- package/lib/typescript/theme/index.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/lib/typescript/theme/types.d.ts +9 -0
- package/lib/typescript/theme/types.d.ts.map +1 -1
- package/package.json +101 -18
- package/registry/alert-provider.json +9 -2
- 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/bottom-sheet.json +6 -2
- package/registry/button.json +1 -1
- package/registry/checkbox.json +1 -1
- package/registry/core.json +9 -5
- package/registry/date-field.json +2 -1
- package/registry/date-picker.json +1 -1
- package/registry/fab.json +1 -1
- package/registry/image.json +1 -1
- package/registry/index.json +108 -39
- package/registry/input.json +1 -1
- package/registry/list.json +2 -1
- package/registry/manifest.json +44 -6
- package/registry/modal.json +3 -2
- package/registry/navigation-bar.json +18 -0
- package/registry/option-card.json +3 -2
- package/registry/pressable-scale.json +1 -1
- package/registry/screen.json +1 -1
- package/registry/search-list.json +2 -1
- package/registry/segmented-control.json +18 -0
- package/registry/select.json +1 -1
- package/registry/skeleton.json +18 -0
- package/registry/stepper.json +3 -2
- package/registry/steps.json +1 -1
- package/registry/theme-toggle.json +5 -4
- package/registry/toast-provider.json +11 -3
- package/registry/toast.json +3 -2
- package/registry/transition.json +19 -0
- package/registry/wizard.json +3 -2
- 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/atoms/PressableScale.tsx +16 -6
- package/src/atoms/Skeleton.tsx +114 -0
- package/src/atoms/Transition.tsx +189 -0
- package/src/atoms/index.ts +4 -0
- package/src/molecules/AvatarHeader.tsx +2 -2
- package/src/molecules/OptionCard.tsx +64 -27
- package/src/molecules/SegmentedControl.tsx +84 -0
- package/src/molecules/Stepper.tsx +147 -32
- package/src/molecules/Steps.tsx +2 -2
- package/src/molecules/index.ts +4 -4
- package/src/organisms/Alert.tsx +6 -5
- package/src/organisms/AlertProvider.tsx +26 -1
- package/src/organisms/BottomSheet.tsx +214 -62
- package/src/organisms/DateField.tsx +2 -2
- package/src/organisms/DatePicker.tsx +3 -3
- package/src/organisms/List.tsx +73 -41
- package/src/organisms/Modal.tsx +49 -61
- package/src/organisms/NavigationBar.tsx +223 -0
- package/src/organisms/Screen.tsx +11 -3
- package/src/organisms/SearchList.tsx +134 -38
- package/src/organisms/Select.tsx +227 -49
- package/src/organisms/ThemeToggle.tsx +30 -0
- package/src/organisms/Toast.tsx +19 -24
- package/src/organisms/ToastProvider.tsx +24 -0
- package/src/organisms/Wizard.tsx +36 -3
- package/src/organisms/imperativeBridge.ts +28 -0
- package/src/organisms/index.ts +7 -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/colors.ts +175 -0
- package/src/theme/createTheme.ts +38 -4
- package/src/theme/index.ts +3 -1
- package/src/theme/palettes.ts +108 -11
- package/src/theme/types.ts +10 -0
- package/lib/commonjs/molecules/ThemeToggle.js.map +0 -1
- package/lib/module/molecules/ThemeToggle.js.map +0 -1
- package/lib/typescript/molecules/ThemeToggle.d.ts.map +0 -1
- package/src/molecules/ThemeToggle.tsx +0 -67
package/registry/modal.json
CHANGED
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/Modal.tsx",
|
|
12
|
-
"content": "import React, { memo, useEffect,
|
|
12
|
+
"content": "import React, { memo, useEffect, useState } from 'react';\nimport {\n Dimensions,\n KeyboardAvoidingView,\n Modal as RNModal,\n Platform,\n Pressable,\n ScrollView,\n View,\n type ModalProps as RNModalProps,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useInsets } from '../theme/UIProvider';\nimport { Title } from '../atoms/Text';\nimport { IconButton } from '../atoms/IconButton';\nimport { Transition, type TransitionPreset } from '../atoms/Transition';\n\nconst { height: SCREEN_HEIGHT } = Dimensions.get('window');\n\nexport type ModalVariant = 'full' | 'overlay' | 'fullScreen';\n\nexport interface ModalProps\n extends Pick<RNModalProps, 'statusBarTranslucent' | 'onRequestClose' | 'hardwareAccelerated'> {\n visible: boolean;\n onClose?: () => void;\n title?: string;\n closeAccessibilityLabel?: string;\n children: React.ReactNode;\n footer?: React.ReactNode;\n containerStyle?: ViewStyle;\n contentStyle?: ViewStyle;\n variant?: ModalVariant;\n scrollable?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n flex1: { flex: 1 },\n fullWrapper: { flex: 1, backgroundColor: theme.colors.background },\n overlayContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: theme.tokens.spacing.xxl - 4 },\n backdrop: { backgroundColor: theme.colors.overlay },\n overlayCard: {\n width: '100%',\n maxHeight: '85%',\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.xxl,\n overflow: 'hidden',\n ...theme.tokens.shadow.lg,\n },\n header: {\n flexDirection: 'row',\n justifyContent: 'space-between',\n alignItems: 'center',\n padding: theme.tokens.spacing.xl,\n borderBottomWidth: 1,\n borderBottomColor: theme.colors.border,\n backgroundColor: theme.colors.surface,\n },\n title: { fontSize: theme.tokens.fontSize.xl, fontWeight: theme.tokens.fontWeight.bold, flex: 1 },\n closeButton: { marginLeft: theme.tokens.spacing.md },\n contentWrapper: { flexShrink: 1, backgroundColor: theme.colors.surface },\n scrollWrapper: { flexShrink: 1 },\n scrollContent: { padding: theme.tokens.spacing.xl, flexGrow: 1 },\n scrollContentFull: { padding: 0, flexGrow: 1 },\n footer: {\n padding: theme.tokens.spacing.xl,\n borderTopWidth: 1,\n borderTopColor: theme.colors.border,\n backgroundColor: theme.colors.surface,\n },\n}));\n\n/**\n * Modal\n * Un solo componente para todos los modales de la app: maneja scroll,\n * teclado, insets y layout según `variant`. Para una hoja desde abajo está\n * BottomSheet, que además trae arrastre para cerrar.\n */\nexport const Modal = memo(\n ({\n visible,\n onClose,\n title,\n closeAccessibilityLabel = 'Close',\n children,\n footer,\n containerStyle,\n contentStyle,\n variant = 'full',\n scrollable = true,\n statusBarTranslucent = true,\n onRequestClose,\n hardwareAccelerated,\n }: ModalProps) => {\n const isFull = variant === 'full';\n const isOverlay = variant === 'overlay';\n const isFullScreen = variant === 'fullScreen';\n const isAnimatedVariant = isOverlay || isFull;\n const insets = useInsets();\n const colors = useColors();\n const styles = useStyles();\n\n const [mounted, setMounted] = useState(!isAnimatedVariant && visible);\n\n useEffect(() => {\n if (!isAnimatedVariant) setMounted(visible);\n else if (visible) setMounted(true);\n }, [visible, isAnimatedVariant]);\n\n // La tarjeta nunca anima opacity: hacerlo dispara un bug de Android donde\n // los hijos con elevation parpadean. El fondo semitransparente sí.\n const cardPreset: TransitionPreset[] = isFull ? ['slide-up'] : ['slide-down', 'scale'];\n const cardDistance = isFull ? SCREEN_HEIGHT : 40;\n\n const behavior = Platform.OS === 'ios' ? (isFull ? undefined : 'padding') : 'height';\n const keyboardOffset = Platform.OS === 'ios' ? 0 : 20;\n\n const renderHeader = () => {\n if (!title && !onClose) return null;\n return (\n <View style={styles.header}>\n {!!title && (\n <Title style={styles.title} numberOfLines={1}>\n {title}\n </Title>\n )}\n {!title && <View style={styles.flex1} />}\n {!!onClose && (\n <IconButton iconName=\"close\" size={24} color={colors.text} onPress={onClose} accessibilityLabel={closeAccessibilityLabel} style={styles.closeButton} />\n )}\n </View>\n );\n };\n\n const renderMainContent = () => (\n <View style={[styles.contentWrapper, isFull && styles.flex1]}>\n {scrollable ? (\n <ScrollView\n style={[styles.scrollWrapper, isFull && styles.flex1]}\n contentContainerStyle={[!isFull ? styles.scrollContent : styles.scrollContentFull, contentStyle]}\n keyboardShouldPersistTaps=\"handled\"\n showsVerticalScrollIndicator={false}\n bounces={Platform.OS === 'ios'}\n automaticallyAdjustKeyboardInsets\n >\n {children}\n </ScrollView>\n ) : (\n <View style={[!isFull ? styles.scrollContent : styles.scrollContentFull, isFull && styles.flex1, contentStyle]}>\n {children}\n </View>\n )}\n </View>\n );\n\n const renderFooter = () => (footer ? <View style={styles.footer}>{footer}</View> : null);\n\n if (!mounted) return null;\n\n return (\n <RNModal\n visible\n onRequestClose={onRequestClose ?? onClose}\n animationType={isAnimatedVariant ? 'none' : 'slide'}\n // `full` anima su propia entrada (translateY) con animationType=\"none\"\n // y necesita `transparent` para que no se vea un fondo negro detrás\n // mientras desliza. RN sólo admite `transparent` junto a\n // 'overFullScreen' — con 'pageSheet' avisa que la combinación no está\n // soportada. Por eso `full` usa overFullScreen: la presentación nativa\n // de pageSheet sería redundante con nuestra animación de todos modos.\n presentationStyle={\n Platform.OS === 'ios' ? (isFullScreen ? 'fullScreen' : 'overFullScreen') : undefined\n }\n transparent={isOverlay || isFull}\n statusBarTranslucent={statusBarTranslucent}\n hardwareAccelerated={hardwareAccelerated}\n >\n <KeyboardAvoidingView behavior={behavior} keyboardVerticalOffset={keyboardOffset} style={styles.flex1}>\n {isOverlay ? (\n <View style={styles.overlayContainer}>\n {/* Sin accessibilityLabel propio: el header ya expone un botón \"Close\"\n accesible; duplicar el label aquí generaría dos elementos con el\n mismo nombre para lectores de pantalla. Tocar afuera es un atajo\n puramente táctil. */}\n <Transition\n visible={visible}\n preset=\"fade\"\n duration={200}\n keepMounted\n style={[{ ...StyleSheetAbsoluteFill }, styles.backdrop]}\n >\n <Pressable style={StyleSheetAbsoluteFill} onPress={onClose} testID=\"modal-backdrop\" />\n </Transition>\n\n <Transition\n visible={visible}\n preset={cardPreset}\n distance={cardDistance}\n duration={250}\n onExited={() => setMounted(false)}\n style={[styles.overlayCard, containerStyle]}\n >\n {renderHeader()}\n {renderMainContent()}\n {renderFooter()}\n </Transition>\n </View>\n ) : isFull ? (\n <Transition\n visible={visible}\n preset={cardPreset}\n distance={cardDistance}\n duration={250}\n onExited={() => setMounted(false)}\n style={[styles.fullWrapper, containerStyle]}\n >\n <View style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>\n {renderHeader()}\n {renderMainContent()}\n {renderFooter()}\n </View>\n </Transition>\n ) : (\n <View style={[styles.fullWrapper, containerStyle]}>\n <View style={[styles.flex1, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>\n {renderHeader()}\n {renderMainContent()}\n {renderFooter()}\n </View>\n </View>\n )}\n </KeyboardAvoidingView>\n </RNModal>\n );\n }\n);\nModal.displayName = 'Modal';\n\nconst StyleSheetAbsoluteFill: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 };\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
16
16
|
"core",
|
|
17
17
|
"title",
|
|
18
18
|
"icon-button",
|
|
19
|
-
"pressable-scale"
|
|
19
|
+
"pressable-scale",
|
|
20
|
+
"transition"
|
|
20
21
|
]
|
|
21
22
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "NavigationBar",
|
|
3
|
+
"slug": "navigation-bar",
|
|
4
|
+
"type": "component",
|
|
5
|
+
"category": "organisms",
|
|
6
|
+
"exportedNames": [
|
|
7
|
+
"NavigationBar"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "organisms/NavigationBar.tsx",
|
|
12
|
+
"content": "import React, { memo, useEffect, useRef, useState } from 'react';\nimport {\n Animated,\n StyleSheet,\n Text,\n TouchableOpacity,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useInsets, useTheme } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface NavigationBarItem {\n key: string;\n label: string;\n iconName?: IconName;\n /** Contador sobre el ícono. `true` pinta sólo el punto. */\n badge?: number | string | true;\n}\n\nexport interface NavigationBarProps {\n items: NavigationBarItem[];\n activeKey: string;\n onChange: (key: string) => void;\n /** Borde de la pantalla donde vive la barra. @default 'bottom' */\n position?: 'bottom' | 'top';\n /** @default true */\n showLabels?: boolean;\n /** Suma el inset de safe area del borde donde vive la barra. @default true */\n safeArea?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n bar: {\n flexDirection: 'row',\n backgroundColor: theme.colors.surface,\n borderColor: theme.colors.border,\n paddingHorizontal: theme.tokens.spacing.xs,\n paddingVertical: theme.tokens.spacing.sm,\n },\n barBottom: { borderTopWidth: StyleSheet.hairlineWidth },\n barTop: { borderBottomWidth: StyleSheet.hairlineWidth },\n indicator: {\n position: 'absolute',\n top: theme.tokens.spacing.xs,\n bottom: theme.tokens.spacing.xs,\n left: theme.tokens.spacing.sm,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.primarySoft,\n },\n item: { flex: 1, paddingVertical: theme.tokens.spacing.xs },\n itemContent: { alignItems: 'center', justifyContent: 'center', gap: 2 },\n label: { fontSize: theme.tokens.fontSize.xs, fontWeight: theme.tokens.fontWeight.medium },\n badge: {\n position: 'absolute',\n top: -4,\n left: '50%',\n marginLeft: 4,\n minWidth: 16,\n height: 16,\n paddingHorizontal: 4,\n borderRadius: theme.tokens.radius.full,\n backgroundColor: theme.colors.error,\n alignItems: 'center',\n justifyContent: 'center',\n },\n badgeDot: { minWidth: 10, height: 10, top: -2, paddingHorizontal: 0 },\n badgeText: {\n fontSize: 10,\n fontWeight: theme.tokens.fontWeight.bold,\n color: theme.colors.onError,\n },\n}));\n\nconst ICON_SIZE = 22;\n\n/**\n * NavigationBar\n * Barra de navegación por pestañas con la misma apariencia y las mismas\n * transiciones en iOS y Android: el indicador se desliza hasta la pestaña\n * activa y su contenido crece y toma el color de acento con un cross-fade.\n * No navega por su cuenta — reporta la pestaña tocada y el consumidor decide.\n */\nexport const NavigationBar = memo(\n ({\n items,\n activeKey,\n onChange,\n position = 'bottom',\n showLabels = true,\n safeArea = true,\n style,\n testID,\n }: NavigationBarProps) => {\n const theme = useTheme();\n const colors = useColors();\n const styles = useStyles();\n const insets = useInsets();\n const allowFontScaling = useAllowFontScaling();\n\n const [barWidth, setBarWidth] = useState(0);\n const slide = useRef(new Animated.Value(0)).current;\n const placed = useRef(false);\n\n const activeIndex = Math.max(\n 0,\n items.findIndex((item) => item.key === activeKey)\n );\n const itemWidth = items.length > 0 ? barWidth / items.length : 0;\n\n useEffect(() => {\n if (itemWidth <= 0) return;\n const to = activeIndex * itemWidth;\n // En el primer layout el indicador aparece en su lugar; animarlo desde 0\n // sería un deslizamiento que el usuario no pidió.\n if (!placed.current) {\n placed.current = true;\n slide.setValue(to);\n return;\n }\n Animated.spring(slide, { toValue: to, useNativeDriver: true, friction: 9, tension: 90 }).start();\n }, [activeIndex, itemWidth, slide]);\n\n const onLayout = (e: LayoutChangeEvent) => {\n const next = e.nativeEvent.layout.width - theme.tokens.spacing.xs * 2;\n if (next !== barWidth) setBarWidth(next);\n };\n\n const renderContent = (item: NavigationBarItem, color: string) => (\n <>\n {!!item.iconName && <Icon name={item.iconName} size={ICON_SIZE} color={color} />}\n {showLabels && (\n <Text allowFontScaling={allowFontScaling} numberOfLines={1} style={[styles.label, { color }]}>\n {item.label}\n </Text>\n )}\n </>\n );\n\n return (\n <View\n testID={testID}\n onLayout={onLayout}\n accessibilityRole=\"tablist\"\n style={[\n styles.bar,\n position === 'bottom' ? styles.barBottom : styles.barTop,\n safeArea && position === 'bottom' && { paddingBottom: theme.tokens.spacing.sm + insets.bottom },\n safeArea && position === 'top' && { paddingTop: theme.tokens.spacing.sm + insets.top },\n style,\n ]}\n >\n {itemWidth > 0 && (\n <Animated.View\n pointerEvents=\"none\"\n style={[\n styles.indicator,\n {\n width: itemWidth - theme.tokens.spacing.sm * 2,\n transform: [{ translateX: slide }],\n },\n ]}\n />\n )}\n\n {items.map((item, index) => {\n const active = index === activeIndex;\n const range = [(index - 1) * itemWidth, index * itemWidth, (index + 1) * itemWidth];\n const scale =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [1, 1.08, 1], extrapolate: 'clamp' })\n : 1;\n const activeOpacity =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [0, 1, 0], extrapolate: 'clamp' })\n : 0;\n\n return (\n <TouchableOpacity\n key={item.key}\n style={styles.item}\n activeOpacity={0.7}\n onPress={() => onChange(item.key)}\n accessibilityRole=\"tab\"\n accessibilityLabel={item.label}\n accessibilityState={{ selected: active }}\n >\n <Animated.View style={[styles.itemContent, { transform: [{ scale }] }]}>\n {renderContent(item, colors.textLight)}\n {/* Copia decorativa: sin ocultarla, el lector lee el label dos veces. */}\n <Animated.View\n pointerEvents=\"none\"\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={[StyleSheet.absoluteFill, styles.itemContent, { opacity: activeOpacity }]}\n >\n {renderContent(item, colors.primaryText)}\n </Animated.View>\n\n {item.badge !== undefined && (\n <View style={[styles.badge, item.badge === true && styles.badgeDot]}>\n {item.badge !== true && (\n <Text allowFontScaling={false} style={styles.badgeText}>\n {item.badge}\n </Text>\n )}\n </View>\n )}\n </Animated.View>\n </TouchableOpacity>\n );\n })}\n </View>\n );\n }\n);\nNavigationBar.displayName = 'NavigationBar';\n"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"core"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "molecules/OptionCard.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { Text,
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport { PressableScale } from '../atoms/PressableScale';\nimport type { IconName } from '../icons/types';\n\nexport interface OptionCardProps {\n label: string;\n /** Línea secundaria: el detalle que decide la elección (\"llega en 3 días\"). */\n description?: string;\n iconName?: IconName;\n isSelected: boolean;\n onPress: () => void;\n disabled?: boolean;\n /** 'vertical': ícono arriba, texto abajo centrado. 'horizontal': en fila. @default 'horizontal' */\n layout?: 'vertical' | 'horizontal';\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n card: {\n // Las tarjetas se reparten la fila que las contenga, sean dos o cinco.\n flex: 1,\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: { flexDirection: 'row', padding: theme.tokens.spacing.md - 2, gap: theme.tokens.spacing.sm },\n cardVertical: { padding: theme.tokens.spacing.md - 2, gap: 6, 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 cardDisabled: { opacity: 0.5 },\n text: { flexShrink: 1 },\n textVertical: { alignItems: 'center' },\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 description: { fontSize: theme.tokens.fontSize.xs, color: theme.colors.textLight, marginTop: 2 },\n descriptionVertical: { textAlign: 'center' },\n}));\n\n/**\n * OptionCard\n * Opción excluyente con ícono y label que resalta cuando `isSelected`. Es un\n * radio con cara de tarjeta, así que se anuncia como tal: envolver el grupo en\n * una View con `accessibilityRole=\"radiogroup\"` para que el lector de pantalla\n * lea \"1 de 3\".\n */\nexport const OptionCard = memo(\n ({\n label,\n description,\n iconName,\n isSelected,\n onPress,\n disabled = false,\n layout = 'horizontal',\n style,\n testID,\n }: OptionCardProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isVertical = layout === 'vertical';\n\n // Feedback por transform y no por opacity: con elevation, animar la opacity\n // dispara un bug de Android que redibuja la sombra como un borde oscuro.\n return (\n <PressableScale\n style={[\n styles.card,\n isVertical ? styles.cardVertical : styles.cardHorizontal,\n isSelected && styles.cardSelected,\n disabled && styles.cardDisabled,\n style,\n ]}\n onPress={onPress}\n disabled={disabled}\n accessibilityRole=\"radio\"\n accessibilityState={{ selected: isSelected, checked: isSelected, disabled }}\n accessibilityLabel={label}\n accessibilityHint={description}\n testID={testID}\n >\n {!!iconName && <Icon name={iconName} size={24} color={isSelected ? colors.primary : colors.textLight} />}\n\n <View style={[styles.text, isVertical && styles.textVertical]}>\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.label, isVertical && styles.labelVertical, isSelected && styles.labelSelected]}\n >\n {label}\n </Text>\n {!!description && (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.description, isVertical && styles.descriptionVertical]}\n >\n {description}\n </Text>\n )}\n </View>\n </PressableScale>\n );\n }\n);\nOptionCard.displayName = 'OptionCard';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
16
|
-
"core"
|
|
16
|
+
"core",
|
|
17
|
+
"pressable-scale"
|
|
17
18
|
]
|
|
18
19
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "atoms/PressableScale.tsx",
|
|
12
|
-
"content": "import React, { memo, useRef } from 'react';\nimport {
|
|
12
|
+
"content": "import React, { memo, useRef } from 'react';\nimport {\n Animated,\n Pressable,\n type GestureResponderEvent,\n type PressableProps,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\n\nexport interface PressableScaleProps extends Omit<PressableProps, 'style'> {\n /** Escala objetivo al presionar. @default 0.96 */\n scaleTo?: number;\n style?: StyleProp<ViewStyle>;\n children: React.ReactNode;\n}\n\nconst AnimatedPressable = Animated.createAnimatedComponent(Pressable);\n\n/**\n * PressableScale\n * Feedback táctil de \"achicarse\" al presionar, con Animated.spring de RN\n * (useNativeDriver: true — corre en el hilo nativo, sin coste de JS por frame).\n * No depende de reanimated: la librería entera es cero-dependencias.\n */\nexport const PressableScale = memo(\n ({ scaleTo = 0.96, style, children, onPressIn, onPressOut, disabled, ...props }: PressableScaleProps) => {\n const scale = useRef(new Animated.Value(1)).current;\n\n const animateTo = (value: number) => {\n Animated.spring(scale, {\n toValue: value,\n useNativeDriver: true,\n speed: 50,\n bounciness: 6,\n }).start();\n };\n\n const handlePressIn = (e: GestureResponderEvent) => {\n if (!disabled) animateTo(scaleTo);\n onPressIn?.(e);\n };\n\n const handlePressOut = (e: GestureResponderEvent) => {\n if (!disabled) animateTo(1);\n onPressOut?.(e);\n };\n\n return (\n <AnimatedPressable\n {...props}\n disabled={disabled}\n onPressIn={handlePressIn}\n onPressOut={handlePressOut}\n style={[style, { transform: [{ scale }] }]}\n >\n {children}\n </AnimatedPressable>\n );\n }\n);\nPressableScale.displayName = 'PressableScale';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/screen.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/Screen.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { KeyboardAvoidingView, Platform, ScrollView, View, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useInsets } from '../theme/UIProvider';\n\nexport type ScreenEdge = 'top' | 'bottom';\n\nexport interface ScreenProps {\n children: React.ReactNode;\n style?: ViewStyle;\n scrollable?: boolean;\n contentContainerStyle?: ViewStyle;\n /** Qué insets de safe area aplicar. @default ['top', 'bottom'] */\n edges?: ScreenEdge[];\n /**\n * Compensa el teclado con un KeyboardAvoidingView. Ponelo en false cuando\n * el contenido ya trae su propio scroller que maneja el teclado (Wizard,\n * List, SearchList): las dos compensaciones se suman y el campo enfocado\n * termina empujado fuera de la pantalla.\n * @default true\n */\n keyboardAvoiding?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n wrapper: { flex: 1, backgroundColor: theme.colors.background },\n container: { flex: 1 },\n contentContainer: { flexGrow: 1 },\n}));\n\n/**\n * Screen\n * Contenedor raíz de pantalla: fondo del theme, safe area (via insets del\n * UIProvider), teclado y scroll opcional resueltos de forma consistente.\n */\nexport const Screen = memo(({\n children,\n style,\n scrollable = true,\n contentContainerStyle,\n edges = ['top', 'bottom'],\n keyboardAvoiding = true,\n}: ScreenProps) => {\n const insets = useInsets();\n const styles = useStyles();\n const applyTop = edges.includes('top');\n const applyBottom = edges.includes('bottom');\n\n const KeyboardWrapper: React.ComponentType<any> =
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { KeyboardAvoidingView, Platform, ScrollView, View, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useInsets } from '../theme/UIProvider';\n\nexport type ScreenEdge = 'top' | 'bottom';\n\nexport interface ScreenProps {\n children: React.ReactNode;\n style?: ViewStyle;\n scrollable?: boolean;\n contentContainerStyle?: ViewStyle;\n /** Qué insets de safe area aplicar. @default ['top', 'bottom'] */\n edges?: ScreenEdge[];\n /**\n * Compensa el teclado: en iOS con scroll lo hace el propio ScrollView, en el\n * resto de los casos con un KeyboardAvoidingView. Ponelo en false cuando\n * el contenido ya trae su propio scroller que maneja el teclado (Wizard,\n * List, SearchList): las dos compensaciones se suman y el campo enfocado\n * termina empujado fuera de la pantalla.\n * @default true\n */\n keyboardAvoiding?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n wrapper: { flex: 1, backgroundColor: theme.colors.background },\n container: { flex: 1 },\n contentContainer: { flexGrow: 1 },\n}));\n\n/**\n * Screen\n * Contenedor raíz de pantalla: fondo del theme, safe area (via insets del\n * UIProvider), teclado y scroll opcional resueltos de forma consistente.\n */\nexport const Screen = memo(({\n children,\n style,\n scrollable = true,\n contentContainerStyle,\n edges = ['top', 'bottom'],\n keyboardAvoiding = true,\n}: ScreenProps) => {\n const insets = useInsets();\n const styles = useStyles();\n const applyTop = edges.includes('top');\n const applyBottom = edges.includes('bottom');\n\n // En iOS el ScrollView de abajo ya sube el campo enfocado con\n // automaticallyAdjustKeyboardInsets; envolverlo además en un\n // KeyboardAvoidingView compensa el teclado dos veces y el input termina\n // empujado fuera del borde superior. Android no tiene ese prop, así que ahí\n // el KeyboardAvoidingView sigue siendo el único mecanismo.\n const avoidWithView = keyboardAvoiding && !(scrollable && Platform.OS === 'ios');\n\n const KeyboardWrapper: React.ComponentType<any> = avoidWithView ? KeyboardAvoidingView : View;\n const keyboardProps = avoidWithView\n ? {\n behavior: Platform.OS === 'ios' ? ('padding' as const) : ('height' as const),\n keyboardVerticalOffset: Platform.OS === 'ios' ? 0 : 20,\n }\n : {};\n\n return (\n <View style={[styles.wrapper, style]}>\n <KeyboardWrapper style={styles.container} {...keyboardProps}>\n {scrollable ? (\n <ScrollView\n style={styles.container}\n contentInsetAdjustmentBehavior=\"automatic\"\n automaticallyAdjustKeyboardInsets\n contentContainerStyle={[\n styles.contentContainer,\n { paddingTop: applyTop ? insets.top : 0 },\n Platform.OS === 'android' && applyBottom && { paddingBottom: insets.bottom },\n contentContainerStyle,\n ]}\n keyboardShouldPersistTaps=\"handled\"\n >\n {children}\n </ScrollView>\n ) : (\n <View style={[styles.container, { paddingTop: applyTop ? insets.top : 0, paddingBottom: applyBottom ? insets.bottom : 0 }]}>\n {children}\n </View>\n )}\n </KeyboardWrapper>\n </View>\n );\n});\nScreen.displayName = 'Screen';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/SearchList.tsx",
|
|
12
|
-
"content": "import React, {
|
|
12
|
+
"content": "import React, { useCallback } from 'react';\nimport {\n ActivityIndicator,\n FlatList,\n RefreshControl,\n Text,\n View,\n type FlatListProps,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { Input } from '../atoms/Input';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Skeleton } from '../atoms/Skeleton';\nimport { Spinner } from '../atoms/Spinner';\nimport { IconButton } from '../atoms/IconButton';\nimport type { IconName } from '../icons/types';\n\nexport interface SearchListProps<T> {\n /** Chrome opcional arriba del buscador (ej. back button + título). */\n header?: React.ReactNode;\n\n searchValue: string;\n onSearchChange: (text: string) => void;\n searchPlaceholder?: string;\n searchIsLoading?: boolean;\n onScanPress?: () => void;\n scanAccessibilityLabel?: string;\n extraActions?: React.ReactNode;\n\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n /** Carga inicial: mientras sea true y no haya datos, se muestran placeholders. */\n isLoading?: boolean;\n /** 'replace' cambia toda la lista por un spinner/skeleton; 'overlay' lo muestra encima manteniendo el layout. */\n loadingMode?: 'replace' | 'overlay';\n /** Recarga con datos ya en pantalla (pull-to-refresh). @default false */\n isRefreshing?: boolean;\n isLoadingMore?: boolean;\n /** Controlado por el consumidor: mientras es false, se muestra un spinner de pantalla completa. @default true */\n isReady?: boolean;\n loadingText?: string;\n loadingMoreText?: string;\n hasMore?: boolean;\n noMoreText?: string;\n\n /** Filas fantasma de la carga inicial. @default 6 */\n skeletonCount?: number;\n /** Placeholder propio, para que calce con la forma real del ítem. */\n renderSkeletonItem?: () => React.ReactElement;\n\n onLoadMore?: () => void;\n onEndReached?: () => void;\n onEndReachedThreshold?: number;\n onRefresh?: () => void;\n\n emptyTitle?: string;\n emptyDescription?: string;\n emptyIconName?: IconName;\n\n ListHeaderComponent?: FlatListProps<T>['ListHeaderComponent'];\n ListFooterComponent?: FlatListProps<T>['ListFooterComponent'];\n\n contentContainerStyle?: StyleProp<ViewStyle>;\n containerStyle?: ViewStyle;\n\n /** Componente de lista a usar (FlashList, etc). @default FlatList */\n ListComponent?: React.ComponentType<any>;\n listProps?: Record<string, unknown>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1, backgroundColor: theme.colors.background },\n searchRow: { flexDirection: 'row', alignItems: 'center', marginBottom: theme.tokens.spacing.lg - 1, gap: theme.tokens.spacing.sm + 2 },\n searchInput: { flex: 1, marginBottom: 0 },\n scanButton: { backgroundColor: theme.colors.primary, width: 50, height: 50, borderRadius: theme.tokens.radius.lg, ...theme.tokens.shadow.sm },\n listArea: { flex: 1 },\n listContent: { paddingBottom: theme.tokens.spacing.xl },\n loadingOverlay: {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n backgroundColor: theme.colors.scrim,\n zIndex: 10,\n justifyContent: 'center',\n alignItems: 'center',\n },\n footer: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center', gap: theme.tokens.spacing.md },\n footerText: { color: theme.colors.textLight, fontSize: theme.tokens.fontSize.md },\n skeletonRow: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n padding: theme.tokens.spacing.md,\n },\n skeletonText: { flex: 1, gap: theme.tokens.spacing.sm },\n}));\n\n/**\n * SearchList\n * Buscador + lista + estados de carga/paginación/vacío. El contenido de\n * cada ítem (renderItem) queda a cargo de quien la usa, ya que difiere por\n * completo entre casos de uso.\n */\nexport function SearchList<T>({\n header,\n searchValue,\n onSearchChange,\n searchPlaceholder,\n searchIsLoading = false,\n onScanPress,\n scanAccessibilityLabel = 'Scan',\n extraActions,\n data,\n keyExtractor,\n renderItem,\n isLoading = false,\n loadingMode = 'replace',\n isRefreshing = false,\n isLoadingMore = false,\n isReady = true,\n loadingText,\n loadingMoreText,\n hasMore = false,\n noMoreText,\n skeletonCount = 6,\n renderSkeletonItem,\n onLoadMore,\n onEndReached,\n onEndReachedThreshold = 0.3,\n onRefresh,\n emptyTitle,\n emptyDescription,\n emptyIconName,\n ListHeaderComponent,\n ListFooterComponent,\n contentContainerStyle,\n containerStyle,\n ListComponent = FlatList,\n listProps,\n}: SearchListProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const resolvedSearchPlaceholder = searchPlaceholder ?? labels.search;\n const resolvedLoadingText = loadingText ?? labels.loading;\n const resolvedLoadingMoreText = loadingMoreText ?? labels.loadingMore;\n\n const handleEndReached = onLoadMore ?? onEndReached;\n\n // El footer propio y el loader de paginación conviven: antes el primero\n // tapaba al segundo y la lista dejaba de avisar que estaba trayendo más.\n const renderFooter = useCallback(() => {\n const custom =\n typeof ListFooterComponent === 'function' ? (\n <ListFooterComponent />\n ) : (\n (ListFooterComponent as React.ReactElement | undefined) ?? null\n );\n\n return (\n <>\n {custom}\n {isLoadingMore && (\n <View style={styles.footer}>\n <Spinner text={resolvedLoadingMoreText} fullscreen={false} />\n </View>\n )}\n {noMoreText && !hasMore && data.length > 0 && (\n <View style={styles.footer}>\n <Text style={styles.footerText}>{noMoreText}</Text>\n </View>\n )}\n </>\n );\n }, [ListFooterComponent, isLoadingMore, resolvedLoadingMoreText, noMoreText, hasMore, data.length, styles.footer, styles.footerText]);\n\n // Vacío sólo cuando ya se sabe que no hay nada: durante la carga inicial se\n // muestran placeholders, no \"sin resultados\".\n const renderEmpty = useCallback(() => {\n if (isLoading || isRefreshing) return null;\n return <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} style={{ marginTop: 50 }} />;\n }, [isLoading, isRefreshing, emptyTitle, emptyDescription, emptyIconName]);\n\n if (!isReady) {\n return (\n <View style={[styles.container, containerStyle]}>\n <Spinner text={resolvedLoadingText} />\n </View>\n );\n }\n\n const isInitialLoading = isLoading && loadingMode === 'replace' && data.length === 0;\n const showOverlayLoading = isLoading && loadingMode === 'overlay' && data.length === 0;\n\n return (\n <View style={[styles.container, containerStyle]}>\n {header}\n\n <View style={styles.searchRow}>\n <Input\n placeholder={resolvedSearchPlaceholder}\n leftIconName=\"search\"\n value={searchValue}\n onChangeText={onSearchChange}\n rightIconName={searchValue.length > 0 ? 'close' : undefined}\n onRightIconPress={() => onSearchChange('')}\n isLoading={searchIsLoading}\n containerStyle={styles.searchInput}\n />\n {onScanPress && (\n <IconButton\n iconName=\"search\"\n onPress={onScanPress}\n color={colors.onPrimary}\n style={styles.scanButton}\n accessibilityLabel={scanAccessibilityLabel}\n />\n )}\n {extraActions}\n </View>\n\n <View style={styles.listArea}>\n {showOverlayLoading && (\n <View style={styles.loadingOverlay}>\n <ActivityIndicator size=\"large\" color={colors.primary} />\n <Text style={styles.footerText}>{resolvedLoadingText}</Text>\n </View>\n )}\n\n {isInitialLoading ? (\n <View testID=\"list-skeleton\" style={[styles.listContent, contentContainerStyle]}>\n {Array.from({ length: skeletonCount }, (_, i) => (\n <React.Fragment key={i}>\n {renderSkeletonItem?.() ?? (\n <View style={styles.skeletonRow}>\n <Skeleton variant=\"circle\" width={40} />\n <View style={styles.skeletonText}>\n <Skeleton width=\"60%\" height={14} />\n <Skeleton width=\"35%\" height={12} />\n </View>\n </View>\n )}\n </React.Fragment>\n ))}\n </View>\n ) : (\n <ListComponent\n data={data}\n keyExtractor={keyExtractor}\n keyboardDismissMode=\"on-drag\"\n contentContainerStyle={[styles.listContent, contentContainerStyle]}\n onEndReached={handleEndReached}\n onEndReachedThreshold={onEndReachedThreshold}\n ListHeaderComponent={ListHeaderComponent}\n ListFooterComponent={renderFooter}\n ListEmptyComponent={renderEmpty}\n renderItem={renderItem}\n refreshControl={\n onRefresh ? (\n <RefreshControl\n refreshing={isRefreshing}\n onRefresh={onRefresh}\n colors={[colors.primary]}\n tintColor={colors.primary}\n />\n ) : undefined\n }\n {...listProps}\n />\n )}\n </View>\n </View>\n );\n}\n\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
16
16
|
"core",
|
|
17
17
|
"input",
|
|
18
18
|
"empty-state",
|
|
19
|
+
"skeleton",
|
|
19
20
|
"spinner",
|
|
20
21
|
"icon-button",
|
|
21
22
|
"pressable-scale"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "SegmentedControl",
|
|
3
|
+
"slug": "segmented-control",
|
|
4
|
+
"type": "component",
|
|
5
|
+
"category": "molecules",
|
|
6
|
+
"exportedNames": [
|
|
7
|
+
"SegmentedControl"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "molecules/SegmentedControl.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 } from '../theme/UIProvider';\n\nexport interface SegmentedControlOption<T extends string = string> {\n value: T;\n label: string;\n disabled?: boolean;\n}\n\nexport interface SegmentedControlProps<T extends string = string> {\n options: SegmentedControlOption<T>[];\n value: T;\n onChange: (value: T) => void;\n disabled?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\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 segmentDisabled: { opacity: 0.5 },\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 * SegmentedControl\n * Elige una opción entre pocas, todas visibles. No conoce ningún estado\n * global: recibe `value` y avisa por `onChange`.\n */\nfunction SegmentedControlInner<T extends string>({\n options,\n value,\n onChange,\n disabled = false,\n style,\n testID,\n}: SegmentedControlProps<T>) {\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n\n return (\n <View style={[styles.track, style]} accessibilityRole=\"radiogroup\" testID={testID}>\n {options.map((option) => {\n const active = option.value === value;\n const isDisabled = disabled || !!option.disabled;\n return (\n <TouchableOpacity\n key={option.value}\n style={[styles.segment, active && styles.segmentActive, isDisabled && styles.segmentDisabled]}\n onPress={() => onChange(option.value)}\n disabled={isDisabled}\n accessibilityRole=\"radio\"\n accessibilityState={{ selected: active, checked: active, disabled: isDisabled }}\n accessibilityLabel={option.label}\n testID={testID && `${testID}-${option.value}`}\n >\n <Text allowFontScaling={allowFontScaling} style={[styles.label, active && styles.labelActive]}>\n {option.label}\n </Text>\n </TouchableOpacity>\n );\n })}\n </View>\n );\n}\n\n// memo() pierde los genéricos al envolver: el cast los devuelve para que\n// `value` y `onChange` sigan atados al union de `options`.\nexport const SegmentedControl = memo(SegmentedControlInner) as typeof SegmentedControlInner;\n"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"core"
|
|
17
|
+
]
|
|
18
|
+
}
|
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.error },\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.primary + '15' },\n optionText: { fontSize: theme.tokens.fontSize.lg - 1, color: theme.colors.text },\n optionTextSelected: { fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.primary },\n errorText: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.error },\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"
|
|
12
|
+
"content": "import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport {\n ActivityIndicator,\n Keyboard,\n Modal,\n Platform,\n Pressable,\n ScrollView,\n Text,\n TextInput,\n View,\n useWindowDimensions,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useLabels } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface SelectOption<T> {\n label: string;\n value: T;\n /** Glifo del set de la librería, a la izquierda del label. */\n iconName?: IconName;\n /** Emoji a la izquierda del label. Gana sobre `iconName`. */\n emoji?: string;\n disabled?: boolean;\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 disabled?: boolean;\n /** Agrega un buscador dentro del desplegable. @default false */\n searchable?: boolean;\n searchPlaceholder?: string;\n /** Alto máximo del desplegable, si el espacio disponible lo permite. @default 280 */\n maxHeight?: number;\n onOpenChange?: (open: boolean) => void;\n containerStyle?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\n/** Aire entre el campo y el desplegable. */\nconst GAP = 8;\n/** Por debajo de esto el desplegable no muestra ni dos filas. */\nconst MIN_HEIGHT = 120;\n\nconst useStyles = createStyles((theme) => ({\n container: { gap: theme.tokens.spacing.sm },\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 fieldOpen: { borderColor: theme.colors.primary },\n fieldDisabled: { opacity: 0.5 },\n fieldValue: { flexDirection: 'row', alignItems: 'center', gap: theme.tokens.spacing.sm, flexShrink: 1 },\n fieldText: { fontSize: theme.tokens.fontSize.lg, color: theme.colors.text, flexShrink: 1 },\n fieldPlaceholder: { color: theme.colors.textLight },\n backdrop: { flex: 1 },\n sheet: {\n position: 'absolute',\n borderWidth: 2,\n borderColor: theme.colors.border,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.surface,\n overflow: 'hidden',\n ...theme.tokens.shadow.md,\n },\n search: {\n fontSize: theme.tokens.fontSize.md,\n color: theme.colors.text,\n paddingHorizontal: theme.tokens.spacing.lg,\n paddingVertical: theme.tokens.spacing.md,\n borderBottomWidth: 1,\n borderBottomColor: theme.colors.border,\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 optionRowDisabled: { opacity: 0.4 },\n optionLabel: { flexDirection: 'row', alignItems: 'center', gap: theme.tokens.spacing.sm, flexShrink: 1 },\n optionText: { fontSize: theme.tokens.fontSize.lg - 1, color: theme.colors.text },\n optionTextSelected: { fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.primaryText },\n emoji: { fontSize: theme.tokens.fontSize.lg },\n empty: {\n padding: theme.tokens.spacing.lg,\n textAlign: 'center',\n color: theme.colors.textLight,\n fontSize: theme.tokens.fontSize.md,\n },\n errorText: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.errorText },\n}));\n\ninterface Anchor {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\n/**\n * Select\n * Dropdown de una sola opción. La lista se mide contra la ventana y se dibuja\n * dentro de un Modal, no en el árbol del campo: así ningún `overflow: hidden`\n * la recorta, no depende del zIndex (poco confiable en Android) y el fondo del\n * Modal resuelve cerrar tocando afuera y con el botón atrás.\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 disabled = false,\n searchable = false,\n searchPlaceholder,\n maxHeight = 280,\n onOpenChange,\n containerStyle,\n testID,\n}: SelectProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const allowFontScaling = useAllowFontScaling();\n const { height: windowHeight } = useWindowDimensions();\n\n const fieldRef = useRef<View>(null);\n const [open, setOpen] = useState(false);\n const [anchor, setAnchor] = useState<Anchor | null>(null);\n const [query, setQuery] = useState('');\n const [keyboardHeight, setKeyboardHeight] = useState(0);\n const isDisabled = disabled || isLoading;\n const selectedOption = options.find((o) => o.value === selectedValue);\n\n const close = useCallback(() => {\n setOpen(false);\n setAnchor(null);\n setQuery('');\n onOpenChange?.(false);\n }, [onOpenChange]);\n\n // Abrir no espera a la medición: si `measureInWindow` no responde, la lista\n // igual aparece (centrada) en vez de que el campo deje de funcionar.\n const openList = () => {\n fieldRef.current?.measureInWindow((x, y, width, height) => setAnchor({ x, y, width, height }));\n setOpen(true);\n onOpenChange?.(true);\n };\n\n // El buscador abre el teclado encima del desplegable: sin descontarlo, la\n // lista se dibuja donde ya no se ve.\n useEffect(() => {\n if (!open) return;\n setKeyboardHeight(Keyboard.metrics()?.height ?? 0);\n const [show, hide] =\n Platform.OS === 'ios' ? ['keyboardWillShow', 'keyboardWillHide'] : ['keyboardDidShow', 'keyboardDidHide'];\n const subs = [\n Keyboard.addListener(show as 'keyboardDidShow', (e) => setKeyboardHeight(e.endCoordinates.height)),\n Keyboard.addListener(hide as 'keyboardDidHide', () => setKeyboardHeight(0)),\n ];\n return () => subs.forEach((sub) => sub.remove());\n }, [open]);\n\n const visibleOptions = useMemo(() => {\n if (!searchable || query.trim() === '') return options;\n const needle = query.trim().toLowerCase();\n return options.filter((option) => option.label.toLowerCase().includes(needle));\n }, [options, query, searchable]);\n\n // Se abre hacia donde haya más aire: contra el borde inferior de la pantalla,\n // hacia arriba es la única forma de que entre.\n const placement = useMemo((): ViewStyle => {\n if (!anchor) return { left: GAP * 2, right: GAP * 2, top: windowHeight * 0.25, maxHeight };\n const usableHeight = windowHeight - keyboardHeight;\n const below = usableHeight - (anchor.y + anchor.height) - GAP * 2;\n const above = anchor.y - GAP * 2;\n const downwards = below >= Math.min(maxHeight, above);\n return {\n left: anchor.x,\n width: anchor.width,\n maxHeight: Math.max(Math.min(maxHeight, downwards ? below : above), MIN_HEIGHT),\n ...(downwards ? { top: anchor.y + anchor.height + GAP } : { bottom: windowHeight - anchor.y + GAP }),\n };\n }, [anchor, keyboardHeight, maxHeight, windowHeight]);\n\n const renderLeading = (option: SelectOption<T> | undefined, color: string) => {\n if (!option) return null;\n if (option.emoji) {\n return (\n <Text allowFontScaling={allowFontScaling} style={styles.emoji}>\n {option.emoji}\n </Text>\n );\n }\n return option.iconName ? <Icon name={option.iconName} size={20} color={color} /> : null;\n };\n\n return (\n <View style={[styles.container, containerStyle]} testID={testID}>\n {label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n {required && <Text style={styles.required}> *</Text>}\n </Text>\n )}\n\n <Pressable\n ref={fieldRef}\n style={[styles.field, open && styles.fieldOpen, isDisabled && styles.fieldDisabled]}\n onPress={openList}\n disabled={isDisabled}\n accessibilityRole=\"combobox\"\n accessibilityLabel={label ?? placeholder ?? labels.selectPlaceholder}\n accessibilityValue={{ text: selectedOption?.label ?? '' }}\n accessibilityState={{ expanded: open, disabled: isDisabled }}\n >\n <View style={styles.fieldValue}>\n {renderLeading(selectedOption, colors.textLight)}\n <Text\n allowFontScaling={allowFontScaling}\n numberOfLines={1}\n style={[styles.fieldText, !selectedOption && styles.fieldPlaceholder]}\n >\n {selectedOption?.label || placeholder || labels.selectPlaceholder}\n </Text>\n </View>\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 && (\n <Modal visible transparent animationType=\"fade\" statusBarTranslucent onRequestClose={close}>\n <Pressable style={styles.backdrop} onPress={close} testID=\"select-backdrop\" />\n\n <View style={[styles.sheet, placement]} accessibilityRole=\"radiogroup\" testID=\"select-list\">\n {searchable && (\n <TextInput\n allowFontScaling={allowFontScaling}\n style={styles.search}\n value={query}\n onChangeText={setQuery}\n placeholder={searchPlaceholder ?? labels.search}\n placeholderTextColor={colors.textLight}\n autoFocus\n accessibilityLabel={searchPlaceholder ?? labels.search}\n />\n )}\n\n <ScrollView keyboardShouldPersistTaps=\"handled\" showsVerticalScrollIndicator={false} bounces={false}>\n {visibleOptions.map((option, index) => {\n const isSelected = option.value === selectedValue;\n return (\n <Pressable\n key={String(option.value)}\n style={[\n styles.optionRow,\n index === visibleOptions.length - 1 && styles.optionRowLast,\n isSelected && styles.optionRowSelected,\n option.disabled && styles.optionRowDisabled,\n ]}\n onPress={() => {\n onSelect(option.value);\n close();\n }}\n disabled={option.disabled}\n accessibilityRole=\"radio\"\n accessibilityLabel={option.label}\n accessibilityState={{ selected: isSelected, checked: isSelected, disabled: !!option.disabled }}\n >\n <View style={styles.optionLabel}>\n {renderLeading(option, isSelected ? colors.primary : colors.textLight)}\n <Text\n allowFontScaling={allowFontScaling}\n numberOfLines={1}\n style={[styles.optionText, isSelected && styles.optionTextSelected]}\n >\n {option.label}\n </Text>\n </View>\n {isSelected && <Icon name=\"check\" size={18} color={colors.primary} />}\n </Pressable>\n );\n })}\n\n {visibleOptions.length === 0 && (\n <Text allowFontScaling={allowFontScaling} style={styles.empty}>\n {labels.noResultsTitle}\n </Text>\n )}\n </ScrollView>\n </View>\n </Modal>\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": [
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Skeleton",
|
|
3
|
+
"slug": "skeleton",
|
|
4
|
+
"type": "component",
|
|
5
|
+
"category": "atoms",
|
|
6
|
+
"exportedNames": [
|
|
7
|
+
"Skeleton"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "atoms/Skeleton.tsx",
|
|
12
|
+
"content": "import React, { memo, useEffect, useRef } from 'react';\nimport {\n Animated,\n Easing,\n View,\n type DimensionValue,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useTheme } from '../theme/UIProvider';\n\nexport type SkeletonVariant = 'rect' | 'text' | 'circle';\n\nexport interface SkeletonProps {\n /** @default '100%' ('circle' lo usa también como diámetro) */\n width?: DimensionValue;\n /** @default 16 ('text' usa el alto de línea; 'circle', el ancho) */\n height?: DimensionValue;\n /** @default 'rect' */\n variant?: SkeletonVariant;\n /** Override del radius del theme. */\n radius?: number;\n /** Líneas apiladas, sólo para variant=\"text\". @default 1 */\n lines?: number;\n /** Ancho de la última línea, para que el párrafo no termine en bloque. @default '60%' */\n lastLineWidth?: DimensionValue;\n /** @default true */\n animated?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n block: { backgroundColor: theme.colors.skeleton },\n lines: { gap: theme.tokens.spacing.sm },\n}));\n\n/**\n * Skeleton\n * Placeholder que late mientras llega el contenido real. Reserva el espacio\n * exacto de lo que reemplaza, así la pantalla no salta cuando aparecen los\n * datos — que es justamente lo que un Spinner centrado no puede hacer.\n */\nexport const Skeleton = memo(\n ({\n width = '100%',\n height = 16,\n variant = 'rect',\n radius,\n lines = 1,\n lastLineWidth = '60%',\n animated = true,\n style,\n testID,\n }: SkeletonProps) => {\n const theme = useTheme();\n const styles = useStyles();\n const pulse = useRef(new Animated.Value(0)).current;\n\n useEffect(() => {\n if (!animated) return;\n const loop = Animated.loop(\n Animated.sequence([\n Animated.timing(pulse, {\n toValue: 1,\n duration: 700,\n useNativeDriver: true,\n easing: Easing.inOut(Easing.quad),\n }),\n Animated.timing(pulse, {\n toValue: 0,\n duration: 700,\n useNativeDriver: true,\n easing: Easing.inOut(Easing.quad),\n }),\n ])\n );\n loop.start();\n return () => loop.stop();\n }, [animated, pulse]);\n\n const isCircle = variant === 'circle';\n const isText = variant === 'text';\n const opacity = animated ? pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 0.4] }) : 1;\n const resolvedHeight = isCircle ? width : isText ? theme.tokens.fontSize.md : height;\n const resolvedRadius =\n radius ?? (isCircle ? theme.tokens.radius.full : isText ? theme.tokens.radius.xs : theme.tokens.radius.sm);\n\n const barStyle = (barWidth: DimensionValue): StyleProp<ViewStyle> => [\n styles.block,\n { width: barWidth, height: resolvedHeight, borderRadius: resolvedRadius, opacity },\n ];\n\n const a11y = { accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants' as const };\n\n if (isText && lines > 1) {\n return (\n <View style={[styles.lines, style]} testID={testID} {...a11y}>\n {Array.from({ length: lines }, (_, i) => (\n <Animated.View\n key={i}\n testID={testID && `${testID}-${i}`}\n style={barStyle(i === lines - 1 ? lastLineWidth : width)}\n />\n ))}\n </View>\n );\n }\n\n return <Animated.View style={[barStyle(width), style]} testID={testID} {...a11y} />;\n }\n);\nSkeleton.displayName = 'Skeleton';\n"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"core"
|
|
17
|
+
]
|
|
18
|
+
}
|
package/registry/stepper.json
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
"type": "component",
|
|
5
5
|
"category": "molecules",
|
|
6
6
|
"exportedNames": [
|
|
7
|
-
"Stepper"
|
|
7
|
+
"Stepper",
|
|
8
|
+
"sanitizeNumeric"
|
|
8
9
|
],
|
|
9
10
|
"files": [
|
|
10
11
|
{
|
|
11
12
|
"path": "molecules/Stepper.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { TextInput, 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 StepperProps {\n value: string;\n onChangeText: (text: string) => void;\n onIncrement: () => void;\n onDecrement: () => void;\n
|
|
13
|
+
"content": "import React, { memo, useState } from 'react';\nimport { TextInput, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useTheme } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\n\nexport type StepperSize = 'sm' | 'md' | 'lg';\n\nexport interface StepperProps {\n value: string;\n onChangeText: (text: string) => void;\n onIncrement: () => void;\n onDecrement: () => void;\n /** Escala del control: alto, botones, ícono y tipografía. @default 'md' */\n size?: StepperSize;\n /** Ocupa todo el ancho disponible y el input se lleva el sobrante. @default false */\n block?: boolean;\n /**\n * En false el valor sólo se cambia con los botones: el teclado no se abre y\n * el campo pierde el fondo que lo anuncia como editable. @default true\n */\n editable?: boolean;\n /**\n * Mínimo permitido. Deshabilita \"-\" al alcanzarlo y acota lo tipeado al\n * salir del campo. Los negativos se aceptan sólo si `min` es negativo.\n */\n min?: number;\n /** Máximo permitido. Deshabilita \"+\" al alcanzarlo y acota lo tipeado. */\n max?: number;\n /** Acepta un separador decimal, normalizando la coma a punto. @default false */\n allowDecimals?: boolean;\n style?: StyleProp<ViewStyle>;\n incrementAccessibilityLabel?: string;\n decrementAccessibilityLabel?: string;\n testID?: string;\n}\n\nconst SIZES: Record<StepperSize, { height: number; icon: number; inputWidth: number }> = {\n sm: { height: 36, icon: 18, inputWidth: 48 },\n md: { height: 44, icon: 20, inputWidth: 56 },\n lg: { height: 52, icon: 22, inputWidth: 64 },\n};\n\nconst useStyles = createStyles((theme) => ({\n control: {\n flexDirection: 'row',\n alignItems: 'center',\n alignSelf: 'flex-start',\n borderWidth: 1,\n borderColor: theme.colors.border,\n borderRadius: theme.tokens.radius.md,\n },\n controlBlock: { alignSelf: 'stretch' },\n btn: { height: '100%', justifyContent: 'center', alignItems: 'center' },\n input: {\n textAlign: 'center',\n height: '100%',\n color: theme.colors.text,\n borderLeftWidth: 1,\n borderRightWidth: 1,\n borderColor: theme.colors.border,\n },\n // El fondo distingue el campo de un valor de sólo lectura: es la única\n // señal de que ahí se puede tipear antes de tocarlo.\n editable: { backgroundColor: theme.colors.inputBackground },\n}));\n\n/** Deja sólo lo que puede formar parte del número permitido por el rango. */\nexport function sanitizeNumeric(text: string, { allowDecimals = false, allowNegative = false } = {}): string {\n const normalized = allowDecimals ? text.replace(',', '.') : text;\n const negative = allowNegative && normalized.trimStart().startsWith('-');\n const digits = normalized.replace(allowDecimals ? /[^0-9.]/g : /[^0-9]/g, '');\n const [whole = '', ...decimals] = digits.split('.');\n const body = decimals.length > 0 ? `${whole}.${decimals.join('')}` : whole;\n return negative ? `-${body}` : body;\n}\n\n/**\n * Stepper\n * Botón \"-\" + input numérico editable + botón \"+\". `size` controla sólo la\n * escala y `block` sólo el ancho, así que los dos ejes se combinan libremente.\n *\n * El componente no hace la aritmética de los botones —eso vive en\n * `onIncrement`/`onDecrement`—, pero sí limpia lo que se tipea y avisa cuándo\n * el valor llegó a un extremo del rango deshabilitando el botón que lo pasaría.\n */\nexport const Stepper = memo(\n ({\n value,\n onChangeText,\n onIncrement,\n onDecrement,\n size = 'md',\n block = false,\n editable = true,\n min,\n max,\n allowDecimals = false,\n style,\n incrementAccessibilityLabel = 'Increment',\n decrementAccessibilityLabel = 'Decrement',\n testID,\n }: StepperProps) => {\n const theme = useTheme();\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const [focused, setFocused] = useState(false);\n\n const { height, icon, inputWidth } = SIZES[size];\n const fontSize =\n size === 'sm' ? theme.tokens.fontSize.md : size === 'md' ? theme.tokens.fontSize.lg : theme.tokens.fontSize.xl;\n\n const allowNegative = min !== undefined && min < 0;\n const numeric = Number(value);\n const isNumber = value.trim() !== '' && Number.isFinite(numeric);\n const atMin = isNumber && min !== undefined && numeric <= min;\n const atMax = isNumber && max !== undefined && numeric >= max;\n\n const handleChangeText = (text: string) => {\n onChangeText(sanitizeNumeric(text, { allowDecimals, allowNegative }));\n };\n\n // El recorte al rango espera al blur: hacerlo mientras se tipea impide\n // llegar a \"12\" cuando el mínimo es 5, porque el \"1\" ya se corregiría.\n const handleBlur = () => {\n setFocused(false);\n if (!isNumber) return;\n const clamped = Math.min(max ?? numeric, Math.max(min ?? numeric, numeric));\n if (clamped !== numeric) onChangeText(String(clamped));\n };\n\n const stepButton = (\n direction: 'decrement' | 'increment',\n onPress: () => void,\n disabled: boolean,\n label: string\n ) => (\n <TouchableOpacity\n onPress={onPress}\n disabled={disabled}\n style={[styles.btn, { width: height }]}\n accessibilityRole=\"button\"\n accessibilityLabel={label}\n accessibilityState={{ disabled }}\n testID={testID && `${testID}-${direction}`}\n >\n <Icon\n name={direction === 'increment' ? 'plus' : 'minus'}\n size={icon}\n color={disabled ? colors.textLight : colors.primary}\n />\n </TouchableOpacity>\n );\n\n return (\n <View\n testID={testID}\n style={[\n styles.control,\n { height },\n block && styles.controlBlock,\n focused && { borderColor: colors.primary },\n style,\n ]}\n >\n {stepButton('decrement', onDecrement, atMin, decrementAccessibilityLabel)}\n <TextInput\n allowFontScaling={allowFontScaling}\n style={[\n styles.input,\n { fontSize },\n editable && styles.editable,\n focused && { borderColor: colors.primary },\n block ? { flex: 1 } : { width: inputWidth },\n ]}\n value={value}\n onChangeText={handleChangeText}\n onFocus={() => setFocused(true)}\n onBlur={handleBlur}\n editable={editable}\n keyboardType={allowDecimals ? 'decimal-pad' : 'number-pad'}\n selectTextOnFocus\n accessibilityLabel=\"Quantity\"\n />\n {stepButton('increment', onIncrement, atMax, incrementAccessibilityLabel)}\n </View>\n );\n }\n);\nStepper.displayName = 'Stepper';\n"
|
|
13
14
|
}
|
|
14
15
|
],
|
|
15
16
|
"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": [
|
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
"name": "ThemeToggle",
|
|
3
3
|
"slug": "theme-toggle",
|
|
4
4
|
"type": "component",
|
|
5
|
-
"category": "
|
|
5
|
+
"category": "organisms",
|
|
6
6
|
"exportedNames": [
|
|
7
7
|
"ThemeToggle"
|
|
8
8
|
],
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
|
-
"path": "
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport {
|
|
11
|
+
"path": "organisms/ThemeToggle.tsx",
|
|
12
|
+
"content": "import React, { memo, useMemo } from 'react';\nimport { type StyleProp, type ViewStyle } from 'react-native';\nimport { useThemeMode, type ThemeMode } from '../theme/UIProvider';\nimport { SegmentedControl } from '../molecules/SegmentedControl';\n\nexport interface ThemeToggleProps {\n style?: StyleProp<ViewStyle>;\n labels?: Partial<Record<ThemeMode, string>>;\n testID?: string;\n}\n\nconst MODES: ThemeMode[] = ['system', 'light', 'dark'];\nconst DEFAULT_LABELS: Record<ThemeMode, string> = { system: 'Auto', light: 'Light', dark: 'Dark' };\n\n/**\n * ThemeToggle\n * Conecta un SegmentedControl con UIProvider.setMode. Sin persistencia — el\n * consumidor la resuelve vía onModeChange en <UIProvider>.\n */\nexport const ThemeToggle = memo(({ style, labels: labelOverrides, testID }: ThemeToggleProps) => {\n const { mode, setMode } = useThemeMode();\n\n const options = useMemo(() => {\n const labels = { ...DEFAULT_LABELS, ...labelOverrides };\n return MODES.map((value) => ({ value, label: labels[value] }));\n }, [labelOverrides]);\n\n return <SegmentedControl options={options} value={mode} onChange={setMode} style={style} testID={testID} />;\n});\nThemeToggle.displayName = 'ThemeToggle';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
16
|
-
"core"
|
|
16
|
+
"core",
|
|
17
|
+
"segmented-control"
|
|
17
18
|
]
|
|
18
19
|
}
|
|
@@ -5,16 +5,24 @@
|
|
|
5
5
|
"category": "organisms",
|
|
6
6
|
"exportedNames": [
|
|
7
7
|
"ToastProvider",
|
|
8
|
-
"useToast"
|
|
8
|
+
"useToast",
|
|
9
|
+
"showToast",
|
|
10
|
+
"hideToast",
|
|
11
|
+
"hideAllToasts"
|
|
9
12
|
],
|
|
10
13
|
"files": [
|
|
11
14
|
{
|
|
12
15
|
"path": "organisms/ToastProvider.tsx",
|
|
13
|
-
"content": "import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport { View } from 'react-native';\nimport { AnimatedToast, Toast, type ToastVariant } from './Toast';\nimport { useInsets } from '../theme/UIProvider';\nimport { createStyles } from '../theme/createStyles';\n\nexport interface ToastOptions {\n title: string;\n message?: string;\n variant?: ToastVariant;\n /** ms antes de auto-descartar. 0 = no se descarta solo. @default 4000 */\n duration?: number;\n onPress?: () => void;\n hideCloseButton?: boolean;\n}\n\ninterface ToastEntry extends ToastOptions {\n id: string;\n}\n\ninterface ToastContextValue {\n /** Muestra un toast y devuelve su id (por si querés cerrarlo antes de tiempo). */\n show: (options: ToastOptions) => string;\n hide: (id: string) => void;\n hideAll: () => void;\n}\n\nconst ToastContext = createContext<ToastContextValue | undefined>(undefined);\n\nconst useStyles = createStyles((theme) => ({\n host: { position: 'absolute', left: 0, right: 0, paddingHorizontal: theme.tokens.spacing.lg, gap: theme.tokens.spacing.sm },\n}));\n\nexport interface ToastProviderProps {\n children: React.ReactNode;\n /** @default 'top' */\n position?: 'top' | 'bottom';\n /** Máximo de toasts simultáneos; los más viejos se descartan. @default 3 */\n maxVisible?: number;\n /** Duración por defecto en ms. @default 4000 */\n defaultDuration?: number;\n}\n\n/**\n * ToastProvider\n * Monta la capa de toasts sobre el resto de la app. Colocar una vez cerca de\n * la raíz, dentro de UIProvider. Los timers de auto-dismiss se limpian al\n * desmontar y al cerrar cada toast, así que no quedan callbacks colgados.\n */\nexport function ToastProvider({\n children,\n position = 'top',\n maxVisible = 3,\n defaultDuration = 4000,\n}: ToastProviderProps) {\n const [toasts, setToasts] = useState<ToastEntry[]>([]);\n const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());\n const counter = useRef(0);\n const insets = useInsets();\n const styles = useStyles();\n\n const clearTimer = useCallback((id: string) => {\n const t = timers.current.get(id);\n if (t) {\n clearTimeout(t);\n timers.current.delete(id);\n }\n }, []);\n\n const hide = useCallback(\n (id: string) => {\n clearTimer(id);\n setToasts((prev) => prev.filter((t) => t.id !== id));\n },\n [clearTimer]\n );\n\n const hideAll = useCallback(() => {\n timers.current.forEach((t) => clearTimeout(t));\n timers.current.clear();\n setToasts([]);\n }, []);\n\n const show = useCallback(\n (options: ToastOptions) => {\n counter.current += 1;\n const id = `toast-${counter.current}`;\n const duration = options.duration ?? defaultDuration;\n\n setToasts((prev) => {\n const next = [...prev, { ...options, id }];\n // Se descartan los más viejos, no los recién llegados: un toast nuevo\n // siempre se ve.\n const overflow = next.length - maxVisible;\n if (overflow > 0) {\n next.slice(0, overflow).forEach((t) => clearTimer(t.id));\n return next.slice(overflow);\n }\n return next;\n });\n\n if (duration > 0) {\n timers.current.set(\n id,\n setTimeout(() => hide(id), duration)\n );\n }\n return id;\n },\n [defaultDuration, maxVisible, hide, clearTimer]\n );\n\n // Al desmontar el provider no debe quedar ningún setTimeout apuntando a un\n // setState de un árbol ya desmontado.\n useEffect(() => {\n const pending = timers.current;\n return () => {\n pending.forEach((t) => clearTimeout(t));\n pending.clear();\n };\n }, []);\n\n const value = useMemo<ToastContextValue>(() => ({ show, hide, hideAll }), [show, hide, hideAll]);\n\n const hostPosition =\n position === 'top'\n ? { top: insets.top + 8 }\n : { bottom: insets.bottom + 8, flexDirection: 'column-reverse' as const };\n\n return (\n <ToastContext.Provider value={value}>\n {children}\n {toasts.length > 0 && (\n <View style={[styles.host, hostPosition]} pointerEvents=\"box-none\" testID=\"toast-host\">\n {toasts.map((t) => (\n <AnimatedToast key={t.id}>\n <Toast\n title={t.title}\n message={t.message}\n variant={t.variant}\n hideCloseButton={t.hideCloseButton}\n onDismiss={() => hide(t.id)}\n onPress={\n t.onPress\n ? () => {\n t.onPress?.();\n hide(t.id);\n }\n : undefined\n }\n />\n </AnimatedToast>\n ))}\n </View>\n )}\n </ToastContext.Provider>\n );\n}\n\nexport function useToast(): ToastContextValue {\n const ctx = useContext(ToastContext);\n if (!ctx) {\n throw new Error('orn-ui: useToast must be used within a <ToastProvider>.');\n }\n return ctx;\n}\n"
|
|
16
|
+
"content": "import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport { View } from 'react-native';\nimport { AnimatedToast, Toast, type ToastVariant } from './Toast';\nimport { useInsets } from '../theme/UIProvider';\nimport { createStyles } from '../theme/createStyles';\nimport { createImperativeBridge } from './imperativeBridge';\n\nexport interface ToastOptions {\n title: string;\n message?: string;\n variant?: ToastVariant;\n /** ms antes de auto-descartar. 0 = no se descarta solo. @default 4000 */\n duration?: number;\n onPress?: () => void;\n hideCloseButton?: boolean;\n}\n\ninterface ToastEntry extends ToastOptions {\n id: string;\n}\n\ninterface ToastContextValue {\n /** Muestra un toast y devuelve su id (por si querés cerrarlo antes de tiempo). */\n show: (options: ToastOptions) => string;\n hide: (id: string) => void;\n hideAll: () => void;\n}\n\nconst ToastContext = createContext<ToastContextValue | undefined>(undefined);\nconst bridge = createImperativeBridge<ToastContextValue>('ToastProvider');\n\n/**\n * Muestra un toast desde fuera del árbol de React —un servicio, un\n * interceptor— sin pasar por el hook. Devuelve el id, o '' si no hay\n * <ToastProvider> montado.\n */\nexport function showToast(options: ToastOptions): string {\n return bridge.get()?.show(options) ?? '';\n}\n\nexport function hideToast(id: string): void {\n bridge.get()?.hide(id);\n}\n\nexport function hideAllToasts(): void {\n bridge.get()?.hideAll();\n}\n\nconst useStyles = createStyles((theme) => ({\n host: { position: 'absolute', left: 0, right: 0, paddingHorizontal: theme.tokens.spacing.lg, gap: theme.tokens.spacing.sm },\n}));\n\nexport interface ToastProviderProps {\n children: React.ReactNode;\n /** @default 'top' */\n position?: 'top' | 'bottom';\n /** Máximo de toasts simultáneos; los más viejos se descartan. @default 3 */\n maxVisible?: number;\n /** Duración por defecto en ms. @default 4000 */\n defaultDuration?: number;\n}\n\n/**\n * ToastProvider\n * Monta la capa de toasts sobre el resto de la app. Colocar una vez cerca de\n * la raíz, dentro de UIProvider. Los timers de auto-dismiss se limpian al\n * desmontar y al cerrar cada toast, así que no quedan callbacks colgados.\n */\nexport function ToastProvider({\n children,\n position = 'top',\n maxVisible = 3,\n defaultDuration = 4000,\n}: ToastProviderProps) {\n const [toasts, setToasts] = useState<ToastEntry[]>([]);\n const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());\n const counter = useRef(0);\n const insets = useInsets();\n const styles = useStyles();\n\n const clearTimer = useCallback((id: string) => {\n const t = timers.current.get(id);\n if (t) {\n clearTimeout(t);\n timers.current.delete(id);\n }\n }, []);\n\n const hide = useCallback(\n (id: string) => {\n clearTimer(id);\n setToasts((prev) => prev.filter((t) => t.id !== id));\n },\n [clearTimer]\n );\n\n const hideAll = useCallback(() => {\n timers.current.forEach((t) => clearTimeout(t));\n timers.current.clear();\n setToasts([]);\n }, []);\n\n const show = useCallback(\n (options: ToastOptions) => {\n counter.current += 1;\n const id = `toast-${counter.current}`;\n const duration = options.duration ?? defaultDuration;\n\n setToasts((prev) => {\n const next = [...prev, { ...options, id }];\n // Se descartan los más viejos, no los recién llegados: un toast nuevo\n // siempre se ve.\n const overflow = next.length - maxVisible;\n if (overflow > 0) {\n next.slice(0, overflow).forEach((t) => clearTimer(t.id));\n return next.slice(overflow);\n }\n return next;\n });\n\n if (duration > 0) {\n timers.current.set(\n id,\n setTimeout(() => hide(id), duration)\n );\n }\n return id;\n },\n [defaultDuration, maxVisible, hide, clearTimer]\n );\n\n // Al desmontar el provider no debe quedar ningún setTimeout apuntando a un\n // setState de un árbol ya desmontado.\n useEffect(() => {\n const pending = timers.current;\n return () => {\n pending.forEach((t) => clearTimeout(t));\n pending.clear();\n };\n }, []);\n\n const value = useMemo<ToastContextValue>(() => ({ show, hide, hideAll }), [show, hide, hideAll]);\n\n useEffect(() => {\n bridge.set(value);\n return () => bridge.set(null);\n }, [value]);\n\n const hostPosition =\n position === 'top'\n ? { top: insets.top + 8 }\n : { bottom: insets.bottom + 8, flexDirection: 'column-reverse' as const };\n\n return (\n <ToastContext.Provider value={value}>\n {children}\n {toasts.length > 0 && (\n <View style={[styles.host, hostPosition]} pointerEvents=\"box-none\" testID=\"toast-host\">\n {toasts.map((t) => (\n <AnimatedToast key={t.id}>\n <Toast\n title={t.title}\n message={t.message}\n variant={t.variant}\n hideCloseButton={t.hideCloseButton}\n onDismiss={() => hide(t.id)}\n onPress={\n t.onPress\n ? () => {\n t.onPress?.();\n hide(t.id);\n }\n : undefined\n }\n />\n </AnimatedToast>\n ))}\n </View>\n )}\n </ToastContext.Provider>\n );\n}\n\nexport function useToast(): ToastContextValue {\n const ctx = useContext(ToastContext);\n if (!ctx) {\n throw new Error('orn-ui: useToast must be used within a <ToastProvider>.');\n }\n return ctx;\n}\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "organisms/imperativeBridge.ts",
|
|
20
|
+
"content": "/**\n * Puente entre el proveedor montado y el código que no es un componente\n * (servicios, capa de API, interceptores). El proveedor se registra al montar y\n * se borra al desmontar, así que fuera del árbol nunca queda una referencia\n * viva a un contexto muerto.\n */\nexport interface ImperativeBridge<T> {\n set: (value: T | null) => void;\n get: () => T | null;\n}\n\nexport function createImperativeBridge<T>(providerName: string): ImperativeBridge<T> {\n let current: T | null = null;\n\n return {\n set: (value) => {\n current = value;\n },\n get: () => {\n if (!current && __DEV__) {\n console.warn(\n `orn-ui: se llamó a la API imperativa sin un <${providerName}> montado. La llamada se ignoró.`\n );\n }\n return current;\n },\n };\n}\n"
|
|
14
21
|
}
|
|
15
22
|
],
|
|
16
23
|
"registryDependencies": [
|
|
17
24
|
"core",
|
|
18
|
-
"toast"
|
|
25
|
+
"toast",
|
|
26
|
+
"transition"
|
|
19
27
|
]
|
|
20
28
|
}
|
package/registry/toast.json
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/Toast.tsx",
|
|
12
|
-
"content": "import React, { memo
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { Pressable, Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Transition } from '../atoms/Transition';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport type ToastVariant = 'success' | 'error' | 'warning' | 'info';\n\nexport interface ToastProps {\n title: string;\n message?: string;\n variant?: ToastVariant;\n /** Se dispara al tocar el toast (además de cerrarlo). */\n onPress?: () => void;\n onDismiss?: () => void;\n /** Oculta la X de cerrar. El toast igual se auto-descarta por tiempo. */\n hideCloseButton?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst ICON_BY_VARIANT: Record<ToastVariant, IconName> = {\n success: 'check',\n error: 'alert',\n warning: 'warning',\n info: 'info',\n};\n\nconst useStyles = createStyles((theme) => ({\n container: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 1,\n borderColor: theme.colors.border,\n paddingVertical: theme.tokens.spacing.md,\n paddingHorizontal: theme.tokens.spacing.lg,\n ...theme.tokens.shadow.lg,\n },\n accent: {\n position: 'absolute',\n left: 0,\n top: 0,\n bottom: 0,\n width: 4,\n borderTopLeftRadius: theme.tokens.radius.lg,\n borderBottomLeftRadius: theme.tokens.radius.lg,\n },\n text: { flex: 1, gap: 2 },\n title: {\n fontSize: theme.tokens.fontSize.md,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.text,\n },\n message: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight, lineHeight: theme.tokens.lineHeight.sm },\n}));\n\n/**\n * Toast\n * Notificación efímera. Normalmente no se usa directo: `ToastProvider` +\n * `useToast()` la montan y la descartan por vos. Se expone igual por si\n * querés colocarla vos mismo dentro de una pantalla concreta.\n */\nexport const Toast = memo(\n ({ title, message, variant = 'info', onPress, onDismiss, hideCloseButton, style, testID }: ToastProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n\n // La barra lateral es relleno (acento pleno); el ícono es contenido\n // sobre la superficie del toast y necesita el rol `*Text`.\n const barColor: Record<ToastVariant, string> = {\n success: colors.success,\n error: colors.error,\n warning: colors.warning,\n info: colors.secondary,\n };\n const iconColor: Record<ToastVariant, string> = {\n success: colors.successText,\n error: colors.errorText,\n warning: colors.warningText,\n info: colors.secondaryText,\n };\n\n const body = (\n <View style={[styles.container, style]} testID={testID} accessibilityRole=\"alert\">\n <View style={[styles.accent, { backgroundColor: barColor[variant] }]} />\n <Icon name={ICON_BY_VARIANT[variant]} size={22} color={iconColor[variant]} />\n <View style={styles.text}>\n <Text allowFontScaling={allowFontScaling} style={styles.title}>\n {title}\n </Text>\n {!!message && (\n <Text allowFontScaling={allowFontScaling} style={styles.message}>\n {message}\n </Text>\n )}\n </View>\n {!hideCloseButton && !!onDismiss && (\n <Pressable onPress={onDismiss} accessibilityRole=\"button\" accessibilityLabel=\"Dismiss\" hitSlop={8}>\n <Icon name=\"close\" size={18} color={colors.textLight} />\n </Pressable>\n )}\n </View>\n );\n\n if (!onPress) return body;\n\n return (\n <Pressable onPress={onPress} accessibilityRole=\"button\" accessibilityLabel={title}>\n {body}\n </Pressable>\n );\n }\n);\nToast.displayName = 'Toast';\n\n/** Envuelve un Toast con su animación de entrada/salida. Uso interno del provider. */\nexport const AnimatedToast = memo(\n ({ children, onHeight }: { children: React.ReactNode; onHeight?: (h: number) => void }) => (\n <Transition preset={['fade', 'slide-down']} distance={16} spring>\n <View onLayout={(e) => onHeight?.(e.nativeEvent.layout.height)}>{children}</View>\n </Transition>\n )\n);\nAnimatedToast.displayName = 'AnimatedToast';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
16
|
-
"core"
|
|
16
|
+
"core",
|
|
17
|
+
"transition"
|
|
17
18
|
]
|
|
18
19
|
}
|