orn-ui 0.2.8 → 0.2.10
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 +6 -2
- package/README.md +6 -6
- package/lib/commonjs/atoms/ShinyText.js +188 -0
- package/lib/commonjs/atoms/ShinyText.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/Chart.js +968 -0
- package/lib/commonjs/organisms/Chart.js.map +1 -0
- package/lib/commonjs/organisms/OptionWheel.js +408 -0
- package/lib/commonjs/organisms/OptionWheel.js.map +1 -0
- package/lib/commonjs/organisms/Timeline.js +420 -0
- package/lib/commonjs/organisms/Timeline.js.map +1 -0
- package/lib/commonjs/organisms/chart/frame.js +373 -0
- package/lib/commonjs/organisms/chart/frame.js.map +1 -0
- package/lib/commonjs/organisms/chart/marks.js +316 -0
- package/lib/commonjs/organisms/chart/marks.js.map +1 -0
- package/lib/commonjs/organisms/chart/math.js +468 -0
- package/lib/commonjs/organisms/chart/math.js.map +1 -0
- package/lib/commonjs/organisms/chart/palette.js +120 -0
- package/lib/commonjs/organisms/chart/palette.js.map +1 -0
- package/lib/commonjs/organisms/index.js +21 -0
- package/lib/commonjs/organisms/index.js.map +1 -1
- package/lib/module/atoms/ShinyText.js +183 -0
- package/lib/module/atoms/ShinyText.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/Chart.js +963 -0
- package/lib/module/organisms/Chart.js.map +1 -0
- package/lib/module/organisms/OptionWheel.js +403 -0
- package/lib/module/organisms/OptionWheel.js.map +1 -0
- package/lib/module/organisms/Timeline.js +415 -0
- package/lib/module/organisms/Timeline.js.map +1 -0
- package/lib/module/organisms/chart/frame.js +367 -0
- package/lib/module/organisms/chart/frame.js.map +1 -0
- package/lib/module/organisms/chart/marks.js +310 -0
- package/lib/module/organisms/chart/marks.js.map +1 -0
- package/lib/module/organisms/chart/math.js +445 -0
- package/lib/module/organisms/chart/math.js.map +1 -0
- package/lib/module/organisms/chart/palette.js +114 -0
- package/lib/module/organisms/chart/palette.js.map +1 -0
- package/lib/module/organisms/index.js +3 -0
- package/lib/module/organisms/index.js.map +1 -1
- package/lib/typescript/atoms/ShinyText.d.ts +48 -0
- package/lib/typescript/atoms/ShinyText.d.ts.map +1 -0
- package/lib/typescript/atoms/index.d.ts +2 -0
- package/lib/typescript/atoms/index.d.ts.map +1 -1
- package/lib/typescript/organisms/Chart.d.ts +208 -0
- package/lib/typescript/organisms/Chart.d.ts.map +1 -0
- package/lib/typescript/organisms/OptionWheel.d.ts +78 -0
- package/lib/typescript/organisms/OptionWheel.d.ts.map +1 -0
- package/lib/typescript/organisms/Timeline.d.ts +61 -0
- package/lib/typescript/organisms/Timeline.d.ts.map +1 -0
- package/lib/typescript/organisms/chart/frame.d.ts +85 -0
- package/lib/typescript/organisms/chart/frame.d.ts.map +1 -0
- package/lib/typescript/organisms/chart/marks.d.ts +88 -0
- package/lib/typescript/organisms/chart/marks.d.ts.map +1 -0
- package/lib/typescript/organisms/chart/math.d.ts +161 -0
- package/lib/typescript/organisms/chart/math.d.ts.map +1 -0
- package/lib/typescript/organisms/chart/palette.d.ts +6 -0
- package/lib/typescript/organisms/chart/palette.d.ts.map +1 -0
- package/lib/typescript/organisms/index.d.ts +6 -0
- package/lib/typescript/organisms/index.d.ts.map +1 -1
- package/llms.txt +3 -3
- package/package.json +23 -2
- package/registry/chart.json +35 -0
- package/registry/index.json +53 -0
- package/registry/manifest.json +28 -0
- package/registry/option-wheel.json +19 -0
- package/registry/shiny-text.json +19 -0
- package/registry/timeline.json +20 -0
- package/src/atoms/ShinyText.tsx +226 -0
- package/src/atoms/index.ts +2 -0
- package/src/organisms/Chart.tsx +1252 -0
- package/src/organisms/OptionWheel.tsx +476 -0
- package/src/organisms/Timeline.tsx +467 -0
- package/src/organisms/chart/frame.tsx +349 -0
- package/src/organisms/chart/marks.tsx +295 -0
- package/src/organisms/chart/math.ts +475 -0
- package/src/organisms/chart/palette.ts +112 -0
- package/src/organisms/index.ts +14 -0
package/registry/index.json
CHANGED
|
@@ -178,6 +178,19 @@
|
|
|
178
178
|
],
|
|
179
179
|
"bytes": 6569
|
|
180
180
|
},
|
|
181
|
+
{
|
|
182
|
+
"name": "ShinyText",
|
|
183
|
+
"slug": "shiny-text",
|
|
184
|
+
"category": "atoms",
|
|
185
|
+
"exportedNames": [
|
|
186
|
+
"ShinyText"
|
|
187
|
+
],
|
|
188
|
+
"registryDependencies": [
|
|
189
|
+
"core",
|
|
190
|
+
"transition"
|
|
191
|
+
],
|
|
192
|
+
"bytes": 7848
|
|
193
|
+
},
|
|
181
194
|
{
|
|
182
195
|
"name": "EmptyState",
|
|
183
196
|
"slug": "empty-state",
|
|
@@ -327,6 +340,20 @@
|
|
|
327
340
|
],
|
|
328
341
|
"bytes": 2773
|
|
329
342
|
},
|
|
343
|
+
{
|
|
344
|
+
"name": "Timeline",
|
|
345
|
+
"slug": "timeline",
|
|
346
|
+
"category": "organisms",
|
|
347
|
+
"exportedNames": [
|
|
348
|
+
"Timeline"
|
|
349
|
+
],
|
|
350
|
+
"registryDependencies": [
|
|
351
|
+
"core",
|
|
352
|
+
"pressable-scale",
|
|
353
|
+
"transition"
|
|
354
|
+
],
|
|
355
|
+
"bytes": 17449
|
|
356
|
+
},
|
|
330
357
|
{
|
|
331
358
|
"name": "Modal",
|
|
332
359
|
"slug": "modal",
|
|
@@ -359,6 +386,19 @@
|
|
|
359
386
|
],
|
|
360
387
|
"bytes": 9678
|
|
361
388
|
},
|
|
389
|
+
{
|
|
390
|
+
"name": "OptionWheel",
|
|
391
|
+
"slug": "option-wheel",
|
|
392
|
+
"category": "organisms",
|
|
393
|
+
"exportedNames": [
|
|
394
|
+
"OptionWheel"
|
|
395
|
+
],
|
|
396
|
+
"registryDependencies": [
|
|
397
|
+
"core",
|
|
398
|
+
"transition"
|
|
399
|
+
],
|
|
400
|
+
"bytes": 18339
|
|
401
|
+
},
|
|
362
402
|
{
|
|
363
403
|
"name": "Select",
|
|
364
404
|
"slug": "select",
|
|
@@ -579,6 +619,19 @@
|
|
|
579
619
|
"gradient"
|
|
580
620
|
],
|
|
581
621
|
"bytes": 21434
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
"name": "Chart",
|
|
625
|
+
"slug": "chart",
|
|
626
|
+
"category": "organisms",
|
|
627
|
+
"exportedNames": [
|
|
628
|
+
"Chart"
|
|
629
|
+
],
|
|
630
|
+
"registryDependencies": [
|
|
631
|
+
"core",
|
|
632
|
+
"segmented-control"
|
|
633
|
+
],
|
|
634
|
+
"bytes": 91806
|
|
582
635
|
}
|
|
583
636
|
]
|
|
584
637
|
}
|
package/registry/manifest.json
CHANGED
|
@@ -106,6 +106,13 @@
|
|
|
106
106
|
"file": "atoms/Transition.tsx",
|
|
107
107
|
"slug": "transition"
|
|
108
108
|
},
|
|
109
|
+
{
|
|
110
|
+
"name": "ShinyText",
|
|
111
|
+
"siblingNames": [],
|
|
112
|
+
"category": "atoms",
|
|
113
|
+
"file": "atoms/ShinyText.tsx",
|
|
114
|
+
"slug": "shiny-text"
|
|
115
|
+
},
|
|
109
116
|
{
|
|
110
117
|
"name": "EmptyState",
|
|
111
118
|
"siblingNames": [],
|
|
@@ -192,6 +199,13 @@
|
|
|
192
199
|
"file": "molecules/SymmetricGrid.tsx",
|
|
193
200
|
"slug": "symmetric-grid"
|
|
194
201
|
},
|
|
202
|
+
{
|
|
203
|
+
"name": "Timeline",
|
|
204
|
+
"siblingNames": [],
|
|
205
|
+
"category": "organisms",
|
|
206
|
+
"file": "organisms/Timeline.tsx",
|
|
207
|
+
"slug": "timeline"
|
|
208
|
+
},
|
|
195
209
|
{
|
|
196
210
|
"name": "Modal",
|
|
197
211
|
"siblingNames": [],
|
|
@@ -206,6 +220,13 @@
|
|
|
206
220
|
"file": "organisms/BottomSheet.tsx",
|
|
207
221
|
"slug": "bottom-sheet"
|
|
208
222
|
},
|
|
223
|
+
{
|
|
224
|
+
"name": "OptionWheel",
|
|
225
|
+
"siblingNames": [],
|
|
226
|
+
"category": "organisms",
|
|
227
|
+
"file": "organisms/OptionWheel.tsx",
|
|
228
|
+
"slug": "option-wheel"
|
|
229
|
+
},
|
|
209
230
|
{
|
|
210
231
|
"name": "Select",
|
|
211
232
|
"siblingNames": [],
|
|
@@ -324,6 +345,13 @@
|
|
|
324
345
|
"category": "organisms",
|
|
325
346
|
"file": "organisms/Slides.tsx",
|
|
326
347
|
"slug": "slides"
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
"name": "Chart",
|
|
351
|
+
"siblingNames": [],
|
|
352
|
+
"category": "organisms",
|
|
353
|
+
"file": "organisms/Chart.tsx",
|
|
354
|
+
"slug": "chart"
|
|
327
355
|
}
|
|
328
356
|
]
|
|
329
357
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "OptionWheel",
|
|
3
|
+
"slug": "option-wheel",
|
|
4
|
+
"type": "component",
|
|
5
|
+
"category": "organisms",
|
|
6
|
+
"exportedNames": [
|
|
7
|
+
"OptionWheel"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "organisms/OptionWheel.tsx",
|
|
12
|
+
"content": "import React, { useCallback, useEffect, useMemo, useRef } from 'react';\nimport {\n Animated,\n ScrollView,\n Text,\n View,\n type LayoutChangeEvent,\n type NativeScrollEvent,\n type NativeSyntheticEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useTheme } from '../theme/UIProvider';\nimport { useReduceMotion } from '../atoms/Transition';\n\nexport interface OptionWheelOption<T> {\n label: string;\n value: T;\n /** The wheel refuses to settle here and slides to the nearest enabled row. */\n disabled?: boolean;\n}\n\nexport type OptionWheelVariant = 'window' | 'spotlight';\n\nexport interface OptionWheelProps<T> {\n options: OptionWheelOption<T>[];\n selectedValue: T | undefined;\n onSelect: (value: T) => void;\n /** Title above the wheel. */\n label?: string;\n /** Required when there's no `label`: the wheel as a whole needs a name. */\n accessibilityLabel?: string;\n /**\n * Rows on screen. Forced to the next odd number so one of them is the\n * centre. @default 5\n */\n visibleCount?: number;\n /** Height of each row. @default 44 */\n itemHeight?: number;\n /** Fixed text to the right of the centre row ('kg', 'min'). */\n unit?: string;\n /** Tilts the far rows to suggest a cylinder. @default true */\n perspective?: boolean;\n /**\n * 'window' marks the pick with a band behind the centre row. 'spotlight'\n * drops the band and lets the far rows fall away and blur instead, which\n * needs a quiet surface under it to read.\n * @default 'window'\n */\n variant?: OptionWheelVariant;\n /**\n * Colour of the option labels. The surface the wheel sits on is whoever\n * composes it to paint — this is how the text keeps contrast against it.\n * @default theme.colors.text\n */\n textColor?: string;\n /**\n * Radius, in pixels, of the wheel the rows sit on. Its centre is off to the\n * side, so the rows bow inwards as they get further from the middle and tilt\n * with the tangent — the wheel reads as a rim seen edge-on instead of a flat\n * list. Smaller radius, rounder arc. Undefined leaves the rows flat.\n */\n curveRadius?: number;\n /** Which side the wheel's centre is on. @default 'left' */\n curveFrom?: 'left' | 'right';\n disabled?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DEFAULT_VISIBLE_COUNT = 5;\n/** 44pt: el mínimo táctil de iOS, y alcanza para una línea `lg` con aire. */\nconst DEFAULT_ITEM_HEIGHT = 44;\n/**\n * Filas hacia cada lado que la interpolación distingue; más lejos, todas\n * iguales (`extrapolate: 'clamp'`). Los tres rangos son simétricos respecto\n * del centro: la fila de arriba y la de abajo se ven igual, sólo que la de\n * arriba se inclina hacia atrás y la de abajo hacia adelante.\n */\nconst FALLOFF = 2;\n\n/**\n * Cada variante es un juego de rangos. 'spotlight' cae mucho más rápido y se\n * inclina más porque no tiene banda que señale el centro: todo el trabajo de\n * decir cuál está elegida lo hacen el contraste y la nitidez.\n */\nconst RANGES: Record<OptionWheelVariant, { opacity: number[]; scale: number[]; rotation: string[] }> = {\n window: {\n opacity: [0.22, 0.55, 1, 0.55, 0.22],\n scale: [0.8, 0.9, 1, 0.9, 0.8],\n rotation: ['60deg', '32deg', '0deg', '-32deg', '-60deg'],\n },\n spotlight: {\n opacity: [0.16, 0.42, 1, 0.42, 0.16],\n scale: [0.72, 0.87, 1, 0.87, 0.72],\n rotation: ['72deg', '40deg', '0deg', '-40deg', '-72deg'],\n },\n};\n\n/**\n * React Native no tiene desenfoque sin dependencia nativa, así que en\n * 'spotlight' cada fila que no está en el centro se dibuja además dos veces\n * corrida y a media tinta. La superposición emborrona el trazo: no es un blur\n * gaussiano, pero a ese tamaño y con esa opacidad lee igual, y sigue siendo\n * `opacity` y `transform` sobre el mismo scroll, o sea el hilo nativo.\n */\n/**\n * Dónde cae cada punto del `inputRange` sobre el arco. `Animated` no sabe\n * calcular raíces ni arcosenos, pero no hace falta: las cinco distancias al\n * centro son fijas por fila, así que la flecha del arco y el ángulo de la\n * tangente se resuelven una vez acá y entran como `outputRange`.\n */\nfunction arcOffsets(radius: number, itemHeight: number, sign: number) {\n const shift: number[] = [];\n const angle: string[] = [];\n for (let step = -FALLOFF; step <= FALLOFF; step += 1) {\n // Más allá del radio la fila ya dio la vuelta: se corta ahí en vez de\n // sacarle la raíz a un negativo.\n const dy = Math.min(Math.abs(step * itemHeight), radius);\n // Flecha del arco: cuánto se mete la fila hacia el centro de la rueda.\n shift.push(sign * (radius - Math.sqrt(radius * radius - dy * dy)));\n // La tangente del arco, para que la fila siga la curva en vez de cruzarla.\n const degrees = Math.asin(dy / radius) * (180 / Math.PI);\n angle.push(`${-sign * Math.sign(step) * degrees}deg`);\n }\n return { shift, angle };\n}\n\nconst GHOST_SHIFTS = [-1, 1];\nconst GHOST_SPREAD = 3;\nconst GHOST_OPACITY_RANGE = [0.55, 0.3, 0, 0.3, 0.55];\n\nconst useStyles = createStyles((theme) => ({\n root: { gap: theme.tokens.spacing.sm },\n label: {\n fontWeight: theme.tokens.fontWeight.bold,\n fontSize: theme.tokens.fontSize.md,\n color: theme.colors.text,\n },\n viewport: { overflow: 'hidden' },\n disabled: { opacity: 0.5 },\n // La ventana central: figura contra el fondo de opciones que pasan.\n window: {\n position: 'absolute',\n left: 0,\n right: 0,\n borderTopWidth: 1,\n borderBottomWidth: 1,\n borderColor: theme.colors.border,\n backgroundColor: theme.colors.primarySoft,\n },\n row: { justifyContent: 'center', alignItems: 'center' },\n ghost: {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n justifyContent: 'center',\n alignItems: 'center',\n },\n rowContent: { flexDirection: 'row', alignItems: 'center', gap: theme.tokens.spacing.xs },\n text: { fontSize: theme.tokens.fontSize.lg, color: theme.colors.text },\n textDisabled: { color: theme.colors.textLight },\n unit: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight },\n}));\n\n/** El índice habilitado más cercano a `index`, o -1 si no hay ninguno. */\nfunction nearestEnabled<T>(options: OptionWheelOption<T>[], index: number): number {\n if (options.length === 0) return -1;\n for (let step = 0; step < options.length; step += 1) {\n const before = index - step;\n const after = index + step;\n if (after < options.length && !options[after]?.disabled) return after;\n if (before >= 0 && !options[before]?.disabled) return before;\n }\n return -1;\n}\n\n/**\n * OptionWheel\n * Selector de rueda: las opciones pasan por una ventana fija y la que queda en\n * el centro es la elegida. El scroll hace snap a la altura de fila, así que\n * soltar el dedo en cualquier lado siempre deja una opción centrada y no media.\n *\n * Gestalt, figura y fondo: lo que marca la selección no es un estilo sobre la\n * fila —que viajaría con ella— sino una ventana quieta encima de la lista. La\n * figura se queda, el fondo se mueve, y por eso la rueda se lee sin leer: el\n * lugar señala, no el color. Y destino común: todas las filas se achican y se\n * inclinan con la misma ley respecto del centro, de modo que se perciben como\n * una superficie rígida que gira y no como N filas animándose por su cuenta.\n *\n * La escala, la opacidad y la inclinación salen de interpolar el\n * `contentOffset` del scroll, no del estado de React: se calculan en el hilo\n * nativo (`useNativeDriver`) y el hilo de JS sólo participa cuando la rueda ya\n * frenó, para avisar qué quedó elegido. Con \"reducir movimiento\" activo se\n * apaga la inclinación y queda el tamaño, que es información, no decoración.\n *\n * `disabled` en una opción no puede impedir que el snap la centre, así que la\n * rueda la centra y se corre sola a la habilitada más cercana.\n */\nexport function OptionWheel<T extends string | number>({\n options,\n selectedValue,\n onSelect,\n label,\n accessibilityLabel,\n visibleCount = DEFAULT_VISIBLE_COUNT,\n itemHeight = DEFAULT_ITEM_HEIGHT,\n unit,\n perspective = true,\n variant = 'window',\n textColor,\n curveRadius,\n curveFrom = 'left',\n disabled = false,\n style,\n testID,\n}: OptionWheelProps<T>) {\n const theme = useTheme();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const reduceMotion = useReduceMotion();\n\n const scrollRef = useRef<ScrollView>(null);\n const scrollY = useRef(new Animated.Value(0)).current;\n // El primer posicionamiento no se anima: la rueda tiene que aparecer ya\n // puesta en su valor, no viajando hasta él.\n const settled = useRef(false);\n\n // Impar siempre: con un número par no hay fila central que alinear con la\n // ventana, y el snap dejaría la selección entre dos filas.\n const rows = visibleCount % 2 === 0 ? visibleCount + 1 : visibleCount;\n const padding = ((rows - 1) / 2) * itemHeight;\n const height = rows * itemHeight;\n\n const selectedIndex = useMemo(\n () => options.findIndex((option) => option.value === selectedValue),\n [options, selectedValue]\n );\n\n const scrollToIndex = useCallback(\n (index: number, animated: boolean) => {\n const y = index * itemHeight;\n scrollRef.current?.scrollTo({ y, animated });\n // `scrollY` sólo lo alimenta `onScroll`, y un salto sin animar no\n // siempre lo emite: sin esto el valor se queda en 0, la interpolación\n // cree que la fila centrada es la primera y apaga la que de verdad está\n // en la ventana. El scroll animado sí emite en todo el recorrido, así\n // que ahí forzarlo pelearía con la animación.\n if (!animated) scrollY.setValue(y);\n },\n [itemHeight, scrollY]\n );\n\n useEffect(() => {\n if (selectedIndex < 0) return;\n scrollToIndex(selectedIndex, settled.current && !reduceMotion);\n settled.current = true;\n }, [selectedIndex, scrollToIndex, reduceMotion]);\n\n /**\n * Corre tanto al final del arrastre como al final de la inercia: en Android\n * un arrastre corto sin impulso no dispara `onMomentumScrollEnd`, y la rueda\n * se quedaría con la selección vieja. Reentrar con el mismo índice es inocuo\n * porque sólo avisa cuando cambia.\n */\n const commit = useCallback(\n (event: NativeSyntheticEvent<NativeScrollEvent>) => {\n const raw = Math.round(event.nativeEvent.contentOffset.y / itemHeight);\n const clamped = Math.min(Math.max(raw, 0), options.length - 1);\n const landed = options[clamped];\n if (!landed) return;\n const target = landed.disabled ? nearestEnabled(options, clamped) : clamped;\n const option = options[target];\n // Sin opciones habilitadas no hay dónde parar: la rueda se queda donde\n // la dejaron y no avisa nada.\n if (!option) return;\n if (target !== clamped) scrollToIndex(target, true);\n if (option.value !== selectedValue) onSelect(option.value);\n },\n [options, itemHeight, selectedValue, onSelect, scrollToIndex]\n );\n\n /** VoiceOver/TalkBack recorren la rueda con increment/decrement, no con el dedo. */\n const step = useCallback(\n (delta: number) => {\n if (disabled || selectedIndex < 0) return;\n const next = nearestEnabled(options, selectedIndex + delta);\n const option = options[next];\n // En el borde, `nearestEnabled` devuelve la fila donde ya estamos.\n if (!option || next === selectedIndex) return;\n onSelect(option.value);\n },\n [disabled, options, selectedIndex, onSelect]\n );\n\n const onLayout = useCallback(\n (_: LayoutChangeEvent) => {\n if (selectedIndex >= 0) scrollToIndex(selectedIndex, false);\n },\n [selectedIndex, scrollToIndex]\n );\n\n const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined;\n const ranges = RANGES[variant];\n // El desenfoque simulado triplica los nodos de texto: sólo va donde aporta,\n // y con \"reducir movimiento\" no va, porque ahí la nitidez es lo único que\n // queda para distinguir la fila elegida.\n const smeared = variant === 'spotlight' && !reduceMotion;\n const arc = useMemo(\n () =>\n curveRadius && curveRadius > 0 && !reduceMotion\n ? arcOffsets(curveRadius, itemHeight, curveFrom === 'left' ? -1 : 1)\n : undefined,\n [curveRadius, itemHeight, curveFrom, reduceMotion]\n );\n\n return (\n <View style={[styles.root, disabled && styles.disabled, style]} testID={testID}>\n {!!label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n </Text>\n )}\n\n <View\n accessible\n accessibilityRole=\"adjustable\"\n accessibilityLabel={label ?? accessibilityLabel}\n accessibilityValue={{ text: selected?.label }}\n accessibilityState={{ disabled }}\n accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}\n onAccessibilityAction={(event) => step(event.nativeEvent.actionName === 'increment' ? 1 : -1)}\n style={[styles.viewport, { height }]}\n testID={testID && `${testID}-viewport`}\n >\n {variant === 'window' && (\n <View\n pointerEvents=\"none\"\n style={[styles.window, { top: padding, height: itemHeight, borderRadius: theme.tokens.radius.sm }]}\n />\n )}\n\n <Animated.ScrollView\n ref={scrollRef as never}\n onLayout={onLayout}\n scrollEnabled={!disabled}\n showsVerticalScrollIndicator={false}\n snapToInterval={itemHeight}\n decelerationRate=\"fast\"\n scrollEventThrottle={16}\n contentContainerStyle={{ paddingVertical: padding }}\n onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {\n useNativeDriver: true,\n })}\n onScrollEndDrag={commit}\n onMomentumScrollEnd={commit}\n testID={testID && `${testID}-scroll`}\n >\n {options.map((option, index) => {\n const centre = index * itemHeight;\n const inputRange = [\n centre - FALLOFF * itemHeight,\n centre - itemHeight,\n centre,\n centre + itemHeight,\n centre + FALLOFF * itemHeight,\n ];\n type Interpolated = Animated.AnimatedInterpolation<string | number>;\n const transform: Array<\n | { perspective: number }\n | { scale: Interpolated }\n | { rotateX: Interpolated }\n | { rotateZ: Interpolated }\n | { translateX: Interpolated }\n > = [\n { scale: scrollY.interpolate({ inputRange, outputRange: ranges.scale, extrapolate: 'clamp' }) },\n ];\n if (perspective && !reduceMotion) {\n // El perspective va primero: aplicado después de la rotación no\n // deforma nada y la fila se ve plana aunque esté rotada.\n transform.unshift({ perspective: height * 2 });\n transform.push({\n rotateX: scrollY.interpolate({ inputRange, outputRange: ranges.rotation, extrapolate: 'clamp' }),\n });\n }\n\n if (arc) {\n transform.push(\n { translateX: scrollY.interpolate({ inputRange, outputRange: arc.shift, extrapolate: 'clamp' }) },\n { rotateZ: scrollY.interpolate({ inputRange, outputRange: arc.angle, extrapolate: 'clamp' }) }\n );\n }\n\n const label = (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.text, option.disabled && styles.textDisabled, !!textColor && { color: textColor }]}\n >\n {option.label}\n </Text>\n );\n\n return (\n <Animated.View\n key={String(option.value)}\n testID={testID && `${testID}-item-${index}`}\n style={[\n styles.row,\n {\n height: itemHeight,\n opacity: scrollY.interpolate({\n inputRange,\n outputRange: ranges.opacity,\n extrapolate: 'clamp',\n }),\n transform,\n },\n ]}\n >\n {smeared &&\n GHOST_SHIFTS.map((direction) => (\n <Animated.View\n key={direction}\n pointerEvents=\"none\"\n // `accessibilityElementsHidden` no alcanza: el lector\n // leería la fila tres veces igual, porque la rueda entera\n // ya es un solo elemento y esto vive adentro.\n style={[\n styles.ghost,\n {\n opacity: scrollY.interpolate({\n inputRange,\n outputRange: GHOST_OPACITY_RANGE,\n extrapolate: 'clamp',\n }),\n transform: [\n {\n translateX: scrollY.interpolate({\n inputRange,\n outputRange: [\n direction * GHOST_SPREAD,\n direction * (GHOST_SPREAD / 2),\n 0,\n direction * (GHOST_SPREAD / 2),\n direction * GHOST_SPREAD,\n ],\n extrapolate: 'clamp',\n }),\n },\n ],\n },\n ]}\n >\n {label}\n </Animated.View>\n ))}\n\n <View style={styles.rowContent}>\n {label}\n {!!unit && index === selectedIndex && (\n <Text allowFontScaling={allowFontScaling} style={styles.unit}>\n {unit}\n </Text>\n )}\n </View>\n </Animated.View>\n );\n })}\n </Animated.ScrollView>\n </View>\n </View>\n );\n}\n"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"core",
|
|
17
|
+
"transition"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -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,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,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';
|
package/src/atoms/index.ts
CHANGED
|
@@ -26,6 +26,8 @@ export { Skeleton } from './Skeleton';
|
|
|
26
26
|
export type { SkeletonProps, SkeletonVariant } from './Skeleton';
|
|
27
27
|
export { Transition, useReduceMotion } from './Transition';
|
|
28
28
|
export type { TransitionProps, TransitionPreset } from './Transition';
|
|
29
|
+
export { ShinyText } from './ShinyText';
|
|
30
|
+
export type { ShinyTextProps } from './ShinyText';
|
|
29
31
|
export { EmptyState } from './EmptyState';
|
|
30
32
|
export type { EmptyStateProps } from './EmptyState';
|
|
31
33
|
export { KeyValueRow } from './KeyValueRow';
|