orn-ui 0.2.6 → 0.2.8
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 +372 -0
- package/README.md +46 -15
- package/lib/commonjs/atoms/Gradient.js +111 -0
- package/lib/commonjs/atoms/Gradient.js.map +1 -0
- package/lib/commonjs/atoms/index.js +7 -0
- package/lib/commonjs/atoms/index.js.map +1 -1
- package/lib/commonjs/organisms/Slides.js +406 -0
- package/lib/commonjs/organisms/Slides.js.map +1 -0
- package/lib/commonjs/organisms/index.js +7 -0
- package/lib/commonjs/organisms/index.js.map +1 -1
- package/lib/commonjs/organisms/useSlides.js +186 -0
- package/lib/commonjs/organisms/useSlides.js.map +1 -0
- package/lib/module/atoms/Gradient.js +106 -0
- package/lib/module/atoms/Gradient.js.map +1 -0
- package/lib/module/atoms/index.js +1 -0
- package/lib/module/atoms/index.js.map +1 -1
- package/lib/module/organisms/Slides.js +401 -0
- package/lib/module/organisms/Slides.js.map +1 -0
- package/lib/module/organisms/index.js +1 -0
- package/lib/module/organisms/index.js.map +1 -1
- package/lib/module/organisms/useSlides.js +182 -0
- package/lib/module/organisms/useSlides.js.map +1 -0
- package/lib/typescript/atoms/Badge.d.ts +1 -1
- package/lib/typescript/atoms/Badge.d.ts.map +1 -1
- package/lib/typescript/atoms/Gradient.d.ts +37 -0
- package/lib/typescript/atoms/Gradient.d.ts.map +1 -0
- 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/atoms/index.d.ts +2 -0
- package/lib/typescript/atoms/index.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/Slides.d.ts +69 -0
- package/lib/typescript/organisms/Slides.d.ts.map +1 -0
- 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/organisms/index.d.ts +2 -0
- package/lib/typescript/organisms/index.d.ts.map +1 -1
- package/lib/typescript/organisms/useSlides.d.ts +52 -0
- package/lib/typescript/organisms/useSlides.d.ts.map +1 -0
- package/lib/typescript/theme/UIProvider.d.ts +9 -9
- package/lib/typescript/theme/UIProvider.d.ts.map +1 -1
- package/llms.txt +34 -0
- package/package.json +34 -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/gradient.json +18 -0
- package/registry/image.json +1 -1
- package/registry/index.json +52 -26
- package/registry/input.json +1 -1
- package/registry/list.json +1 -1
- package/registry/manifest.json +14 -0
- 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/slides.json +24 -0
- 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/Gradient.tsx +99 -0
- 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/atoms/index.ts +2 -0
- 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/Slides.tsx +413 -0
- package/src/organisms/Toast.tsx +2 -2
- package/src/organisms/ToastProvider.tsx +2 -2
- package/src/organisms/Wizard.tsx +7 -7
- package/src/organisms/index.ts +8 -0
- package/src/organisms/useSlides.ts +220 -0
- package/src/theme/UIProvider.tsx +9 -9
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": [
|
package/registry/skeleton.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
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'
|
|
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' also uses this as the diameter) */\n width?: DimensionValue;\n /** @default 16 ('text' uses the line height; 'circle', the width) */\n height?: DimensionValue;\n /** @default 'rect' */\n variant?: SkeletonVariant;\n /** Override of the theme's radius. */\n radius?: number;\n /** Stacked lines, only for variant=\"text\". @default 1 */\n lines?: number;\n /** Width of the last line, so the paragraph doesn't end as a solid block. @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
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Slides",
|
|
3
|
+
"slug": "slides",
|
|
4
|
+
"type": "component",
|
|
5
|
+
"category": "organisms",
|
|
6
|
+
"exportedNames": [
|
|
7
|
+
"Slides"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "organisms/Slides.tsx",
|
|
12
|
+
"content": "import React, { memo, useEffect, useRef } from 'react';\nimport {\n Animated,\n Easing,\n ScrollView,\n StyleSheet,\n Text,\n TouchableOpacity,\n View,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useTheme } from '../theme/UIProvider';\nimport { useReduceMotion } from '../atoms/Transition';\nimport { Gradient, type GradientDirection } from '../atoms/Gradient';\nimport { useSlides, type SlidesOrientation } from './useSlides';\n\nexport type { SlidesOrientation } from './useSlides';\nexport type SlidesIndicators = 'dots' | 'numbers' | 'none';\nexport type SlidesIndicatorPlacement = 'overlay' | 'outside';\n\n/** Un color sólido, o dos o más para un degradado (ver `Gradient`). */\nexport type SlideBackground = string | string[];\n\nexport interface SlidesProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n /** Contenido de la slide: imágenes, botones, lo que sea. Se dibuja sobre `background`. */\n renderItem: (item: T, index: number) => React.ReactNode;\n /** Fondo por slide: `'#101014'` pinta plano, `['#004cef', '#00cae1']` degrada. */\n background?: (item: T, index: number) => SlideBackground | undefined;\n /** Dirección del degradado cuando `background` devuelve varios colores. @default 'vertical' */\n gradientDirection?: GradientDirection;\n /** @default 'horizontal' */\n orientation?: SlidesOrientation;\n /** Círculos, círculos numerados, o ninguno. @default 'dots' */\n indicators?: SlidesIndicators;\n /**\n * 'overlay' los apoya sobre la slide, en blanco; 'outside' los saca fuera\n * del área deslizable, con los colores del tema.\n * @default 'overlay'\n */\n indicatorPlacement?: SlidesIndicatorPlacement;\n /** Después de la última vuelve a la primera (y al revés). @default false */\n loop?: boolean;\n /** Avanza solo. Se pausa mientras el dedo arrastra. @default false */\n autoPlay?: boolean;\n /** ms entre avances de `autoPlay`. @default 4000 */\n interval?: number;\n /**\n * Botón para frenar el avance automático. Sólo aparece con `autoPlay`, y va\n * en `true` porque contenido que se mueve solo necesita una forma de\n * pausarlo (WCAG 2.2.2).\n * @default true\n */\n showAutoPlayToggle?: boolean;\n /** Hueco entre slides, en px. En 0 quedan pegadas. @default 0 */\n spacing?: number;\n /** Índice controlado. Sin él, el componente maneja el suyo desde `defaultIndex`. */\n index?: number;\n /** @default 0 */\n defaultIndex?: number;\n onIndexChange?: (index: number) => void;\n /** Alto del área deslizable. @default 240 */\n height?: number;\n /** @default true */\n swipeEnabled?: boolean;\n /** Estilos de la caja visible de cada slide — padding, alineación, radio. */\n slideStyle?: StyleProp<ViewStyle>;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DOT = 8;\nconst DOT_ACTIVE = 20;\nconst NUMBER_CIRCLE = 24;\nconst TOGGLE = 32;\nconst DEFAULT_HEIGHT = 240;\nconst DEFAULT_INTERVAL = 4000;\n\nconst useStyles = createStyles((theme) => ({\n // Deja redondear el bloque por `style` sin que el degradado se salga.\n root: { overflow: 'hidden' },\n viewport: { flex: 1 },\n // La slide visible es la caja de adentro: con `spacing` la página es más\n // ancha que ella, y el fondo tiene que recortarse acá y no en la página, o\n // el degradado pinta también el hueco.\n slide: { flex: 1, overflow: 'hidden' },\n // Con indicadores fuera y orientación vertical el contenedor es una fila:\n // sin flex la ventana no tiene ancho propio y colapsa a 0.\n viewportInRow: { flex: 1 },\n slideContent: { flex: 1 },\n\n indicatorsRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },\n indicatorsColumn: { flexDirection: 'column', alignItems: 'center', justifyContent: 'center' },\n indicatorsOverlayHorizontal: { position: 'absolute', left: 0, right: 0, bottom: theme.tokens.spacing.md },\n indicatorsOverlayVertical: { position: 'absolute', top: 0, bottom: 0, right: theme.tokens.spacing.md },\n indicatorsOutsideHorizontal: { paddingTop: theme.tokens.spacing.md },\n indicatorsOutsideVertical: { paddingLeft: theme.tokens.spacing.md },\n indicatorSlot: { padding: theme.tokens.spacing.xs },\n\n dot: { width: DOT, height: DOT, borderRadius: DOT / 2 },\n\n autoPlayToggle: {\n position: 'absolute',\n top: theme.tokens.spacing.md,\n right: theme.tokens.spacing.md,\n width: TOGGLE,\n height: TOGGLE,\n borderRadius: TOGGLE / 2,\n alignItems: 'center',\n justifyContent: 'center',\n flexDirection: 'row',\n gap: 3,\n },\n pauseBar: { width: 3, height: 12, borderRadius: 1 },\n // Triángulo con bordes: un \"play\" sin SVG ni set de íconos.\n playTriangle: {\n width: 0,\n height: 0,\n marginLeft: 2,\n borderTopWidth: 6,\n borderBottomWidth: 6,\n borderLeftWidth: 10,\n borderTopColor: 'transparent',\n borderBottomColor: 'transparent',\n },\n\n numberCircle: {\n minWidth: NUMBER_CIRCLE,\n height: NUMBER_CIRCLE,\n borderRadius: NUMBER_CIRCLE / 2,\n borderWidth: 1,\n paddingHorizontal: theme.tokens.spacing.xs,\n alignItems: 'center',\n justifyContent: 'center',\n },\n numberText: { fontSize: theme.tokens.fontSize.xs, fontWeight: theme.tokens.fontWeight.semibold },\n\n outsideHorizontal: { flexDirection: 'column' },\n outsideVertical: { flexDirection: 'row' },\n}));\n\ninterface DotProps {\n selected: boolean;\n isHorizontal: boolean;\n /** En overlay el contraste lo da la opacidad; afuera, el color. */\n overlay: boolean;\n activeColor: string;\n inactiveColor: string;\n testID?: string;\n}\n\n/**\n * El punto activo se estira en vez de saltar: el cambio de golpe se lee como\n * \"apareció otro punto\", no como \"el mismo se movió\". Ancho y color son props\n * de layout/pintura, así que van por el driver de JS.\n */\nconst Dot = memo(({ selected, isHorizontal, overlay, activeColor, inactiveColor, testID }: DotProps) => {\n const styles = useStyles();\n const theme = useTheme();\n const reduceMotion = useReduceMotion();\n const progress = useRef(new Animated.Value(selected ? 1 : 0)).current;\n\n useEffect(() => {\n Animated.timing(progress, {\n toValue: selected ? 1 : 0,\n duration: reduceMotion ? 0 : theme.tokens.duration.base,\n easing: Easing.out(Easing.quad),\n useNativeDriver: false,\n }).start();\n }, [progress, reduceMotion, selected, theme]);\n\n const length = progress.interpolate({ inputRange: [0, 1], outputRange: [DOT, DOT_ACTIVE] });\n const backgroundColor = progress.interpolate({ inputRange: [0, 1], outputRange: [inactiveColor, activeColor] });\n\n return (\n <Animated.View\n testID={testID}\n style={[\n styles.dot,\n isHorizontal ? { width: length } : { height: length },\n { backgroundColor },\n overlay && { opacity: progress.interpolate({ inputRange: [0, 1], outputRange: [0.45, 1] }) },\n ]}\n />\n );\n});\nDot.displayName = 'SlidesDot';\n\n/**\n * Slides\n * Carrusel con fondo propio (color plano o degradado) y contenido libre:\n * `renderItem` decide si adentro va una imagen, texto o botones. El estado\n * vive en `useSlides`; acá sólo está el árbol.\n *\n * El deslizamiento es un `ScrollView` con `pagingEnabled` en vez de gestos a\n * mano: es lo único que da snap nativo en iOS y Android sin\n * `react-native-gesture-handler`, en todo el rango soportado (Expo SDK 54–57).\n */\nexport function Slides<T>({\n data,\n keyExtractor,\n renderItem,\n background,\n gradientDirection = 'vertical',\n orientation = 'horizontal',\n indicators = 'dots',\n indicatorPlacement = 'overlay',\n loop = false,\n autoPlay = false,\n interval = DEFAULT_INTERVAL,\n showAutoPlayToggle = true,\n spacing = 0,\n index,\n defaultIndex = 0,\n onIndexChange,\n height = DEFAULT_HEIGHT,\n swipeEnabled = true,\n slideStyle,\n style,\n testID,\n}: SlidesProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n\n const total = data.length;\n const {\n active,\n pages,\n isHorizontal,\n size,\n pageSize,\n scrollRef,\n autoPlayPaused,\n motionReduced,\n goTo,\n toggleAutoPlay,\n onLayout,\n onMomentumScrollEnd,\n onScrollBeginDrag,\n onScrollEndDrag,\n } = useSlides({ total, orientation, loop, autoPlay, interval, index, defaultIndex, onIndexChange });\n\n const isOverlay = indicatorPlacement === 'overlay';\n\n const renderBackground = (item: T, real: number) => {\n const value = background?.(item, real);\n if (!value) return null;\n if (Array.isArray(value)) {\n return <Gradient colors={value} direction={gradientDirection} style={StyleSheet.absoluteFill} />;\n }\n return <View style={[StyleSheet.absoluteFill, { backgroundColor: value }]} pointerEvents=\"none\" />;\n };\n\n const renderIndicator = (position: number) => {\n const selected = position === active;\n\n if (indicators === 'numbers') {\n const numberColors = isOverlay\n ? {\n background: selected ? colors.white : 'transparent',\n border: colors.white,\n text: selected ? colors.black : colors.white,\n }\n : {\n background: selected ? colors.primary : 'transparent',\n border: selected ? colors.primary : colors.border,\n text: selected ? colors.onPrimary : colors.textLight,\n };\n return (\n <View\n style={[\n styles.numberCircle,\n { backgroundColor: numberColors.background, borderColor: numberColors.border },\n ]}\n >\n <Text allowFontScaling={allowFontScaling} style={[styles.numberText, { color: numberColors.text }]}>\n {position + 1}\n </Text>\n </View>\n );\n }\n\n return (\n <Dot\n testID={`${testID ?? 'slides'}-dot-${position}`}\n selected={selected}\n isHorizontal={isHorizontal}\n overlay={isOverlay}\n activeColor={isOverlay ? colors.white : colors.primary}\n inactiveColor={isOverlay ? colors.white : colors.border}\n />\n );\n };\n\n // Con reduce-motion el autoPlay ya está apagado: no hay nada que pausar.\n const renderAutoPlayToggle = () => {\n if (!autoPlay || !showAutoPlayToggle || motionReduced || total < 2) return null;\n return (\n <TouchableOpacity\n testID={`${testID ?? 'slides'}-autoplay-toggle`}\n style={[styles.autoPlayToggle, { backgroundColor: colors.black, opacity: 0.55 }]}\n activeOpacity={0.7}\n onPress={toggleAutoPlay}\n accessibilityRole=\"button\"\n accessibilityLabel={autoPlayPaused ? 'Resume automatic slides' : 'Pause automatic slides'}\n accessibilityState={{ selected: !autoPlayPaused }}\n >\n {autoPlayPaused ? (\n <View style={[styles.playTriangle, { borderLeftColor: colors.white }]} />\n ) : (\n <>\n <View style={[styles.pauseBar, { backgroundColor: colors.white }]} />\n <View style={[styles.pauseBar, { backgroundColor: colors.white }]} />\n </>\n )}\n </TouchableOpacity>\n );\n };\n\n const renderIndicators = () => {\n if (indicators === 'none' || total < 2) return null;\n return (\n <View\n testID={`${testID ?? 'slides'}-indicators`}\n style={[\n isHorizontal ? styles.indicatorsRow : styles.indicatorsColumn,\n isOverlay\n ? isHorizontal\n ? styles.indicatorsOverlayHorizontal\n : styles.indicatorsOverlayVertical\n : isHorizontal\n ? styles.indicatorsOutsideHorizontal\n : styles.indicatorsOutsideVertical,\n ]}\n pointerEvents=\"box-none\"\n >\n {data.map((item, position) => (\n <TouchableOpacity\n key={`indicator-${keyExtractor(item, position)}`}\n testID={`${testID ?? 'slides'}-indicator-${position}`}\n style={styles.indicatorSlot}\n activeOpacity={0.7}\n onPress={() => goTo(position)}\n accessibilityRole=\"button\"\n accessibilityLabel={`Slide ${position + 1} of ${total}`}\n accessibilityState={{ selected: position === active }}\n >\n {renderIndicator(position)}\n </TouchableOpacity>\n ))}\n </View>\n );\n };\n\n const viewport = (\n <View\n testID={testID}\n style={[styles.root, { height }, !isOverlay && !isHorizontal && styles.viewportInRow, isOverlay && style]}\n onLayout={onLayout}\n >\n <ScrollView\n ref={scrollRef}\n testID={`${testID ?? 'slides'}-scroll`}\n style={styles.viewport}\n horizontal={isHorizontal}\n pagingEnabled\n scrollEnabled={swipeEnabled && total > 1}\n showsHorizontalScrollIndicator={false}\n showsVerticalScrollIndicator={false}\n onMomentumScrollEnd={onMomentumScrollEnd}\n onScrollBeginDrag={onScrollBeginDrag}\n onScrollEndDrag={onScrollEndDrag}\n >\n {pageSize > 0 &&\n pages.map(({ real, clone }) => {\n const item = data[real] as T;\n return (\n // El hueco se reparte a los dos lados de cada página: así la\n // separación entre dos slides es `spacing` entero y la página\n // sigue midiendo exactamente lo que pagina el ScrollView.\n <View\n key={clone ? `slides-clone-${clone}` : keyExtractor(item, real)}\n style={[\n { width: size.width, height: size.height },\n isHorizontal ? { paddingHorizontal: spacing / 2 } : { paddingVertical: spacing / 2 },\n ]}\n >\n <View style={[styles.slide, slideStyle]}>\n {renderBackground(item, real)}\n <View style={styles.slideContent}>{renderItem(item, real)}</View>\n </View>\n </View>\n );\n })}\n </ScrollView>\n {renderAutoPlayToggle()}\n {isOverlay && renderIndicators()}\n </View>\n );\n\n if (isOverlay) return viewport;\n\n return (\n <View style={[isHorizontal ? styles.outsideHorizontal : styles.outsideVertical, style]}>\n {viewport}\n {renderIndicators()}\n </View>\n );\n}\n"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"path": "organisms/useSlides.ts",
|
|
16
|
+
"content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView } from 'react-native';\nimport { useReduceMotion } from '../atoms/Transition';\n\nexport type SlidesOrientation = 'horizontal' | 'vertical';\n\nexport interface UseSlidesOptions {\n total: number;\n orientation: SlidesOrientation;\n loop: boolean;\n autoPlay: boolean;\n interval: number;\n /** Índice controlado; sin él manda el estado interno. */\n index?: number;\n defaultIndex: number;\n onIndexChange?: (index: number) => void;\n}\n\n/** Una página del scroll. Los clones del modo infinito repiten el `real` de un vecino. */\nexport interface SlidesPage {\n real: number;\n clone: 'head' | 'tail' | null;\n}\n\nexport interface UseSlidesResult {\n active: number;\n pages: SlidesPage[];\n isHorizontal: boolean;\n isLoop: boolean;\n size: { width: number; height: number };\n /** Largo de una página sobre el eje del scroll. En 0 todavía no se midió. */\n pageSize: number;\n scrollRef: React.RefObject<ScrollView | null>;\n autoPlayPaused: boolean;\n /** El sistema pidió reducir movimiento: no hay autoPlay ni transiciones. */\n motionReduced: boolean;\n goTo: (index: number) => void;\n toggleAutoPlay: () => void;\n onLayout: (event: LayoutChangeEvent) => void;\n onMomentumScrollEnd: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;\n onScrollBeginDrag: () => void;\n onScrollEndDrag: () => void;\n}\n\nexport function clampIndex(value: number, total: number): number {\n if (total === 0) return 0;\n if (value < 0) return 0;\n if (value > total - 1) return total - 1;\n return value;\n}\n\n/**\n * useSlides\n * Todo lo que en `Slides` no se ve: qué página está encuadrada, cuándo mover el\n * scroll, cómo se mapean los clones del modo infinito a índices reales, y el\n * reloj del avance automático.\n *\n * Trabaja sólo con índices, nunca con los ítems: así no necesita el genérico\n * `T` del componente, y el UI resuelve `pages[i].real` contra su propio `data`.\n */\nexport function useSlides({\n total,\n orientation,\n loop,\n autoPlay,\n interval,\n index,\n defaultIndex,\n onIndexChange,\n}: UseSlidesOptions): UseSlidesResult {\n const motionReduced = useReduceMotion();\n const isHorizontal = orientation === 'horizontal';\n // Un solo elemento no tiene a dónde ciclar: quedaría deslizable entre tres\n // copias de lo mismo.\n const isLoop = loop && total > 1;\n\n const [uncontrolled, setUncontrolled] = useState(() => clampIndex(defaultIndex, total));\n const active = clampIndex(index ?? uncontrolled, total);\n const activeRef = useRef(active);\n activeRef.current = active;\n\n const [autoPlayPaused, setAutoPlayPaused] = useState(false);\n const [size, setSize] = useState({ width: 0, height: 0 });\n const pageSize = isHorizontal ? size.width : size.height;\n\n const scrollRef = useRef<ScrollView>(null);\n /** Página realmente visible (índice dentro de `pages`, clones incluidos). */\n const offsetPageRef = useRef(isLoop ? 1 : 0);\n const didInitRef = useRef(false);\n const lastPageSizeRef = useRef(0);\n const draggingRef = useRef(false);\n\n const pages = useMemo<SlidesPage[]>(() => {\n const real = Array.from({ length: total }, (_, i) => ({ real: i, clone: null }));\n if (!isLoop) return real;\n return [{ real: total - 1, clone: 'head' }, ...real, { real: 0, clone: 'tail' }];\n }, [isLoop, total]);\n\n const scrollToPage = useCallback(\n (page: number, animated: boolean) => {\n const offset = page * pageSize;\n scrollRef.current?.scrollTo({\n x: isHorizontal ? offset : 0,\n y: isHorizontal ? 0 : offset,\n animated,\n });\n },\n [isHorizontal, pageSize]\n );\n\n const goTo = useCallback(\n (next: number) => {\n if (next === activeRef.current) return;\n activeRef.current = next;\n if (index == null) setUncontrolled(next);\n onIndexChange?.(next);\n },\n [index, onIndexChange]\n );\n\n // Sólo actúa cuando el índice y la página visible discrepan: un cambio que\n // vino del propio gesto no dispara un segundo scroll encima del que frenó.\n useEffect(() => {\n if (pageSize === 0 || total === 0) return;\n const target = isLoop ? active + 1 : active;\n const resized = lastPageSizeRef.current !== pageSize;\n if (offsetPageRef.current === target && didInitRef.current && !resized) return;\n // Ni el primer posicionamiento ni el reacomodo por rotación son un cambio\n // de slide: animarlos sería un deslizamiento que nadie pidió.\n const animated = didInitRef.current && !resized;\n didInitRef.current = true;\n lastPageSizeRef.current = pageSize;\n offsetPageRef.current = target;\n scrollToPage(target, animated);\n }, [active, isLoop, pageSize, scrollToPage, total]);\n\n const onLayout = useCallback((event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));\n }, []);\n\n const onMomentumScrollEnd = useCallback(\n (event: NativeSyntheticEvent<NativeScrollEvent>) => {\n draggingRef.current = false;\n if (pageSize === 0) return;\n const { contentOffset } = event.nativeEvent;\n const page = Math.round((isHorizontal ? contentOffset.x : contentOffset.y) / pageSize);\n\n if (isLoop) {\n if (page === 0) {\n offsetPageRef.current = total;\n scrollToPage(total, false);\n goTo(total - 1);\n return;\n }\n if (page === total + 1) {\n offsetPageRef.current = 1;\n scrollToPage(1, false);\n goTo(0);\n return;\n }\n offsetPageRef.current = page;\n goTo(page - 1);\n return;\n }\n\n offsetPageRef.current = page;\n goTo(clampIndex(page, total));\n },\n [goTo, isHorizontal, isLoop, pageSize, scrollToPage, total]\n );\n\n const onScrollBeginDrag = useCallback(() => {\n draggingRef.current = true;\n }, []);\n\n const onScrollEndDrag = useCallback(() => {\n // Un arrastre corto puede soltarse sin inercia y no emitir\n // momentumScrollEnd: sin esto el autoPlay quedaría pausado para siempre.\n draggingRef.current = false;\n }, []);\n\n const toggleAutoPlay = useCallback(() => setAutoPlayPaused((paused) => !paused), []);\n\n useEffect(() => {\n if (!autoPlay || autoPlayPaused || motionReduced || total < 2 || pageSize === 0) return;\n const timer = setInterval(() => {\n if (draggingRef.current) return;\n const from = activeRef.current;\n if (from < total - 1) {\n goTo(from + 1);\n return;\n }\n if (!isLoop) return;\n // Última en modo infinito: avanza al clon de la primera en vez de\n // rebobinar; el salto al original lo cierra momentumScrollEnd.\n offsetPageRef.current = total + 1;\n scrollToPage(total + 1, true);\n }, interval);\n return () => clearInterval(timer);\n }, [autoPlay, autoPlayPaused, goTo, interval, isLoop, motionReduced, pageSize, scrollToPage, total]);\n\n return {\n active,\n pages,\n isHorizontal,\n isLoop,\n size,\n pageSize,\n scrollRef,\n autoPlayPaused,\n motionReduced,\n goTo,\n toggleAutoPlay,\n onLayout,\n onMomentumScrollEnd,\n onScrollBeginDrag,\n onScrollEndDrag,\n };\n}\n"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"registryDependencies": [
|
|
20
|
+
"core",
|
|
21
|
+
"transition",
|
|
22
|
+
"gradient"
|
|
23
|
+
]
|
|
24
|
+
}
|
package/registry/spinner.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "atoms/Spinner.tsx",
|
|
12
|
-
"content": "import React, { memo, useEffect, useRef } from 'react';\nimport { ActivityIndicator, Animated, Easing, Text, View } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\n\nexport type SpinnerVariant = 'native' | 'dots' | 'ring';\n\nexport interface SpinnerProps {\n size?: 'small' | 'large';\n color?: string;\n text?: string;\n /** @default true.
|
|
12
|
+
"content": "import React, { memo, useEffect, useRef } from 'react';\nimport { ActivityIndicator, Animated, Easing, Text, View } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\n\nexport type SpinnerVariant = 'native' | 'dots' | 'ring';\n\nexport interface SpinnerProps {\n size?: 'small' | 'large';\n color?: string;\n text?: string;\n /** @default true. When false, doesn't take up flex:1 (useful inline, inside a button/row). */\n fullscreen?: boolean;\n /**\n * 'native' uses the system's ActivityIndicator; 'dots' and 'ring' are\n * custom indicators, identical on iOS and Android.\n * @default 'native'\n */\n variant?: SpinnerVariant;\n /** Fully custom indicator. Takes precedence over `variant`. */\n indicator?: React.ReactNode;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { justifyContent: 'center', alignItems: 'center', padding: theme.tokens.spacing.xl },\n text: { marginTop: theme.tokens.spacing.md, color: theme.colors.textLight, fontSize: theme.tokens.fontSize.md },\n dots: { flexDirection: 'row', gap: theme.tokens.spacing.sm },\n}));\n\n/** Tres puntos que laten en secuencia. Animated + useNativeDriver: corre en el hilo nativo. */\nconst DotsIndicator = memo(({ color, dotSize }: { color: string; dotSize: number }) => {\n const dots = [useRef(new Animated.Value(0)).current, useRef(new Animated.Value(0)).current, useRef(new Animated.Value(0)).current];\n\n useEffect(() => {\n const animations = dots.map((dot, i) =>\n Animated.loop(\n Animated.sequence([\n Animated.delay(i * 140),\n Animated.timing(dot, { toValue: 1, duration: 320, useNativeDriver: true, easing: Easing.out(Easing.quad) }),\n Animated.timing(dot, { toValue: 0, duration: 320, useNativeDriver: true, easing: Easing.in(Easing.quad) }),\n Animated.delay((2 - i) * 140),\n ])\n )\n );\n animations.forEach((a) => a.start());\n // Sin stop() los loops siguen corriendo tras desmontar y mantienen vivo\n // el árbol de Animated.\n return () => animations.forEach((a) => a.stop());\n }, []);\n\n return (\n <View style={{ flexDirection: 'row', gap: dotSize * 0.6 }}>\n {dots.map((dot, i) => (\n <Animated.View\n key={i}\n style={{\n width: dotSize,\n height: dotSize,\n borderRadius: dotSize / 2,\n backgroundColor: color,\n opacity: dot.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }),\n transform: [{ scale: dot.interpolate({ inputRange: [0, 1], outputRange: [0.75, 1] }) }],\n }}\n />\n ))}\n </View>\n );\n});\nDotsIndicator.displayName = 'DotsIndicator';\n\n/** Anillo que gira: un borde con un solo lado coloreado. */\nconst RingIndicator = memo(({ color, diameter }: { color: string; diameter: number }) => {\n const spin = useRef(new Animated.Value(0)).current;\n\n useEffect(() => {\n const animation = Animated.loop(\n Animated.timing(spin, { toValue: 1, duration: 900, useNativeDriver: true, easing: Easing.linear })\n );\n animation.start();\n return () => animation.stop();\n }, []);\n\n return (\n <Animated.View\n style={{\n width: diameter,\n height: diameter,\n borderRadius: diameter / 2,\n borderWidth: Math.max(2, diameter / 10),\n borderColor: color + '33',\n borderTopColor: color,\n transform: [{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }) }],\n }}\n />\n );\n});\nRingIndicator.displayName = 'RingIndicator';\n\nexport const Spinner = memo(\n ({ size = 'large', color, text, fullscreen = true, variant = 'native', indicator, testID }: SpinnerProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const resolvedColor = color || colors.primary;\n const scale = size === 'small' ? 8 : 12;\n\n const renderIndicator = () => {\n if (indicator) return indicator;\n if (variant === 'dots') return <DotsIndicator color={resolvedColor} dotSize={scale} />;\n if (variant === 'ring') return <RingIndicator color={resolvedColor} diameter={scale * 3} />;\n return <ActivityIndicator size={size} color={resolvedColor} />;\n };\n\n return (\n <View style={[styles.container, fullscreen && { flex: 1 }]} testID={testID}>\n {renderIndicator()}\n {text && (\n <Text allowFontScaling={allowFontScaling} style={styles.text}>\n {text}\n </Text>\n )}\n </View>\n );\n }\n);\nSpinner.displayName = 'Spinner';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/stepper.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
{
|
|
12
12
|
"path": "molecules/Stepper.tsx",
|
|
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 /**
|
|
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 /** Scale of the control: height, buttons, icon and typography. @default 'md' */\n size?: StepperSize;\n /** Takes up all available width and the input absorbs the extra space. @default false */\n block?: boolean;\n /**\n * When false the value can only be changed with the buttons: the keyboard doesn't open and\n * the field loses the background that marks it as editable. @default true\n */\n editable?: boolean;\n /**\n * Minimum allowed value. Disables \"-\" when reached and clamps the typed value on\n * blur. Negative values are only accepted if `min` is negative.\n */\n min?: number;\n /** Maximum allowed value. Disables \"+\" when reached and clamps the typed value. */\n max?: number;\n /** Accepts a decimal separator, normalizing comma to dot. @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"
|
|
14
14
|
}
|
|
15
15
|
],
|
|
16
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 { ScrollView, Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\n\nexport interface StepItem {\n label: string;\n description?: string;\n}\n\nexport type StepStatus = 'completed' | 'current' | 'pending';\n\nexport interface StepsProps {\n steps: StepItem[];\n /**
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { ScrollView, Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\n\nexport interface StepItem {\n label: string;\n description?: string;\n}\n\nexport type StepStatus = 'completed' | 'current' | 'pending';\n\nexport interface StepsProps {\n steps: StepItem[];\n /** Index of the current step (0-based). Earlier steps become 'completed'. */\n current: number;\n /** @default 'horizontal' */\n orientation?: 'horizontal' | 'vertical';\n /**\n * What the circle of an already-completed step shows: a check, or its number.\n * @default 'check'\n */\n completedIndicator?: 'check' | 'number';\n /** If provided, each step is tappable and reports its index. */\n onStepPress?: (index: number) => void;\n style?: StyleProp<ViewStyle>;\n}\n\nconst CIRCLE = 32;\n/** Ancho mínimo de una columna horizontal: por debajo, las etiquetas se parten. */\nconst STEP_MIN_WIDTH = 76;\n\nconst useStyles = createStyles((theme) => ({\n rowContainer: { flexDirection: 'row', alignItems: 'flex-start' },\n columnContainer: { flexDirection: 'column' },\n\n // horizontal\n // ScrollView trae flexGrow: 1 de fábrica y se comía todo el alto disponible\n // del contenedor, dejando los pasos pegados arriba de una caja enorme.\n hScrollView: { flexGrow: 0, flexShrink: 0 },\n hScroll: { flexGrow: 1, flexDirection: 'row', alignItems: 'flex-start' },\n // `minWidth` es lo que evita que RN parta palabras a la mitad\n // (\"Almacé / n\"): antes que apretar la columna por debajo de una palabra\n // corriente, la fila se vuelve scrollable.\n hStep: { flex: 1, minWidth: STEP_MIN_WIDTH, alignItems: 'center', gap: theme.tokens.spacing.sm },\n hLabels: { alignItems: 'center', gap: 2, paddingHorizontal: 4 },\n // El conector toma una fracción chica del ancho: compitiendo de igual a\n // igual con los pasos (flex: 1) cada etiqueta se quedaba con 1/(2n-1) del\n // total.\n hConnector: { height: 2, flex: 0.3, minWidth: 12, marginTop: CIRCLE / 2 - 1 },\n hLabel: { fontSize: theme.tokens.fontSize.sm },\n hDescription: { fontSize: theme.tokens.fontSize.xs },\n\n // vertical\n vStep: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n vIndicatorColumn: { alignItems: 'center' },\n vConnector: { width: 2, flex: 1, minHeight: 20, marginVertical: 4 },\n vLabels: { flex: 1, gap: 2, paddingBottom: theme.tokens.spacing.lg },\n\n circle: {\n width: CIRCLE,\n height: CIRCLE,\n borderRadius: CIRCLE / 2,\n borderWidth: 2,\n alignItems: 'center',\n justifyContent: 'center',\n },\n circleNumber: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.semibold },\n label: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.text },\n labelCentered: { textAlign: 'center' },\n description: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight },\n descriptionCentered: { textAlign: 'center' },\n}));\n\nfunction statusOf(index: number, current: number): StepStatus {\n if (index < current) return 'completed';\n if (index === current) return 'current';\n return 'pending';\n}\n\n/**\n * Steps\n * Indicador de progreso multi-paso. Base visual de `Wizard`, pero utilizable\n * suelto para mostrar el avance de un flujo que se navega de otra forma.\n */\nexport const Steps = memo(\n ({ steps, current, orientation = 'horizontal', completedIndicator = 'check', onStepPress, style }: StepsProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isVertical = orientation === 'vertical';\n\n const colorsFor = (status: StepStatus) => {\n if (status === 'completed') return { bg: colors.primary, border: colors.primary, fg: colors.onPrimary };\n if (status === 'current') return { bg: 'transparent', border: colors.primary, fg: colors.primaryText };\n return { bg: 'transparent', border: colors.border, fg: colors.textLight };\n };\n\n const renderCircle = (index: number, status: StepStatus) => {\n const c = colorsFor(status);\n const showCheck = status === 'completed' && completedIndicator === 'check';\n return (\n <View style={[styles.circle, { backgroundColor: c.bg, borderColor: c.border }]}>\n {showCheck ? (\n <Icon name=\"check\" size={18} color={c.fg} />\n ) : (\n <Text allowFontScaling={allowFontScaling} style={[styles.circleNumber, { color: c.fg }]}>\n {index + 1}\n </Text>\n )}\n </View>\n );\n };\n\n const renderLabels = (step: StepItem, status: StepStatus, centered: boolean) => (\n <View style={centered ? styles.hLabels : styles.vLabels}>\n <Text\n allowFontScaling={allowFontScaling}\n style={[\n styles.label,\n centered && styles.labelCentered,\n centered && styles.hLabel,\n status === 'pending' && { color: colors.textLight },\n ]}\n >\n {step.label}\n </Text>\n {!!step.description && (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.description, centered && styles.descriptionCentered, centered && styles.hDescription]}\n >\n {step.description}\n </Text>\n )}\n </View>\n );\n\n const stepAccessibility = (index: number, step: StepItem, status: StepStatus) => ({\n accessibilityRole: (onStepPress ? 'button' : 'text') as 'button' | 'text',\n accessibilityLabel: `Step ${index + 1}: ${step.label}`,\n accessibilityState: { selected: status === 'current', disabled: false },\n });\n\n if (isVertical) {\n return (\n <View style={[styles.columnContainer, style]}>\n {steps.map((step, index) => {\n const status = statusOf(index, current);\n const isLast = index === steps.length - 1;\n const Wrapper = onStepPress ? TouchableOpacity : View;\n // Sólo tocable si hay onStepPress: pasarle onPress/activeOpacity\n // a un View plano no crashea, pero los deja como ruido silencioso.\n const interactiveProps = onStepPress\n ? { onPress: () => onStepPress(index), activeOpacity: 0.7 }\n : undefined;\n return (\n <Wrapper\n key={`${step.label}-${index}`}\n style={styles.vStep}\n {...interactiveProps}\n {...stepAccessibility(index, step, status)}\n >\n <View style={styles.vIndicatorColumn}>\n {renderCircle(index, status)}\n {!isLast && (\n <View\n style={[\n styles.vConnector,\n { backgroundColor: index < current ? colors.primary : colors.border },\n ]}\n />\n )}\n </View>\n {renderLabels(step, status, false)}\n </Wrapper>\n );\n })}\n </View>\n );\n }\n\n return (\n <ScrollView\n testID=\"steps-row\"\n horizontal\n showsHorizontalScrollIndicator={false}\n // Con espacio de sobra los pasos se reparten la fila igual que antes\n // (flexGrow); cuando no entran, se desplaza en vez de apretar el texto.\n style={[styles.hScrollView, style]}\n contentContainerStyle={styles.hScroll}\n >\n {steps.map((step, index) => {\n const status = statusOf(index, current);\n const isLast = index === steps.length - 1;\n const Wrapper = onStepPress ? TouchableOpacity : View;\n const interactiveProps = onStepPress\n ? { onPress: () => onStepPress(index), activeOpacity: 0.7 }\n : undefined;\n return (\n <React.Fragment key={`${step.label}-${index}`}>\n <Wrapper\n style={styles.hStep}\n {...interactiveProps}\n {...stepAccessibility(index, step, status)}\n >\n {renderCircle(index, status)}\n {renderLabels(step, status, true)}\n </Wrapper>\n {!isLast && (\n <View\n style={[styles.hConnector, { backgroundColor: index < current ? colors.primary : colors.border }]}\n />\n )}\n </React.Fragment>\n );\n })}\n </ScrollView>\n );\n }\n);\nSteps.displayName = 'Steps';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "molecules/SymmetricGrid.tsx",
|
|
12
|
-
"content": "import React, { useMemo } from 'react';\nimport { View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\n\nexport interface SymmetricGridProps<T> {\n data: T[];\n renderItem: (item: T, index: number) => React.ReactElement;\n keyExtractor: (item: T, index: number) => string;\n /**
|
|
12
|
+
"content": "import React, { useMemo } from 'react';\nimport { View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\n\nexport interface SymmetricGridProps<T> {\n data: T[];\n renderItem: (item: T, index: number) => React.ReactElement;\n keyExtractor: (item: T, index: number) => string;\n /** Columns per row. @default 2 */\n columns?: number;\n /** Spacing between cells, on both axes. @default theme.tokens.spacing.md */\n gap?: number;\n /**\n * Incomplete last row: centered (symmetric) instead of stuck to the\n * left with a gap on the right. @default true\n */\n balanceLastRow?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n grid: { gap: theme.tokens.spacing.md },\n row: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n rowBalanced: { justifyContent: 'center' },\n rowFull: { justifyContent: 'flex-start' },\n cell: { flexGrow: 0, flexShrink: 0 },\n}));\n\nfunction chunk<T>(data: T[], size: number): T[][] {\n const rows: T[][] = [];\n for (let i = 0; i < data.length; i += size) rows.push(data.slice(i, i + size));\n return rows;\n}\n\n/**\n * SymmetricGrid\n * Ley de Prägnanz (buena forma): agrupa en filas parejas de N columnas.\n * La última fila, cuando queda incompleta, se centra por defecto en vez de\n * quedar pegada a un borde — eso es lo que la lee como \"fila corta\" y no\n * como un layout roto con un hueco a un lado.\n */\nexport function SymmetricGrid<T>({\n data,\n renderItem,\n keyExtractor,\n columns = 2,\n gap,\n balanceLastRow = true,\n style,\n}: SymmetricGridProps<T>) {\n const styles = useStyles();\n const rows = useMemo(() => chunk(data, columns), [data, columns]);\n const cellWidth = `${100 / columns}%` as const;\n\n return (\n <View style={[styles.grid, gap !== undefined && { gap }, style]}>\n {rows.map((row, rowIndex) => {\n const isLastRow = rowIndex === rows.length - 1;\n const isIncomplete = row.length < columns;\n const centered = isLastRow && isIncomplete && balanceLastRow;\n\n return (\n <View\n // `columns` en la key: si cambia, las filas no reusan nodos DOM\n // de una fila armada con otra cantidad de columnas.\n key={`row-${columns}-${rowIndex}`}\n style={[styles.row, centered ? styles.rowBalanced : styles.rowFull, gap !== undefined && { gap }]}\n >\n {row.map((item, colIndex) => {\n const index = rowIndex * columns + colIndex;\n return (\n <View key={keyExtractor(item, index)} style={[styles.cell, { width: cellWidth }]}>\n {renderItem(item, index)}\n </View>\n );\n })}\n </View>\n );\n })}\n </View>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
{
|
|
15
15
|
"path": "organisms/ToastProvider.tsx",
|
|
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 /**
|
|
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 /** Maximum simultaneous toasts; the oldest ones are dropped. @default 3 */\n maxVisible?: number;\n /** Default duration in 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
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"path": "organisms/imperativeBridge.ts",
|
package/registry/toast.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/Toast.tsx",
|
|
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 /**
|
|
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 /** Fires when the toast is tapped (in addition to closing it). */\n onPress?: () => void;\n onDismiss?: () => void;\n /** Hides the close X. The toast still auto-dismisses by timeout. */\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": [
|
package/registry/transition.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
{
|
|
12
12
|
"path": "atoms/Transition.tsx",
|
|
13
|
-
"content": "import React, { memo, useEffect, useMemo, useRef, useState } from 'react';\nimport { AccessibilityInfo, Animated, Easing, type StyleProp, type ViewStyle } from 'react-native';\nimport { useTheme } from '../theme/UIProvider';\n\nexport type TransitionPreset =\n | 'fade'\n | 'scale'\n | 'pop'\n | 'slide-up'\n | 'slide-down'\n | 'slide-left'\n | 'slide-right';\n\nexport interface TransitionProps {\n children: React.ReactNode;\n /**
|
|
13
|
+
"content": "import React, { memo, useEffect, useMemo, useRef, useState } from 'react';\nimport { AccessibilityInfo, Animated, Easing, type StyleProp, type ViewStyle } from 'react-native';\nimport { useTheme } from '../theme/UIProvider';\n\nexport type TransitionPreset =\n | 'fade'\n | 'scale'\n | 'pop'\n | 'slide-up'\n | 'slide-down'\n | 'slide-left'\n | 'slide-right';\n\nexport interface TransitionProps {\n children: React.ReactNode;\n /** Enters when true, exits (and unmounts) when false. @default true */\n visible?: boolean;\n /** One or more: 'fade scale' combines opacity and scale. @default 'fade' */\n preset?: TransitionPreset | TransitionPreset[];\n /** Timing duration in ms. @default tokens.duration.base */\n duration?: number;\n /** ms before starting. A per-index delay staggers a list. @default 0 */\n delay?: number;\n /** Pixels traveled by the slide presets. @default 24 */\n distance?: number;\n /** Spring instead of timing (implicit in 'pop'). */\n spring?: boolean;\n /** Also animates the first render. When false it starts already present. @default true */\n appear?: boolean;\n /** Keeps the node mounted on exit, only hides it. @default false */\n keepMounted?: boolean;\n /**\n * 0→1 progress driven from outside. With this, Transition only interpolates:\n * whoever passes it decides when and how it advances (a PanResponder, for example).\n */\n value?: Animated.Value;\n onEntered?: () => void;\n onExited?: () => void;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst SCALE_FROM: Record<'scale' | 'pop', number> = { scale: 0.92, pop: 0.8 };\n\n/**\n * Sigue el ajuste de \"reducir movimiento\" del sistema: con él activo las\n * animaciones saltan al estado final en vez de desactivarse, así que los\n * callbacks y el desmontaje siguen ocurriendo igual.\n */\nexport function useReduceMotion(): boolean {\n const [reduceMotion, setReduceMotion] = useState(false);\n\n useEffect(() => {\n let alive = true;\n // Sólo se actualiza el estado si el ajuste está activo: el valor inicial ya\n // es `false` y un setState de false a false costaría un render por montaje.\n AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {\n if (alive && enabled) setReduceMotion(true);\n });\n const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);\n return () => {\n alive = false;\n sub.remove();\n };\n }, []);\n\n return reduceMotion;\n}\n\n/**\n * Transition\n * Envuelve cualquier árbol y lo anima al entrar y al salir. Todos los presets\n * se reducen a `opacity` y `transform` sobre un único Animated.Value, que es\n * lo que `useNativeDriver` sabe mandar al hilo nativo: la animación corre ahí\n * y el hilo de JS no participa de ningún frame — por eso no hay presets de\n * alto, ancho o color, que obligarían a volver a JS en cada frame.\n *\n * El nodo se mantiene montado hasta que termina la animación de salida, y el\n * comportamiento es idéntico en iOS y Android: no hay ninguna rama por\n * plataforma.\n */\nexport const Transition = memo(\n ({\n children,\n visible = true,\n preset = 'fade',\n duration,\n delay = 0,\n distance = 24,\n spring,\n appear = true,\n keepMounted = false,\n value,\n onEntered,\n onExited,\n style,\n testID,\n }: TransitionProps) => {\n const theme = useTheme();\n const reduceMotion = useReduceMotion();\n const internal = useRef(new Animated.Value(visible && !appear ? 1 : 0)).current;\n const progress = value ?? internal;\n const driven = value === undefined;\n\n const [mounted, setMounted] = useState(visible || keepMounted);\n\n // Los callbacks viven en un ref: pasados inline cambian de identidad en\n // cada render y, como dependencias del efecto, reiniciarían la animación.\n const callbacks = useRef({ onEntered, onExited });\n useEffect(() => {\n callbacks.current = { onEntered, onExited };\n });\n\n const presetKey = Array.isArray(preset) ? preset.join(' ') : preset;\n const presets = useMemo(() => presetKey.split(' ') as TransitionPreset[], [presetKey]);\n\n useEffect(() => {\n if (visible) setMounted(true);\n }, [visible]);\n\n useEffect(() => {\n if (!driven) return;\n const toValue = visible ? 1 : 0;\n const useSpring = spring ?? presets.includes('pop');\n\n const animation = reduceMotion\n ? Animated.timing(progress, { toValue, duration: 0, delay, useNativeDriver: true })\n : useSpring\n ? Animated.spring(progress, { toValue, delay, friction: 9, tension: 90, useNativeDriver: true })\n : Animated.timing(progress, {\n toValue,\n delay,\n duration: duration ?? theme.tokens.duration.base,\n easing: visible ? Easing.out(Easing.cubic) : Easing.in(Easing.cubic),\n useNativeDriver: true,\n });\n\n animation.start(({ finished }) => {\n if (!finished) return;\n if (visible) {\n callbacks.current.onEntered?.();\n return;\n }\n callbacks.current.onExited?.();\n if (!keepMounted) setMounted(false);\n });\n\n return () => animation.stop();\n }, [visible, driven, reduceMotion, spring, delay, duration, keepMounted, presets, progress, theme]);\n\n const animatedStyle = useMemo(() => {\n type Interpolated = Animated.AnimatedInterpolation<number>;\n const transform: Array<{ scale: Interpolated } | { translateX: Interpolated } | { translateY: Interpolated }> = [];\n let opacity: Animated.Value | undefined;\n\n for (const item of presets) {\n if (item === 'fade') {\n opacity = progress;\n continue;\n }\n if (item === 'scale' || item === 'pop') {\n transform.push({\n scale: progress.interpolate({ inputRange: [0, 1], outputRange: [SCALE_FROM[item], 1] }),\n });\n continue;\n }\n const vertical = item === 'slide-up' || item === 'slide-down';\n const from = item === 'slide-up' || item === 'slide-left' ? distance : -distance;\n const offset = progress.interpolate({ inputRange: [0, 1], outputRange: [from, 0] });\n transform.push(vertical ? { translateY: offset } : { translateX: offset });\n }\n\n return { opacity, transform };\n }, [presets, progress, distance]);\n\n if (!mounted) return null;\n\n return (\n <Animated.View\n testID={testID}\n pointerEvents={visible ? undefined : 'none'}\n style={[style, animatedStyle]}\n >\n {children}\n </Animated.View>\n );\n }\n);\nTransition.displayName = 'Transition';\n"
|
|
14
14
|
}
|
|
15
15
|
],
|
|
16
16
|
"registryDependencies": [
|
package/registry/wizard.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/Wizard.tsx",
|
|
12
|
-
"content": "import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { ScrollView, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { Steps, type StepItem } from '../molecules/Steps';\nimport { Button } from '../atoms/Button';\nimport { Transition } from '../atoms/Transition';\n\nexport interface WizardStep extends StepItem {\n content: React.ReactNode;\n /** false bloquea el avance desde este paso (validación). @default true */\n canGoNext?: boolean;\n}\n\nexport interface WizardProps {\n steps: WizardStep[];\n /**
|
|
12
|
+
"content": "import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { ScrollView, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { Steps, type StepItem } from '../molecules/Steps';\nimport { Button } from '../atoms/Button';\nimport { Transition } from '../atoms/Transition';\n\nexport interface WizardStep extends StepItem {\n content: React.ReactNode;\n /** false bloquea el avance desde este paso (validación). @default true */\n canGoNext?: boolean;\n}\n\nexport interface WizardProps {\n steps: WizardStep[];\n /** Current step (controlled). If omitted, the Wizard manages its own state. */\n current?: number;\n onStepChange?: (index: number) => void;\n onFinish?: () => void;\n /** @default 'horizontal' */\n orientation?: 'horizontal' | 'vertical';\n completedIndicator?: 'check' | 'number';\n /** Allows returning to an already-completed step by tapping it in the indicator. @default true */\n allowStepNavigation?: boolean;\n backLabel?: string;\n nextLabel?: string;\n finishLabel?: string;\n /**\n * The step content scrolls when it doesn't fit. Set to false only if\n * a step renders its own virtualized list (nesting a\n * VirtualizedList inside a ScrollView breaks virtualization).\n * @default true\n */\n scrollableContent?: boolean;\n /**\n * The step content slides in following the navigation direction.\n * Only opacity and transform, so the animation runs on the native thread.\n * @default true\n */\n animated?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1, gap: theme.tokens.spacing.xl },\n content: { flex: 1 },\n contentInner: { flexGrow: 1, paddingBottom: theme.tokens.spacing.sm },\n footer: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n footerButton: { flex: 1 },\n}));\n\n/**\n * Wizard\n * Flujo multi-paso: indicador (Steps) + contenido del paso actual + barra de\n * navegación. Funciona controlado (`current` + `onStepChange`) o por su\n * cuenta. `canGoNext: false` en un paso deshabilita el avance, que es donde\n * se engancha la validación del formulario de ese paso.\n */\nexport function Wizard({\n steps,\n current: controlledCurrent,\n onStepChange,\n onFinish,\n orientation = 'horizontal',\n completedIndicator = 'check',\n allowStepNavigation = true,\n backLabel = 'Back',\n nextLabel = 'Next',\n finishLabel = 'Finish',\n scrollableContent = true,\n animated = true,\n style,\n}: WizardProps) {\n const styles = useStyles();\n const [uncontrolled, setUncontrolled] = useState(0);\n const isControlled = controlledCurrent !== undefined;\n const current = isControlled ? controlledCurrent : uncontrolled;\n\n const goTo = useCallback(\n (index: number) => {\n const clamped = Math.max(0, Math.min(index, steps.length - 1));\n if (!isControlled) setUncontrolled(clamped);\n onStepChange?.(clamped);\n },\n [isControlled, onStepChange, steps.length]\n );\n\n const stepItems = useMemo(\n () => steps.map(({ label, description }) => ({ label, description })),\n [steps]\n );\n\n // El ref todavía guarda el paso anterior durante el render que trae el nuevo.\n const previous = useRef(current);\n const forward = current >= previous.current;\n const entered = useRef(false);\n useEffect(() => {\n previous.current = current;\n entered.current = true;\n }, [current]);\n\n const activeStep = steps[current];\n const isFirst = current === 0;\n const isLast = current === steps.length - 1;\n const canGoNext = activeStep?.canGoNext !== false;\n\n const handleNext = () => {\n if (isLast) onFinish?.();\n else goTo(current + 1);\n };\n\n // El paso saliente no se anima: mantener los dos montados a la vez duplicaría\n // los formularios y su estado.\n const renderContent = () =>\n animated ? (\n <Transition\n key={current}\n testID=\"wizard-step\"\n preset={['fade', forward ? 'slide-left' : 'slide-right']}\n appear={entered.current}\n >\n {activeStep?.content}\n </Transition>\n ) : (\n activeStep?.content\n );\n\n return (\n <View style={[styles.container, style]}>\n <Steps\n steps={stepItems}\n current={current}\n orientation={orientation}\n completedIndicator={completedIndicator}\n // Sólo se puede volver atrás tocando el indicador; saltar hacia\n // adelante se saltearía la validación de los pasos intermedios.\n onStepPress={allowStepNavigation ? (index) => index < current && goTo(index) : undefined}\n />\n\n {/* El contenido scrollea en vez de desbordar: con el teclado abierto un\n form largo se comía el footer y los botones quedaban pintados encima\n de los campos. */}\n {scrollableContent ? (\n <ScrollView\n testID=\"wizard-content\"\n style={styles.content}\n contentContainerStyle={styles.contentInner}\n keyboardShouldPersistTaps=\"handled\"\n automaticallyAdjustKeyboardInsets\n showsVerticalScrollIndicator={false}\n >\n {renderContent()}\n </ScrollView>\n ) : (\n <View style={styles.content}>{renderContent()}</View>\n )}\n\n <View style={styles.footer}>\n {!isFirst && (\n <Button title={backLabel} variant=\"outline\" onPress={() => goTo(current - 1)} style={styles.footerButton} />\n )}\n <Button\n title={isLast ? finishLabel : nextLabel}\n onPress={handleNext}\n disabled={!canGoNext}\n style={styles.footerButton}\n />\n </View>\n </View>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/src/atoms/Badge.tsx
CHANGED
|
@@ -8,7 +8,7 @@ export type BadgeVariant = 'success' | 'error' | 'warning' | 'info' | 'neutral';
|
|
|
8
8
|
export interface BadgeProps {
|
|
9
9
|
label: string;
|
|
10
10
|
variant?: BadgeVariant;
|
|
11
|
-
/**
|
|
11
|
+
/** Manual override, takes precedence over `variant`. */
|
|
12
12
|
backgroundColor?: string;
|
|
13
13
|
textColor?: string;
|
|
14
14
|
style?: StyleProp<ViewStyle>;
|