orn-ui 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/AGENTS.md +25 -3
  2. package/README.md +9 -8
  3. package/lib/commonjs/atoms/Gradient.js +111 -0
  4. package/lib/commonjs/atoms/Gradient.js.map +1 -0
  5. package/lib/commonjs/atoms/ShinyText.js +188 -0
  6. package/lib/commonjs/atoms/ShinyText.js.map +1 -0
  7. package/lib/commonjs/atoms/index.js +14 -0
  8. package/lib/commonjs/atoms/index.js.map +1 -1
  9. package/lib/commonjs/organisms/Chart.js +947 -0
  10. package/lib/commonjs/organisms/Chart.js.map +1 -0
  11. package/lib/commonjs/organisms/OptionWheel.js +408 -0
  12. package/lib/commonjs/organisms/OptionWheel.js.map +1 -0
  13. package/lib/commonjs/organisms/Slides.js +406 -0
  14. package/lib/commonjs/organisms/Slides.js.map +1 -0
  15. package/lib/commonjs/organisms/Timeline.js +420 -0
  16. package/lib/commonjs/organisms/Timeline.js.map +1 -0
  17. package/lib/commonjs/organisms/chart/frame.js +373 -0
  18. package/lib/commonjs/organisms/chart/frame.js.map +1 -0
  19. package/lib/commonjs/organisms/chart/marks.js +316 -0
  20. package/lib/commonjs/organisms/chart/marks.js.map +1 -0
  21. package/lib/commonjs/organisms/chart/math.js +468 -0
  22. package/lib/commonjs/organisms/chart/math.js.map +1 -0
  23. package/lib/commonjs/organisms/chart/palette.js +120 -0
  24. package/lib/commonjs/organisms/chart/palette.js.map +1 -0
  25. package/lib/commonjs/organisms/index.js +28 -0
  26. package/lib/commonjs/organisms/index.js.map +1 -1
  27. package/lib/commonjs/organisms/useSlides.js +186 -0
  28. package/lib/commonjs/organisms/useSlides.js.map +1 -0
  29. package/lib/module/atoms/Gradient.js +106 -0
  30. package/lib/module/atoms/Gradient.js.map +1 -0
  31. package/lib/module/atoms/ShinyText.js +183 -0
  32. package/lib/module/atoms/ShinyText.js.map +1 -0
  33. package/lib/module/atoms/index.js +2 -0
  34. package/lib/module/atoms/index.js.map +1 -1
  35. package/lib/module/organisms/Chart.js +942 -0
  36. package/lib/module/organisms/Chart.js.map +1 -0
  37. package/lib/module/organisms/OptionWheel.js +403 -0
  38. package/lib/module/organisms/OptionWheel.js.map +1 -0
  39. package/lib/module/organisms/Slides.js +401 -0
  40. package/lib/module/organisms/Slides.js.map +1 -0
  41. package/lib/module/organisms/Timeline.js +415 -0
  42. package/lib/module/organisms/Timeline.js.map +1 -0
  43. package/lib/module/organisms/chart/frame.js +367 -0
  44. package/lib/module/organisms/chart/frame.js.map +1 -0
  45. package/lib/module/organisms/chart/marks.js +310 -0
  46. package/lib/module/organisms/chart/marks.js.map +1 -0
  47. package/lib/module/organisms/chart/math.js +445 -0
  48. package/lib/module/organisms/chart/math.js.map +1 -0
  49. package/lib/module/organisms/chart/palette.js +114 -0
  50. package/lib/module/organisms/chart/palette.js.map +1 -0
  51. package/lib/module/organisms/index.js +4 -0
  52. package/lib/module/organisms/index.js.map +1 -1
  53. package/lib/module/organisms/useSlides.js +182 -0
  54. package/lib/module/organisms/useSlides.js.map +1 -0
  55. package/lib/typescript/atoms/Gradient.d.ts +37 -0
  56. package/lib/typescript/atoms/Gradient.d.ts.map +1 -0
  57. package/lib/typescript/atoms/ShinyText.d.ts +48 -0
  58. package/lib/typescript/atoms/ShinyText.d.ts.map +1 -0
  59. package/lib/typescript/atoms/index.d.ts +4 -0
  60. package/lib/typescript/atoms/index.d.ts.map +1 -1
  61. package/lib/typescript/organisms/Chart.d.ts +208 -0
  62. package/lib/typescript/organisms/Chart.d.ts.map +1 -0
  63. package/lib/typescript/organisms/OptionWheel.d.ts +78 -0
  64. package/lib/typescript/organisms/OptionWheel.d.ts.map +1 -0
  65. package/lib/typescript/organisms/Slides.d.ts +69 -0
  66. package/lib/typescript/organisms/Slides.d.ts.map +1 -0
  67. package/lib/typescript/organisms/Timeline.d.ts +61 -0
  68. package/lib/typescript/organisms/Timeline.d.ts.map +1 -0
  69. package/lib/typescript/organisms/chart/frame.d.ts +85 -0
  70. package/lib/typescript/organisms/chart/frame.d.ts.map +1 -0
  71. package/lib/typescript/organisms/chart/marks.d.ts +88 -0
  72. package/lib/typescript/organisms/chart/marks.d.ts.map +1 -0
  73. package/lib/typescript/organisms/chart/math.d.ts +161 -0
  74. package/lib/typescript/organisms/chart/math.d.ts.map +1 -0
  75. package/lib/typescript/organisms/chart/palette.d.ts +6 -0
  76. package/lib/typescript/organisms/chart/palette.d.ts.map +1 -0
  77. package/lib/typescript/organisms/index.d.ts +8 -0
  78. package/lib/typescript/organisms/index.d.ts.map +1 -1
  79. package/lib/typescript/organisms/useSlides.d.ts +52 -0
  80. package/lib/typescript/organisms/useSlides.d.ts.map +1 -0
  81. package/llms.txt +6 -5
  82. package/package.json +33 -2
  83. package/registry/chart.json +35 -0
  84. package/registry/gradient.json +18 -0
  85. package/registry/index.json +79 -0
  86. package/registry/manifest.json +42 -0
  87. package/registry/option-wheel.json +19 -0
  88. package/registry/shiny-text.json +19 -0
  89. package/registry/slides.json +24 -0
  90. package/registry/timeline.json +20 -0
  91. package/src/atoms/Gradient.tsx +99 -0
  92. package/src/atoms/ShinyText.tsx +226 -0
  93. package/src/atoms/index.ts +4 -0
  94. package/src/organisms/Chart.tsx +1223 -0
  95. package/src/organisms/OptionWheel.tsx +476 -0
  96. package/src/organisms/Slides.tsx +413 -0
  97. package/src/organisms/Timeline.tsx +467 -0
  98. package/src/organisms/chart/frame.tsx +349 -0
  99. package/src/organisms/chart/marks.tsx +295 -0
  100. package/src/organisms/chart/math.ts +475 -0
  101. package/src/organisms/chart/palette.ts +112 -0
  102. package/src/organisms/index.ts +22 -0
  103. package/src/organisms/useSlides.ts +220 -0
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "ShinyText",
3
+ "slug": "shiny-text",
4
+ "type": "component",
5
+ "category": "atoms",
6
+ "exportedNames": [
7
+ "ShinyText"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "atoms/ShinyText.tsx",
12
+ "content": "import React, { memo, useEffect, useMemo, useRef } from 'react';\nimport {\n Animated,\n Easing,\n Text,\n View,\n type StyleProp,\n type TextStyle,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useTheme } from '../theme/UIProvider';\nimport { useReduceMotion } from './Transition';\n\nexport interface ShinyTextProps {\n /** The text to animate. Only a string: the effect needs the characters. */\n text: string;\n /** Milliseconds a single sweep takes end to end. @default 2400 */\n duration?: number;\n /** Pause between sweeps, in ms. @default 600 */\n delay?: number;\n /**\n * Width of the bright band as a fraction of the text, from 0 to 1. Small\n * values give a narrow glint, large ones a soft wave. @default 0.28\n */\n spread?: number;\n /** When false it renders plain text in `shineColor`. @default true */\n animated?: boolean;\n /** Resting color of the characters. @default theme.colors.textLight */\n color?: string;\n /** Color of the band that travels across. @default theme.colors.primary */\n shineColor?: string;\n /** @default 'left' */\n align?: 'left' | 'center' | 'right';\n /** Typography only: `color` and `shineColor` win over any color set here. */\n style?: StyleProp<TextStyle>;\n testID?: string;\n}\n\nconst DEFAULT_DURATION = 2400;\nconst DEFAULT_DELAY = 600;\nconst DEFAULT_SPREAD = 0.28;\n\nconst useStyles = createStyles((theme) => ({\n text: {\n fontSize: theme.tokens.fontSize.xl,\n lineHeight: theme.tokens.lineHeight.lg,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.text,\n },\n row: { flexDirection: 'row', flexWrap: 'wrap' },\n // Sólo top/left: un absoluteFill estiraría la letra al ancho del grupo y la\n // volvería a alinear. Anclada a la esquina cae exactamente sobre la de abajo.\n overlay: { position: 'absolute', top: 0, left: 0 },\n // Una palabra y los espacios que la siguen: el salto de línea ocurre entre\n // grupos, nunca entre las letras de una palabra.\n group: { flexDirection: 'row' },\n}));\n\nconst ALIGN: Record<'left' | 'center' | 'right', ViewStyle['justifyContent']> = {\n left: 'flex-start',\n center: 'center',\n right: 'flex-end',\n};\n\n/** Una palabra con los espacios que la siguen, y el índice de su primera letra. */\ninterface Group {\n start: number;\n chars: string[];\n}\n\nfunction toGroups(text: string): Group[] {\n const chars = Array.from(text);\n const groups: Group[] = [];\n let current: string[] = [];\n let start = 0;\n\n chars.forEach((char, index) => {\n if (current.length === 0) start = index;\n current.push(char);\n const next = chars[index + 1];\n // Cierra el grupo cuando termina la racha de espacios que sigue a la\n // palabra: así el espacio viaja con ella y no abre línea por sí solo.\n const isSpace = /\\s/.test(char);\n if (isSpace && next !== undefined && !/\\s/.test(next)) {\n groups.push({ start, chars: current });\n current = [];\n }\n });\n if (current.length > 0) groups.push({ start, chars: current });\n\n return groups;\n}\n\n/**\n * ShinyText\n * Una banda de luz recorre el texto de izquierda a derecha. Sin\n * `background-clip: text` ni máscaras —que React Native no tiene en el rango\n * soportado— el brillo se compone letra por letra: cada una lleva su color de\n * reposo y encima, superpuesta, la misma letra en el color del brillo con la\n * opacidad animada. La banda es una ventana de interpolación que se desplaza:\n * lo que cambia por frame es sólo `opacity`, o sea que la animación entera\n * corre en el hilo nativo y JS no participa de ningún frame.\n *\n * Gestalt, destino común: las letras se encienden en secuencia y con el mismo\n * ritmo, así que se leen como una sola cosa que se mueve —una palabra\n * iluminándose— y no como N letras parpadeando por su cuenta. Por eso el ancho\n * de banda es relativo al texto y no una cantidad fija de píxeles: un título\n * largo y uno corto tardan lo mismo y se perciben igual.\n *\n * Con \"reducir movimiento\" activo, o con `animated={false}`, el texto se\n * renderiza plano en `shineColor` —legible, un solo nodo y seleccionable—\n * porque el efecto es decorativo y nada de lo que comunica está en el brillo.\n */\nexport const ShinyText = memo(\n ({\n text,\n duration = DEFAULT_DURATION,\n delay = DEFAULT_DELAY,\n spread = DEFAULT_SPREAD,\n animated = true,\n color,\n shineColor,\n align = 'left',\n style,\n testID,\n }: ShinyTextProps) => {\n const theme = useTheme();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const reduceMotion = useReduceMotion();\n const progress = useRef(new Animated.Value(0)).current;\n\n const base = color ?? theme.colors.textLight;\n // El acento, no `text`: contra `textLight` la diferencia es sólo de\n // luminancia y el barrido casi no se ve. El brillo cambia tono además de\n // claridad, que es lo que hace legible el movimiento.\n const shine = shineColor ?? theme.colors.primary;\n const running = animated && !reduceMotion && text.length > 0;\n\n useEffect(() => {\n if (!running) return;\n const loop = Animated.loop(\n Animated.sequence([\n Animated.timing(progress, {\n toValue: 1,\n duration,\n easing: Easing.linear,\n useNativeDriver: true,\n }),\n Animated.delay(delay),\n ])\n );\n loop.start();\n return () => loop.stop();\n }, [running, duration, delay, progress]);\n\n const groups = useMemo(() => (running ? toGroups(text) : []), [running, text]);\n\n if (!running) {\n return (\n <Text\n testID={testID}\n allowFontScaling={allowFontScaling}\n style={[styles.text, style, { color: shine, textAlign: align }]}\n >\n {text}\n </Text>\n );\n }\n\n const total = Array.from(text).length;\n // La banda entra por la izquierda y sale por la derecha, así que el\n // recorrido normalizado mide 1 + 2·spread. `half` es media banda en esa\n // escala: el tramo de progreso que una letra tarda en encenderse.\n const half = spread / (1 + 2 * spread);\n\n return (\n <View\n testID={testID}\n accessible\n accessibilityRole=\"text\"\n accessibilityLabel={text}\n style={[styles.row, { justifyContent: ALIGN[align] }]}\n >\n {groups.map((group) => (\n <View key={group.start} style={styles.group}>\n {group.chars.map((char, offset) => {\n const index = group.start + offset;\n const position = total === 1 ? 0.5 : index / (total - 1);\n // Progreso en el que el centro de la banda cae sobre esta letra.\n const peak = (position + spread) / (1 + 2 * spread);\n const charStyle: StyleProp<TextStyle> = [styles.text, style];\n\n return (\n <View key={index}>\n <Text allowFontScaling={allowFontScaling} style={[charStyle, { color: base }]}>\n {char}\n </Text>\n <Animated.Text\n allowFontScaling={allowFontScaling}\n style={[\n charStyle,\n styles.overlay,\n {\n color: shine,\n opacity: progress.interpolate({\n inputRange: [peak - half, peak, peak + half],\n outputRange: [0, 1, 0],\n extrapolate: 'clamp',\n }),\n },\n ]}\n >\n {char}\n </Animated.Text>\n </View>\n );\n })}\n </View>\n ))}\n </View>\n );\n }\n);\nShinyText.displayName = 'ShinyText';\n"
13
+ }
14
+ ],
15
+ "registryDependencies": [
16
+ "core",
17
+ "transition"
18
+ ]
19
+ }
@@ -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
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "Timeline",
3
+ "slug": "timeline",
4
+ "type": "component",
5
+ "category": "organisms",
6
+ "exportedNames": [
7
+ "Timeline"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "organisms/Timeline.tsx",
12
+ "content": "import React, { memo, useEffect, useMemo, useRef } from 'react';\nimport { Animated, Easing, Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { PressableScale } from '../atoms/PressableScale';\nimport { useReduceMotion } from '../atoms/Transition';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport type TimelineStatus = 'done' | 'pending';\n\n/**\n * Qué puede hacer un toque.\n * - 'free': cualquier hito responde y la línea sigue a `items` para donde\n * vaya, adelante o atrás. Es el que sirve cuando el recorrido no es un\n * progreso, o cuando lo maneja otra cosa.\n * - 'sequential': sólo responde el que sigue al alcanzado, y la línea no\n * retrocede nunca. Avance estricto.\n * - 'revisit': responden el que sigue y todos los ya alcanzados, y la línea\n * tampoco retrocede. Es el caso de \"puedo volver a mirar lo anterior sin\n * perder lo que llevo\".\n */\nexport type TimelineAdvance = 'free' | 'sequential' | 'revisit';\n\nexport interface TimelineItem {\n label: string;\n /** Glyph from the library's set, to the left of the label. */\n iconName?: IconName;\n /** Emoji to the left of the label. Wins over `iconName`. */\n emoji?: string;\n /** @default 'done' */\n status?: TimelineStatus;\n}\n\nexport interface TimelineProps {\n items: TimelineItem[];\n /** Pixels the line bows away from the straight path. 0 draws it straight. @default 28 */\n curve?: number;\n /** Vertical distance between one node and the next. @default 84 */\n spacing?: number;\n /** Side the first pill goes to; the rest alternate. @default 'right' */\n startSide?: 'left' | 'right';\n /** Halo behind each pill. @default true */\n glow?: boolean;\n /** Makes each pill tappable and reports its index. */\n onItemPress?: (index: number) => void;\n /**\n * Which taps are allowed, and whether the line may go back. @default 'free'\n *\n * In 'sequential' and 'revisit' the line keeps the furthest point it has\n * been asked to draw, so a parent that hands back a shorter `items` does not\n * undo the progress. Swapping in a different list keeps it too — reset it by\n * remounting (a `key`).\n */\n advance?: TimelineAdvance;\n /**\n * The milestone being looked at right now, highlighted. It is not the\n * progress: it can sit behind the line without pulling it back.\n */\n selectedIndex?: number;\n /**\n * Milliseconds the line takes to travel one gap when a milestone is\n * reached. @default 420\n */\n duration?: number;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DEFAULT_CURVE = 28;\nconst DEFAULT_SPACING = 84;\nconst NODE = 14;\nconst LINE = 2;\n/** Tramos rectos por cada vano: con menos, la curva se ve poligonal. */\nconst SEGMENTS = 14;\nconst DOT = 3;\n/** Un punto cada cuántos tramos: el camino entero va punteado por debajo. */\nconst DOT_EVERY = 2;\nconst DEFAULT_DURATION = 420;\n/**\n * Cuánto tarda un tramo en encenderse, medido en hitos. Chico para que el\n * frente de la línea se lea como un borde y no como un degradado largo.\n */\nconst FADE = 0.06;\n/**\n * Capas del halo, de afuera hacia adentro. Son muchas y muy tenues a\n * propósito: con tres capas se ven los anillos, que es justo lo contrario de\n * un difuminado. Cuantos más escalones y más chico el salto de opacidad, más\n * cerca queda de un degradado.\n */\n/** Lo que el halo sobresale de la píldora: es lo que hay que dejarle de aire. */\nconst GLOW_BLEED = 20;\nconst GLOW_LAYERS = [\n { inset: -GLOW_BLEED, opacity: 0.03 },\n { inset: -16, opacity: 0.035 },\n { inset: -12, opacity: 0.04 },\n { inset: -8, opacity: 0.045 },\n { inset: -5, opacity: 0.05 },\n { inset: -2, opacity: 0.055 },\n];\n\nconst useStyles = createStyles((theme) => ({\n root: { alignSelf: 'stretch' },\n row: { flexDirection: 'row', alignItems: 'center' },\n side: { flex: 1, justifyContent: 'center' },\n sideLeft: { alignItems: 'flex-end', paddingRight: theme.tokens.spacing.md },\n sideRight: { alignItems: 'flex-start', paddingLeft: theme.tokens.spacing.md },\n node: {\n position: 'absolute',\n width: NODE,\n height: NODE,\n borderRadius: NODE / 2,\n borderWidth: LINE,\n },\n nodePending: { backgroundColor: 'transparent', borderColor: theme.colors.border },\n // Ocupa el círculo entero menos el borde, así el relleno no lo tapa.\n nodeFill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, borderRadius: NODE / 2 },\n pill: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.sm,\n paddingVertical: theme.tokens.spacing.sm,\n paddingHorizontal: theme.tokens.spacing.lg,\n borderRadius: theme.tokens.radius.full,\n borderWidth: 1,\n borderColor: theme.colors.border,\n backgroundColor: theme.colors.surface,\n },\n // El foco se marca con el borde, no con el relleno: el relleno ya está\n // diciendo otra cosa (si el hito está alcanzado o no).\n pillSelected: { borderColor: theme.colors.primary, borderWidth: 2 },\n label: { fontSize: theme.tokens.fontSize.md, color: theme.colors.text },\n labelPending: { color: theme.colors.textLight },\n emoji: { fontSize: theme.tokens.fontSize.md },\n glow: {\n position: 'absolute',\n borderRadius: theme.tokens.radius.full,\n backgroundColor: theme.colors.text,\n },\n // La línea y los nodos viven en la misma capa y se posicionan con las mismas\n // coordenadas. Es lo único que garantiza que el círculo caiga sobre la\n // curva: en el flujo de la fila el nodo lo corre el flex —y los paddings que\n // cada lado necesita para no cortar su píldora—, así que terminaba a un\n // costado de la línea en vez de encima.\n lineLayer: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center' },\n // Hueco del ancho del nodo en la fila: la píldora se separa del centro lo\n // mismo que antes, pero el círculo ya no se dibuja acá.\n nodeSlot: { width: NODE },\n segment: { position: 'absolute', width: LINE },\n dot: { position: 'absolute', width: DOT, height: DOT, borderRadius: DOT / 2 },\n}));\n\n/**\n * Desvío horizontal del nodo `index` respecto del eje. Una onda entera a lo\n * largo de la lista: el primero y el último caen sobre el eje, y los del medio\n * se van a un lado y al otro. Es lo que convierte una columna en un camino.\n */\nfunction offsetAt(index: number, count: number, curve: number): number {\n if (count < 2) return 0;\n return curve * Math.sin((2 * Math.PI * index) / (count - 1));\n}\n\nexport const Timeline = memo(\n ({\n items,\n curve = DEFAULT_CURVE,\n spacing = DEFAULT_SPACING,\n startSide = 'right',\n glow = true,\n onItemPress,\n advance = 'free',\n selectedIndex,\n duration = DEFAULT_DURATION,\n style,\n testID,\n }: TimelineProps) => {\n const styles = useStyles();\n const colors = useColors();\n const allowFontScaling = useAllowFontScaling();\n const reduceMotion = useReduceMotion();\n\n const count = items.length;\n\n /**\n * Hasta dónde llegó el recorrido, en unidades de hito. Se cuenta desde el\n * principio y se corta en el primer pendiente: un hito cumplido después de\n * uno que no lo está no adelanta la línea, porque el camino es un camino.\n */\n const reached = useMemo(() => {\n let last = -1;\n for (let i = 0; i < items.length; i += 1) {\n if (items[i]?.status === 'pending') break;\n last = i;\n }\n return last;\n }, [items]);\n\n /**\n * Marca de agua: salvo en 'free', la línea se queda en lo más lejos que\n * llegó. El progreso es del usuario, no del render — que el padre mande\n * una lista más corta no puede desandar lo que ya recorrió.\n */\n const furthest = useRef(reached);\n if (advance === 'free') furthest.current = reached;\n else furthest.current = Math.max(furthest.current, reached);\n const lineTo = furthest.current;\n\n const progress = useRef(new Animated.Value(lineTo)).current;\n\n useEffect(() => {\n const animation = Animated.timing(progress, {\n toValue: lineTo,\n // Un hito por `duration`: avanzar tres de una vez tarda tres veces\n // más, así se ve el recorrido y no un salto.\n duration: reduceMotion ? 0 : duration,\n easing: Easing.inOut(Easing.cubic),\n useNativeDriver: true,\n });\n animation.start();\n return () => animation.stop();\n }, [lineTo, duration, reduceMotion, progress]);\n\n /**\n * Cada vano partido en tramos rectos cortos, cada uno con su largo y su\n * ángulo. Es la misma idea que `Gradient`: la plataforma no tiene curvas\n * sin dependencia nativa, así que la curva se compone con pedazos rectos\n * lo bastante chicos como para que el ojo no vea el quiebre.\n */\n const pieces = useMemo(() => {\n const out: {\n key: string;\n x: number;\n y: number;\n length: number;\n angle: string;\n at: number;\n dotted: boolean;\n }[] = [];\n\n for (let i = 0; i < count - 1; i += 1) {\n for (let step = 0; step < SEGMENTS; step += 1) {\n const from = i + step / SEGMENTS;\n const to = i + (step + 1) / SEGMENTS;\n const x1 = offsetAt(from, count, curve);\n const x2 = offsetAt(to, count, curve);\n const y1 = from * spacing;\n const y2 = to * spacing;\n const dx = x2 - x1;\n const dy = y2 - y1;\n const length = Math.sqrt(dx * dx + dy * dy);\n\n out.push({\n key: `${i}-${step}`,\n x: (x1 + x2) / 2,\n y: (y1 + y2) / 2,\n length,\n // El tramo se dibuja vertical y se gira hasta la pendiente: el\n // ángulo se mide desde el eje Y, no desde el X.\n angle: `${Math.atan2(dx, dy) * (180 / Math.PI) * -1}deg`,\n // Posición del tramo medida en hitos: es la misma escala que\n // `progress`, así que comparar una cosa con la otra es directo.\n at: to,\n dotted: step % DOT_EVERY === 0,\n });\n }\n }\n\n return out;\n }, [count, curve, spacing]);\n\n if (count === 0) return null;\n\n return (\n <View\n style={[\n styles.root,\n { height: count * spacing },\n style,\n ]}\n testID={testID}\n >\n <View style={styles.lineLayer} pointerEvents=\"none\">\n {/* El camino entero, punteado: es lo que falta recorrer. */}\n {pieces.map(\n (piece) =>\n piece.dotted && (\n <View\n key={`dot-${piece.key}`}\n style={[\n styles.dot,\n {\n backgroundColor: colors.border,\n top: spacing / 2 + piece.y - DOT / 2,\n transform: [{ translateX: piece.x }],\n },\n ]}\n />\n )\n )}\n\n {/* Y encima la línea llena, que se revela tramo a tramo a medida que\n `progress` avanza. Lo único que cambia por frame es `opacity`, o\n sea que el recorrido se dibuja en el hilo nativo. */}\n {pieces.map((piece) => (\n <Animated.View\n key={`line-${piece.key}`}\n style={[\n styles.segment,\n {\n backgroundColor: colors.textLight,\n height: piece.length,\n top: spacing / 2 + piece.y - piece.length / 2,\n opacity: progress.interpolate({\n inputRange: [piece.at - FADE, piece.at],\n outputRange: [0, 1],\n extrapolate: 'clamp',\n }),\n transform: [{ translateX: piece.x }, { rotate: piece.angle }],\n },\n ]}\n />\n ))}\n\n {items.map((item, index) => (\n <View\n key={`node-${item.label}-${index}`}\n style={[\n styles.node,\n styles.nodePending,\n {\n top: spacing / 2 + index * spacing - NODE / 2,\n transform: [{ translateX: offsetAt(index, count, curve) }],\n },\n ]}\n >\n {/* El relleno es un nodo aparte encima del hueco: el color no se\n puede animar en el hilo nativo, la opacidad sí. */}\n <Animated.View\n style={[\n styles.nodeFill,\n {\n backgroundColor: colors.textLight,\n opacity: progress.interpolate({\n inputRange: [index - FADE, index],\n outputRange: [0, 1],\n extrapolate: 'clamp',\n }),\n transform: [\n {\n scale: progress.interpolate({\n inputRange: [index - FADE, index],\n outputRange: [0.4, 1],\n extrapolate: 'clamp',\n }),\n },\n ],\n },\n ]}\n />\n </View>\n ))}\n </View>\n\n {items.map((item, index) => {\n const pending = item.status === 'pending';\n const onRight = (index % 2 === 0) === (startSide === 'right');\n const offset = offsetAt(index, count, curve);\n // La fila entera se corre con la curva, píldora incluida, así que el\n // lado hacia el que se fue pierde ese espacio contra el borde. Se lo\n // devolvemos como padding, más lo que sobresale el halo: sin esto la\n // píldora de más afuera queda cortada al ras.\n const bleed = glow ? GLOW_BLEED : 0;\n const roomLeft = Math.max(0, -offset) + bleed;\n const roomRight = Math.max(0, offset) + bleed;\n\n const pill = (\n <View>\n {glow &&\n GLOW_LAYERS.map((layer) => (\n <View\n key={layer.inset}\n pointerEvents=\"none\"\n style={[\n styles.glow,\n {\n top: layer.inset,\n bottom: layer.inset,\n left: layer.inset,\n right: layer.inset,\n opacity: pending ? layer.opacity / 2 : layer.opacity,\n },\n ]}\n />\n ))}\n <View style={[styles.pill, index === selectedIndex && styles.pillSelected]}>\n {item.emoji ? (\n <Text allowFontScaling={allowFontScaling} style={styles.emoji}>\n {item.emoji}\n </Text>\n ) : (\n !!item.iconName && (\n <Icon name={item.iconName} size={16} color={pending ? colors.textLight : colors.text} />\n )\n )}\n <Text allowFontScaling={allowFontScaling} style={[styles.label, pending && styles.labelPending]}>\n {item.label}\n </Text>\n </View>\n </View>\n );\n\n // 'sequential' deja tocar sólo el que sigue; 'revisit' también los\n // ya recorridos. El que no responde se marca como deshabilitado en\n // vez de quedarse mudo: un botón que no hace nada y no lo dice es\n // peor que un botón apagado.\n const reachable =\n advance === 'sequential'\n ? index === lineTo + 1\n : advance === 'revisit'\n ? index <= lineTo + 1\n : true;\n\n const pressable = onItemPress ? (\n <PressableScale\n onPress={() => onItemPress(index)}\n disabled={!reachable}\n accessibilityRole=\"button\"\n accessibilityLabel={item.label}\n accessibilityState={{ checked: !pending, disabled: !reachable, selected: index === selectedIndex }}\n testID={testID && `${testID}-press-${index}`}\n >\n {pill}\n </PressableScale>\n ) : (\n pill\n );\n\n return (\n <View\n key={`${item.label}-${index}`}\n testID={testID && `${testID}-item-${index}`}\n accessibilityRole={onItemPress ? undefined : 'text'}\n accessibilityLabel={onItemPress ? undefined : item.label}\n accessibilityState={onItemPress ? undefined : { checked: !pending }}\n style={[styles.row, { height: spacing, transform: [{ translateX: offset }] }]}\n >\n <View\n testID={testID && `${testID}-item-${index}-left`}\n style={[styles.side, styles.sideLeft, { paddingLeft: roomLeft }]}\n >\n {!onRight && pressable}\n </View>\n <View style={styles.nodeSlot} />\n <View\n testID={testID && `${testID}-item-${index}-right`}\n style={[styles.side, styles.sideRight, { paddingRight: roomRight }]}\n >\n {onRight && pressable}\n </View>\n </View>\n );\n })}\n </View>\n );\n }\n);\nTimeline.displayName = 'Timeline';\n"
13
+ }
14
+ ],
15
+ "registryDependencies": [
16
+ "core",
17
+ "pressable-scale",
18
+ "transition"
19
+ ]
20
+ }
@@ -0,0 +1,99 @@
1
+ import React, { memo, useMemo } from 'react';
2
+ import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
3
+
4
+ export type GradientDirection = 'vertical' | 'horizontal' | 'diagonal' | 'diagonal-reverse';
5
+
6
+ export interface GradientProps {
7
+ /** Paradas del degradado. Uno solo pinta plano; ninguno no pinta nada. */
8
+ colors: string[];
9
+ /**
10
+ * 'vertical' va de arriba a abajo, 'horizontal' de izquierda a derecha,
11
+ * 'diagonal' de arriba-izquierda a abajo-derecha y 'diagonal-reverse' de
12
+ * arriba-derecha a abajo-izquierda.
13
+ * @default 'vertical'
14
+ */
15
+ direction?: GradientDirection;
16
+ /**
17
+ * Bandas por tramo (un tramo = un par de colores consecutivos). Más bandas
18
+ * = transición más suave y más vistas en el árbol.
19
+ * @default 24
20
+ */
21
+ steps?: number;
22
+ /** Contenido por encima del degradado. */
23
+ children?: React.ReactNode;
24
+ style?: StyleProp<ViewStyle>;
25
+ testID?: string;
26
+ }
27
+
28
+ const DEFAULT_STEPS = 24;
29
+
30
+ const styles = StyleSheet.create({
31
+ // Recorta el plano rotado de las diagonales y el degradado al borderRadius.
32
+ root: { overflow: 'hidden' },
33
+ segment: { flex: 1 },
34
+ band: { flex: 1 },
35
+ // Plano √2 centrado: rotado 45° cubre las esquinas sin medir el contenedor.
36
+ diagonalPlane: { position: 'absolute', top: '-21%', left: '-21%', width: '142%', height: '142%' },
37
+ });
38
+
39
+ /**
40
+ * Gradient
41
+ * Degradado lineal sin dependencias: React Native no trae uno en el rango
42
+ * soportado (Expo SDK 54–57) y `experimental_backgroundImage` no está en toda
43
+ * esa ventana, así que se compone con bandas.
44
+ *
45
+ * Cada tramo pinta el color inicial y encima N bandas del final con opacidad
46
+ * de 0 a 1. El compositor mezcla alpha linealmente: el resultado es la
47
+ * interpolación sRGB entre paradas sin parsear el color, y por eso vale
48
+ * cualquier formato que acepte RN (hex, `rgba()`, nombre).
49
+ */
50
+ export const Gradient = memo(
51
+ ({ colors, direction = 'vertical', steps = DEFAULT_STEPS, children, style, testID }: GradientProps) => {
52
+ const isRow = direction === 'horizontal';
53
+ const isDiagonal = direction === 'diagonal' || direction === 'diagonal-reverse';
54
+ // rotate positivo gira en horario (Y crece hacia abajo): -45° manda el eje
55
+ // del degradado hacia abajo-derecha.
56
+ const rotate = direction === 'diagonal' ? '-45deg' : '45deg';
57
+ const bandCount = Math.max(2, Math.floor(steps));
58
+
59
+ const painted = useMemo(() => {
60
+ if (colors.length === 0) return null;
61
+ if (colors.length === 1) {
62
+ return <View style={[StyleSheet.absoluteFill, { backgroundColor: colors[0] }]} />;
63
+ }
64
+
65
+ const segments = colors.slice(0, -1).map((from, index) => {
66
+ const to = colors[index + 1] as string;
67
+ return (
68
+ <View
69
+ key={`${from}-${to}-${index}`}
70
+ style={[styles.segment, { backgroundColor: from, flexDirection: isRow ? 'row' : 'column' }]}
71
+ >
72
+ {Array.from({ length: bandCount }, (_, band) => (
73
+ <View key={band} style={[styles.band, { backgroundColor: to, opacity: band / (bandCount - 1) }]} />
74
+ ))}
75
+ </View>
76
+ );
77
+ });
78
+
79
+ const stack = (
80
+ <View style={[StyleSheet.absoluteFill, { flexDirection: isRow ? 'row' : 'column' }]}>{segments}</View>
81
+ );
82
+
83
+ if (!isDiagonal) return stack;
84
+ return <View style={[styles.diagonalPlane, { transform: [{ rotate }] }]}>{segments}</View>;
85
+ }, [colors, isRow, isDiagonal, rotate, bandCount]);
86
+
87
+ return (
88
+ <View testID={testID} style={[styles.root, style]}>
89
+ {/* pointerEvents va en el pintado, no en la raíz: el degradado no debe
90
+ robarle los toques a lo que el consumidor ponga encima. */}
91
+ <View style={StyleSheet.absoluteFill} pointerEvents="none">
92
+ {painted}
93
+ </View>
94
+ {children}
95
+ </View>
96
+ );
97
+ }
98
+ );
99
+ Gradient.displayName = 'Gradient';
@@ -0,0 +1,226 @@
1
+ import React, { memo, useEffect, useMemo, useRef } from 'react';
2
+ import {
3
+ Animated,
4
+ Easing,
5
+ Text,
6
+ View,
7
+ type StyleProp,
8
+ type TextStyle,
9
+ type ViewStyle,
10
+ } from 'react-native';
11
+ import { createStyles } from '../theme/createStyles';
12
+ import { useAllowFontScaling, useTheme } from '../theme/UIProvider';
13
+ import { useReduceMotion } from './Transition';
14
+
15
+ export interface ShinyTextProps {
16
+ /** The text to animate. Only a string: the effect needs the characters. */
17
+ text: string;
18
+ /** Milliseconds a single sweep takes end to end. @default 2400 */
19
+ duration?: number;
20
+ /** Pause between sweeps, in ms. @default 600 */
21
+ delay?: number;
22
+ /**
23
+ * Width of the bright band as a fraction of the text, from 0 to 1. Small
24
+ * values give a narrow glint, large ones a soft wave. @default 0.28
25
+ */
26
+ spread?: number;
27
+ /** When false it renders plain text in `shineColor`. @default true */
28
+ animated?: boolean;
29
+ /** Resting color of the characters. @default theme.colors.textLight */
30
+ color?: string;
31
+ /** Color of the band that travels across. @default theme.colors.primary */
32
+ shineColor?: string;
33
+ /** @default 'left' */
34
+ align?: 'left' | 'center' | 'right';
35
+ /** Typography only: `color` and `shineColor` win over any color set here. */
36
+ style?: StyleProp<TextStyle>;
37
+ testID?: string;
38
+ }
39
+
40
+ const DEFAULT_DURATION = 2400;
41
+ const DEFAULT_DELAY = 600;
42
+ const DEFAULT_SPREAD = 0.28;
43
+
44
+ const useStyles = createStyles((theme) => ({
45
+ text: {
46
+ fontSize: theme.tokens.fontSize.xl,
47
+ lineHeight: theme.tokens.lineHeight.lg,
48
+ fontWeight: theme.tokens.fontWeight.semibold,
49
+ color: theme.colors.text,
50
+ },
51
+ row: { flexDirection: 'row', flexWrap: 'wrap' },
52
+ // Sólo top/left: un absoluteFill estiraría la letra al ancho del grupo y la
53
+ // volvería a alinear. Anclada a la esquina cae exactamente sobre la de abajo.
54
+ overlay: { position: 'absolute', top: 0, left: 0 },
55
+ // Una palabra y los espacios que la siguen: el salto de línea ocurre entre
56
+ // grupos, nunca entre las letras de una palabra.
57
+ group: { flexDirection: 'row' },
58
+ }));
59
+
60
+ const ALIGN: Record<'left' | 'center' | 'right', ViewStyle['justifyContent']> = {
61
+ left: 'flex-start',
62
+ center: 'center',
63
+ right: 'flex-end',
64
+ };
65
+
66
+ /** Una palabra con los espacios que la siguen, y el índice de su primera letra. */
67
+ interface Group {
68
+ start: number;
69
+ chars: string[];
70
+ }
71
+
72
+ function toGroups(text: string): Group[] {
73
+ const chars = Array.from(text);
74
+ const groups: Group[] = [];
75
+ let current: string[] = [];
76
+ let start = 0;
77
+
78
+ chars.forEach((char, index) => {
79
+ if (current.length === 0) start = index;
80
+ current.push(char);
81
+ const next = chars[index + 1];
82
+ // Cierra el grupo cuando termina la racha de espacios que sigue a la
83
+ // palabra: así el espacio viaja con ella y no abre línea por sí solo.
84
+ const isSpace = /\s/.test(char);
85
+ if (isSpace && next !== undefined && !/\s/.test(next)) {
86
+ groups.push({ start, chars: current });
87
+ current = [];
88
+ }
89
+ });
90
+ if (current.length > 0) groups.push({ start, chars: current });
91
+
92
+ return groups;
93
+ }
94
+
95
+ /**
96
+ * ShinyText
97
+ * Una banda de luz recorre el texto de izquierda a derecha. Sin
98
+ * `background-clip: text` ni máscaras —que React Native no tiene en el rango
99
+ * soportado— el brillo se compone letra por letra: cada una lleva su color de
100
+ * reposo y encima, superpuesta, la misma letra en el color del brillo con la
101
+ * opacidad animada. La banda es una ventana de interpolación que se desplaza:
102
+ * lo que cambia por frame es sólo `opacity`, o sea que la animación entera
103
+ * corre en el hilo nativo y JS no participa de ningún frame.
104
+ *
105
+ * Gestalt, destino común: las letras se encienden en secuencia y con el mismo
106
+ * ritmo, así que se leen como una sola cosa que se mueve —una palabra
107
+ * iluminándose— y no como N letras parpadeando por su cuenta. Por eso el ancho
108
+ * de banda es relativo al texto y no una cantidad fija de píxeles: un título
109
+ * largo y uno corto tardan lo mismo y se perciben igual.
110
+ *
111
+ * Con "reducir movimiento" activo, o con `animated={false}`, el texto se
112
+ * renderiza plano en `shineColor` —legible, un solo nodo y seleccionable—
113
+ * porque el efecto es decorativo y nada de lo que comunica está en el brillo.
114
+ */
115
+ export const ShinyText = memo(
116
+ ({
117
+ text,
118
+ duration = DEFAULT_DURATION,
119
+ delay = DEFAULT_DELAY,
120
+ spread = DEFAULT_SPREAD,
121
+ animated = true,
122
+ color,
123
+ shineColor,
124
+ align = 'left',
125
+ style,
126
+ testID,
127
+ }: ShinyTextProps) => {
128
+ const theme = useTheme();
129
+ const styles = useStyles();
130
+ const allowFontScaling = useAllowFontScaling();
131
+ const reduceMotion = useReduceMotion();
132
+ const progress = useRef(new Animated.Value(0)).current;
133
+
134
+ const base = color ?? theme.colors.textLight;
135
+ // El acento, no `text`: contra `textLight` la diferencia es sólo de
136
+ // luminancia y el barrido casi no se ve. El brillo cambia tono además de
137
+ // claridad, que es lo que hace legible el movimiento.
138
+ const shine = shineColor ?? theme.colors.primary;
139
+ const running = animated && !reduceMotion && text.length > 0;
140
+
141
+ useEffect(() => {
142
+ if (!running) return;
143
+ const loop = Animated.loop(
144
+ Animated.sequence([
145
+ Animated.timing(progress, {
146
+ toValue: 1,
147
+ duration,
148
+ easing: Easing.linear,
149
+ useNativeDriver: true,
150
+ }),
151
+ Animated.delay(delay),
152
+ ])
153
+ );
154
+ loop.start();
155
+ return () => loop.stop();
156
+ }, [running, duration, delay, progress]);
157
+
158
+ const groups = useMemo(() => (running ? toGroups(text) : []), [running, text]);
159
+
160
+ if (!running) {
161
+ return (
162
+ <Text
163
+ testID={testID}
164
+ allowFontScaling={allowFontScaling}
165
+ style={[styles.text, style, { color: shine, textAlign: align }]}
166
+ >
167
+ {text}
168
+ </Text>
169
+ );
170
+ }
171
+
172
+ const total = Array.from(text).length;
173
+ // La banda entra por la izquierda y sale por la derecha, así que el
174
+ // recorrido normalizado mide 1 + 2·spread. `half` es media banda en esa
175
+ // escala: el tramo de progreso que una letra tarda en encenderse.
176
+ const half = spread / (1 + 2 * spread);
177
+
178
+ return (
179
+ <View
180
+ testID={testID}
181
+ accessible
182
+ accessibilityRole="text"
183
+ accessibilityLabel={text}
184
+ style={[styles.row, { justifyContent: ALIGN[align] }]}
185
+ >
186
+ {groups.map((group) => (
187
+ <View key={group.start} style={styles.group}>
188
+ {group.chars.map((char, offset) => {
189
+ const index = group.start + offset;
190
+ const position = total === 1 ? 0.5 : index / (total - 1);
191
+ // Progreso en el que el centro de la banda cae sobre esta letra.
192
+ const peak = (position + spread) / (1 + 2 * spread);
193
+ const charStyle: StyleProp<TextStyle> = [styles.text, style];
194
+
195
+ return (
196
+ <View key={index}>
197
+ <Text allowFontScaling={allowFontScaling} style={[charStyle, { color: base }]}>
198
+ {char}
199
+ </Text>
200
+ <Animated.Text
201
+ allowFontScaling={allowFontScaling}
202
+ style={[
203
+ charStyle,
204
+ styles.overlay,
205
+ {
206
+ color: shine,
207
+ opacity: progress.interpolate({
208
+ inputRange: [peak - half, peak, peak + half],
209
+ outputRange: [0, 1, 0],
210
+ extrapolate: 'clamp',
211
+ }),
212
+ },
213
+ ]}
214
+ >
215
+ {char}
216
+ </Animated.Text>
217
+ </View>
218
+ );
219
+ })}
220
+ </View>
221
+ ))}
222
+ </View>
223
+ );
224
+ }
225
+ );
226
+ ShinyText.displayName = 'ShinyText';
@@ -14,6 +14,8 @@ export { Card } from './Card';
14
14
  export type { CardProps } from './Card';
15
15
  export { Divider } from './Divider';
16
16
  export type { DividerProps } from './Divider';
17
+ export { Gradient } from './Gradient';
18
+ export type { GradientProps, GradientDirection } from './Gradient';
17
19
  export { Avatar } from './Avatar';
18
20
  export type { AvatarProps } from './Avatar';
19
21
  export { Image, prefetchImage } from './Image';
@@ -24,6 +26,8 @@ export { Skeleton } from './Skeleton';
24
26
  export type { SkeletonProps, SkeletonVariant } from './Skeleton';
25
27
  export { Transition, useReduceMotion } from './Transition';
26
28
  export type { TransitionProps, TransitionPreset } from './Transition';
29
+ export { ShinyText } from './ShinyText';
30
+ export type { ShinyTextProps } from './ShinyText';
27
31
  export { EmptyState } from './EmptyState';
28
32
  export type { EmptyStateProps } from './EmptyState';
29
33
  export { KeyValueRow } from './KeyValueRow';