panelui-native 0.22.6 → 0.25.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 (135) hide show
  1. package/README.md +1 -0
  2. package/lib/module/components/area-chart/index.js +826 -0
  3. package/lib/module/components/area-chart/index.js.map +1 -0
  4. package/lib/module/components/avatar/index.js +1 -1
  5. package/lib/module/components/avatar/index.js.map +1 -1
  6. package/lib/module/components/bar-chart/index.js +853 -0
  7. package/lib/module/components/bar-chart/index.js.map +1 -0
  8. package/lib/module/components/bottom-sheet/index.js +207 -23
  9. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  10. package/lib/module/components/breadcrumb/index.js +275 -0
  11. package/lib/module/components/breadcrumb/index.js.map +1 -0
  12. package/lib/module/components/chip/index.js +2 -2
  13. package/lib/module/components/direction/index.js +13 -19
  14. package/lib/module/components/direction/index.js.map +1 -1
  15. package/lib/module/components/frame/index.js +17 -0
  16. package/lib/module/components/frame/index.js.map +1 -1
  17. package/lib/module/components/heatmap-chart/index.js +28 -8
  18. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  19. package/lib/module/components/input-group/index.js +2 -2
  20. package/lib/module/components/input-group/index.js.map +1 -1
  21. package/lib/module/components/line-chart/index.js +4 -146
  22. package/lib/module/components/line-chart/index.js.map +1 -1
  23. package/lib/module/components/loader/index.js +758 -0
  24. package/lib/module/components/loader/index.js.map +1 -0
  25. package/lib/module/components/map/basemap.js +265 -0
  26. package/lib/module/components/map/basemap.js.map +1 -0
  27. package/lib/module/components/map/index.js +850 -0
  28. package/lib/module/components/map/index.js.map +1 -0
  29. package/lib/module/components/map/maplibre.js +87 -0
  30. package/lib/module/components/map/maplibre.js.map +1 -0
  31. package/lib/module/components/message/index.js +8 -7
  32. package/lib/module/components/message/index.js.map +1 -1
  33. package/lib/module/components/otp-input/index.js +2 -2
  34. package/lib/module/components/progress/index.js +8 -2
  35. package/lib/module/components/progress/index.js.map +1 -1
  36. package/lib/module/components/ring-chart/index.js +423 -0
  37. package/lib/module/components/ring-chart/index.js.map +1 -0
  38. package/lib/module/components/section-rail/index.js +2 -2
  39. package/lib/module/components/separator/index.js +76 -22
  40. package/lib/module/components/separator/index.js.map +1 -1
  41. package/lib/module/components/shimmer/index.js +10 -1
  42. package/lib/module/components/shimmer/index.js.map +1 -1
  43. package/lib/module/components/slider/index.js +19 -5
  44. package/lib/module/components/slider/index.js.map +1 -1
  45. package/lib/module/components/steps/index.js +1 -1
  46. package/lib/module/components/switch/index.js +6 -1
  47. package/lib/module/components/switch/index.js.map +1 -1
  48. package/lib/module/components/typography/index.js +5 -0
  49. package/lib/module/components/typography/index.js.map +1 -1
  50. package/lib/module/hooks/use-direction.js +43 -0
  51. package/lib/module/hooks/use-direction.js.map +1 -0
  52. package/lib/module/icons/index.js +117 -2
  53. package/lib/module/icons/index.js.map +1 -1
  54. package/lib/module/index.js +8 -2
  55. package/lib/module/index.js.map +1 -1
  56. package/lib/module/primitives/text.js +30 -10
  57. package/lib/module/primitives/text.js.map +1 -1
  58. package/lib/module/utils/chart.js +279 -0
  59. package/lib/module/utils/chart.js.map +1 -0
  60. package/lib/typescript/src/components/area-chart/index.d.ts +197 -0
  61. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -0
  62. package/lib/typescript/src/components/bar-chart/index.d.ts +228 -0
  63. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -0
  64. package/lib/typescript/src/components/bottom-sheet/index.d.ts +75 -4
  65. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  66. package/lib/typescript/src/components/breadcrumb/index.d.ts +100 -0
  67. package/lib/typescript/src/components/breadcrumb/index.d.ts.map +1 -0
  68. package/lib/typescript/src/components/direction/index.d.ts +11 -10
  69. package/lib/typescript/src/components/direction/index.d.ts.map +1 -1
  70. package/lib/typescript/src/components/frame/index.d.ts +17 -0
  71. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  72. package/lib/typescript/src/components/heatmap-chart/index.d.ts +22 -3
  73. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  74. package/lib/typescript/src/components/line-chart/index.d.ts +1 -0
  75. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  76. package/lib/typescript/src/components/loader/index.d.ts +38 -0
  77. package/lib/typescript/src/components/loader/index.d.ts.map +1 -0
  78. package/lib/typescript/src/components/map/basemap.d.ts +76 -0
  79. package/lib/typescript/src/components/map/basemap.d.ts.map +1 -0
  80. package/lib/typescript/src/components/map/index.d.ts +367 -0
  81. package/lib/typescript/src/components/map/index.d.ts.map +1 -0
  82. package/lib/typescript/src/components/map/maplibre.d.ts +193 -0
  83. package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -0
  84. package/lib/typescript/src/components/message/index.d.ts.map +1 -1
  85. package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
  86. package/lib/typescript/src/components/ring-chart/index.d.ts +156 -0
  87. package/lib/typescript/src/components/ring-chart/index.d.ts.map +1 -0
  88. package/lib/typescript/src/components/separator/index.d.ts +36 -1
  89. package/lib/typescript/src/components/separator/index.d.ts.map +1 -1
  90. package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -1
  91. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  92. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  93. package/lib/typescript/src/components/typography/index.d.ts +6 -0
  94. package/lib/typescript/src/components/typography/index.d.ts.map +1 -1
  95. package/lib/typescript/src/hooks/use-direction.d.ts +20 -0
  96. package/lib/typescript/src/hooks/use-direction.d.ts.map +1 -0
  97. package/lib/typescript/src/icons/index.d.ts +5 -0
  98. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  99. package/lib/typescript/src/index.d.ts +9 -3
  100. package/lib/typescript/src/index.d.ts.map +1 -1
  101. package/lib/typescript/src/primitives/text.d.ts.map +1 -1
  102. package/lib/typescript/src/utils/chart.d.ts +96 -0
  103. package/lib/typescript/src/utils/chart.d.ts.map +1 -0
  104. package/package.json +9 -1
  105. package/src/components/area-chart/index.tsx +994 -0
  106. package/src/components/avatar/index.tsx +1 -1
  107. package/src/components/bar-chart/index.tsx +1048 -0
  108. package/src/components/bottom-sheet/index.tsx +280 -20
  109. package/src/components/breadcrumb/index.tsx +365 -0
  110. package/src/components/chip/index.tsx +2 -2
  111. package/src/components/direction/index.tsx +20 -21
  112. package/src/components/frame/index.tsx +17 -0
  113. package/src/components/heatmap-chart/index.tsx +58 -11
  114. package/src/components/input-group/index.tsx +2 -2
  115. package/src/components/line-chart/index.tsx +13 -181
  116. package/src/components/loader/index.tsx +812 -0
  117. package/src/components/map/basemap.ts +304 -0
  118. package/src/components/map/index.tsx +1118 -0
  119. package/src/components/map/maplibre.ts +201 -0
  120. package/src/components/message/index.tsx +8 -7
  121. package/src/components/otp-input/index.tsx +2 -2
  122. package/src/components/progress/index.tsx +10 -2
  123. package/src/components/ring-chart/index.tsx +543 -0
  124. package/src/components/section-rail/index.tsx +2 -2
  125. package/src/components/separator/index.tsx +77 -19
  126. package/src/components/shimmer/index.tsx +10 -1
  127. package/src/components/slider/index.tsx +20 -5
  128. package/src/components/steps/index.tsx +1 -1
  129. package/src/components/switch/index.tsx +6 -1
  130. package/src/components/typography/index.tsx +5 -0
  131. package/src/hooks/use-direction.ts +45 -0
  132. package/src/icons/index.tsx +66 -0
  133. package/src/index.ts +84 -0
  134. package/src/primitives/text.tsx +25 -7
  135. package/src/utils/chart.ts +355 -0
@@ -0,0 +1,812 @@
1
+ /**
2
+ * Loading animations.
3
+ *
4
+ * ## Why one component and not nine
5
+ *
6
+ * These are interchangeable. A loader is chosen once, for the character it
7
+ * gives a screen, and then it is the same loader everywhere — so the thing
8
+ * that varies is a value, not a component. `variant` keeps the swap to one
9
+ * word and keeps every one of them on the same props: the same colour
10
+ * resolution, the same `speed`, the same reduced-motion behaviour, the same
11
+ * accessibility. Nine exports would mean nine chances for those to drift.
12
+ *
13
+ * ## Why they are drawn the way they are
14
+ *
15
+ * Anything with a fixed, small number of moving pieces is a view per piece
16
+ * with a `useAnimatedStyle` on it: three dots is three transforms a frame, and
17
+ * that is cheaper than rebuilding a path string.
18
+ *
19
+ * Anything whose count is high, or whose shape is derived rather than
20
+ * declared, is a single SVG path rebuilt on the UI thread. Fifteen bars
21
+ * following a bouncing ball is one animated prop that way and fifteen the
22
+ * other, and the fifteen do not stay in step with each other for free.
23
+ *
24
+ * ## Reduced motion
25
+ *
26
+ * Every variant draws a representative still frame rather than freezing at
27
+ * whatever it happened to be showing. A stopped animation should look like a
28
+ * shape someone drew, not like a bug caught mid-cycle.
29
+ */
30
+ import { memo, useEffect, type ReactNode } from 'react';
31
+ import { View, type ViewProps } from 'react-native';
32
+ import Svg, { Ellipse, Path } from 'react-native-svg';
33
+ import Animated, {
34
+ Easing,
35
+ cancelAnimation,
36
+ useAnimatedProps,
37
+ useAnimatedStyle,
38
+ useFrameCallback,
39
+ useReducedMotion,
40
+ useSharedValue,
41
+ withDelay,
42
+ withRepeat,
43
+ withTiming,
44
+ } from 'react-native-reanimated';
45
+ import { useCSSVariable } from 'uniwind';
46
+ import { tv } from 'tailwind-variants';
47
+ import { useIconColor } from '../../icons';
48
+ import { cn } from '../../utils/cn';
49
+
50
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
51
+ const AnimatedEllipse = Animated.createAnimatedComponent(Ellipse);
52
+
53
+ export type LoaderVariant =
54
+ | 'pulse-dots'
55
+ | 'bounce-dots'
56
+ | 'pulsating-dots'
57
+ | 'liquid-dots'
58
+ | 'bar-cascade'
59
+ | 'bouncing-bars'
60
+ | 'symmetric-wave'
61
+ | 'morph-ring'
62
+ | 'wave-physics';
63
+
64
+ export type LoaderSize = 'sm' | 'md' | 'lg';
65
+
66
+ const loaderVariants = tv({
67
+ base: 'items-center justify-center',
68
+ });
69
+
70
+ export interface LoaderProps extends Omit<ViewProps, 'children'> {
71
+ /**
72
+ * Which animation to draw. They are interchangeable — pick one for the
73
+ * character it gives the screen and keep it.
74
+ */
75
+ variant?: LoaderVariant;
76
+ /** Overall scale. The geometry of every variant is multiplied by it. */
77
+ size?: LoaderSize;
78
+ /**
79
+ * Ink. Takes a theme token by name — `"--color-primary"` — as readily as a
80
+ * literal, and the token is usually what you want: a literal cannot follow
81
+ * the theme into dark mode.
82
+ *
83
+ * With neither, the loader draws in the readable foreground of the surface
84
+ * it is on, so one inside a filled button is legible without being told.
85
+ */
86
+ color?: string;
87
+ /** Multiplier on the tempo. `1` is the designed speed; `2` is twice as fast. */
88
+ speed?: number;
89
+ /**
90
+ * What a screen reader announces. Defaults to "Loading". Say what is
91
+ * loading when the loader is the only thing on the screen.
92
+ */
93
+ label?: string;
94
+ className?: string;
95
+ }
96
+
97
+ /** Geometry at `md`. Every number here is multiplied by the size scale. */
98
+ const GEOMETRY = {
99
+ 'pulse-dots': { dot: 10, gap: 6, count: 3 },
100
+ 'bounce-dots': { dot: 10, gap: 6, count: 3, travel: 8 },
101
+ 'pulsating-dots': { dot: 11, gap: 8, count: 3 },
102
+ 'liquid-dots': { radius: 11, travel: 15, width: 72, height: 32 },
103
+ 'bar-cascade': { bar: 5, gap: 4, count: 5, min: 8, max: 26 },
104
+ 'bouncing-bars': { bar: 5, gap: 6, count: 3, height: 30 },
105
+ 'symmetric-wave': { bar: 5, gap: 5, count: 9, min: 7, max: 26 },
106
+ 'morph-ring': { box: 34, border: 3 },
107
+ 'wave-physics': { bar: 5, gap: 3, count: 15, base: 7, peak: 26 },
108
+ } as const;
109
+
110
+ const SIZE_SCALE: Record<LoaderSize, number> = { sm: 0.75, md: 1, lg: 1.35 };
111
+
112
+ /**
113
+ * A theme token rather than a colour. Tokens are named, not written, so the
114
+ * leading `--` tells the two apart without the caller having to say which.
115
+ */
116
+ function isToken(value: string | undefined): value is string {
117
+ return typeof value === 'string' && value.startsWith('--');
118
+ }
119
+
120
+ /** One decimal is past the point a path string can show, and cheaper to build. */
121
+ function q(value: number): number {
122
+ 'worklet';
123
+ return Math.round(value * 10) / 10;
124
+ }
125
+
126
+ /** A closed circle as a path subpath, so several can share one `Path`. */
127
+ function circle(cx: number, cy: number, r: number): string {
128
+ 'worklet';
129
+ return (
130
+ `M${q(cx - r)},${q(cy)}` +
131
+ `A${q(r)},${q(r)} 0 1 0 ${q(cx + r)},${q(cy)}` +
132
+ `A${q(r)},${q(r)} 0 1 0 ${q(cx - r)},${q(cy)}Z`
133
+ );
134
+ }
135
+
136
+ /* -------------------------------------------------------------------------- */
137
+ /* dots */
138
+ /* -------------------------------------------------------------------------- */
139
+
140
+ /**
141
+ * One dot.
142
+ *
143
+ * A component rather than a loop of `useAnimatedStyle` inside the parent: the
144
+ * count is geometry, and a loop of hooks is a rule waiting to be broken by
145
+ * whoever changes it.
146
+ */
147
+ const Dot = memo(function Dot({
148
+ size,
149
+ color,
150
+ delay,
151
+ duration,
152
+ from,
153
+ to,
154
+ property,
155
+ still,
156
+ animate,
157
+ }: {
158
+ size: number;
159
+ color: string;
160
+ delay: number;
161
+ duration: number;
162
+ from: number;
163
+ to: number;
164
+ property: 'opacity' | 'translateY' | 'scale';
165
+ /** The value to hold when nothing is animating. */
166
+ still: number;
167
+ animate: boolean;
168
+ }) {
169
+ const progress = useSharedValue(animate ? from : still);
170
+
171
+ useEffect(() => {
172
+ if (!animate) {
173
+ progress.value = still;
174
+ return;
175
+ }
176
+ progress.value = from;
177
+ progress.value = withDelay(
178
+ delay,
179
+ // Reversing rather than a three-stop keyframe: every one of these
180
+ // animations returns to where it started, and a reversed repeat says so
181
+ // in one line without a midpoint to keep in step.
182
+ withRepeat(
183
+ withTiming(to, { duration, easing: Easing.inOut(Easing.quad) }),
184
+ -1,
185
+ true
186
+ )
187
+ );
188
+ return () => cancelAnimation(progress);
189
+ }, [animate, delay, duration, from, to, still, progress]);
190
+
191
+ const style = useAnimatedStyle(() => {
192
+ if (property === 'opacity') return { opacity: progress.value };
193
+ if (property === 'translateY') {
194
+ return { transform: [{ translateY: progress.value }] };
195
+ }
196
+ return {
197
+ opacity: 0.5 + (progress.value - 1) * 1,
198
+ transform: [{ scale: progress.value }],
199
+ };
200
+ });
201
+
202
+ return (
203
+ <Animated.View
204
+ style={[
205
+ { width: size, height: size, borderRadius: size / 2, backgroundColor: color },
206
+ style,
207
+ ]}
208
+ />
209
+ );
210
+ });
211
+
212
+ /* -------------------------------------------------------------------------- */
213
+ /* bars */
214
+ /* -------------------------------------------------------------------------- */
215
+
216
+ /** One bar. Same reasoning as `Dot`. */
217
+ const Bar = memo(function Bar({
218
+ width,
219
+ color,
220
+ delay,
221
+ duration,
222
+ from,
223
+ to,
224
+ mode,
225
+ barHeight,
226
+ still,
227
+ animate,
228
+ }: {
229
+ width: number;
230
+ color: string;
231
+ delay: number;
232
+ duration: number;
233
+ /*
234
+ * In `height` mode these are pixels; in `scale` mode they are multipliers of
235
+ * `barHeight`. Keeping the box separate from the range is the whole reason
236
+ * `barHeight` exists — conflated, a scale target reads as a height and the
237
+ * bar grows to thirty times its size.
238
+ */
239
+ from: number;
240
+ to: number;
241
+ /** `height` grows the bar from its baseline; `scale` squashes a fixed one. */
242
+ mode: 'height' | 'scale';
243
+ /** The bar's laid-out height. Only used by `scale`. */
244
+ barHeight?: number;
245
+ still: number;
246
+ animate: boolean;
247
+ }) {
248
+ const progress = useSharedValue(animate ? from : still);
249
+
250
+ useEffect(() => {
251
+ if (!animate) {
252
+ progress.value = still;
253
+ return;
254
+ }
255
+ progress.value = from;
256
+ progress.value = withDelay(
257
+ delay,
258
+ withRepeat(
259
+ withTiming(to, { duration, easing: Easing.inOut(Easing.quad) }),
260
+ -1,
261
+ true
262
+ )
263
+ );
264
+ return () => cancelAnimation(progress);
265
+ }, [animate, delay, duration, from, to, still, progress]);
266
+
267
+ const style = useAnimatedStyle(() =>
268
+ mode === 'height'
269
+ ? { height: progress.value }
270
+ : { transform: [{ scaleY: progress.value }] }
271
+ );
272
+
273
+ return (
274
+ <Animated.View
275
+ style={[
276
+ {
277
+ width,
278
+ borderRadius: width / 2,
279
+ backgroundColor: color,
280
+ ...(mode === 'scale' ? { height: barHeight } : null),
281
+ },
282
+ style,
283
+ ]}
284
+ />
285
+ );
286
+ });
287
+
288
+ /* -------------------------------------------------------------------------- */
289
+ /* liquid dots */
290
+ /* -------------------------------------------------------------------------- */
291
+
292
+ /**
293
+ * Two blobs that merge as they cross.
294
+ *
295
+ * The merge is the whole animation, and it is drawn rather than filtered: a
296
+ * blur-and-threshold pass is how this is usually done, and SVG filters do not
297
+ * render on native at all. Instead the two circles and the neck between them
298
+ * are three subpaths of one path, unioned by the non-zero fill rule — which
299
+ * costs one animated prop a frame, and gives a crisper edge than a threshold
300
+ * ever does.
301
+ *
302
+ * The neck is what sells it. It pinches inward as the blobs separate and
303
+ * vanishes once they are far enough apart, so they let go of each other
304
+ * instead of snapping.
305
+ */
306
+ function LiquidDots({
307
+ color,
308
+ scale,
309
+ speed,
310
+ animate,
311
+ }: {
312
+ color: string;
313
+ scale: number;
314
+ speed: number;
315
+ animate: boolean;
316
+ }) {
317
+ const g = GEOMETRY['liquid-dots'];
318
+ const r = g.radius * scale;
319
+ const travel = g.travel * scale;
320
+ const width = g.width * scale;
321
+ const height = g.height * scale;
322
+ const cy = height / 2;
323
+ const mid = width / 2;
324
+
325
+ // -1 to 1: where the left blob is along its travel. The right blob mirrors.
326
+ const t = useSharedValue(animate ? -1 : -0.35);
327
+
328
+ useEffect(() => {
329
+ if (!animate) {
330
+ t.value = -0.35;
331
+ return;
332
+ }
333
+ t.value = -1;
334
+ t.value = withRepeat(
335
+ withTiming(1, { duration: 2000 / speed, easing: Easing.inOut(Easing.quad) }),
336
+ -1,
337
+ true
338
+ );
339
+ return () => cancelAnimation(t);
340
+ }, [animate, speed, t]);
341
+
342
+ const props = useAnimatedProps(() => {
343
+ const x1 = mid + t.value * travel;
344
+ const x2 = mid - t.value * travel;
345
+ const left = Math.min(x1, x2);
346
+ const right = Math.max(x1, x2);
347
+ const gap = right - left;
348
+
349
+ let d = circle(left, cy, r) + circle(right, cy, r);
350
+
351
+ /*
352
+ * The neck only exists while the blobs are close enough to be pulling on
353
+ * each other: `1` when they are concentric, and gone by the time they are
354
+ * a radius apart. Squared, so it thins out quickly rather than trailing.
355
+ *
356
+ * The thickness is what the cutoff is really on. A falloff that only
357
+ * approaches zero leaves a hairline spanning the gap for the whole of the
358
+ * rest of the travel, and a one-pixel bar between two circles reads as a
359
+ * drawing mistake rather than as a thinning neck — so below the point
360
+ * where it is still legibly a neck, it is not drawn at all.
361
+ */
362
+ const pull = Math.max(0, 1 - gap / (r * 2.2));
363
+ const half = r * pull * pull * 0.95;
364
+ if (half > r * 0.12 && gap > 1) {
365
+ // Pinched at the middle: the control point sits inside the straight
366
+ // line between the two, which is what makes the join read as surface
367
+ // tension rather than as a rectangle.
368
+ const waist = half * 0.55;
369
+ d +=
370
+ `M${q(left)},${q(cy - half)}` +
371
+ `Q${q((left + right) / 2)},${q(cy - waist)} ${q(right)},${q(cy - half)}` +
372
+ `L${q(right)},${q(cy + half)}` +
373
+ `Q${q((left + right) / 2)},${q(cy + waist)} ${q(left)},${q(cy + half)}Z`;
374
+ }
375
+
376
+ return { d };
377
+ });
378
+
379
+ return (
380
+ <Svg width={width} height={height}>
381
+ <AnimatedPath animatedProps={props} fill={color} fillRule="nonzero" />
382
+ </Svg>
383
+ );
384
+ }
385
+
386
+ /* -------------------------------------------------------------------------- */
387
+ /* wave physics */
388
+ /* -------------------------------------------------------------------------- */
389
+
390
+ /** How many bounces the ball takes crossing the row once. */
391
+ const BOUNCES = 4;
392
+
393
+ /**
394
+ * A ball bouncing along a row of bars, pushing them down where it lands.
395
+ *
396
+ * The only variant with anything like physics in it, and the reason it is
397
+ * procedural rather than a keyframe list: the bar heights are a function of
398
+ * where the ball is, so they are derived every frame instead of stored. Fifteen
399
+ * bars precomputed would be fifteen arrays that have to agree with each other
400
+ * and with the ball, and none of them could respond to `speed`.
401
+ *
402
+ * Both the bars and the ball read the same clock, which is what keeps the
403
+ * dent under the ball rather than near it.
404
+ */
405
+ function WavePhysics({
406
+ color,
407
+ scale,
408
+ speed,
409
+ animate,
410
+ }: {
411
+ color: string;
412
+ scale: number;
413
+ speed: number;
414
+ animate: boolean;
415
+ }) {
416
+ const g = GEOMETRY['wave-physics'];
417
+ const barWidth = g.bar * scale;
418
+ const step = (g.bar + g.gap) * scale;
419
+ const base = g.base * scale;
420
+ const peak = g.peak * scale;
421
+ const ballR = (g.bar * 1.1) * scale;
422
+ const bounce = peak * 0.9;
423
+
424
+ const width = (g.count - 1) * step + barWidth;
425
+ const height = base + peak + bounce + ballR * 2;
426
+ const floor = height;
427
+
428
+ const clock = useSharedValue(0);
429
+
430
+ const frame = useFrameCallback((info) => {
431
+ 'worklet';
432
+ // Accumulated rather than read off the total, so `speed` can change
433
+ // mid-animation without the ball jumping to where the new rate would have
434
+ // put it. A dropped frame is clamped: a 300ms hitch played at full rate
435
+ // is a lurch, not a bounce.
436
+ const delta = Math.min(info.timeSincePreviousFrame ?? 16, 48) / 1000;
437
+ clock.value += delta * speed * 0.35;
438
+ }, false);
439
+
440
+ const { setActive } = frame;
441
+ useEffect(() => {
442
+ setActive(animate);
443
+ return () => setActive(false);
444
+ }, [animate, setActive]);
445
+
446
+ /** Where the ball is, as a bar index, and how far through its hop. */
447
+ const ballAt = (time: number) => {
448
+ 'worklet';
449
+ // Back and forth along the row, then back again — one continuous pass.
450
+ const cycle = time % 2;
451
+ const across = cycle < 1 ? cycle : 2 - cycle;
452
+ const index = across * (g.count - 1);
453
+ let phase = (across * BOUNCES) % 1;
454
+ if (across === 0 || across === 1) phase = 0;
455
+ // A parabola per hop: the ball is slowest at the top, which is the only
456
+ // part of a bounce anyone actually reads.
457
+ const lift = 4 * phase * (1 - phase);
458
+ return { index, lift };
459
+ };
460
+
461
+ const barProps = useAnimatedProps(() => {
462
+ const { index, lift } = animate ? ballAt(clock.value) : { index: 7, lift: 0.5 };
463
+ // The ball presses hardest when it is on the ground, so the dent is the
464
+ // inverse of the hop.
465
+ const press = Math.max(0, 1 - lift * 2);
466
+ let d = '';
467
+
468
+ for (let i = 0; i < g.count; i++) {
469
+ const distance = Math.abs(i - index);
470
+ // A raised crest travelling with the ball, three bars wide either side.
471
+ const crest = distance < 3 ? Math.cos((distance / 3) * (Math.PI / 2)) : 0;
472
+ // And a dent right under it, narrower than the crest so the bar the
473
+ // ball is standing on is pushed down through its own rise.
474
+ const dent =
475
+ distance < 1.5
476
+ ? Math.cos((distance / 1.5) * (Math.PI / 2)) * press * (peak * 0.5)
477
+ : 0;
478
+ const barHeight = Math.max(base * 0.5, base + crest * peak - dent);
479
+ const x = q(barWidth / 2 + i * step);
480
+ d += `M${x},${q(floor)}L${x},${q(floor - barHeight)}`;
481
+ }
482
+
483
+ return { d };
484
+ });
485
+
486
+ const ballProps = useAnimatedProps(() => {
487
+ const { index, lift } = animate ? ballAt(clock.value) : { index: 7, lift: 0.5 };
488
+ const press = Math.max(0, 1 - lift * 2);
489
+ // The ball rides the bar directly under it, which is the crest of the
490
+ // wave (so the full `peak`) minus however hard it is pressing.
491
+ const rest = base + peak - press * (peak * 0.5);
492
+
493
+ // Squash on the ground, stretch at the top — the oldest trick there is,
494
+ // and the thing that separates a bouncing ball from a moving circle.
495
+ const squash = press;
496
+ return {
497
+ cx: q(barWidth / 2 + index * step),
498
+ cy: q(floor - rest - ballR + squash * ballR * 0.3 - lift * bounce),
499
+ rx: q(ballR * (1 + squash * 0.25)),
500
+ ry: q(ballR * (1 - squash * 0.28)),
501
+ };
502
+ });
503
+
504
+ return (
505
+ <Svg width={width} height={height}>
506
+ <AnimatedPath
507
+ animatedProps={barProps}
508
+ stroke={color}
509
+ strokeWidth={barWidth}
510
+ strokeLinecap="round"
511
+ fill="none"
512
+ opacity={0.55}
513
+ />
514
+ <AnimatedEllipse animatedProps={ballProps} fill={color} />
515
+ </Svg>
516
+ );
517
+ }
518
+
519
+ /* -------------------------------------------------------------------------- */
520
+ /* morph ring */
521
+ /* -------------------------------------------------------------------------- */
522
+
523
+ /**
524
+ * A square turning into a circle and back while it rotates.
525
+ *
526
+ * The rotation is what makes the morph legible: a square becoming a circle in
527
+ * place mostly reads as a square getting smaller, and turning it means the
528
+ * corners are visibly going somewhere as they round off.
529
+ */
530
+ function MorphRing({
531
+ color,
532
+ scale,
533
+ speed,
534
+ animate,
535
+ }: {
536
+ color: string;
537
+ scale: number;
538
+ speed: number;
539
+ animate: boolean;
540
+ }) {
541
+ const g = GEOMETRY['morph-ring'];
542
+ const box = g.box * scale;
543
+ const border = Math.max(2, g.border * scale);
544
+
545
+ const progress = useSharedValue(animate ? 0 : 0.5);
546
+
547
+ useEffect(() => {
548
+ if (!animate) {
549
+ progress.value = 0.5;
550
+ return;
551
+ }
552
+ progress.value = 0;
553
+ progress.value = withRepeat(
554
+ withTiming(1, { duration: 2000 / speed, easing: Easing.inOut(Easing.quad) }),
555
+ -1,
556
+ true
557
+ );
558
+ return () => cancelAnimation(progress);
559
+ }, [animate, speed, progress]);
560
+
561
+ const style = useAnimatedStyle(() => ({
562
+ borderRadius: box * (0.1 + progress.value * 0.4),
563
+ transform: [{ rotate: `${progress.value * 180}deg` }],
564
+ }));
565
+
566
+ /*
567
+ * The padding is the rotation's. A square turned 45° needs its diagonal, so
568
+ * it sweeps past its own box by nearly half its width on each side — laid
569
+ * out flush, the corners spill over whatever is next to it.
570
+ */
571
+ const overhang = (box * Math.SQRT2 - box) / 2;
572
+
573
+ return (
574
+ <View style={{ padding: overhang }}>
575
+ <Animated.View
576
+ style={[
577
+ { width: box, height: box, borderWidth: border, borderColor: color },
578
+ style,
579
+ ]}
580
+ />
581
+ </View>
582
+ );
583
+ }
584
+
585
+ /* -------------------------------------------------------------------------- */
586
+ /* root */
587
+ /* -------------------------------------------------------------------------- */
588
+
589
+ /**
590
+ * A loading animation.
591
+ *
592
+ * Nine of them, chosen with `variant`. They all take the same colour, the same
593
+ * `speed`, and the same treatment under reduced motion, so swapping one for
594
+ * another is a one-word change.
595
+ */
596
+ export const Loader = memo(function Loader({
597
+ variant = 'pulse-dots',
598
+ size = 'md',
599
+ color,
600
+ speed = 1,
601
+ label = 'Loading',
602
+ className,
603
+ ...props
604
+ }: LoaderProps) {
605
+ const reducedMotion = useReducedMotion();
606
+ const animate = !reducedMotion && speed > 0;
607
+ const scale = SIZE_SCALE[size];
608
+ const rate = speed > 0 ? speed : 1;
609
+
610
+ /*
611
+ * A loader inside a filled surface has to be drawn in that surface's
612
+ * foreground rather than the page's: a button in the primary colour is one
613
+ * of the most likely places for a loader to appear, and in most themes the
614
+ * primary colour *is* the page's foreground — so one resolving
615
+ * `--color-foreground` for itself would be invisible exactly there.
616
+ * Surfaces already publish their readable foreground for icons.
617
+ */
618
+ const inherited = useIconColor();
619
+ const token = useCSSVariable(isToken(color) ? color : '--color-foreground');
620
+ const resolved = typeof token === 'string' ? token : undefined;
621
+ const ink =
622
+ (isToken(color) ? resolved : color) ?? inherited ?? resolved ?? '#0a0a0a';
623
+
624
+ const frame = (content: ReactNode) => (
625
+ <View
626
+ accessibilityRole="progressbar"
627
+ accessibilityLabel={label}
628
+ className={loaderVariants({ className })}
629
+ {...props}
630
+ >
631
+ {content}
632
+ </View>
633
+ );
634
+
635
+ if (variant === 'liquid-dots') {
636
+ return frame(
637
+ <LiquidDots color={ink} scale={scale} speed={rate} animate={animate} />
638
+ );
639
+ }
640
+
641
+ if (variant === 'wave-physics') {
642
+ return frame(
643
+ <WavePhysics color={ink} scale={scale} speed={rate} animate={animate} />
644
+ );
645
+ }
646
+
647
+ if (variant === 'morph-ring') {
648
+ return frame(
649
+ <MorphRing color={ink} scale={scale} speed={rate} animate={animate} />
650
+ );
651
+ }
652
+
653
+ if (variant === 'pulse-dots') {
654
+ const g = GEOMETRY['pulse-dots'];
655
+ return frame(
656
+ <View className="flex-row items-center" style={{ gap: g.gap * scale }}>
657
+ {Array.from({ length: g.count }, (_unused, i) => (
658
+ <Dot
659
+ key={i}
660
+ size={g.dot * scale}
661
+ color={ink}
662
+ delay={(i * 200) / rate}
663
+ duration={700 / rate}
664
+ from={0.2}
665
+ to={1}
666
+ still={i === 1 ? 1 : 0.45}
667
+ property="opacity"
668
+ animate={animate}
669
+ />
670
+ ))}
671
+ </View>
672
+ );
673
+ }
674
+
675
+ if (variant === 'bounce-dots') {
676
+ const g = GEOMETRY['bounce-dots'];
677
+ const travel = g.travel * scale;
678
+ return frame(
679
+ <View
680
+ className="flex-row items-center"
681
+ style={{ gap: g.gap * scale, paddingTop: travel }}
682
+ >
683
+ {Array.from({ length: g.count }, (_unused, i) => (
684
+ <Dot
685
+ key={i}
686
+ size={g.dot * scale}
687
+ color={ink}
688
+ delay={(i * 100) / rate}
689
+ duration={300 / rate}
690
+ from={0}
691
+ to={-travel}
692
+ still={-travel * (i === 1 ? 1 : 0.35)}
693
+ property="translateY"
694
+ animate={animate}
695
+ />
696
+ ))}
697
+ </View>
698
+ );
699
+ }
700
+
701
+ if (variant === 'pulsating-dots') {
702
+ const g = GEOMETRY['pulsating-dots'];
703
+ return frame(
704
+ <View
705
+ className="flex-row items-center"
706
+ // A dot at its largest is half again its laid-out size, and it grows
707
+ // about its centre — so a quarter of a dot on each side is the room
708
+ // the scale needs, plus a little for the rounding.
709
+ style={{ gap: g.gap * scale, padding: g.dot * scale * 0.3 }}
710
+ >
711
+ {Array.from({ length: g.count }, (_unused, i) => (
712
+ <Dot
713
+ key={i}
714
+ size={g.dot * scale}
715
+ color={ink}
716
+ delay={(i * 200) / rate}
717
+ duration={600 / rate}
718
+ from={1}
719
+ to={1.5}
720
+ still={i === 1 ? 1.4 : 1.1}
721
+ property="scale"
722
+ animate={animate}
723
+ />
724
+ ))}
725
+ </View>
726
+ );
727
+ }
728
+
729
+ if (variant === 'bar-cascade') {
730
+ const g = GEOMETRY['bar-cascade'];
731
+ return frame(
732
+ <View
733
+ className="flex-row items-center"
734
+ style={{ gap: g.gap * scale, height: g.max * scale }}
735
+ >
736
+ {Array.from({ length: g.count }, (_unused, i) => (
737
+ <Bar
738
+ key={i}
739
+ width={g.bar * scale}
740
+ color={ink}
741
+ delay={(i * 100) / rate}
742
+ duration={500 / rate}
743
+ from={g.min * scale}
744
+ to={g.max * scale}
745
+ // A still frame that shows the cascade's shape rather than a row
746
+ // of equal bars, which would say nothing about what it does.
747
+ still={(g.min + (g.max - g.min) * Math.abs(Math.sin(i * 0.9))) * scale}
748
+ mode="height"
749
+ animate={animate}
750
+ />
751
+ ))}
752
+ </View>
753
+ );
754
+ }
755
+
756
+ if (variant === 'bouncing-bars') {
757
+ const g = GEOMETRY['bouncing-bars'];
758
+ return frame(
759
+ <View
760
+ className="flex-row items-center"
761
+ style={{ gap: g.gap * scale, height: g.height * scale }}
762
+ >
763
+ {Array.from({ length: g.count }, (_unused, i) => (
764
+ <Bar
765
+ key={i}
766
+ width={g.bar * scale}
767
+ color={ink}
768
+ delay={(i * 200) / rate}
769
+ duration={500 / rate}
770
+ from={0.3}
771
+ to={1}
772
+ barHeight={g.height * scale}
773
+ still={0.4 + i * 0.25}
774
+ mode="scale"
775
+ animate={animate}
776
+ />
777
+ ))}
778
+ </View>
779
+ );
780
+ }
781
+
782
+ // symmetric-wave
783
+ const g = GEOMETRY['symmetric-wave'];
784
+ /*
785
+ * The delay is symmetric about the middle rather than running left to right,
786
+ * so the wave opens outward from the centre and arrives back at it. A
787
+ * straight ramp reads as a queue; this reads as one thing breathing.
788
+ */
789
+ const offsets = [0, 1, 2, 3, 4, 3, 2, 1, 0];
790
+ return frame(
791
+ <View
792
+ className="flex-row items-center"
793
+ style={{ gap: g.gap * scale, height: g.max * scale }}
794
+ >
795
+ {Array.from({ length: g.count }, (_unused, i) => (
796
+ <Bar
797
+ key={i}
798
+ width={g.bar * scale}
799
+ color={ink}
800
+ delay={((offsets[i] ?? 0) * 100) / rate}
801
+ duration={600 / rate}
802
+ from={g.min * scale}
803
+ to={g.max * scale}
804
+ still={(g.min + (g.max - g.min) * (1 - (offsets[i] ?? 0) / 4)) * scale}
805
+ mode="height"
806
+ animate={animate}
807
+ />
808
+ ))}
809
+ </View>
810
+ );
811
+ });
812
+ Loader.displayName = 'Loader';