panelui-native 0.40.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +4 -2
  2. package/lib/module/components/card/index.js +172 -3
  3. package/lib/module/components/card/index.js.map +1 -1
  4. package/lib/module/components/color-picker/index.js +1395 -0
  5. package/lib/module/components/color-picker/index.js.map +1 -0
  6. package/lib/module/components/combobox/index.js +87 -19
  7. package/lib/module/components/combobox/index.js.map +1 -1
  8. package/lib/module/components/drawer/index.js +13 -4
  9. package/lib/module/components/drawer/index.js.map +1 -1
  10. package/lib/module/components/kpi-chart/index.js +648 -0
  11. package/lib/module/components/kpi-chart/index.js.map +1 -0
  12. package/lib/module/components/map/index.js +72 -12
  13. package/lib/module/components/map/index.js.map +1 -1
  14. package/lib/module/components/menu/index.js +40 -10
  15. package/lib/module/components/menu/index.js.map +1 -1
  16. package/lib/module/components/pagination/index.js +19 -9
  17. package/lib/module/components/pagination/index.js.map +1 -1
  18. package/lib/module/components/panelside/index.js +68 -18
  19. package/lib/module/components/panelside/index.js.map +1 -1
  20. package/lib/module/components/radar-chart/index.js +704 -0
  21. package/lib/module/components/radar-chart/index.js.map +1 -0
  22. package/lib/module/components/section-rail/index.js +32 -7
  23. package/lib/module/components/section-rail/index.js.map +1 -1
  24. package/lib/module/components/slider/index.js +240 -39
  25. package/lib/module/components/slider/index.js.map +1 -1
  26. package/lib/module/components/tabs/index.js +194 -10
  27. package/lib/module/components/tabs/index.js.map +1 -1
  28. package/lib/module/components/text-animation/index.js +857 -0
  29. package/lib/module/components/text-animation/index.js.map +1 -0
  30. package/lib/module/icons/index.js +25 -7
  31. package/lib/module/icons/index.js.map +1 -1
  32. package/lib/module/index.js +5 -0
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/theme/use-theme.js +1 -1
  35. package/lib/module/utils/chart.js +44 -0
  36. package/lib/module/utils/chart.js.map +1 -1
  37. package/lib/module/utils/color.js +268 -0
  38. package/lib/module/utils/color.js.map +1 -0
  39. package/lib/typescript/src/components/card/index.d.ts +20 -0
  40. package/lib/typescript/src/components/card/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/color-picker/index.d.ts +258 -0
  42. package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -0
  43. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  44. package/lib/typescript/src/components/drawer/index.d.ts +13 -1
  45. package/lib/typescript/src/components/drawer/index.d.ts.map +1 -1
  46. package/lib/typescript/src/components/kpi-chart/index.d.ts +490 -0
  47. package/lib/typescript/src/components/kpi-chart/index.d.ts.map +1 -0
  48. package/lib/typescript/src/components/map/index.d.ts +37 -2
  49. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  51. package/lib/typescript/src/components/pagination/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/panelside/index.d.ts +50 -4
  53. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  54. package/lib/typescript/src/components/radar-chart/index.d.ts +214 -0
  55. package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -0
  56. package/lib/typescript/src/components/section-rail/index.d.ts +8 -1
  57. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/slider/index.d.ts +19 -0
  59. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  60. package/lib/typescript/src/components/tabs/index.d.ts +11 -1
  61. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  62. package/lib/typescript/src/components/text-animation/index.d.ts +268 -0
  63. package/lib/typescript/src/components/text-animation/index.d.ts.map +1 -0
  64. package/lib/typescript/src/icons/index.d.ts +6 -0
  65. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  66. package/lib/typescript/src/index.d.ts +8 -3
  67. package/lib/typescript/src/index.d.ts.map +1 -1
  68. package/lib/typescript/src/utils/chart.d.ts +21 -0
  69. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  70. package/lib/typescript/src/utils/color.d.ts +74 -0
  71. package/lib/typescript/src/utils/color.d.ts.map +1 -0
  72. package/package.json +2 -1
  73. package/src/components/card/index.tsx +227 -2
  74. package/src/components/color-picker/index.tsx +1618 -0
  75. package/src/components/combobox/index.tsx +89 -19
  76. package/src/components/drawer/index.tsx +31 -9
  77. package/src/components/kpi-chart/index.tsx +719 -0
  78. package/src/components/map/index.tsx +158 -14
  79. package/src/components/menu/index.tsx +51 -6
  80. package/src/components/pagination/index.tsx +18 -6
  81. package/src/components/panelside/index.tsx +134 -19
  82. package/src/components/radar-chart/index.tsx +910 -0
  83. package/src/components/section-rail/index.tsx +42 -8
  84. package/src/components/slider/index.tsx +283 -41
  85. package/src/components/tabs/index.tsx +263 -8
  86. package/src/components/text-animation/index.tsx +1093 -0
  87. package/src/icons/index.tsx +23 -4
  88. package/src/index.ts +73 -1
  89. package/src/theme/use-theme.ts +1 -1
  90. package/src/utils/chart.ts +52 -0
  91. package/src/utils/color.ts +246 -0
  92. package/theme.css +125 -112
@@ -0,0 +1,1093 @@
1
+ /**
2
+ * TextAnimation — the five ways a piece of text arrives.
3
+ *
4
+ * ```tsx
5
+ * <TextAnimation.Typing text="Everything ships with its accessibility done" />
6
+ * <TextAnimation.Rotating text={['fast', 'native', 'yours']} />
7
+ * <TextAnimation.Counting value={2048} />
8
+ * <TextAnimation.Sliding value={price} decimals={2} />
9
+ * <TextAnimation.Scrolling value={120} step={10} />
10
+ * ```
11
+ *
12
+ * ## Why one component with five parts
13
+ *
14
+ * They are one idea — a value that changes and wants to be seen changing —
15
+ * and they share every prop that says *how*: `duration`, `delay`, `loop`,
16
+ * `enabled`. Put on the `TextAnimation` root, those become the defaults for
17
+ * everything inside it, so a hero with three of these in it is configured once
18
+ * rather than three times.
19
+ *
20
+ * ## What React Native forces
21
+ *
22
+ * Nested `Text` is the only thing that gets real line-breaking, and it cannot
23
+ * be transformed — a `translateY` on it is ignored. So the two parts that
24
+ * animate a whole string, `Typing` and `Counting`, stay real text and keep
25
+ * wrapping; the three that slide glyphs past each other lay out as rows of
26
+ * views, which buys the transform and costs line-breaking. None of the three
27
+ * is ever a paragraph, so the trade only ever falls the right way.
28
+ *
29
+ * ## Why the digits are drawn rather than measured
30
+ *
31
+ * A number animated as React state is a re-render per frame. `Counting` runs
32
+ * the value on the UI thread and only crosses back when the *rounded* number
33
+ * changes, which for a whole number is a couple of dozen times rather than
34
+ * sixty a second. The sliding and scrolling parts do not cross back at all:
35
+ * every digit is already rendered, and the animation is a transform on a
36
+ * column of them.
37
+ */
38
+ import {
39
+ createContext,
40
+ useContext,
41
+ useEffect,
42
+ useMemo,
43
+ useRef,
44
+ useState,
45
+ type ReactNode,
46
+ } from 'react';
47
+ import { Text as RNText, View, type ViewProps } from 'react-native';
48
+ import Animated, {
49
+ Easing,
50
+ cancelAnimation,
51
+ runOnJS,
52
+ useAnimatedReaction,
53
+ useAnimatedStyle,
54
+ useReducedMotion,
55
+ useSharedValue,
56
+ withRepeat,
57
+ withSequence,
58
+ withSpring,
59
+ withTiming,
60
+ } from 'react-native-reanimated';
61
+ import { LinearGradient } from 'expo-linear-gradient';
62
+ import { tv } from 'tailwind-variants';
63
+ import { useCSSVariable } from 'uniwind';
64
+ import { cn } from '../../utils/cn';
65
+ import { Text, type TextProps } from '../../primitives/text';
66
+
67
+ const AnimatedText = Animated.createAnimatedComponent(RNText);
68
+
69
+ /** How long a caret spends lit, and then dark. */
70
+ const CARET_PERIOD = 520;
71
+ /** The spring every number rides. Slow enough to read the digits going past. */
72
+ const NUMBER_SPRING = { damping: 26, stiffness: 90, mass: 1 } as const;
73
+ /** How far a rotating phrase travels, in points. */
74
+ const ROTATE_DISTANCE = 22;
75
+
76
+ const textAnimationVariants = tv({
77
+ slots: {
78
+ /*
79
+ * `tabular-nums` on everything numeric, without exception. Proportional
80
+ * digits are different widths, so a rolling number changes width as it
81
+ * rolls and shoves whatever is beside it back and forth — which reads as
82
+ * the layout being broken rather than as the number being alive.
83
+ */
84
+ digits: 'flex-row items-center tabular-nums',
85
+ column: 'items-center',
86
+ caret: 'text-foreground',
87
+ highlight: 'rounded-lg bg-secondary',
88
+ },
89
+ });
90
+
91
+ /* -------------------------------------------------------------------------- */
92
+ /* Root */
93
+ /* -------------------------------------------------------------------------- */
94
+
95
+ interface TextAnimationContextValue {
96
+ duration?: number;
97
+ delay?: number;
98
+ loop?: boolean;
99
+ enabled?: boolean;
100
+ }
101
+
102
+ const TextAnimationContext = createContext<TextAnimationContextValue>({});
103
+
104
+ export interface TextAnimationProps extends ViewProps {
105
+ className?: string;
106
+ /**
107
+ * How long one pass takes, in milliseconds. What that measures depends on
108
+ * the part: a keystroke for `Typing`, a phrase's turn on screen for
109
+ * `Rotating`, the whole journey for the three that count.
110
+ */
111
+ duration?: number;
112
+ /** How long to wait before starting, in milliseconds. */
113
+ delay?: number;
114
+ /** Start again from the beginning when the run finishes. */
115
+ loop?: boolean;
116
+ /**
117
+ * Animate at all. `false` draws the finished text or the final number
118
+ * immediately, which is also what a reduced-motion setting does.
119
+ */
120
+ enabled?: boolean;
121
+ children?: ReactNode;
122
+ }
123
+
124
+ /**
125
+ * Shared configuration, and a row to lay the parts out in.
126
+ *
127
+ * Optional in every case — each part works standalone with its own props, and
128
+ * this is only here so a line with three of them in it says `duration` once.
129
+ */
130
+ function TextAnimationRoot({
131
+ className,
132
+ duration,
133
+ delay,
134
+ loop,
135
+ enabled,
136
+ children,
137
+ ...props
138
+ }: TextAnimationProps) {
139
+ const value = useMemo(
140
+ () => ({ duration, delay, loop, enabled }),
141
+ [delay, duration, enabled, loop]
142
+ );
143
+
144
+ return (
145
+ <TextAnimationContext.Provider value={value}>
146
+ <View className={cn('flex-row items-center', className)} {...props}>
147
+ {children}
148
+ </View>
149
+ </TextAnimationContext.Provider>
150
+ );
151
+ }
152
+ TextAnimationRoot.displayName = 'TextAnimation';
153
+
154
+ /**
155
+ * The part's own prop, then the root's, then the part's own default.
156
+ *
157
+ * Written out because `??` alone would take the root's value over an explicit
158
+ * `false` on the part, and "the root says loop, this one does not" is the
159
+ * whole reason the root's values are defaults rather than settings.
160
+ */
161
+ function useShared<K extends keyof TextAnimationContextValue>(
162
+ key: K,
163
+ own: TextAnimationContextValue[K],
164
+ fallback: NonNullable<TextAnimationContextValue[K]>
165
+ ): NonNullable<TextAnimationContextValue[K]> {
166
+ const inherited = useContext(TextAnimationContext);
167
+ return (own ?? inherited[key] ?? fallback) as NonNullable<TextAnimationContextValue[K]>;
168
+ }
169
+
170
+ /**
171
+ * The same colour at a given alpha.
172
+ *
173
+ * A gradient stop of `transparent` is black at zero alpha on Android, so a
174
+ * fade to it arrives through a grey smear on the way — it has to be the same
175
+ * colour, faded.
176
+ */
177
+ function withAlpha(color: string, alpha: number): string {
178
+ if (color.startsWith('#')) {
179
+ const hex = color.slice(1);
180
+ const full =
181
+ hex.length === 3
182
+ ? hex
183
+ .split('')
184
+ .map((c) => c + c)
185
+ .join('')
186
+ : hex.slice(0, 6);
187
+ const r = parseInt(full.slice(0, 2), 16);
188
+ const g = parseInt(full.slice(2, 4), 16);
189
+ const b = parseInt(full.slice(4, 6), 16);
190
+ if (Number.isNaN(r + g + b)) return color;
191
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
192
+ }
193
+
194
+ const channels = color.match(/rgba?\(([^)]+)\)/)?.[1];
195
+ if (channels) {
196
+ const [r, g, b] = channels.split(',').map((part) => part.trim());
197
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
198
+ }
199
+
200
+ return color;
201
+ }
202
+
203
+ /** True when nothing should move: asked for, or asked for by the system. */
204
+ function useStill(enabled: boolean): boolean {
205
+ return useReducedMotion() || !enabled;
206
+ }
207
+
208
+ /* -------------------------------------------------------------------------- */
209
+ /* Typing */
210
+ /* -------------------------------------------------------------------------- */
211
+
212
+ export interface TextAnimationTypingProps extends Omit<TextProps, 'children'> {
213
+ className?: string;
214
+ /**
215
+ * What to type. An array is typed, held, erased and replaced by the next,
216
+ * which is the shape a rotating headline wants.
217
+ */
218
+ text: string | string[];
219
+ /** Milliseconds per keystroke. */
220
+ duration?: number;
221
+ /** Milliseconds before the first keystroke. */
222
+ delay?: number;
223
+ /** How long a finished string sits before it is erased, in milliseconds. */
224
+ hold?: number;
225
+ /** Start again after the last string. Only means anything for an array. */
226
+ loop?: boolean;
227
+ /** Draw a blinking caret after the text. */
228
+ caret?: boolean;
229
+ /** Styles the caret. */
230
+ caretClassName?: string;
231
+ /** Called once the last string has finished being typed. */
232
+ onDone?: () => void;
233
+ enabled?: boolean;
234
+ }
235
+
236
+ /**
237
+ * A string arriving one character at a time.
238
+ *
239
+ * The text is React state rather than anything on the UI thread, and it has
240
+ * to be: a character is a different string, and a string is a re-render
241
+ * whichever thread decided on it. That is fine at a keystroke every 50-odd
242
+ * milliseconds, which is three orders of magnitude slower than a frame.
243
+ *
244
+ * It reserves no space. A line that grows as it types pushes whatever is under
245
+ * it down the screen on every keystroke, so give the container a height, or
246
+ * type into a block that has one already.
247
+ */
248
+ function TextAnimationTyping({
249
+ className,
250
+ text,
251
+ duration,
252
+ delay,
253
+ hold = 1400,
254
+ loop,
255
+ caret = false,
256
+ caretClassName,
257
+ onDone,
258
+ enabled,
259
+ ...props
260
+ }: TextAnimationTypingProps) {
261
+ const step = useShared('duration', duration, 55);
262
+ const wait = useShared('delay', delay, 0);
263
+ const repeat = useShared('loop', loop, false);
264
+ const still = useStill(useShared('enabled', enabled, true));
265
+
266
+ const strings = useMemo(() => (typeof text === 'string' ? [text] : text), [text]);
267
+ const [shown, setShown] = useState(() => (still ? (strings[0] ?? '') : ''));
268
+ const [typing, setTyping] = useState(false);
269
+
270
+ // Held in a ref so the effect below does not restart the whole run every
271
+ // time the caller passes a new inline arrow function.
272
+ const done = useRef(onDone);
273
+ done.current = onDone;
274
+
275
+ useEffect(() => {
276
+ if (still) {
277
+ setShown(strings[0] ?? '');
278
+ setTyping(false);
279
+ return;
280
+ }
281
+
282
+ const timers: ReturnType<typeof setTimeout>[] = [];
283
+ let cancelled = false;
284
+
285
+ const run = (index: number) => {
286
+ const value = strings[index] ?? '';
287
+ let at = 0;
288
+ setTyping(true);
289
+
290
+ const type = () => {
291
+ if (cancelled) return;
292
+ setShown(value.slice(0, at));
293
+ if (at < value.length) {
294
+ at += 1;
295
+ timers.push(setTimeout(type, step));
296
+ return;
297
+ }
298
+ setTyping(false);
299
+ const last = index === strings.length - 1;
300
+ if (last && !repeat) {
301
+ done.current?.();
302
+ return;
303
+ }
304
+ timers.push(setTimeout(() => erase(value, index), hold));
305
+ };
306
+
307
+ type();
308
+ };
309
+
310
+ const erase = (value: string, index: number) => {
311
+ if (cancelled) return;
312
+ let at = value.length;
313
+ setTyping(true);
314
+
315
+ const back = () => {
316
+ if (cancelled) return;
317
+ setShown(value.slice(0, at));
318
+ if (at > 0) {
319
+ at -= 1;
320
+ timers.push(setTimeout(back, step));
321
+ return;
322
+ }
323
+ setTyping(false);
324
+ run(index === strings.length - 1 ? 0 : index + 1);
325
+ };
326
+
327
+ back();
328
+ };
329
+
330
+ timers.push(setTimeout(() => run(0), wait));
331
+
332
+ return () => {
333
+ cancelled = true;
334
+ timers.forEach(clearTimeout);
335
+ };
336
+ }, [hold, repeat, step, still, strings, wait]);
337
+
338
+ return (
339
+ /*
340
+ * The caret is nested *inside* the text rather than laid out beside it. As
341
+ * a sibling in a row it sits against the right of the whole block, so the
342
+ * moment a line wraps it stops being after the last character and starts
343
+ * floating at the end of the paragraph — which is exactly where a caret
344
+ * should never be. Nested, it is one more glyph in the flow and lands
345
+ * wherever typing has got to, on whichever line that is.
346
+ */
347
+ <Text className={className} {...props}>
348
+ {shown}
349
+ {caret ? <TypingCaret blinking={!typing} still={still} className={caretClassName} /> : null}
350
+ </Text>
351
+ );
352
+ }
353
+ TextAnimationTyping.displayName = 'TextAnimation.Typing';
354
+
355
+ /**
356
+ * The caret. Solid while characters are arriving, blinking while they are not
357
+ * — which is what a real one does, and what says "waiting" rather than
358
+ * "finished".
359
+ *
360
+ * A glyph rather than a view, because only a glyph takes part in line-breaking
361
+ * — and because a character is sized by the font it is in, so it is the right
362
+ * height at every text size without being told any of them. Nested text can be
363
+ * faded but not transformed, which the opacity here is well inside.
364
+ */
365
+ function TypingCaret({
366
+ blinking,
367
+ still,
368
+ className,
369
+ }: {
370
+ blinking: boolean;
371
+ still: boolean;
372
+ className?: string;
373
+ }) {
374
+ const { caret } = textAnimationVariants();
375
+ const opacity = useSharedValue(1);
376
+
377
+ useEffect(() => {
378
+ if (still || !blinking) {
379
+ cancelAnimation(opacity);
380
+ opacity.value = 1;
381
+ return;
382
+ }
383
+ opacity.value = withRepeat(
384
+ withSequence(
385
+ withTiming(1, { duration: CARET_PERIOD }),
386
+ withTiming(0, { duration: CARET_PERIOD })
387
+ ),
388
+ -1,
389
+ false
390
+ );
391
+ return () => cancelAnimation(opacity);
392
+ }, [blinking, opacity, still]);
393
+
394
+ const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
395
+
396
+ return (
397
+ <AnimatedText
398
+ // Decorative: the text it follows is what a screen reader should read.
399
+ accessibilityElementsHidden
400
+ importantForAccessibility="no-hide-descendants"
401
+ style={style}
402
+ className={caret({ className })}
403
+ >
404
+ {'\u258F'}
405
+ </AnimatedText>
406
+ );
407
+ }
408
+
409
+ /* -------------------------------------------------------------------------- */
410
+ /* Rotating */
411
+ /* -------------------------------------------------------------------------- */
412
+
413
+ export interface TextAnimationRotatingProps extends Omit<TextProps, 'children'> {
414
+ className?: string;
415
+ /** The phrases to cycle. One string never rotates, which is a valid state. */
416
+ text: string | string[];
417
+ /** How long each phrase holds, in milliseconds. */
418
+ duration?: number;
419
+ /** Milliseconds before the first change. */
420
+ delay?: number;
421
+ enabled?: boolean;
422
+ }
423
+
424
+ /**
425
+ * One phrase replaced by the next, the outgoing one leaving upward and the
426
+ * incoming one arriving from below.
427
+ *
428
+ * The box is sized by the longest phrase and not by the current one. A box
429
+ * that resizes as the words change makes the line around it jump, which is
430
+ * more distracting than the effect is interesting — and a box sized by the
431
+ * *first* phrase clips every longer one that follows, which is worse than
432
+ * either.
433
+ *
434
+ * So the width comes from a sizer: every phrase, laid out invisibly in a view
435
+ * with no height. Yoga still takes a column's width from its children when
436
+ * their height is fixed at zero, so the box ends up as wide as the longest
437
+ * phrase and as tall as one line of them.
438
+ */
439
+ function TextAnimationRotating({
440
+ className,
441
+ text,
442
+ duration,
443
+ delay,
444
+ enabled,
445
+ ...props
446
+ }: TextAnimationRotatingProps) {
447
+ const period = useShared('duration', duration, 2200);
448
+ const wait = useShared('delay', delay, 0);
449
+ const still = useStill(useShared('enabled', enabled, true));
450
+
451
+ const phrases = useMemo(() => (typeof text === 'string' ? [text] : text), [text]);
452
+ const [index, setIndex] = useState(0);
453
+
454
+ useEffect(() => {
455
+ if (still || phrases.length < 2) return;
456
+ let interval: ReturnType<typeof setInterval> | undefined;
457
+ const start = setTimeout(() => {
458
+ setIndex((current) => (current + 1) % phrases.length);
459
+ interval = setInterval(
460
+ () => setIndex((current) => (current + 1) % phrases.length),
461
+ period
462
+ );
463
+ }, wait + period);
464
+
465
+ return () => {
466
+ clearTimeout(start);
467
+ if (interval) clearInterval(interval);
468
+ };
469
+ }, [period, phrases.length, still, wait]);
470
+
471
+ return (
472
+ <View className={cn('overflow-hidden', className)}>
473
+ {/* The sizer. No height, so it contributes nothing but width, and
474
+ invisible, so what it contributes is only a measurement. */}
475
+ <View style={{ height: 0 }} pointerEvents="none" accessibilityElementsHidden>
476
+ {phrases.map((phrase) => (
477
+ <Text key={phrase} {...props} className="opacity-0">
478
+ {phrase}
479
+ </Text>
480
+ ))}
481
+ </View>
482
+
483
+ {phrases.map((phrase, at) => (
484
+ <RotatingPhrase
485
+ key={phrase}
486
+ phrase={phrase}
487
+ // The first is laid out in flow and gives the box its height; the
488
+ // rest are stacked over it, so one line is the height whichever
489
+ // phrase is showing.
490
+ measured={at === 0}
491
+ active={at === index}
492
+ still={still}
493
+ textProps={props}
494
+ />
495
+ ))}
496
+ </View>
497
+ );
498
+ }
499
+ TextAnimationRotating.displayName = 'TextAnimation.Rotating';
500
+
501
+ function RotatingPhrase({
502
+ phrase,
503
+ measured,
504
+ active,
505
+ still,
506
+ textProps,
507
+ }: {
508
+ phrase: string;
509
+ measured: boolean;
510
+ active: boolean;
511
+ still: boolean;
512
+ textProps: Omit<TextProps, 'children'>;
513
+ }) {
514
+ const travel = useSharedValue(active ? 0 : ROTATE_DISTANCE);
515
+ const opacity = useSharedValue(active ? 1 : 0);
516
+
517
+ useEffect(() => {
518
+ if (still) {
519
+ travel.value = active ? 0 : ROTATE_DISTANCE;
520
+ opacity.value = active ? 1 : 0;
521
+ return;
522
+ }
523
+ if (active) {
524
+ // From below, not from wherever it was left: a phrase coming back after
525
+ // a full cycle should arrive the same way it did the first time.
526
+ travel.value = ROTATE_DISTANCE;
527
+ travel.value = withTiming(0, { duration: 300, easing: Easing.out(Easing.cubic) });
528
+ opacity.value = withTiming(1, { duration: 240 });
529
+ } else {
530
+ travel.value = withTiming(-ROTATE_DISTANCE, {
531
+ duration: 300,
532
+ easing: Easing.in(Easing.cubic),
533
+ });
534
+ opacity.value = withTiming(0, { duration: 200 });
535
+ }
536
+ }, [active, opacity, still, travel]);
537
+
538
+ const style = useAnimatedStyle(() => ({
539
+ opacity: opacity.value,
540
+ transform: [{ translateY: travel.value }],
541
+ }));
542
+
543
+ return (
544
+ <Animated.View
545
+ style={[style, measured ? undefined : { position: 'absolute', left: 0, right: 0 }]}
546
+ // Only the phrase on screen is announced. All of them are laid out, and
547
+ // a screen reader reading the whole list is the cost of that.
548
+ accessibilityElementsHidden={!active}
549
+ importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
550
+ >
551
+ <Text {...textProps}>{phrase}</Text>
552
+ </Animated.View>
553
+ );
554
+ }
555
+
556
+ /* -------------------------------------------------------------------------- */
557
+ /* Counting */
558
+ /* -------------------------------------------------------------------------- */
559
+
560
+ /** Formats a number the way both counting parts agree to. */
561
+ function useFormatter(decimals: number, formatOptions?: Intl.NumberFormatOptions) {
562
+ return useMemo(() => {
563
+ if (formatOptions) {
564
+ try {
565
+ const format = new Intl.NumberFormat(undefined, formatOptions);
566
+ return (value: number) => format.format(value);
567
+ } catch {
568
+ // Some engines ship a partial Intl; fall through to the plain form.
569
+ }
570
+ }
571
+ return (value: number) => value.toFixed(decimals);
572
+ }, [decimals, formatOptions]);
573
+ }
574
+
575
+ export interface TextAnimationCountingProps extends Omit<TextProps, 'children'> {
576
+ className?: string;
577
+ /** Where the number ends up. */
578
+ value: number;
579
+ /** Where it starts from. Defaults to zero. */
580
+ from?: number;
581
+ /** How long the whole journey takes, in milliseconds. */
582
+ duration?: number;
583
+ /** Milliseconds before it starts. */
584
+ delay?: number;
585
+ /** Digits after the point. */
586
+ decimals?: number;
587
+ /**
588
+ * Formatting for the number, as `Intl.NumberFormat` options — a currency, a
589
+ * percentage, grouped thousands. Falls back to a plain fixed-point string on
590
+ * an engine whose `Intl` cannot do it.
591
+ */
592
+ formatOptions?: Intl.NumberFormatOptions;
593
+ enabled?: boolean;
594
+ }
595
+
596
+ /**
597
+ * A number counting up to itself.
598
+ *
599
+ * The text is rewritten from a Reanimated reaction rather than from state:
600
+ * sixty re-renders a second for a number that is only ever a string is the
601
+ * kind of thing that makes an otherwise still screen drop frames. Only the
602
+ * rounded value crosses back to JavaScript, so the work per frame is one
603
+ * comparison and, at most, one `setState` on a value that actually changed.
604
+ */
605
+ function TextAnimationCounting({
606
+ className,
607
+ value,
608
+ from = 0,
609
+ duration,
610
+ delay,
611
+ decimals = 0,
612
+ formatOptions,
613
+ enabled,
614
+ ...props
615
+ }: TextAnimationCountingProps) {
616
+ const span = useShared('duration', duration, 1200);
617
+ const wait = useShared('delay', delay, 0);
618
+ const still = useStill(useShared('enabled', enabled, true));
619
+
620
+ const format = useFormatter(decimals, formatOptions);
621
+ const progress = useSharedValue(still ? value : from);
622
+ const [shown, setShown] = useState(() => (still ? value : from));
623
+
624
+ useEffect(() => {
625
+ if (still) {
626
+ cancelAnimation(progress);
627
+ progress.value = value;
628
+ setShown(value);
629
+ return;
630
+ }
631
+ progress.value = from;
632
+ const timer = setTimeout(() => {
633
+ progress.value = withTiming(value, {
634
+ duration: span,
635
+ // Fast at first and settling at the end, which is what makes the last
636
+ // few digits readable instead of a blur that stops.
637
+ easing: Easing.out(Easing.cubic),
638
+ });
639
+ }, wait);
640
+
641
+ return () => {
642
+ clearTimeout(timer);
643
+ cancelAnimation(progress);
644
+ };
645
+ }, [from, progress, span, still, value, wait]);
646
+
647
+ const factor = 10 ** decimals;
648
+
649
+ useAnimatedReaction(
650
+ () => Math.round(progress.value * factor) / factor,
651
+ (current, previous) => {
652
+ if (current !== previous) runOnJS(setShown)(current);
653
+ },
654
+ [factor]
655
+ );
656
+
657
+ return (
658
+ <Text
659
+ // The final value, always: a screen reader should be told the number,
660
+ // not read a slot machine.
661
+ accessibilityLabel={format(value)}
662
+ className={cn('tabular-nums', className)}
663
+ {...props}
664
+ >
665
+ {format(shown)}
666
+ </Text>
667
+ );
668
+ }
669
+ TextAnimationCounting.displayName = 'TextAnimation.Counting';
670
+
671
+ /* -------------------------------------------------------------------------- */
672
+ /* Sliding */
673
+ /* -------------------------------------------------------------------------- */
674
+
675
+ export interface TextAnimationSlidingProps extends ViewProps {
676
+ className?: string;
677
+ /** The number to show. Each digit rolls to its new value independently. */
678
+ value: number;
679
+ /** Digits after the point. */
680
+ decimals?: number;
681
+ /** Pad the whole part to this many digits with leading zeroes. */
682
+ padStart?: number;
683
+ /** A separator every three digits — `','` for `1,024`. */
684
+ thousandSeparator?: string;
685
+ /** The decimal mark. */
686
+ decimalSeparator?: string;
687
+ /** Styles the digits. */
688
+ textClassName?: string;
689
+ /** Size of the digits, as on `Text`. */
690
+ size?: TextProps['size'];
691
+ /** Weight of the digits, as on `Text`. */
692
+ weight?: TextProps['weight'];
693
+ /** Milliseconds before the roll starts. */
694
+ delay?: number;
695
+ enabled?: boolean;
696
+ }
697
+
698
+ /**
699
+ * An odometer: every digit is a column of ten, and each column rolls to the
700
+ * one it should be showing.
701
+ *
702
+ * Nothing here is measured at runtime. A column is ten stacked digits and the
703
+ * whole thing is moved by a fraction of its own height, so the roll needs no
704
+ * `onLayout`, no first frame at the wrong offset, and no re-render per digit
705
+ * per frame. The height comes from one hidden `0` in flow, which is what makes
706
+ * the column as tall as the font is rather than as tall as a number happens to
707
+ * be.
708
+ */
709
+ function TextAnimationSliding({
710
+ className,
711
+ value,
712
+ decimals = 0,
713
+ padStart = 1,
714
+ thousandSeparator,
715
+ decimalSeparator = '.',
716
+ textClassName,
717
+ size,
718
+ weight,
719
+ delay,
720
+ enabled,
721
+ ...props
722
+ }: TextAnimationSlidingProps) {
723
+ const wait = useShared('delay', delay, 0);
724
+ const still = useStill(useShared('enabled', enabled, true));
725
+ const { digits } = textAnimationVariants();
726
+
727
+ const { whole, fraction, negative } = useMemo(() => {
728
+ const absolute = Math.abs(value);
729
+ const fixed = absolute.toFixed(decimals);
730
+ const [left = '0', right = ''] = fixed.split('.');
731
+ return {
732
+ whole: left.padStart(padStart, '0'),
733
+ fraction: right,
734
+ negative: value < 0,
735
+ };
736
+ }, [decimals, padStart, value]);
737
+
738
+ const columns: ReactNode[] = [];
739
+
740
+ whole.split('').forEach((digit, index) => {
741
+ const fromEnd = whole.length - index - 1;
742
+ columns.push(
743
+ <SlidingColumn
744
+ key={`w${index}`}
745
+ digit={Number(digit)}
746
+ delay={wait}
747
+ still={still}
748
+ className={textClassName}
749
+ size={size}
750
+ weight={weight}
751
+ />
752
+ );
753
+ if (thousandSeparator && fromEnd > 0 && fromEnd % 3 === 0) {
754
+ columns.push(
755
+ <Text key={`s${index}`} size={size} weight={weight} className={textClassName}>
756
+ {thousandSeparator}
757
+ </Text>
758
+ );
759
+ }
760
+ });
761
+
762
+ if (fraction) {
763
+ columns.push(
764
+ <Text key="point" size={size} weight={weight} className={textClassName}>
765
+ {decimalSeparator}
766
+ </Text>
767
+ );
768
+ fraction.split('').forEach((digit, index) => {
769
+ columns.push(
770
+ <SlidingColumn
771
+ key={`f${index}`}
772
+ digit={Number(digit)}
773
+ delay={wait}
774
+ still={still}
775
+ className={textClassName}
776
+ size={size}
777
+ weight={weight}
778
+ />
779
+ );
780
+ });
781
+ }
782
+
783
+ return (
784
+ <View
785
+ // The number as one thing, so a screen reader says "1,024" rather than
786
+ // reading four columns of ten digits each.
787
+ accessibilityRole="text"
788
+ accessibilityLabel={`${negative ? '-' : ''}${whole}${fraction ? decimalSeparator + fraction : ''}`}
789
+ className={digits({ className })}
790
+ {...props}
791
+ >
792
+ {negative ? (
793
+ <Text size={size} weight={weight} className={textClassName}>
794
+ −
795
+ </Text>
796
+ ) : null}
797
+ {columns}
798
+ </View>
799
+ );
800
+ }
801
+ TextAnimationSliding.displayName = 'TextAnimation.Sliding';
802
+
803
+ const TEN = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
804
+
805
+ function SlidingColumn({
806
+ digit,
807
+ delay,
808
+ still,
809
+ className,
810
+ size,
811
+ weight,
812
+ }: {
813
+ digit: number;
814
+ delay: number;
815
+ still: boolean;
816
+ className?: string;
817
+ size?: TextProps['size'];
818
+ weight?: TextProps['weight'];
819
+ }) {
820
+ const { column } = textAnimationVariants();
821
+ const offset = useSharedValue(still ? digit : 0);
822
+
823
+ useEffect(() => {
824
+ if (still) {
825
+ cancelAnimation(offset);
826
+ offset.value = digit;
827
+ return;
828
+ }
829
+ const timer = setTimeout(() => {
830
+ offset.value = withSpring(digit, NUMBER_SPRING);
831
+ }, delay);
832
+ return () => {
833
+ clearTimeout(timer);
834
+ cancelAnimation(offset);
835
+ };
836
+ }, [delay, digit, offset, still]);
837
+
838
+ /*
839
+ * A percentage of the column's own height, so nothing has to be measured.
840
+ * The column is ten digits tall; moving it by `-n/10` of that puts the nth
841
+ * one in the window, whatever the font size turns out to be.
842
+ */
843
+ const style = useAnimatedStyle(() => ({
844
+ transform: [{ translateY: `${(-offset.value / TEN.length) * 100}%` }],
845
+ }));
846
+
847
+ return (
848
+ <View className="overflow-hidden">
849
+ {/* In flow and invisible: it is what gives the window a digit's height
850
+ and a digit's width without either being a number in the source. */}
851
+ <Text size={size} weight={weight} className={cn('opacity-0', className)}>
852
+ 0
853
+ </Text>
854
+ <Animated.View style={[style, { position: 'absolute', top: 0 }]} className={column()}>
855
+ {TEN.map((n) => (
856
+ <Text key={n} size={size} weight={weight} className={cn('tabular-nums', className)}>
857
+ {n}
858
+ </Text>
859
+ ))}
860
+ </Animated.View>
861
+ </View>
862
+ );
863
+ }
864
+
865
+ /* -------------------------------------------------------------------------- */
866
+ /* Scrolling */
867
+ /* -------------------------------------------------------------------------- */
868
+
869
+ export interface TextAnimationScrollingProps extends ViewProps {
870
+ className?: string;
871
+ /** The value to land on. */
872
+ value: number;
873
+ /** The gap between the values either side of it. */
874
+ step?: number;
875
+ /** How many values to show above and below the one in the window. */
876
+ around?: number;
877
+ /** How long the run takes, in milliseconds. */
878
+ duration?: number;
879
+ /** Milliseconds before it starts. */
880
+ delay?: number;
881
+ /** Formatting for each value, as `Intl.NumberFormat` options. */
882
+ formatOptions?: Intl.NumberFormatOptions;
883
+ /** Styles the values. */
884
+ textClassName?: string;
885
+ /** Size of the values, as on `Text`. */
886
+ size?: TextProps['size'];
887
+ /** Weight of the values, as on `Text`. */
888
+ weight?: TextProps['weight'];
889
+ /**
890
+ * Draw a band behind the value in the window, so the one being chosen is
891
+ * told apart from the scale around it.
892
+ */
893
+ highlight?: boolean;
894
+ /** Styles that band. */
895
+ highlightClassName?: string;
896
+ /**
897
+ * What the top and bottom of the window fade into — a theme token name, or
898
+ * any colour. It has to be told: the fade is painted, so it can only be the
899
+ * right colour if it is the colour of whatever is behind the window.
900
+ * Defaults to `--color-background`; pass `--color-card` inside a card.
901
+ *
902
+ * `false` turns the fade off, for a window on a surface that is not one flat
903
+ * colour.
904
+ */
905
+ fadeColor?: string | false;
906
+ enabled?: boolean;
907
+ }
908
+
909
+ /**
910
+ * A column of values scrolling past a window, coming to rest on one.
911
+ *
912
+ * The difference from `Sliding` is what the reader is being told. An odometer
913
+ * says *this number changed*; a column that scrolls past its neighbours says
914
+ * *this number was chosen from a scale*, and the values either side of it are
915
+ * the scale. Reach for it for a target, a threshold, a picked quantity — and
916
+ * for a plain change of value, reach for the other one.
917
+ */
918
+ function TextAnimationScrolling({
919
+ className,
920
+ value,
921
+ step = 1,
922
+ around = 2,
923
+ duration,
924
+ delay,
925
+ formatOptions,
926
+ textClassName,
927
+ size,
928
+ weight,
929
+ highlight = false,
930
+ highlightClassName,
931
+ fadeColor = '--color-background',
932
+ enabled,
933
+ ...props
934
+ }: TextAnimationScrollingProps) {
935
+ const span = useShared('duration', duration, 1400);
936
+ const wait = useShared('delay', delay, 0);
937
+ const still = useStill(useShared('enabled', enabled, true));
938
+ const { column, highlight: band } = textAnimationVariants();
939
+
940
+ const token = useCSSVariable(
941
+ typeof fadeColor === 'string' && fadeColor.startsWith('--')
942
+ ? fadeColor
943
+ : '--color-background'
944
+ );
945
+ const resolvedToken = typeof token === 'string' ? token : undefined;
946
+ const fade =
947
+ fadeColor === false
948
+ ? undefined
949
+ : (fadeColor.startsWith('--') ? resolvedToken : fadeColor) ?? resolvedToken;
950
+
951
+ const format = useFormatter(0, formatOptions);
952
+
953
+ // The window shows `around` either side of the resting value, so the run
954
+ // starts that many steps below zero and ends that many above the target.
955
+ const values = useMemo(() => {
956
+ const out: number[] = [];
957
+ for (let i = -around; value + around * step >= i * step; i += 1) {
958
+ out.push(i * step);
959
+ if (out.length > 400) break;
960
+ }
961
+ return out;
962
+ }, [around, step, value]);
963
+
964
+ const rows = around * 2 + 1;
965
+ /*
966
+ * The window shows `rows` values and the chosen one belongs in the middle of
967
+ * them, so the column comes to rest `around` short of the target's own index.
968
+ * Landing on the index itself would put the answer against the top edge with
969
+ * the whole scale below it, which reads as having overshot.
970
+ */
971
+ const found = values.indexOf(value);
972
+ const target = Math.max(0, (found >= 0 ? found : values.length - 1) - around);
973
+
974
+ const offset = useSharedValue(still ? target : 0);
975
+
976
+ useEffect(() => {
977
+ if (still) {
978
+ cancelAnimation(offset);
979
+ offset.value = target;
980
+ return;
981
+ }
982
+ offset.value = 0;
983
+ const timer = setTimeout(() => {
984
+ offset.value = withTiming(target, {
985
+ duration: span,
986
+ easing: Easing.out(Easing.cubic),
987
+ });
988
+ }, wait);
989
+ return () => {
990
+ clearTimeout(timer);
991
+ cancelAnimation(offset);
992
+ };
993
+ }, [offset, span, still, target, wait]);
994
+
995
+ const style = useAnimatedStyle(() => ({
996
+ transform: [{ translateY: `${(-offset.value / values.length) * 100}%` }],
997
+ }));
998
+
999
+ return (
1000
+ <View
1001
+ accessibilityRole="text"
1002
+ accessibilityLabel={format(value)}
1003
+ className={cn('overflow-hidden', className)}
1004
+ {...props}
1005
+ >
1006
+ {/* `rows` invisible values in flow: the window is as tall as the number
1007
+ of rows asked for, at whatever height the font makes them. */}
1008
+ <View className="opacity-0">
1009
+ {Array.from({ length: rows }, (_, i) => (
1010
+ <Text key={i} size={size} weight={weight} className={textClassName}>
1011
+ 0
1012
+ </Text>
1013
+ ))}
1014
+ </View>
1015
+
1016
+ {/* Behind the column, so the value reads on top of it rather than
1017
+ through it. One row tall and in the middle, which is where the run
1018
+ comes to rest. */}
1019
+ {highlight ? (
1020
+ <View
1021
+ pointerEvents="none"
1022
+ className={band({ className: highlightClassName })}
1023
+ style={{
1024
+ position: 'absolute',
1025
+ left: 0,
1026
+ right: 0,
1027
+ top: `${(around / rows) * 100}%`,
1028
+ height: `${(1 / rows) * 100}%`,
1029
+ }}
1030
+ />
1031
+ ) : null}
1032
+
1033
+ <Animated.View
1034
+ style={[style, { position: 'absolute', top: 0, left: 0, right: 0 }]}
1035
+ className={column()}
1036
+ >
1037
+ {values.map((entry) => (
1038
+ <Text
1039
+ key={entry}
1040
+ size={size}
1041
+ weight={weight}
1042
+ className={cn('tabular-nums', textClassName)}
1043
+ >
1044
+ {format(entry)}
1045
+ </Text>
1046
+ ))}
1047
+ </Animated.View>
1048
+
1049
+ {/*
1050
+ * The neighbours have to recede or the window is a list of five numbers
1051
+ * with no answer in it. Fading the edges into the surface is what says
1052
+ * the column continues past them — and it is painted rather than masked
1053
+ * because a mask needs a native module this component would otherwise
1054
+ * not want.
1055
+ */}
1056
+ {fade ? (
1057
+ <>
1058
+ <LinearGradient
1059
+ pointerEvents="none"
1060
+ colors={[fade, withAlpha(fade, 0)]}
1061
+ style={{
1062
+ position: 'absolute',
1063
+ top: 0,
1064
+ left: 0,
1065
+ right: 0,
1066
+ height: `${(around / rows) * 100}%`,
1067
+ }}
1068
+ />
1069
+ <LinearGradient
1070
+ pointerEvents="none"
1071
+ colors={[withAlpha(fade, 0), fade]}
1072
+ style={{
1073
+ position: 'absolute',
1074
+ bottom: 0,
1075
+ left: 0,
1076
+ right: 0,
1077
+ height: `${(around / rows) * 100}%`,
1078
+ }}
1079
+ />
1080
+ </>
1081
+ ) : null}
1082
+ </View>
1083
+ );
1084
+ }
1085
+ TextAnimationScrolling.displayName = 'TextAnimation.Scrolling';
1086
+
1087
+ export const TextAnimation = Object.assign(TextAnimationRoot, {
1088
+ Typing: TextAnimationTyping,
1089
+ Rotating: TextAnimationRotating,
1090
+ Counting: TextAnimationCounting,
1091
+ Sliding: TextAnimationSliding,
1092
+ Scrolling: TextAnimationScrolling,
1093
+ });