orn-ui 0.2.6 → 0.2.7
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/AGENTS.md +354 -0
- package/README.md +43 -13
- package/lib/typescript/atoms/Badge.d.ts +1 -1
- package/lib/typescript/atoms/Badge.d.ts.map +1 -1
- package/lib/typescript/atoms/Image.d.ts +6 -6
- package/lib/typescript/atoms/Image.d.ts.map +1 -1
- package/lib/typescript/atoms/Input.d.ts +1 -1
- package/lib/typescript/atoms/Input.d.ts.map +1 -1
- package/lib/typescript/atoms/PressableScale.d.ts +1 -1
- package/lib/typescript/atoms/PressableScale.d.ts.map +1 -1
- package/lib/typescript/atoms/Skeleton.d.ts +5 -5
- package/lib/typescript/atoms/Skeleton.d.ts.map +1 -1
- package/lib/typescript/atoms/Spinner.d.ts +4 -4
- package/lib/typescript/atoms/Spinner.d.ts.map +1 -1
- package/lib/typescript/atoms/Transition.d.ts +10 -10
- package/lib/typescript/atoms/Transition.d.ts.map +1 -1
- package/lib/typescript/molecules/AvatarHeader.d.ts +2 -2
- package/lib/typescript/molecules/AvatarHeader.d.ts.map +1 -1
- package/lib/typescript/molecules/FormActions.d.ts +1 -1
- package/lib/typescript/molecules/FormActions.d.ts.map +1 -1
- package/lib/typescript/molecules/OptionCard.d.ts +2 -2
- package/lib/typescript/molecules/OptionCard.d.ts.map +1 -1
- package/lib/typescript/molecules/Stepper.d.ts +8 -8
- package/lib/typescript/molecules/Stepper.d.ts.map +1 -1
- package/lib/typescript/molecules/Steps.d.ts +3 -3
- package/lib/typescript/molecules/Steps.d.ts.map +1 -1
- package/lib/typescript/molecules/SymmetricGrid.d.ts +4 -4
- package/lib/typescript/molecules/SymmetricGrid.d.ts.map +1 -1
- package/lib/typescript/organisms/BottomSheet.d.ts +8 -8
- package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
- package/lib/typescript/organisms/DateField.d.ts +10 -10
- package/lib/typescript/organisms/DateField.d.ts.map +1 -1
- package/lib/typescript/organisms/DatePicker.d.ts +10 -10
- package/lib/typescript/organisms/DatePicker.d.ts.map +1 -1
- package/lib/typescript/organisms/List.d.ts +6 -6
- package/lib/typescript/organisms/List.d.ts.map +1 -1
- package/lib/typescript/organisms/NavigationBar.d.ts +2 -2
- package/lib/typescript/organisms/NavigationBar.d.ts.map +1 -1
- package/lib/typescript/organisms/ReorderableList.d.ts +7 -7
- package/lib/typescript/organisms/ReorderableList.d.ts.map +1 -1
- package/lib/typescript/organisms/Screen.d.ts +6 -6
- package/lib/typescript/organisms/SearchList.d.ts +8 -8
- package/lib/typescript/organisms/SearchList.d.ts.map +1 -1
- package/lib/typescript/organisms/Select.d.ts +2 -2
- package/lib/typescript/organisms/Select.d.ts.map +1 -1
- package/lib/typescript/organisms/Toast.d.ts +2 -2
- package/lib/typescript/organisms/Toast.d.ts.map +1 -1
- package/lib/typescript/organisms/ToastProvider.d.ts +2 -2
- package/lib/typescript/organisms/ToastProvider.d.ts.map +1 -1
- package/lib/typescript/organisms/Wizard.d.ts +7 -7
- package/lib/typescript/organisms/Wizard.d.ts.map +1 -1
- package/lib/typescript/theme/UIProvider.d.ts +9 -9
- package/lib/typescript/theme/UIProvider.d.ts.map +1 -1
- package/llms.txt +33 -0
- package/package.json +24 -6
- package/registry/avatar-header.json +1 -1
- package/registry/badge.json +1 -1
- package/registry/bottom-sheet.json +1 -1
- package/registry/core.json +1 -1
- package/registry/date-field.json +1 -1
- package/registry/date-picker.json +1 -1
- package/registry/form-actions.json +1 -1
- package/registry/image.json +1 -1
- package/registry/index.json +26 -26
- package/registry/input.json +1 -1
- package/registry/list.json +1 -1
- package/registry/navigation-bar.json +1 -1
- package/registry/option-card.json +1 -1
- package/registry/pressable-scale.json +1 -1
- package/registry/reorderable-list.json +1 -1
- package/registry/screen.json +1 -1
- package/registry/search-list.json +1 -1
- package/registry/select.json +1 -1
- package/registry/skeleton.json +1 -1
- package/registry/spinner.json +1 -1
- package/registry/stepper.json +1 -1
- package/registry/steps.json +1 -1
- package/registry/symmetric-grid.json +1 -1
- package/registry/toast-provider.json +1 -1
- package/registry/toast.json +1 -1
- package/registry/transition.json +1 -1
- package/registry/wizard.json +1 -1
- package/src/atoms/Badge.tsx +1 -1
- package/src/atoms/Image.tsx +6 -6
- package/src/atoms/Input.tsx +1 -1
- package/src/atoms/PressableScale.tsx +1 -1
- package/src/atoms/Skeleton.tsx +5 -5
- package/src/atoms/Spinner.tsx +4 -4
- package/src/atoms/Transition.tsx +10 -10
- package/src/molecules/AvatarHeader.tsx +2 -2
- package/src/molecules/FormActions.tsx +1 -1
- package/src/molecules/OptionCard.tsx +2 -2
- package/src/molecules/Stepper.tsx +8 -8
- package/src/molecules/Steps.tsx +3 -3
- package/src/molecules/SymmetricGrid.tsx +4 -4
- package/src/organisms/BottomSheet.tsx +8 -8
- package/src/organisms/DateField.tsx +10 -10
- package/src/organisms/DatePicker.tsx +10 -10
- package/src/organisms/List.tsx +6 -6
- package/src/organisms/NavigationBar.tsx +2 -2
- package/src/organisms/ReorderableList.tsx +7 -7
- package/src/organisms/Screen.tsx +6 -6
- package/src/organisms/SearchList.tsx +8 -8
- package/src/organisms/Select.tsx +2 -2
- package/src/organisms/Toast.tsx +2 -2
- package/src/organisms/ToastProvider.tsx +2 -2
- package/src/organisms/Wizard.tsx +7 -7
- package/src/theme/UIProvider.tsx +9 -9
package/registry/index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"core": {
|
|
3
|
-
"bytes":
|
|
3
|
+
"bytes": 37332
|
|
4
4
|
},
|
|
5
5
|
"components": [
|
|
6
6
|
{
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"registryDependencies": [
|
|
54
54
|
"core"
|
|
55
55
|
],
|
|
56
|
-
"bytes":
|
|
56
|
+
"bytes": 5455
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"name": "Checkbox",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"registryDependencies": [
|
|
78
78
|
"core"
|
|
79
79
|
],
|
|
80
|
-
"bytes":
|
|
80
|
+
"bytes": 2183
|
|
81
81
|
},
|
|
82
82
|
{
|
|
83
83
|
"name": "Card",
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
"core",
|
|
128
128
|
"spinner"
|
|
129
129
|
],
|
|
130
|
-
"bytes":
|
|
130
|
+
"bytes": 4566
|
|
131
131
|
},
|
|
132
132
|
{
|
|
133
133
|
"name": "Spinner",
|
|
@@ -139,7 +139,7 @@
|
|
|
139
139
|
"registryDependencies": [
|
|
140
140
|
"core"
|
|
141
141
|
],
|
|
142
|
-
"bytes":
|
|
142
|
+
"bytes": 4640
|
|
143
143
|
},
|
|
144
144
|
{
|
|
145
145
|
"name": "Skeleton",
|
|
@@ -151,7 +151,7 @@
|
|
|
151
151
|
"registryDependencies": [
|
|
152
152
|
"core"
|
|
153
153
|
],
|
|
154
|
-
"bytes":
|
|
154
|
+
"bytes": 3550
|
|
155
155
|
},
|
|
156
156
|
{
|
|
157
157
|
"name": "Transition",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"registryDependencies": [
|
|
165
165
|
"core"
|
|
166
166
|
],
|
|
167
|
-
"bytes":
|
|
167
|
+
"bytes": 6569
|
|
168
168
|
},
|
|
169
169
|
{
|
|
170
170
|
"name": "EmptyState",
|
|
@@ -213,7 +213,7 @@
|
|
|
213
213
|
"registryDependencies": [
|
|
214
214
|
"core"
|
|
215
215
|
],
|
|
216
|
-
"bytes":
|
|
216
|
+
"bytes": 1706
|
|
217
217
|
},
|
|
218
218
|
{
|
|
219
219
|
"name": "Stepper",
|
|
@@ -226,7 +226,7 @@
|
|
|
226
226
|
"registryDependencies": [
|
|
227
227
|
"core"
|
|
228
228
|
],
|
|
229
|
-
"bytes":
|
|
229
|
+
"bytes": 6745
|
|
230
230
|
},
|
|
231
231
|
{
|
|
232
232
|
"name": "OptionCard",
|
|
@@ -239,7 +239,7 @@
|
|
|
239
239
|
"core",
|
|
240
240
|
"pressable-scale"
|
|
241
241
|
],
|
|
242
|
-
"bytes":
|
|
242
|
+
"bytes": 4152
|
|
243
243
|
},
|
|
244
244
|
{
|
|
245
245
|
"name": "InfoRow",
|
|
@@ -265,7 +265,7 @@
|
|
|
265
265
|
"core",
|
|
266
266
|
"button"
|
|
267
267
|
],
|
|
268
|
-
"bytes":
|
|
268
|
+
"bytes": 1524
|
|
269
269
|
},
|
|
270
270
|
{
|
|
271
271
|
"name": "AvatarHeader",
|
|
@@ -277,7 +277,7 @@
|
|
|
277
277
|
"registryDependencies": [
|
|
278
278
|
"core"
|
|
279
279
|
],
|
|
280
|
-
"bytes":
|
|
280
|
+
"bytes": 2492
|
|
281
281
|
},
|
|
282
282
|
{
|
|
283
283
|
"name": "SegmentedControl",
|
|
@@ -301,7 +301,7 @@
|
|
|
301
301
|
"registryDependencies": [
|
|
302
302
|
"core"
|
|
303
303
|
],
|
|
304
|
-
"bytes":
|
|
304
|
+
"bytes": 8628
|
|
305
305
|
},
|
|
306
306
|
{
|
|
307
307
|
"name": "SymmetricGrid",
|
|
@@ -313,7 +313,7 @@
|
|
|
313
313
|
"registryDependencies": [
|
|
314
314
|
"core"
|
|
315
315
|
],
|
|
316
|
-
"bytes":
|
|
316
|
+
"bytes": 2773
|
|
317
317
|
},
|
|
318
318
|
{
|
|
319
319
|
"name": "Modal",
|
|
@@ -345,7 +345,7 @@
|
|
|
345
345
|
"pressable-scale",
|
|
346
346
|
"transition"
|
|
347
347
|
],
|
|
348
|
-
"bytes":
|
|
348
|
+
"bytes": 9678
|
|
349
349
|
},
|
|
350
350
|
{
|
|
351
351
|
"name": "Select",
|
|
@@ -357,7 +357,7 @@
|
|
|
357
357
|
"registryDependencies": [
|
|
358
358
|
"core"
|
|
359
359
|
],
|
|
360
|
-
"bytes":
|
|
360
|
+
"bytes": 12117
|
|
361
361
|
},
|
|
362
362
|
{
|
|
363
363
|
"name": "Alert",
|
|
@@ -400,7 +400,7 @@
|
|
|
400
400
|
"registryDependencies": [
|
|
401
401
|
"core"
|
|
402
402
|
],
|
|
403
|
-
"bytes":
|
|
403
|
+
"bytes": 3227
|
|
404
404
|
},
|
|
405
405
|
{
|
|
406
406
|
"name": "List",
|
|
@@ -415,7 +415,7 @@
|
|
|
415
415
|
"skeleton",
|
|
416
416
|
"spinner"
|
|
417
417
|
],
|
|
418
|
-
"bytes":
|
|
418
|
+
"bytes": 5999
|
|
419
419
|
},
|
|
420
420
|
{
|
|
421
421
|
"name": "SearchList",
|
|
@@ -433,7 +433,7 @@
|
|
|
433
433
|
"icon-button",
|
|
434
434
|
"pressable-scale"
|
|
435
435
|
],
|
|
436
|
-
"bytes":
|
|
436
|
+
"bytes": 9245
|
|
437
437
|
},
|
|
438
438
|
{
|
|
439
439
|
"name": "Toast",
|
|
@@ -446,7 +446,7 @@
|
|
|
446
446
|
"core",
|
|
447
447
|
"transition"
|
|
448
448
|
],
|
|
449
|
-
"bytes":
|
|
449
|
+
"bytes": 4405
|
|
450
450
|
},
|
|
451
451
|
{
|
|
452
452
|
"name": "ToastProvider",
|
|
@@ -464,7 +464,7 @@
|
|
|
464
464
|
"toast",
|
|
465
465
|
"transition"
|
|
466
466
|
],
|
|
467
|
-
"bytes":
|
|
467
|
+
"bytes": 6359
|
|
468
468
|
},
|
|
469
469
|
{
|
|
470
470
|
"name": "DatePicker",
|
|
@@ -481,7 +481,7 @@
|
|
|
481
481
|
"icon-button",
|
|
482
482
|
"pressable-scale"
|
|
483
483
|
],
|
|
484
|
-
"bytes":
|
|
484
|
+
"bytes": 13397
|
|
485
485
|
},
|
|
486
486
|
{
|
|
487
487
|
"name": "DateField",
|
|
@@ -500,7 +500,7 @@
|
|
|
500
500
|
"date-picker",
|
|
501
501
|
"button"
|
|
502
502
|
],
|
|
503
|
-
"bytes":
|
|
503
|
+
"bytes": 6476
|
|
504
504
|
},
|
|
505
505
|
{
|
|
506
506
|
"name": "Wizard",
|
|
@@ -515,7 +515,7 @@
|
|
|
515
515
|
"button",
|
|
516
516
|
"transition"
|
|
517
517
|
],
|
|
518
|
-
"bytes":
|
|
518
|
+
"bytes": 5599
|
|
519
519
|
},
|
|
520
520
|
{
|
|
521
521
|
"name": "ThemeToggle",
|
|
@@ -540,7 +540,7 @@
|
|
|
540
540
|
"registryDependencies": [
|
|
541
541
|
"core"
|
|
542
542
|
],
|
|
543
|
-
"bytes":
|
|
543
|
+
"bytes": 7867
|
|
544
544
|
},
|
|
545
545
|
{
|
|
546
546
|
"name": "ReorderableList",
|
|
@@ -552,7 +552,7 @@
|
|
|
552
552
|
"registryDependencies": [
|
|
553
553
|
"core"
|
|
554
554
|
],
|
|
555
|
-
"bytes":
|
|
555
|
+
"bytes": 7324
|
|
556
556
|
}
|
|
557
557
|
]
|
|
558
558
|
}
|
package/registry/input.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "atoms/Input.tsx",
|
|
12
|
-
"content": "import React, { memo, useRef, useState } from 'react';\nimport {\n ActivityIndicator,\n Animated,\n Text,\n TextInput,\n TouchableOpacity,\n View,\n type StyleProp,\n type TextInputProps,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface InputProps extends Omit<TextInputProps, 'style'> {\n label?: string;\n /**
|
|
12
|
+
"content": "import React, { memo, useRef, useState } from 'react';\nimport {\n ActivityIndicator,\n Animated,\n Text,\n TextInput,\n TouchableOpacity,\n View,\n type StyleProp,\n type TextInputProps,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface InputProps extends Omit<TextInputProps, 'style'> {\n label?: string;\n /** Shows an asterisk next to the label. Purely visual, doesn't validate anything. */\n required?: boolean;\n error?: string;\n containerStyle?: StyleProp<ViewStyle>;\n style?: TextInputProps['style'];\n leftIconName?: IconName;\n rightIconName?: IconName;\n onRightIconPress?: () => void;\n isPassword?: boolean;\n isLoading?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { marginBottom: theme.tokens.spacing.lg, width: '100%' },\n label: {\n marginBottom: theme.tokens.spacing.sm,\n fontSize: theme.tokens.fontSize.md,\n fontWeight: theme.tokens.fontWeight.medium,\n color: theme.colors.text,\n },\n required: { color: theme.colors.errorText },\n inputWrapper: {\n flexDirection: 'row',\n alignItems: 'center',\n backgroundColor: theme.colors.inputBackground,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 2,\n minHeight: 50,\n },\n input: {\n flex: 1,\n paddingVertical: theme.tokens.spacing.md,\n paddingHorizontal: theme.tokens.spacing.lg,\n fontSize: theme.tokens.fontSize.lg,\n color: theme.colors.text,\n },\n leftIcon: { marginLeft: theme.tokens.spacing.lg, marginRight: -theme.tokens.spacing.xs },\n rightIcon: { padding: theme.tokens.spacing.sm + 2, marginRight: theme.tokens.spacing.xs },\n errorText: { marginTop: theme.tokens.spacing.xs, fontSize: theme.tokens.fontSize.sm, color: theme.colors.errorText },\n}));\n\n/**\n * Input\n * El borde anima color entre border/primary/error con Animated (no\n * useNativeDriver: los navegadores de color en RN sin Fabric solo son\n * confiables en el hilo de JS; el costo es despreciable, se anima un borde).\n */\nexport const Input = memo(\n ({\n label,\n required = false,\n error,\n containerStyle,\n leftIconName,\n rightIconName,\n onRightIconPress,\n isPassword = false,\n isLoading = false,\n style,\n onFocus,\n onBlur,\n testID,\n ...props\n }: InputProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const [isFocused, setIsFocused] = useState(false);\n const [showPassword, setShowPassword] = useState(false);\n const focusProgress = useRef(new Animated.Value(0)).current;\n\n const handleFocus: TextInputProps['onFocus'] = (e) => {\n setIsFocused(true);\n Animated.timing(focusProgress, { toValue: 1, duration: 200, useNativeDriver: false }).start();\n onFocus?.(e);\n };\n\n const handleBlur: TextInputProps['onBlur'] = (e) => {\n setIsFocused(false);\n Animated.timing(focusProgress, { toValue: 0, duration: 200, useNativeDriver: false }).start();\n onBlur?.(e);\n };\n\n const borderColor = error\n ? colors.error\n : focusProgress.interpolate({ outputRange: [colors.border, colors.primary], inputRange: [0, 1] });\n\n return (\n <View style={[styles.container, containerStyle]} testID={testID}>\n {label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n {required && <Text style={styles.required}> *</Text>}\n </Text>\n )}\n\n <Animated.View style={[styles.inputWrapper, { borderColor }]}>\n {leftIconName && <Icon name={leftIconName} size={20} color={colors.textLight} style={styles.leftIcon} />}\n\n <TextInput\n style={[styles.input, style]}\n placeholderTextColor={colors.textLight}\n onFocus={handleFocus}\n onBlur={handleBlur}\n secureTextEntry={isPassword && !showPassword}\n allowFontScaling={allowFontScaling}\n textAlignVertical=\"center\"\n accessibilityLabel={label ?? props.placeholder}\n {...props}\n />\n\n {isLoading ? (\n <View style={styles.rightIcon}>\n <ActivityIndicator size=\"small\" color={colors.primary} />\n </View>\n ) : isPassword ? (\n <TouchableOpacity\n onPress={() => setShowPassword((v) => !v)}\n style={styles.rightIcon}\n accessibilityRole=\"button\"\n accessibilityLabel={showPassword ? 'Hide password' : 'Show password'}\n >\n <Icon name={showPassword ? 'eye-off' : 'eye'} size={20} color={colors.textLight} />\n </TouchableOpacity>\n ) : rightIconName ? (\n <TouchableOpacity\n onPress={onRightIconPress}\n style={styles.rightIcon}\n disabled={!onRightIconPress}\n accessibilityRole=\"button\"\n accessibilityLabel=\"Clear\"\n >\n <Icon name={rightIconName} size={20} color={colors.textLight} />\n </TouchableOpacity>\n ) : null}\n </Animated.View>\n\n {!!error && (\n <Text allowFontScaling={allowFontScaling} style={styles.errorText}>\n {error}\n </Text>\n )}\n </View>\n );\n }\n);\nInput.displayName = 'Input';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/list.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/List.tsx",
|
|
12
|
-
"content": "import React, { useCallback } from 'react';\nimport { FlatList, RefreshControl, View, type FlatListProps, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Skeleton } from '../atoms/Skeleton';\nimport { Spinner } from '../atoms/Spinner';\nimport type { IconName } from '../icons/types';\n\nexport interface ListProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n /**
|
|
12
|
+
"content": "import React, { useCallback } from 'react';\nimport { FlatList, RefreshControl, View, type FlatListProps, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Skeleton } from '../atoms/Skeleton';\nimport { Spinner } from '../atoms/Spinner';\nimport type { IconName } from '../icons/types';\n\nexport interface ListProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n renderItem: FlatListProps<T>['renderItem'];\n\n /** Initial load: while true and there's no data, placeholders are shown. */\n isLoading: boolean;\n /** Reload with data already on screen (pull-to-refresh). @default false */\n isRefreshing?: boolean;\n isLoadingMore?: boolean;\n /** Controlled by the consumer: while false, a fullscreen spinner is shown. @default true */\n isReady?: boolean;\n loadingText?: string;\n loadingMoreText?: string;\n\n /** Ghost rows for the initial load. @default 6 */\n skeletonCount?: number;\n /** Custom placeholder, to match the real shape of the item. */\n renderSkeletonItem?: () => React.ReactElement;\n\n onEndReached?: () => void;\n onEndReachedThreshold?: number;\n onRefresh?: () => void;\n\n emptyTitle?: string;\n emptyDescription?: string;\n emptyIconName?: IconName;\n\n ListHeaderComponent?: FlatListProps<T>['ListHeaderComponent'];\n ListFooterComponent?: FlatListProps<T>['ListFooterComponent'];\n\n contentContainerStyle?: ViewStyle;\n containerStyle?: ViewStyle;\n\n /** List component to use (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 listContent: { padding: theme.tokens.spacing.md },\n footerLoader: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center' },\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 * List\n * Lista con estados de carga/paginación/vacío resueltos, sin atarse a\n * expo-router ni a FlashList: `ListComponent` permite inyectar cualquiera\n * (por defecto FlatList del propio React Native).\n */\nexport function List<T>({\n data,\n keyExtractor,\n renderItem,\n isLoading,\n isRefreshing = false,\n isLoadingMore = false,\n isReady = true,\n loadingText,\n loadingMoreText,\n skeletonCount = 6,\n renderSkeletonItem,\n onEndReached,\n onEndReachedThreshold = 0.3,\n onRefresh,\n emptyTitle,\n emptyDescription,\n emptyIconName,\n ListHeaderComponent,\n ListFooterComponent,\n contentContainerStyle,\n containerStyle,\n ListComponent = FlatList,\n listProps,\n}: ListProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const resolvedLoadingText = loadingText ?? labels.loading;\n const resolvedLoadingMoreText = loadingMoreText ?? labels.loadingMore;\n\n const isInitialLoading = isLoading && data.length === 0;\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.footerLoader}>\n <Spinner text={resolvedLoadingMoreText} fullscreen={false} />\n </View>\n )}\n </>\n );\n }, [isLoadingMore, resolvedLoadingMoreText, ListFooterComponent, styles.footerLoader]);\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}>\n <Spinner text={resolvedLoadingText} />\n </View>\n );\n }\n\n if (isInitialLoading) {\n return (\n <View style={[styles.container, containerStyle]} testID=\"list-skeleton\">\n <View 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 </View>\n );\n }\n\n return (\n <View style={[styles.container, containerStyle]}>\n <ListComponent\n data={data}\n keyExtractor={keyExtractor}\n renderItem={renderItem}\n contentContainerStyle={[styles.listContent, contentContainerStyle]}\n onEndReached={onEndReached}\n onEndReachedThreshold={onEndReachedThreshold}\n ListHeaderComponent={ListHeaderComponent}\n ListFooterComponent={renderFooter}\n ListEmptyComponent={renderEmpty}\n refreshControl={\n onRefresh ? (\n <RefreshControl\n refreshing={isRefreshing}\n onRefresh={onRefresh}\n colors={[colors.primary]}\n tintColor={colors.primary}\n />\n ) : undefined\n }\n {...listProps}\n />\n </View>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "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 /**
|
|
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 /** Screen edge where the bar lives. @default 'bottom' */\n position?: 'bottom' | 'top';\n /** @default true */\n showLabels?: boolean;\n /** Adds the safe area inset for the edge where the bar lives. @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 // El indicador se posiciona contra el borde de la barra, no contra su caja\n // interna: al `left` hay que sumarle el padding horizontal para que quede\n // centrado sobre la pestaña y no corrido hacia la izquierda.\n left: theme.tokens.spacing.xs + theme.tokens.spacing.sm,\n borderRadius: theme.tokens.radius.lg,\n backgroundColor: theme.colors.primarySoft,\n },\n item: { flex: 1, paddingVertical: theme.tokens.spacing.xs },\n itemContent: { alignItems: 'center', justifyContent: 'center', gap: 2 },\n label: { fontSize: theme.tokens.fontSize.xs, fontWeight: theme.tokens.fontWeight.medium },\n badge: {\n position: 'absolute',\n top: -4,\n left: '50%',\n marginLeft: 4,\n minWidth: 16,\n height: 16,\n paddingHorizontal: 4,\n borderRadius: theme.tokens.radius.full,\n backgroundColor: theme.colors.error,\n alignItems: 'center',\n justifyContent: 'center',\n },\n badgeDot: { minWidth: 10, height: 10, top: -2, paddingHorizontal: 0 },\n badgeText: {\n fontSize: 10,\n fontWeight: theme.tokens.fontWeight.bold,\n color: theme.colors.onError,\n },\n}));\n\nconst ICON_SIZE = 22;\n\n/**\n * NavigationBar\n * Barra de navegación por pestañas con la misma apariencia y las mismas\n * transiciones en iOS y Android: el indicador se desliza hasta la pestaña\n * activa y su contenido crece y toma el color de acento con un cross-fade.\n * No navega por su cuenta — reporta la pestaña tocada y el consumidor decide.\n */\nexport const NavigationBar = memo(\n ({\n items,\n activeKey,\n onChange,\n position = 'bottom',\n showLabels = true,\n safeArea = true,\n style,\n testID,\n }: NavigationBarProps) => {\n const theme = useTheme();\n const colors = useColors();\n const styles = useStyles();\n const insets = useInsets();\n const allowFontScaling = useAllowFontScaling();\n\n const [barWidth, setBarWidth] = useState(0);\n const slide = useRef(new Animated.Value(0)).current;\n const placed = useRef(false);\n\n const activeIndex = Math.max(\n 0,\n items.findIndex((item) => item.key === activeKey)\n );\n const itemWidth = items.length > 0 ? barWidth / items.length : 0;\n\n useEffect(() => {\n if (itemWidth <= 0) return;\n const to = activeIndex * itemWidth;\n // En el primer layout el indicador aparece en su lugar; animarlo desde 0\n // sería un deslizamiento que el usuario no pidió.\n if (!placed.current) {\n placed.current = true;\n slide.setValue(to);\n return;\n }\n Animated.spring(slide, { toValue: to, useNativeDriver: true, friction: 9, tension: 90 }).start();\n }, [activeIndex, itemWidth, slide]);\n\n const onLayout = (e: LayoutChangeEvent) => {\n const next = e.nativeEvent.layout.width - theme.tokens.spacing.xs * 2;\n if (next !== barWidth) setBarWidth(next);\n };\n\n const renderContent = (item: NavigationBarItem, color: string) => (\n <>\n {!!item.iconName && <Icon name={item.iconName} size={ICON_SIZE} color={color} />}\n {showLabels && (\n <Text allowFontScaling={allowFontScaling} numberOfLines={1} style={[styles.label, { color }]}>\n {item.label}\n </Text>\n )}\n </>\n );\n\n return (\n <View\n testID={testID}\n onLayout={onLayout}\n accessibilityRole=\"tablist\"\n style={[\n styles.bar,\n position === 'bottom' ? styles.barBottom : styles.barTop,\n safeArea && position === 'bottom' && { paddingBottom: theme.tokens.spacing.sm + insets.bottom },\n safeArea && position === 'top' && { paddingTop: theme.tokens.spacing.sm + insets.top },\n style,\n ]}\n >\n {itemWidth > 0 && (\n <Animated.View\n testID=\"navigation-bar-indicator\"\n pointerEvents=\"none\"\n style={[\n styles.indicator,\n safeArea && position === 'bottom' && { bottom: theme.tokens.spacing.xs + insets.bottom },\n safeArea && position === 'top' && { top: theme.tokens.spacing.xs + insets.top },\n {\n width: itemWidth - theme.tokens.spacing.sm * 2,\n transform: [{ translateX: slide }],\n },\n ]}\n />\n )}\n\n {items.map((item, index) => {\n const active = index === activeIndex;\n const range = [(index - 1) * itemWidth, index * itemWidth, (index + 1) * itemWidth];\n const scale =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [1, 1.08, 1], extrapolate: 'clamp' })\n : 1;\n const activeOpacity =\n itemWidth > 0\n ? slide.interpolate({ inputRange: range, outputRange: [0, 1, 0], extrapolate: 'clamp' })\n : 0;\n\n return (\n <TouchableOpacity\n key={item.key}\n style={styles.item}\n activeOpacity={0.7}\n onPress={() => onChange(item.key)}\n accessibilityRole=\"tab\"\n accessibilityLabel={item.label}\n accessibilityState={{ selected: active }}\n >\n <Animated.View style={[styles.itemContent, { transform: [{ scale }] }]}>\n {renderContent(item, colors.textLight)}\n {/* Copia decorativa: sin ocultarla, el lector lee el label dos veces. */}\n <Animated.View\n pointerEvents=\"none\"\n accessibilityElementsHidden\n importantForAccessibility=\"no-hide-descendants\"\n style={[StyleSheet.absoluteFill, styles.itemContent, { opacity: activeOpacity }]}\n >\n {renderContent(item, colors.primaryText)}\n </Animated.View>\n\n {item.badge !== undefined && (\n <View style={[styles.badge, item.badge === true && styles.badgeDot]}>\n {item.badge !== true && (\n <Text allowFontScaling={false} style={styles.badgeText}>\n {item.badge}\n </Text>\n )}\n </View>\n )}\n </Animated.View>\n </TouchableOpacity>\n );\n })}\n </View>\n );\n }\n);\nNavigationBar.displayName = 'NavigationBar';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "molecules/OptionCard.tsx",
|
|
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 /**
|
|
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 /** Secondary line: the detail that decides the choice (\"arrives in 3 days\"). */\n description?: string;\n iconName?: IconName;\n isSelected: boolean;\n onPress: () => void;\n disabled?: boolean;\n /** 'vertical': icon on top, text centered below. 'horizontal': in a row. @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": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "atoms/PressableScale.tsx",
|
|
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 /**
|
|
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 /** Target scale when pressed. @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": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/ReorderableList.tsx",
|
|
12
|
-
"content": "import React, { useRef, useState } from 'react';\nimport { Animated, PanResponder, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\n\nexport interface ReorderableListProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n /**
|
|
12
|
+
"content": "import React, { useRef, useState } from 'react';\nimport { Animated, PanResponder, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\n\nexport interface ReorderableListProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n /** Fixed height of each row: needed to calculate the target index without measuring layout async. */\n itemHeight: number;\n renderItem: (item: T, index: number, dragging: boolean) => React.ReactElement;\n /** Called on release, with the array already reordered. Does not mutate `data`. */\n onReorder: (data: T[]) => void;\n /**\n * Starts dragging row `index`. Use it to turn off the container's scroll\n * (`scrollEnabled={false}`) while it lasts: on iOS the parent\n * `UIScrollView` cancels touches on the content as soon as its\n * vertical gesture starts, and without this the drag dies within a few pixels.\n */\n onDragStart?: (index: number) => void;\n /** Ends the drag — on release or cancellation. Always runs if `onDragStart` ran. */\n onDragEnd?: () => void;\n disabled?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\n/** Reordena moviendo el ítem de `from` a `to`, sin mutar `data`. Exportada aparte por ser testeable sin simular gestos. */\nexport function move<T>(data: T[], from: number, to: number): T[] {\n const next = data.slice();\n const [item] = next.splice(from, 1) as [T];\n next.splice(to, 0, item);\n return next;\n}\n\nconst useStyles = createStyles(() => ({\n row: { width: '100%' },\n}));\n\n/**\n * ReorderableList\n * Ley de destino común (common fate): al arrastrar una fila, las filas que\n * tiene que atravesar se desplazan juntas en el mismo sentido — eso es lo\n * que comunica \"van a intercambiar lugar\" antes de soltar el dedo, no un\n * simple resaltado del ítem activo.\n *\n * Sin dependencias de gestos/animación externas (reanimated, gesture-handler):\n * PanResponder + Animated alcanzan porque el cálculo de índice destino\n * necesita el valor de arrastre en JS en cada movimiento (no algo que\n * useNativeDriver pueda resolver solo).\n *\n * Dentro de un scroll vertical hay que cablear `onDragStart`/`onDragEnd` a\n * `scrollEnabled`, o en iOS el arrastre no llega a empezar:\n *\n * ```tsx\n * const [dragging, setDragging] = useState(false);\n * <ScrollView scrollEnabled={!dragging}>\n * <ReorderableList onDragStart={() => setDragging(true)} onDragEnd={() => setDragging(false)} … />\n * </ScrollView>\n * ```\n */\nexport function ReorderableList<T>({\n data,\n keyExtractor,\n itemHeight,\n renderItem,\n onReorder,\n onDragStart,\n onDragEnd,\n disabled = false,\n style,\n}: ReorderableListProps<T>) {\n const styles = useStyles();\n const [activeIndex, setActiveIndex] = useState<number | null>(null);\n const [targetIndex, setTargetIndex] = useState<number | null>(null);\n const dy = useRef(new Animated.Value(0)).current;\n const activeIndexRef = useRef<number | null>(null);\n // El PanResponder de cada fila se crea una sola vez (se cachea en el Map de\n // abajo) y su closure queda fijo al render de esa primera vez: leer\n // `targetIndex` (state) directamente ahí siempre daría el valor del\n // montaje. Un ref sí refleja la última escritura sin importar cuándo se\n // creó la closure que lo lee.\n const targetIndexRef = useRef<number | null>(null);\n const dataRef = useRef(data);\n dataRef.current = data;\n // Igual razón que targetIndexRef: el responder de cada fila se crea una\n // sola vez, así que leer `disabled` directo del closure congelaría el\n // valor del momento en que esa fila se creó.\n const disabledRef = useRef(disabled);\n disabledRef.current = disabled;\n // Misma razón: los callbacks se leen desde closures creadas una sola vez.\n const onReorderRef = useRef(onReorder);\n onReorderRef.current = onReorder;\n const onDragStartRef = useRef(onDragStart);\n onDragStartRef.current = onDragStart;\n const onDragEndRef = useRef(onDragEnd);\n onDragEndRef.current = onDragEnd;\n\n const panResponders = useRef(new Map<number, ReturnType<typeof PanResponder.create>>()).current;\n // Si `data` se achica, los responders de índices que ya no existen quedan\n // colgados en el Map para siempre: se podan en cada render.\n for (const index of panResponders.keys()) {\n if (index >= data.length) panResponders.delete(index);\n }\n\n function panResponderFor(index: number) {\n let responder = panResponders.get(index);\n if (responder) return responder;\n\n const endDrag = () => {\n dy.setValue(0);\n activeIndexRef.current = null;\n targetIndexRef.current = null;\n setActiveIndex(null);\n setTargetIndex(null);\n onDragEndRef.current?.();\n };\n\n responder = PanResponder.create({\n onStartShouldSetPanResponder: () => !disabledRef.current,\n onMoveShouldSetPanResponder: (_, gesture) => !disabledRef.current && Math.abs(gesture.dy) > 2,\n // Un scroll padre pide el responder apenas su gesto vertical arranca —\n // el mismo eje que el arrastre. Cederlo cancelaría el drag a mitad de\n // camino, así que la fila se lo queda hasta soltar.\n onPanResponderTerminationRequest: () => false,\n onPanResponderGrant: () => {\n activeIndexRef.current = index;\n targetIndexRef.current = index;\n setActiveIndex(index);\n setTargetIndex(index);\n dy.setValue(0);\n onDragStartRef.current?.(index);\n },\n onPanResponderMove: (_, gesture) => {\n dy.setValue(gesture.dy);\n const from = activeIndexRef.current;\n if (from === null) return;\n const rawTarget = from + Math.round(gesture.dy / itemHeight);\n const clamped = Math.max(0, Math.min(dataRef.current.length - 1, rawTarget));\n targetIndexRef.current = clamped;\n setTargetIndex((prev) => (prev === clamped ? prev : clamped));\n },\n onPanResponderRelease: () => {\n const from = activeIndexRef.current;\n const to = targetIndexRef.current;\n endDrag();\n if (from === null || to === null || from === to) return;\n onReorderRef.current(move(dataRef.current, from, to));\n },\n onPanResponderTerminate: endDrag,\n });\n panResponders.set(index, responder);\n return responder;\n }\n\n return (\n <View style={style}>\n {data.map((item, index) => {\n const isActive = activeIndex === index;\n // Filas entre el origen y el destino se corren un lugar entero en el\n // sentido del arrastre: es el efecto de \"grupo que se mueve junto\".\n let shift = 0;\n if (activeIndex !== null && targetIndex !== null && !isActive) {\n if (activeIndex < targetIndex && index > activeIndex && index <= targetIndex) shift = -itemHeight;\n else if (activeIndex > targetIndex && index < activeIndex && index >= targetIndex) shift = itemHeight;\n }\n\n return (\n <Animated.View\n key={keyExtractor(item, index)}\n style={[\n styles.row,\n isActive\n ? { transform: [{ translateY: dy }], zIndex: 1, elevation: 4 }\n : { transform: [{ translateY: shift }] },\n ]}\n {...panResponderFor(index).panHandlers}\n >\n {renderItem(item, index, isActive)}\n </Animated.View>\n );\n })}\n </View>\n );\n}\n"
|
|
13
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 /**
|
|
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 /** Which safe area insets to apply. @default ['top', 'bottom'] */\n edges?: ScreenEdge[];\n /**\n * Compensates for the keyboard: on iOS with scroll the ScrollView itself does it, in\n * other cases a KeyboardAvoidingView does. Set to false when\n * the content already brings its own scroller that handles the keyboard (Wizard,\n * List, SearchList): the two compensations add up and the focused field\n * ends up pushed off screen.\n * @default true\n */\n keyboardAvoiding?: boolean;\n}\n\nconst useStyles = createStyles((theme) => ({\n wrapper: { flex: 1, backgroundColor: theme.colors.background },\n container: { flex: 1 },\n contentContainer: { flexGrow: 1 },\n}));\n\n/**\n * Screen\n * Contenedor raíz de pantalla: fondo del theme, safe area (via insets del\n * UIProvider), teclado y scroll opcional resueltos de forma consistente.\n */\nexport const Screen = memo(({\n children,\n style,\n scrollable = true,\n contentContainerStyle,\n edges = ['top', 'bottom'],\n keyboardAvoiding = true,\n}: ScreenProps) => {\n const insets = useInsets();\n const styles = useStyles();\n const applyTop = edges.includes('top');\n const applyBottom = edges.includes('bottom');\n\n // En iOS el ScrollView de abajo ya sube el campo enfocado con\n // automaticallyAdjustKeyboardInsets; envolverlo además en un\n // KeyboardAvoidingView compensa el teclado dos veces y el input termina\n // empujado fuera del borde superior. Android no tiene ese prop, así que ahí\n // el KeyboardAvoidingView sigue siendo el único mecanismo.\n const avoidWithView = keyboardAvoiding && !(scrollable && Platform.OS === 'ios');\n\n const KeyboardWrapper: React.ComponentType<any> = avoidWithView ? KeyboardAvoidingView : View;\n const keyboardProps = avoidWithView\n ? {\n behavior: Platform.OS === 'ios' ? ('padding' as const) : ('height' as const),\n keyboardVerticalOffset: Platform.OS === 'ios' ? 0 : 20,\n }\n : {};\n\n return (\n <View style={[styles.wrapper, style]}>\n <KeyboardWrapper style={styles.container} {...keyboardProps}>\n {scrollable ? (\n <ScrollView\n style={styles.container}\n automaticallyAdjustKeyboardInsets\n contentContainerStyle={[\n styles.contentContainer,\n { paddingTop: applyTop ? insets.top : 0 },\n applyBottom && { paddingBottom: insets.bottom },\n contentContainerStyle,\n ]}\n keyboardShouldPersistTaps=\"handled\"\n >\n {children}\n </ScrollView>\n ) : (\n <View style={[styles.container, { paddingTop: applyTop ? insets.top : 0, paddingBottom: applyBottom ? insets.bottom : 0 }]}>\n {children}\n </View>\n )}\n </KeyboardWrapper>\n </View>\n );\n});\nScreen.displayName = 'Screen';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/SearchList.tsx",
|
|
12
|
-
"content": "import React, { useCallback } from 'react';\nimport {\n ActivityIndicator,\n FlatList,\n RefreshControl,\n Text,\n View,\n type FlatListProps,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useLabels } from '../theme/UIProvider';\nimport { Input } from '../atoms/Input';\nimport { EmptyState } from '../atoms/EmptyState';\nimport { Skeleton } from '../atoms/Skeleton';\nimport { Spinner } from '../atoms/Spinner';\nimport { IconButton } from '../atoms/IconButton';\nimport type { IconName } from '../icons/types';\n\nexport interface SearchListProps<T> {\n /**
|
|
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 /** Optional chrome above the search bar (e.g. back button + title). */\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 /** Initial load: while true and there's no data, placeholders are shown. */\n isLoading?: boolean;\n /** 'replace' swaps the whole list for a spinner/skeleton; 'overlay' shows it on top while keeping the layout. */\n loadingMode?: 'replace' | 'overlay';\n /** Reload with data already on screen (pull-to-refresh). @default false */\n isRefreshing?: boolean;\n isLoadingMore?: boolean;\n /** Controlled by the consumer: while false, a fullscreen spinner is shown. @default true */\n isReady?: boolean;\n loadingText?: string;\n loadingMoreText?: string;\n hasMore?: boolean;\n noMoreText?: string;\n\n /** Ghost rows for the initial load. @default 6 */\n skeletonCount?: number;\n /** Custom placeholder, to match the real shape of the item. */\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 /** List component to use (FlashList, etc). @default FlatList */\n ListComponent?: React.ComponentType<any>;\n listProps?: Record<string, unknown>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1 },\n searchRow: { flexDirection: 'row', alignItems: 'center', marginBottom: theme.tokens.spacing.lg - 1, gap: theme.tokens.spacing.sm + 2 },\n searchInput: { flex: 1, marginBottom: 0 },\n scanButton: { backgroundColor: theme.colors.primary, width: 50, height: 50, borderRadius: theme.tokens.radius.lg, ...theme.tokens.shadow.sm },\n listArea: { flex: 1 },\n listContent: { paddingBottom: theme.tokens.spacing.xl },\n loadingOverlay: {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n backgroundColor: theme.colors.scrim,\n zIndex: 10,\n justifyContent: 'center',\n alignItems: 'center',\n },\n footer: { paddingVertical: theme.tokens.spacing.xl, alignItems: 'center', gap: theme.tokens.spacing.md },\n footerText: { color: theme.colors.textLight, fontSize: theme.tokens.fontSize.md },\n skeletonRow: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n padding: theme.tokens.spacing.md,\n },\n skeletonText: { flex: 1, gap: theme.tokens.spacing.sm },\n}));\n\n/**\n * SearchList\n * Buscador + lista + estados de carga/paginación/vacío. El contenido de\n * cada ítem (renderItem) queda a cargo de quien la usa, ya que difiere por\n * completo entre casos de uso.\n */\nexport function SearchList<T>({\n header,\n searchValue,\n onSearchChange,\n searchPlaceholder,\n searchIsLoading = false,\n onScanPress,\n scanAccessibilityLabel = 'Scan',\n extraActions,\n data,\n keyExtractor,\n renderItem,\n isLoading = false,\n loadingMode = 'replace',\n isRefreshing = false,\n isLoadingMore = false,\n isReady = true,\n loadingText,\n loadingMoreText,\n hasMore = false,\n noMoreText,\n skeletonCount = 6,\n renderSkeletonItem,\n onLoadMore,\n onEndReached,\n onEndReachedThreshold = 0.3,\n onRefresh,\n emptyTitle,\n emptyDescription,\n emptyIconName,\n ListHeaderComponent,\n ListFooterComponent,\n contentContainerStyle,\n containerStyle,\n ListComponent = FlatList,\n listProps,\n}: SearchListProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const resolvedSearchPlaceholder = searchPlaceholder ?? labels.search;\n const resolvedLoadingText = loadingText ?? labels.loading;\n const resolvedLoadingMoreText = loadingMoreText ?? labels.loadingMore;\n\n const handleEndReached = onLoadMore ?? onEndReached;\n\n // El footer propio y el loader de paginación conviven: antes el primero\n // tapaba al segundo y la lista dejaba de avisar que estaba trayendo más.\n const renderFooter = useCallback(() => {\n const custom =\n typeof ListFooterComponent === 'function' ? (\n <ListFooterComponent />\n ) : (\n (ListFooterComponent as React.ReactElement | undefined) ?? null\n );\n\n return (\n <>\n {custom}\n {isLoadingMore && (\n <View style={styles.footer}>\n <Spinner text={resolvedLoadingMoreText} fullscreen={false} />\n </View>\n )}\n {noMoreText && !hasMore && data.length > 0 && (\n <View style={styles.footer}>\n <Text style={styles.footerText}>{noMoreText}</Text>\n </View>\n )}\n </>\n );\n }, [ListFooterComponent, isLoadingMore, resolvedLoadingMoreText, noMoreText, hasMore, data.length, styles.footer, styles.footerText]);\n\n // Vacío sólo cuando ya se sabe que no hay nada: durante la carga inicial se\n // muestran placeholders, no \"sin resultados\".\n const renderEmpty = useCallback(() => {\n if (isLoading || isRefreshing) return null;\n return <EmptyState title={emptyTitle} description={emptyDescription} iconName={emptyIconName} style={{ marginTop: 50 }} />;\n }, [isLoading, isRefreshing, emptyTitle, emptyDescription, emptyIconName]);\n\n if (!isReady) {\n return (\n <View style={[styles.container, containerStyle]}>\n <Spinner text={resolvedLoadingText} />\n </View>\n );\n }\n\n const isInitialLoading = isLoading && loadingMode === 'replace' && data.length === 0;\n const showOverlayLoading = isLoading && loadingMode === 'overlay' && data.length === 0;\n\n return (\n <View style={[styles.container, containerStyle]}>\n {header}\n\n <View style={styles.searchRow}>\n <Input\n placeholder={resolvedSearchPlaceholder}\n leftIconName=\"search\"\n value={searchValue}\n onChangeText={onSearchChange}\n rightIconName={searchValue.length > 0 ? 'close' : undefined}\n onRightIconPress={() => onSearchChange('')}\n isLoading={searchIsLoading}\n containerStyle={styles.searchInput}\n />\n {onScanPress && (\n <IconButton\n iconName=\"search\"\n onPress={onScanPress}\n color={colors.onPrimary}\n style={styles.scanButton}\n accessibilityLabel={scanAccessibilityLabel}\n />\n )}\n {extraActions}\n </View>\n\n <View style={styles.listArea}>\n {showOverlayLoading && (\n <View style={styles.loadingOverlay}>\n <ActivityIndicator size=\"large\" color={colors.primary} />\n <Text style={styles.footerText}>{resolvedLoadingText}</Text>\n </View>\n )}\n\n {isInitialLoading ? (\n <View testID=\"list-skeleton\" style={[styles.listContent, contentContainerStyle]}>\n {Array.from({ length: skeletonCount }, (_, i) => (\n <React.Fragment key={i}>\n {renderSkeletonItem?.() ?? (\n <View style={styles.skeletonRow}>\n <Skeleton variant=\"circle\" width={40} />\n <View style={styles.skeletonText}>\n <Skeleton width=\"60%\" height={14} />\n <Skeleton width=\"35%\" height={12} />\n </View>\n </View>\n )}\n </React.Fragment>\n ))}\n </View>\n ) : (\n <ListComponent\n data={data}\n keyExtractor={keyExtractor}\n keyboardDismissMode=\"on-drag\"\n contentContainerStyle={[styles.listContent, contentContainerStyle]}\n onEndReached={handleEndReached}\n onEndReachedThreshold={onEndReachedThreshold}\n ListHeaderComponent={ListHeaderComponent}\n ListFooterComponent={renderFooter}\n ListEmptyComponent={renderEmpty}\n renderItem={renderItem}\n refreshControl={\n onRefresh ? (\n <RefreshControl\n refreshing={isRefreshing}\n onRefresh={onRefresh}\n colors={[colors.primary]}\n tintColor={colors.primary}\n />\n ) : undefined\n }\n {...listProps}\n />\n )}\n </View>\n </View>\n );\n}\n\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/select.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/Select.tsx",
|
|
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"
|
|
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 /** Adds a search box inside the dropdown. @default false */\n searchable?: boolean;\n searchPlaceholder?: string;\n /** Maximum height of the dropdown, if the available space allows it. @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": [
|