panelui-native 0.77.0 → 0.79.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 (142) hide show
  1. package/lib/module/components/area-chart/index.js +32 -25
  2. package/lib/module/components/area-chart/index.js.map +1 -1
  3. package/lib/module/components/bottom-sheet/index.js +97 -15
  4. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  5. package/lib/module/components/funnel-chart/index.js +15 -2
  6. package/lib/module/components/funnel-chart/index.js.map +1 -1
  7. package/lib/module/components/heatmap-chart/index.js +19 -27
  8. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  9. package/lib/module/components/hex-chart/index.js +2 -0
  10. package/lib/module/components/hex-chart/index.js.map +1 -1
  11. package/lib/module/components/line-chart/index.js +38 -31
  12. package/lib/module/components/line-chart/index.js.map +1 -1
  13. package/lib/module/components/map/index.js +63 -39
  14. package/lib/module/components/map/index.js.map +1 -1
  15. package/lib/module/components/map/map-children.js +54 -0
  16. package/lib/module/components/map/map-children.js.map +1 -0
  17. package/lib/module/components/map/maplibre.js +31 -3
  18. package/lib/module/components/map/maplibre.js.map +1 -1
  19. package/lib/module/components/markdown-editor/index.js +288 -34
  20. package/lib/module/components/markdown-editor/index.js.map +1 -1
  21. package/lib/module/components/markdown-editor/markdown-transforms.js +272 -9
  22. package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -1
  23. package/lib/module/components/marquee/index.js +205 -46
  24. package/lib/module/components/marquee/index.js.map +1 -1
  25. package/lib/module/components/panelside/index.js +162 -34
  26. package/lib/module/components/panelside/index.js.map +1 -1
  27. package/lib/module/components/pie-chart/index.js +15 -2
  28. package/lib/module/components/pie-chart/index.js.map +1 -1
  29. package/lib/module/components/plot/index.js +42 -44
  30. package/lib/module/components/plot/index.js.map +1 -1
  31. package/lib/module/components/progress-button/index.js +617 -0
  32. package/lib/module/components/progress-button/index.js.map +1 -0
  33. package/lib/module/components/progress-button/progress-button-hold.js +91 -0
  34. package/lib/module/components/progress-button/progress-button-hold.js.map +1 -0
  35. package/lib/module/components/scatter-chart/index.js +6 -25
  36. package/lib/module/components/scatter-chart/index.js.map +1 -1
  37. package/lib/module/components/section-rail/index.js +40 -17
  38. package/lib/module/components/section-rail/index.js.map +1 -1
  39. package/lib/module/components/split-view/index.js +54 -10
  40. package/lib/module/components/split-view/index.js.map +1 -1
  41. package/lib/module/components/theme-selector/index.js +239 -0
  42. package/lib/module/components/theme-selector/index.js.map +1 -0
  43. package/lib/module/components/theme-selector/theme-preview.js +320 -0
  44. package/lib/module/components/theme-selector/theme-preview.js.map +1 -0
  45. package/lib/module/components/timeline/index.js +145 -14
  46. package/lib/module/components/timeline/index.js.map +1 -1
  47. package/lib/module/components/toast/index.js +45 -11
  48. package/lib/module/components/toast/index.js.map +1 -1
  49. package/lib/module/components/treemap-chart/index.js +63 -6
  50. package/lib/module/components/treemap-chart/index.js.map +1 -1
  51. package/lib/module/components/waterfall-chart/index.js +20 -4
  52. package/lib/module/components/waterfall-chart/index.js.map +1 -1
  53. package/lib/module/hooks/index.js +1 -0
  54. package/lib/module/hooks/index.js.map +1 -1
  55. package/lib/module/hooks/use-scroll-sections.js +44 -2
  56. package/lib/module/hooks/use-scroll-sections.js.map +1 -1
  57. package/lib/module/hooks/use-skeleton-handoff.js +58 -0
  58. package/lib/module/hooks/use-skeleton-handoff.js.map +1 -0
  59. package/lib/module/index.js +2 -0
  60. package/lib/module/index.js.map +1 -1
  61. package/lib/typescript/src/components/area-chart/index.d.ts +1 -1
  62. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  63. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  64. package/lib/typescript/src/components/funnel-chart/index.d.ts.map +1 -1
  65. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  66. package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/line-chart/index.d.ts +1 -1
  68. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  69. package/lib/typescript/src/components/map/index.d.ts +8 -4
  70. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  71. package/lib/typescript/src/components/map/map-children.d.ts +41 -0
  72. package/lib/typescript/src/components/map/map-children.d.ts.map +1 -0
  73. package/lib/typescript/src/components/map/maplibre.d.ts +8 -4
  74. package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -1
  75. package/lib/typescript/src/components/markdown-editor/index.d.ts +64 -5
  76. package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +33 -5
  78. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -1
  79. package/lib/typescript/src/components/marquee/index.d.ts +55 -2
  80. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
  81. package/lib/typescript/src/components/panelside/index.d.ts +79 -4
  82. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/pie-chart/index.d.ts.map +1 -1
  84. package/lib/typescript/src/components/plot/index.d.ts +1 -3
  85. package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
  86. package/lib/typescript/src/components/progress-button/index.d.ts +311 -0
  87. package/lib/typescript/src/components/progress-button/index.d.ts.map +1 -0
  88. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts +60 -0
  89. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts.map +1 -0
  90. package/lib/typescript/src/components/scatter-chart/index.d.ts +1 -1
  91. package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -1
  92. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
  93. package/lib/typescript/src/components/split-view/index.d.ts +10 -1
  94. package/lib/typescript/src/components/split-view/index.d.ts.map +1 -1
  95. package/lib/typescript/src/components/theme-selector/index.d.ts +107 -0
  96. package/lib/typescript/src/components/theme-selector/index.d.ts.map +1 -0
  97. package/lib/typescript/src/components/theme-selector/theme-preview.d.ts +32 -0
  98. package/lib/typescript/src/components/theme-selector/theme-preview.d.ts.map +1 -0
  99. package/lib/typescript/src/components/timeline/index.d.ts +7 -0
  100. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  101. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  102. package/lib/typescript/src/components/treemap-chart/index.d.ts.map +1 -1
  103. package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -1
  104. package/lib/typescript/src/hooks/index.d.ts +1 -0
  105. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  106. package/lib/typescript/src/hooks/use-scroll-sections.d.ts +1 -0
  107. package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -1
  108. package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts +11 -0
  109. package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts.map +1 -0
  110. package/lib/typescript/src/index.d.ts +6 -4
  111. package/lib/typescript/src/index.d.ts.map +1 -1
  112. package/package.json +1 -1
  113. package/src/components/area-chart/index.tsx +49 -37
  114. package/src/components/bottom-sheet/index.tsx +109 -11
  115. package/src/components/funnel-chart/index.tsx +10 -2
  116. package/src/components/heatmap-chart/index.tsx +27 -22
  117. package/src/components/hex-chart/index.tsx +22 -6
  118. package/src/components/line-chart/index.tsx +57 -43
  119. package/src/components/map/index.tsx +63 -36
  120. package/src/components/map/map-children.ts +66 -0
  121. package/src/components/map/maplibre.ts +32 -4
  122. package/src/components/markdown-editor/index.tsx +413 -35
  123. package/src/components/markdown-editor/markdown-transforms.ts +274 -10
  124. package/src/components/marquee/index.tsx +254 -58
  125. package/src/components/panelside/index.tsx +179 -40
  126. package/src/components/pie-chart/index.tsx +10 -2
  127. package/src/components/plot/index.tsx +66 -50
  128. package/src/components/progress-button/index.tsx +684 -0
  129. package/src/components/progress-button/progress-button-hold.ts +88 -0
  130. package/src/components/scatter-chart/index.tsx +4 -25
  131. package/src/components/section-rail/index.tsx +39 -19
  132. package/src/components/split-view/index.tsx +53 -8
  133. package/src/components/theme-selector/index.tsx +281 -0
  134. package/src/components/theme-selector/theme-preview.tsx +205 -0
  135. package/src/components/timeline/index.tsx +143 -17
  136. package/src/components/toast/index.tsx +60 -11
  137. package/src/components/treemap-chart/index.tsx +65 -8
  138. package/src/components/waterfall-chart/index.tsx +14 -4
  139. package/src/hooks/index.ts +5 -0
  140. package/src/hooks/use-scroll-sections.ts +52 -3
  141. package/src/hooks/use-skeleton-handoff.ts +69 -0
  142. package/src/index.ts +25 -0
@@ -11,17 +11,26 @@ import {
11
11
  type ReactElement,
12
12
  type ReactNode,
13
13
  } from 'react';
14
- import { Pressable, useWindowDimensions, View, type ViewProps } from 'react-native';
14
+ import {
15
+ Pressable,
16
+ StyleSheet,
17
+ useWindowDimensions,
18
+ View,
19
+ type ViewProps,
20
+ } from 'react-native';
15
21
  import { useCSSVariable } from 'uniwind';
16
22
  import { Gesture, GestureDetector } from 'react-native-gesture-handler';
17
23
  import Animated, {
24
+ Extrapolation,
18
25
  FadeIn,
26
+ interpolate,
19
27
  FadeOut,
20
28
  SlideInDown,
21
29
  SlideOutDown,
22
30
  runOnJS,
23
31
  useAnimatedScrollHandler,
24
32
  useAnimatedStyle,
33
+ useReducedMotion,
25
34
  useSharedValue,
26
35
  withSpring,
27
36
  withTiming,
@@ -36,11 +45,60 @@ import { Scrim } from '../../primitives/scrim';
36
45
  import { Text, textChildren } from '../../primitives/text';
37
46
  import { useBackHandler } from '../../hooks/use-back-handler';
38
47
  import { cn } from '../../utils/cn';
48
+ import { impactKnock } from '../../utils/haptics';
49
+
50
+ /**
51
+ * One spring for the whole surface, in Apple's two designer parameters rather
52
+ * than mass/stiffness/damping — the sheet arriving, the sheet snapping back and
53
+ * the sheet leaving used to be three slightly different springs, which is three
54
+ * different weights for one object.
55
+ *
56
+ * A little under critically damped, so it settles without a bounce. A sheet
57
+ * that overshoots reads as light, and a sheet is not light.
58
+ *
59
+ * Slower than the 300ms a small state change gets, and deliberately. This is a
60
+ * large surface travelling most of the screen, and it is the same category of
61
+ * movement as a screen transition rather than as a toggle — the platform's own
62
+ * sheets are slower still. Taken at toggle speed it arrives before the eye has
63
+ * followed it, which reads as a jump-cut to a different screen rather than as
64
+ * something coming up from the bottom.
65
+ */
66
+ const SPRING = { duration: 420, dampingRatio: 0.9 } as const;
67
+
68
+ /**
69
+ * Leaving, where the far side of the travel is off screen: clamped, because a
70
+ * spring allowed to overshoot past the bottom flashes a gap under the sheet on
71
+ * its way back.
72
+ */
73
+ const EXIT_SPRING = { duration: 380, dampingRatio: 1, overshootClamping: true } as const;
39
74
 
40
- const SPRING = { damping: 22, stiffness: 280, mass: 0.7 } as const;
41
75
  const DISMISS_DISTANCE = 120;
42
76
  const DISMISS_VELOCITY = 800;
43
77
 
78
+ /*
79
+ * Built once at module scope. A layout-animation builder constructed in render
80
+ * is a new object every commit, and the sheet re-renders while it is open.
81
+ */
82
+ const ENTERING = SlideInDown.springify().dampingRatio(0.9).duration(420);
83
+ // Leaving is quicker than arriving, but not by much: the sheet is still
84
+ // crossing the whole screen, and a surface that big snapping out of existence
85
+ // is more startling than the delay it saves is worth.
86
+ const EXITING = SlideOutDown.springify().dampingRatio(1).duration(340);
87
+
88
+ /**
89
+ * Where a flick would come to rest if it kept decelerating — Apple's
90
+ * exponential-decay form.
91
+ *
92
+ * Distance alone makes the sheet heavy: a short fast flick downward is
93
+ * unmistakably a dismissal, and asking it to also travel 120 points first means
94
+ * the reader has to throw the sheet twice.
95
+ */
96
+ function project(velocity: number): number {
97
+ 'worklet';
98
+ const decelerationRate = 0.998;
99
+ return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);
100
+ }
101
+
44
102
  const sheetVariants = tv({
45
103
  base: 'border border-border bg-popover px-5 pt-2 shadow-lg',
46
104
  variants: {
@@ -287,6 +345,7 @@ function BottomSheetContent({
287
345
  const { open, setOpen } = context;
288
346
  const nativeSheet = useContext(NativeSheetContext);
289
347
  const { height: screenHeight, width: screenWidth } = useWindowDimensions();
348
+ const reducedMotion = useReducedMotion();
290
349
  const insets = useSafeAreaInsets();
291
350
  const translateY = useSharedValue(0);
292
351
  const closeTint = useCSSVariable('--color-muted-foreground');
@@ -353,15 +412,31 @@ function BottomSheetContent({
353
412
  translateY.value = next > 0 ? next : next / 3;
354
413
  })
355
414
  .onEnd((event) => {
415
+ /*
416
+ * Velocity decides as much as distance does, and it is handed to the
417
+ * animation rather than only consulted by it. A flat timing from
418
+ * wherever the finger let go ignores how fast it was moving, so the
419
+ * frame the touch ends is a visible seam between a fast drag and a
420
+ * slow slide — the single detail that separates a sheet that follows
421
+ * your hand from one that merely goes where you put it.
422
+ */
423
+ const projected = translateY.value + project(event.velocityY);
356
424
  if (
357
- translateY.value > DISMISS_DISTANCE ||
425
+ projected > DISMISS_DISTANCE ||
358
426
  event.velocityY > DISMISS_VELOCITY
359
427
  ) {
360
- translateY.value = withTiming(screenHeight, { duration: 200 }, () => {
361
- runOnJS(close)();
362
- });
428
+ translateY.value = withSpring(
429
+ screenHeight,
430
+ { ...EXIT_SPRING, velocity: event.velocityY },
431
+ (finished) => {
432
+ if (finished) runOnJS(close)();
433
+ }
434
+ );
363
435
  } else {
364
- translateY.value = withSpring(0, SPRING);
436
+ translateY.value = withSpring(0, { ...SPRING, velocity: event.velocityY });
437
+ // It caught rather than went. Fired here, at the moment the sheet
438
+ // commits to staying, not when it finishes arriving.
439
+ runOnJS(impactKnock)();
365
440
  }
366
441
  }),
367
442
  // Rebuilt only when one of these changes. Built inline it would be a new
@@ -374,6 +449,15 @@ function BottomSheetContent({
374
449
  transform: [{ translateY: translateY.value }],
375
450
  }));
376
451
 
452
+ const backdropStyle = useAnimatedStyle(() => ({
453
+ opacity: interpolate(
454
+ translateY.value,
455
+ [0, screenHeight],
456
+ [1, 0],
457
+ Extrapolation.CLAMP
458
+ ),
459
+ }));
460
+
377
461
  /*
378
462
  * A sized sheet is clamped rather than handed its fraction outright.
379
463
  * `insets.top` is the status bar and the notch, and content that runs under
@@ -453,7 +537,12 @@ function BottomSheetContent({
453
537
  subtree — re-provide the context so nested consumers keep working. */}
454
538
  <BottomSheetContext.Provider value={context}>
455
539
  <View className="absolute inset-0 justify-end">
456
- <View className="absolute inset-0">
540
+ {/* Derived from the same value the sheet moves on, so the backdrop
541
+ cannot disagree with it: dragging the sheet halfway down lightens
542
+ the screen behind it by half. Left to its own fade the scrim stayed
543
+ fully dark until the sheet had gone, which reads as a dimmed screen
544
+ with nothing on it. */}
545
+ <Animated.View style={[StyleSheet.absoluteFill, backdropStyle]}>
457
546
  {/* Scrim draws the backdrop and its own fade; the Pressable over it
458
547
  is what closes the sheet, since the scrim takes no touches. */}
459
548
  <Scrim blur={blur} />
@@ -462,11 +551,20 @@ function BottomSheetContent({
462
551
  className="flex-1"
463
552
  onPress={dismissible ? close : undefined}
464
553
  />
465
- </View>
554
+ </Animated.View>
466
555
  <GestureDetector gesture={pan}>
467
556
  <Animated.View
468
- entering={SlideInDown.springify().damping(22).stiffness(240).mass(0.8)}
469
- exiting={SlideOutDown.duration(200)}
557
+ /*
558
+ * The same spring the drag settles with, so a sheet caught halfway
559
+ * through arriving and a sheet released after a drag are the same
560
+ * object moving at the same weight.
561
+ *
562
+ * Reduced motion keeps the scrim's fade and drops the slide: the
563
+ * state change still has to be legible, and it is the travel that
564
+ * setting is about.
565
+ */
566
+ entering={reducedMotion ? FadeIn.duration(150) : ENTERING}
567
+ exiting={reducedMotion ? FadeOut.duration(150) : EXITING}
470
568
  accessibilityViewIsModal
471
569
  className={sheetVariants({ detached, className })}
472
570
  {...props}
@@ -90,6 +90,7 @@ import { useDirection } from '../../hooks/use-direction';
90
90
  import { Text } from '../../primitives/text';
91
91
  import { compactNumber, ribbonPath, useSeriesColor } from '../../utils/chart';
92
92
  import { cn } from '../../utils/cn';
93
+ import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff';
93
94
 
94
95
  const AnimatedPath = Animated.createAnimatedComponent(Path);
95
96
 
@@ -790,12 +791,19 @@ function FunnelChartSkeleton({ color }: FunnelChartSkeletonProps) {
790
791
  const token = useCSSVariable('--color-skeleton');
791
792
  const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
792
793
 
793
- if (status !== 'loading' || width <= 0 || height <= 0) return null;
794
+ // Held through the fade rather than to the frame the data lands, so the plain
795
+ // taper dissolves under the stages growing across it instead of leaving a
796
+ // blank panel between the two.
797
+ const { mounted, opacity } = useSkeletonHandoff(status === 'loading');
798
+ const animatedProps = useAnimatedProps(() => ({ opacity: opacity.value }));
799
+
800
+ if (!mounted || width <= 0 || height <= 0) return null;
794
801
 
795
802
  const extent = Math.max(0, height / 2 - textBand);
796
803
 
797
804
  return (
798
- <Path
805
+ <AnimatedPath
806
+ animatedProps={animatedProps}
799
807
  d={ribbonPath(0, width, extent, extent * 0.4, middle, curve)}
800
808
  fill={fill}
801
809
  />
@@ -54,18 +54,18 @@ import Animated, {
54
54
  Easing,
55
55
  runOnJS,
56
56
  useAnimatedProps,
57
+ useAnimatedStyle,
57
58
  useReducedMotion,
58
59
  useSharedValue,
59
60
  withTiming,
60
61
  } from 'react-native-reanimated';
61
- import Svg, { ClipPath, Defs, G, Line as SvgLine, Rect } from 'react-native-svg';
62
+ import Svg, { G, Line as SvgLine, Rect } from 'react-native-svg';
62
63
  import { useCSSVariable } from 'uniwind';
63
64
  import { Text } from '../../primitives/text';
64
65
  import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility';
65
66
  import { cn } from '../../utils/cn';
66
67
  import { normalizeWeekStart, startOfDay } from '../../utils/date';
67
68
 
68
- const AnimatedRect = Animated.createAnimatedComponent(Rect);
69
69
 
70
70
  /**
71
71
  * Rows in a week — the default, and what the calendar helper and the weekday
@@ -187,7 +187,6 @@ interface HeatmapContextValue {
187
187
  cornerRadius: number;
188
188
  inactiveOpacity: number;
189
189
  weekStartDay: number;
190
- clipId: string;
191
190
  activeCell: HeatmapCell | null;
192
191
  setActiveCell: (cell: HeatmapCell | null) => void;
193
192
  }
@@ -409,7 +408,6 @@ const HeatmapChartRoot = forwardRef<View, HeatmapChartProps>(function HeatmapCha
409
408
  ) {
410
409
  const [width, setWidth] = useState(0);
411
410
  const [activeCell, setActiveCellState] = useState<HeatmapCell | null>(null);
412
- const clipId = `panelui-heat-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
413
411
 
414
412
  const reveal = useSharedValue(0);
415
413
  const reducedMotion = useReducedMotion();
@@ -551,7 +549,6 @@ const HeatmapChartRoot = forwardRef<View, HeatmapChartProps>(function HeatmapCha
551
549
  cornerRadius,
552
550
  inactiveOpacity,
553
551
  weekStartDay,
554
- clipId,
555
552
  activeCell,
556
553
  setActiveCell,
557
554
  }),
@@ -565,7 +562,6 @@ const HeatmapChartRoot = forwardRef<View, HeatmapChartProps>(function HeatmapCha
565
562
  cornerRadius,
566
563
  inactiveOpacity,
567
564
  weekStartDay,
568
- clipId,
569
565
  activeCell,
570
566
  setActiveCell,
571
567
  ]
@@ -576,7 +572,7 @@ const HeatmapChartRoot = forwardRef<View, HeatmapChartProps>(function HeatmapCha
576
572
  * effect is the same — columns arriving in order — and it costs one animated
577
573
  * value instead of one per week, which for a year is fifty-two.
578
574
  */
579
- const clipProps = useAnimatedProps(() => ({ width: grid.width * reveal.value }));
575
+ const revealStyle = useAnimatedStyle(() => ({ width: grid.width * reveal.value }));
580
576
 
581
577
  return (
582
578
  <HeatmapContext.Provider value={context}>
@@ -625,23 +621,32 @@ const HeatmapChartRoot = forwardRef<View, HeatmapChartProps>(function HeatmapCha
625
621
  <View style={{ width: grid.width, height: grid.height }}>
626
622
  {grid.width > 0 ? (
627
623
  <>
628
- <Svg width={grid.width} height={grid.height} style={StyleSheet.absoluteFill}>
629
- <Defs>
630
- <ClipPath id={clipId}>
631
- <AnimatedRect
632
- x={0}
633
- y={0}
634
- width={grid.width}
635
- height={grid.height}
636
- animatedProps={clipProps}
637
- />
638
- </ClipPath>
639
- </Defs>
640
- <G clipPath={`url(#${clipId})`}>
624
+ {/*
625
+ * The reveal is a view that grows, not an SVG clip path.
626
+ *
627
+ * It used to be an animated `<Rect>` inside `<Defs>`, and on
628
+ * Android those animated props never reach the native clip, so
629
+ * the grid drew complete and the reveal did not play at all. A
630
+ * view with `overflow: 'hidden'` is clipped by the platform
631
+ * itself, which both platforms agree on.
632
+ *
633
+ * Animating `width` is normally a layout pass per frame. This
634
+ * view is absolutely positioned and its only child is an
635
+ * `<Svg>` with an explicit width and height, so it is one node
636
+ * and nothing around it moves.
637
+ */}
638
+ <Animated.View
639
+ pointerEvents="none"
640
+ style={[
641
+ { position: 'absolute', top: 0, bottom: 0, left: 0, overflow: 'hidden' },
642
+ revealStyle,
643
+ ]}
644
+ >
645
+ <Svg width={grid.width} height={grid.height}>
641
646
  {parts.rules}
642
647
  {parts.cells}
643
- </G>
644
- </Svg>
648
+ </Svg>
649
+ </Animated.View>
645
650
  {parts.tooltip}
646
651
  </>
647
652
  ) : null}
@@ -526,21 +526,37 @@ const HexChartRoot = forwardRef<HexChartHandle, HexChartProps>(function HexChart
526
526
  <>
527
527
  <Svg width={width} height={height}>
528
528
  <Defs>
529
+ {/*
530
+ * The reveal's own geometry is declared statically as well as
531
+ * animated, and it has to be. Animated props on an element
532
+ * inside `Defs` do not reach the native clip on every
533
+ * platform — Android is the one that does not — and with the
534
+ * size coming only from the animation there is no size at all
535
+ * where they do not: an empty clip, and a honeycomb whose
536
+ * series never appear while its field draws normally.
537
+ *
538
+ * Declared, the worst case is the reveal not playing and the
539
+ * split being there from the first frame, which is a chart
540
+ * that is merely less pleasing rather than a chart that is
541
+ * missing its answer.
542
+ */}
529
543
  <ClipPath id={clipId}>
530
544
  {shape === 'blob' ? (
531
545
  <AnimatedEllipse
532
546
  cx={left + fieldWidth / 2}
533
547
  cy={top + fieldHeight / 2}
548
+ rx={(fieldWidth / 2) * CORNER_REACH}
549
+ ry={(fieldHeight / 2) * CORNER_REACH}
534
550
  animatedProps={ellipseProps}
535
551
  />
536
552
  ) : (
537
553
  <AnimatedRect
538
- x={0}
539
- y={0}
540
- width={width}
541
- height={height}
542
- animatedProps={rectProps}
543
- />
554
+ x={0}
555
+ y={0}
556
+ width={width}
557
+ height={height}
558
+ animatedProps={rectProps}
559
+ />
544
560
  )}
545
561
  </ClipPath>
546
562
  </Defs>
@@ -71,13 +71,11 @@ import Animated, {
71
71
  } from 'react-native-reanimated';
72
72
  import Svg, {
73
73
  Circle,
74
- ClipPath,
75
74
  Defs,
76
75
  G,
77
76
  Line as SvgLine,
78
77
  LinearGradient,
79
78
  Path,
80
- Rect,
81
79
  Stop,
82
80
  } from 'react-native-svg';
83
81
  import { useCSSVariable } from 'uniwind';
@@ -96,7 +94,6 @@ import {
96
94
  import { cn } from '../../utils/cn';
97
95
 
98
96
  const AnimatedPath = Animated.createAnimatedComponent(Path);
99
- const AnimatedRect = Animated.createAnimatedComponent(Rect);
100
97
  const AnimatedCircle = Animated.createAnimatedComponent(Circle);
101
98
  const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
102
99
 
@@ -114,7 +111,7 @@ const Y_AXIS_GUTTER = 6;
114
111
  * stays a flat list of children instead of two nested slots the caller has to
115
112
  * remember the order of.
116
113
  */
117
- type Layer = 'svg' | 'overlay' | 'header';
114
+ type Layer = 'svg' | 'series' | 'overlay' | 'header';
118
115
 
119
116
  export type LineChartStatus = 'loading' | 'ready';
120
117
  export type LineChartCurve = 'monotone' | 'linear';
@@ -143,7 +140,6 @@ interface LineChartContextValue {
143
140
  activeIndex: SharedValue<number>;
144
141
  activeIndexJS: number;
145
142
  setActiveIndexJS: (index: number) => void;
146
- clipId: string;
147
143
  }
148
144
 
149
145
  const LineChartContext = createContext<LineChartContextValue | null>(null);
@@ -242,7 +238,6 @@ const LineChartRoot = forwardRef<LineChartHandle, LineChartProps>(function LineC
242
238
  const [size, setSize] = useState({ width: 0, height: 0 });
243
239
  const [series, setSeries] = useState<[string, string][]>([]);
244
240
  const [activeIndexJS, setActiveIndexJS] = useState(-1);
245
- const clipId = `panelui-clip-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
246
241
 
247
242
  const reveal = useSharedValue(0);
248
243
  const domainMin = useSharedValue(0);
@@ -373,7 +368,7 @@ const LineChartRoot = forwardRef<LineChartHandle, LineChartProps>(function LineC
373
368
  // `replay()` re-runs the reveal — for a control the caller wires up.
374
369
  useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
375
370
 
376
- const clipProps = useAnimatedProps(() => ({ width: plot.width * reveal.value }));
371
+ const revealStyle = useAnimatedStyle(() => ({ width: plot.width * reveal.value }));
377
372
 
378
373
  // One place the crosshair index lands, so the chart's own children and a
379
374
  // readout outside it never disagree about which point is active.
@@ -411,7 +406,6 @@ const LineChartRoot = forwardRef<LineChartHandle, LineChartProps>(function LineC
411
406
  activeIndex,
412
407
  activeIndexJS,
413
408
  setActiveIndexJS: handleActiveIndex,
414
- clipId,
415
409
  }),
416
410
  // `plot` is rebuilt every render from `size`, so it is compared by value.
417
411
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -433,11 +427,10 @@ const LineChartRoot = forwardRef<LineChartHandle, LineChartProps>(function LineC
433
427
  activeIndex,
434
428
  activeIndexJS,
435
429
  handleActiveIndex,
436
- clipId,
437
430
  ]
438
431
  );
439
432
 
440
- const { svg, overlay, header } = partition(children);
433
+ const { svg, series: seriesLayer, overlay, header } = partition(children);
441
434
 
442
435
  /*
443
436
  * Two views, because the header is not part of the plot. `aspectRatio` and
@@ -472,32 +465,42 @@ const LineChartRoot = forwardRef<LineChartHandle, LineChartProps>(function LineC
472
465
  {plot.width > 0 ? (
473
466
  <>
474
467
  <Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
475
- <Defs>
476
- {/*
477
- * One clip for everything in the plot. Sharing it is what
478
- * makes the reveal read as the chart arriving, rather than as
479
- * three separate things animating in at once.
480
- *
481
- * `width` is set statically as well as animated, and it has
482
- * to be. Animated props on an element inside `Defs` do not
483
- * reach the native clip on every platform, and with the width
484
- * coming only from the animation there is no width at all
485
- * when they do not — an empty clip, and a chart whose marks
486
- * are all invisible while its axes draw normally. Declared,
487
- * the worst case is the reveal not playing.
488
- */}
489
- <ClipPath id={clipId}>
490
- <AnimatedRect
491
- x={plot.left}
492
- y={0}
493
- width={plot.width}
494
- height={size.height}
495
- animatedProps={clipProps}
496
- />
497
- </ClipPath>
498
- </Defs>
499
468
  {svg}
500
469
  </Svg>
470
+ {/*
471
+ * The reveal is a view that grows, not an SVG clip path.
472
+ *
473
+ * It used to be an animated `<Rect>` inside `<Defs>`, and on
474
+ * Android those animated props never reach the native clip — the
475
+ * rect keeps whatever width was declared on it, so the chart drew
476
+ * complete and the reveal simply did not play. A view with
477
+ * `overflow: 'hidden'` is clipped by the platform itself, which
478
+ * both platforms agree on.
479
+ *
480
+ * Animating `width` is normally a layout pass per frame. Here the
481
+ * view is absolutely positioned and its only child is an `<Svg>`
482
+ * with an explicit width and height, so the work is one node and
483
+ * nothing around it moves.
484
+ *
485
+ * The inner `<Svg>` is pulled back by `plot.left` so the series
486
+ * keeps the same coordinate space as the grid underneath it —
487
+ * otherwise every mark would shift by the y-axis gutter.
488
+ */}
489
+ <Animated.View
490
+ pointerEvents="none"
491
+ style={[
492
+ { position: 'absolute', top: 0, bottom: 0, left: plot.left, overflow: 'hidden' },
493
+ revealStyle,
494
+ ]}
495
+ >
496
+ <Svg
497
+ width={size.width}
498
+ height={size.height}
499
+ style={{ position: 'absolute', top: 0, left: -plot.left }}
500
+ >
501
+ {seriesLayer}
502
+ </Svg>
503
+ </Animated.View>
501
504
  {overlay}
502
505
  </>
503
506
  ) : null}
@@ -511,20 +514,29 @@ LineChartRoot.displayName = 'LineChart';
511
514
  /** Sorts the children into the SVG tree and the view layer over it. */
512
515
  function partition(children: ReactNode) {
513
516
  const svg: ReactNode[] = [];
517
+ const series: ReactNode[] = [];
514
518
  const overlay: ReactNode[] = [];
515
519
  const header: ReactNode[] = [];
516
520
 
517
521
  Children.forEach(children, (child, index) => {
518
522
  if (!isValidElement(child)) return;
519
523
  const layer = (child.type as { layer?: Layer }).layer ?? 'svg';
520
- (layer === 'header' ? header : layer === 'overlay' ? overlay : svg).push(
524
+ const bucket =
525
+ layer === 'header'
526
+ ? header
527
+ : layer === 'overlay'
528
+ ? overlay
529
+ : layer === 'series'
530
+ ? series
531
+ : svg;
532
+ bucket.push(
521
533
  // Children of a `Children.forEach` need keys of their own once they are
522
534
  // put into a new array.
523
535
  <ChildSlot key={index}>{child}</ChildSlot>
524
536
  );
525
537
  });
526
538
 
527
- return { svg, overlay, header };
539
+ return { svg, series, overlay, header };
528
540
  }
529
541
 
530
542
  /** Identity wrapper, purely so the partitioned arrays can carry keys. */
@@ -600,7 +612,7 @@ function LineChartLine({
600
612
  dashArray,
601
613
  showMarkers = false,
602
614
  }: LineChartLineProps) {
603
- const { data, plot, domainMin, domainMax, curve, status, registerSeries, unregisterSeries, clipId } =
615
+ const { data, plot, domainMin, domainMax, curve, status, registerSeries, unregisterSeries } =
604
616
  useChart('LineChart.Line');
605
617
  const stroke = useSeriesColor(color, colorIndex);
606
618
 
@@ -617,7 +629,7 @@ function LineChartLine({
617
629
  }));
618
630
 
619
631
  return (
620
- <G clipPath={`url(#${clipId})`}>
632
+ <G>
621
633
  <AnimatedPath
622
634
  animatedProps={animatedProps}
623
635
  fill="none"
@@ -647,7 +659,7 @@ function LineChartLine({
647
659
  );
648
660
  }
649
661
  LineChartLine.displayName = 'LineChart.Line';
650
- LineChartLine.layer = 'svg' as Layer;
662
+ LineChartLine.layer = 'series' as Layer;
651
663
 
652
664
  /** A dot at one point. Follows the y-domain tween exactly as the line does. */
653
665
  function PointMarker({
@@ -690,7 +702,7 @@ export interface LineChartAreaProps {
690
702
  * fills over each other make a third colour that means nothing.
691
703
  */
692
704
  function LineChartArea({ dataKey, color, colorIndex = 1, opacity = 0.18 }: LineChartAreaProps) {
693
- const { data, plot, domainMin, domainMax, curve, status, clipId } = useChart('LineChart.Area');
705
+ const { data, plot, domainMin, domainMax, curve, status } = useChart('LineChart.Area');
694
706
  const fill = useSeriesColor(color, colorIndex);
695
707
  const gradientId = `panelui-area-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
696
708
 
@@ -702,7 +714,7 @@ function LineChartArea({ dataKey, color, colorIndex = 1, opacity = 0.18 }: LineC
702
714
  }));
703
715
 
704
716
  return (
705
- <G clipPath={`url(#${clipId})`}>
717
+ <G>
706
718
  <Defs>
707
719
  <LinearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
708
720
  <Stop offset="0" stopColor={fill} stopOpacity={opacity} />
@@ -714,7 +726,7 @@ function LineChartArea({ dataKey, color, colorIndex = 1, opacity = 0.18 }: LineC
714
726
  );
715
727
  }
716
728
  LineChartArea.displayName = 'LineChart.Area';
717
- LineChartArea.layer = 'svg' as Layer;
729
+ LineChartArea.layer = 'series' as Layer;
718
730
 
719
731
  export interface LineChartSkeletonProps {
720
732
  /** Milliseconds for one pass of the sweep. */
@@ -733,6 +745,9 @@ function LineChartSkeleton({ duration = 1400, color }: LineChartSkeletonProps) {
733
745
  const token = useCSSVariable('--color-skeleton');
734
746
  const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
735
747
  const highlight = useSeriesColor(undefined, 1);
748
+ // Unique per instance: a fixed id makes two of these on one screen share a
749
+ // gradient, and the second one to mount wins.
750
+ const gradientId = `panelui-chart-skeleton-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
736
751
 
737
752
  const sweep = useSharedValue(0);
738
753
  const reducedMotion = useReducedMotion();
@@ -759,7 +774,6 @@ function LineChartSkeleton({ duration = 1400, color }: LineChartSkeletonProps) {
759
774
  if (!loading) return null;
760
775
 
761
776
  const y = plot.top + plot.height / 2;
762
- const gradientId = 'panelui-chart-skeleton';
763
777
 
764
778
  return (
765
779
  <G>