panelui-native 0.76.0 → 0.78.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 (125) hide show
  1. package/lib/module/components/ai-input/index.js +23 -7
  2. package/lib/module/components/ai-input/index.js.map +1 -1
  3. package/lib/module/components/area-chart/index.js +32 -25
  4. package/lib/module/components/area-chart/index.js.map +1 -1
  5. package/lib/module/components/bottom-sheet/index.js +98 -15
  6. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  7. package/lib/module/components/button/index.js +7 -1
  8. package/lib/module/components/button/index.js.map +1 -1
  9. package/lib/module/components/funnel-chart/index.js +15 -2
  10. package/lib/module/components/funnel-chart/index.js.map +1 -1
  11. package/lib/module/components/heatmap-chart/index.js +19 -27
  12. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  13. package/lib/module/components/hex-chart/index.js +2 -0
  14. package/lib/module/components/hex-chart/index.js.map +1 -1
  15. package/lib/module/components/line-chart/index.js +38 -31
  16. package/lib/module/components/line-chart/index.js.map +1 -1
  17. package/lib/module/components/marquee/index.js +91 -5
  18. package/lib/module/components/marquee/index.js.map +1 -1
  19. package/lib/module/components/pie-chart/index.js +15 -2
  20. package/lib/module/components/pie-chart/index.js.map +1 -1
  21. package/lib/module/components/plot/index.js +42 -44
  22. package/lib/module/components/plot/index.js.map +1 -1
  23. package/lib/module/components/progress-button/index.js +390 -0
  24. package/lib/module/components/progress-button/index.js.map +1 -0
  25. package/lib/module/components/progress-button/progress-button-hold.js +57 -0
  26. package/lib/module/components/progress-button/progress-button-hold.js.map +1 -0
  27. package/lib/module/components/scatter-chart/index.js +6 -25
  28. package/lib/module/components/scatter-chart/index.js.map +1 -1
  29. package/lib/module/components/section-rail/index.js +40 -17
  30. package/lib/module/components/section-rail/index.js.map +1 -1
  31. package/lib/module/components/select/index.js +1 -0
  32. package/lib/module/components/select/index.js.map +1 -1
  33. package/lib/module/components/slider/index.js +1 -0
  34. package/lib/module/components/slider/index.js.map +1 -1
  35. package/lib/module/components/split-view/index.js +54 -10
  36. package/lib/module/components/split-view/index.js.map +1 -1
  37. package/lib/module/components/switch/index.js +1 -0
  38. package/lib/module/components/switch/index.js.map +1 -1
  39. package/lib/module/components/timeline/index.js +145 -14
  40. package/lib/module/components/timeline/index.js.map +1 -1
  41. package/lib/module/components/toast/index.js +45 -11
  42. package/lib/module/components/toast/index.js.map +1 -1
  43. package/lib/module/components/treemap-chart/index.js +63 -6
  44. package/lib/module/components/treemap-chart/index.js.map +1 -1
  45. package/lib/module/components/waterfall-chart/index.js +20 -4
  46. package/lib/module/components/waterfall-chart/index.js.map +1 -1
  47. package/lib/module/hooks/index.js +1 -0
  48. package/lib/module/hooks/index.js.map +1 -1
  49. package/lib/module/hooks/use-scroll-sections.js +44 -2
  50. package/lib/module/hooks/use-scroll-sections.js.map +1 -1
  51. package/lib/module/hooks/use-skeleton-handoff.js +58 -0
  52. package/lib/module/hooks/use-skeleton-handoff.js.map +1 -0
  53. package/lib/module/index.js +1 -0
  54. package/lib/module/index.js.map +1 -1
  55. package/lib/module/native/index.js.map +1 -1
  56. package/lib/typescript/src/components/ai-input/index.d.ts +24 -5
  57. package/lib/typescript/src/components/ai-input/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/area-chart/index.d.ts +1 -1
  59. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  60. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  61. package/lib/typescript/src/components/button/index.d.ts +15 -0
  62. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  63. package/lib/typescript/src/components/funnel-chart/index.d.ts.map +1 -1
  64. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  65. package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -1
  66. package/lib/typescript/src/components/line-chart/index.d.ts +1 -1
  67. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  68. package/lib/typescript/src/components/marquee/index.d.ts +55 -2
  69. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
  70. package/lib/typescript/src/components/pie-chart/index.d.ts.map +1 -1
  71. package/lib/typescript/src/components/plot/index.d.ts +1 -3
  72. package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
  73. package/lib/typescript/src/components/progress-button/index.d.ts +295 -0
  74. package/lib/typescript/src/components/progress-button/index.d.ts.map +1 -0
  75. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts +40 -0
  76. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts.map +1 -0
  77. package/lib/typescript/src/components/scatter-chart/index.d.ts +1 -1
  78. package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -1
  79. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
  80. package/lib/typescript/src/components/split-view/index.d.ts +10 -1
  81. package/lib/typescript/src/components/split-view/index.d.ts.map +1 -1
  82. package/lib/typescript/src/components/timeline/index.d.ts +7 -0
  83. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  84. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  85. package/lib/typescript/src/components/treemap-chart/index.d.ts.map +1 -1
  86. package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -1
  87. package/lib/typescript/src/hooks/index.d.ts +1 -0
  88. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  89. package/lib/typescript/src/hooks/use-scroll-sections.d.ts +1 -0
  90. package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -1
  91. package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts +11 -0
  92. package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts.map +1 -0
  93. package/lib/typescript/src/index.d.ts +3 -2
  94. package/lib/typescript/src/index.d.ts.map +1 -1
  95. package/lib/typescript/src/native/index.d.ts +17 -0
  96. package/lib/typescript/src/native/index.d.ts.map +1 -1
  97. package/package.json +1 -1
  98. package/src/components/ai-input/index.tsx +46 -12
  99. package/src/components/area-chart/index.tsx +49 -37
  100. package/src/components/bottom-sheet/index.tsx +110 -12
  101. package/src/components/button/index.tsx +20 -1
  102. package/src/components/funnel-chart/index.tsx +10 -2
  103. package/src/components/heatmap-chart/index.tsx +27 -22
  104. package/src/components/hex-chart/index.tsx +22 -6
  105. package/src/components/line-chart/index.tsx +57 -43
  106. package/src/components/marquee/index.tsx +129 -6
  107. package/src/components/pie-chart/index.tsx +10 -2
  108. package/src/components/plot/index.tsx +66 -50
  109. package/src/components/progress-button/index.tsx +465 -0
  110. package/src/components/progress-button/progress-button-hold.ts +57 -0
  111. package/src/components/scatter-chart/index.tsx +4 -25
  112. package/src/components/section-rail/index.tsx +39 -19
  113. package/src/components/select/index.tsx +1 -1
  114. package/src/components/slider/index.tsx +1 -1
  115. package/src/components/split-view/index.tsx +53 -8
  116. package/src/components/switch/index.tsx +1 -1
  117. package/src/components/timeline/index.tsx +143 -17
  118. package/src/components/toast/index.tsx +60 -11
  119. package/src/components/treemap-chart/index.tsx +65 -8
  120. package/src/components/waterfall-chart/index.tsx +14 -4
  121. package/src/hooks/index.ts +5 -0
  122. package/src/hooks/use-scroll-sections.ts +52 -3
  123. package/src/hooks/use-skeleton-handoff.ts +69 -0
  124. package/src/index.ts +10 -0
  125. package/src/native/index.ts +17 -0
@@ -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>
@@ -44,7 +44,15 @@
44
44
  * Screen readers get one copy. The rest are duplicates of content already
45
45
  * announced, and hearing a sponsor list four times over is not thoroughness.
46
46
  */
47
- import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
47
+ import {
48
+ createContext,
49
+ useContext,
50
+ useEffect,
51
+ useMemo,
52
+ useState,
53
+ type ComponentType,
54
+ type ReactNode,
55
+ } from 'react';
48
56
  import {
49
57
  Platform,
50
58
  Pressable,
@@ -99,7 +107,13 @@ export interface MarqueeProps extends Omit<ViewProps, 'children'> {
99
107
  reverse?: boolean;
100
108
  /** Set false to hold the content where it is. */
101
109
  playing?: boolean;
102
- /** Show the built-in user pause/play control while motion is enabled. */
110
+ /**
111
+ * Show the built-in user pause/play control while motion is enabled.
112
+ *
113
+ * Defaults to `true` on its own, and to `false` inside a `Marquee.Group` —
114
+ * the group draws one control for everything in it, and a control per row is
115
+ * how two of them end up stacked on top of each other.
116
+ */
103
117
  showPauseControl?: boolean;
104
118
  /** Visible and spoken label for the moving state. */
105
119
  pauseLabel?: string;
@@ -109,7 +123,18 @@ export interface MarqueeProps extends Omit<ViewProps, 'children'> {
109
123
  onPlayingChange?: (playing: boolean) => void;
110
124
  }
111
125
 
112
- export function Marquee({
126
+ /**
127
+ * Set by `Marquee.Group` so the marquees inside it stop drawing a pause control
128
+ * each and take the group's instead.
129
+ */
130
+ interface MarqueeGroupContextValue {
131
+ /** True while the group's control is holding everything in it still. */
132
+ paused: boolean;
133
+ }
134
+
135
+ const MarqueeGroupContext = createContext<MarqueeGroupContextValue | null>(null);
136
+
137
+ function MarqueeRoot({
113
138
  className,
114
139
  children,
115
140
  speed: speedProp = DEFAULT_MARQUEE_SPEED,
@@ -117,7 +142,7 @@ export function Marquee({
117
142
  direction = 'horizontal',
118
143
  reverse = false,
119
144
  playing = true,
120
- showPauseControl = true,
145
+ showPauseControl,
121
146
  pauseLabel = 'Pause',
122
147
  playLabel = 'Play',
123
148
  onPlayingChange,
@@ -135,7 +160,13 @@ export function Marquee({
135
160
  const [content, setContent] = useState(0);
136
161
  const [userPaused, setUserPaused] = useState(false);
137
162
  const speed = normalizeMarqueeSpeed(speedProp);
138
- const moving = playing && !userPaused;
163
+
164
+ // A group's pause is the same instruction as this one's, so it is read here
165
+ // rather than pushed down as a `playing` prop — a caller who set `playing`
166
+ // themselves should not have it overwritten by the container.
167
+ const group = useContext(MarqueeGroupContext);
168
+ const showControl = showPauseControl ?? group === null;
169
+ const moving = playing && !userPaused && !(group?.paused ?? false);
139
170
 
140
171
  // The distance from one copy to the same point on the next, and therefore
141
172
  // both the layout step and the exact loop length. They are the same number
@@ -250,7 +281,7 @@ export function Marquee({
250
281
  );
251
282
  })}
252
283
  </Animated.View>
253
- {showPauseControl && playing && period > 0 && speed > 0 ? (
284
+ {showControl && playing && period > 0 && speed > 0 ? (
254
285
  <Pressable
255
286
  accessibilityRole="button"
256
287
  accessibilityLabel={userPaused ? playLabel : pauseLabel}
@@ -270,6 +301,98 @@ export function Marquee({
270
301
  );
271
302
  }
272
303
 
304
+ export interface MarqueeGroupProps extends ViewProps {
305
+ className?: string;
306
+ /** Set false to hold every marquee in the group where it is. */
307
+ playing?: boolean;
308
+ /** Show the group's pause/play control. */
309
+ showPauseControl?: boolean;
310
+ /** Visible and spoken label for the moving state. */
311
+ pauseLabel?: string;
312
+ /** Visible and spoken label for the user-paused state. */
313
+ playLabel?: string;
314
+ /** Reports changes made by the group's control. */
315
+ onPlayingChange?: (playing: boolean) => void;
316
+ children?: ReactNode;
317
+ }
318
+
319
+ /**
320
+ * Rows of travelling content that stop and start together, under one control.
321
+ *
322
+ * Moving content needs a way to stop it, so a marquee draws its own pause
323
+ * button. Stacked — two rows of logos travelling against each other — that is
324
+ * one button per row, each pinned to its own bottom corner, and the upper one
325
+ * lands on top of the row beneath it. Two buttons for one piece of motion, and
326
+ * one of them covering the content it belongs to.
327
+ *
328
+ * A group is the answer to both: the marquees inside it stop drawing their own
329
+ * control, and the group draws a single one below them rather than over them.
330
+ *
331
+ * ```tsx
332
+ * <Marquee.Group>
333
+ * <Marquee>{topRow}</Marquee>
334
+ * <Marquee reverse>{bottomRow}</Marquee>
335
+ * </Marquee.Group>
336
+ * ```
337
+ *
338
+ * Each marquee keeps its own `playing` prop. The group's pause is an additional
339
+ * hold, not a replacement — a row already held still stays still.
340
+ */
341
+ function MarqueeGroup({
342
+ className,
343
+ children,
344
+ playing = true,
345
+ showPauseControl = true,
346
+ pauseLabel = 'Pause',
347
+ playLabel = 'Play',
348
+ onPlayingChange,
349
+ ...props
350
+ }: MarqueeGroupProps) {
351
+ const [userPaused, setUserPaused] = useState(false);
352
+ const reducedMotion = useReducedMotion();
353
+
354
+ const value = useMemo(() => ({ paused: userPaused }), [userPaused]);
355
+
356
+ // Nothing is moving, so there is nothing to stop. The control is not hidden
357
+ // to save space — offering to pause content that is already still is the
358
+ // same lie as a disabled button with no reason on it.
359
+ const showControl = showPauseControl && playing && !reducedMotion;
360
+
361
+ return (
362
+ <MarqueeGroupContext.Provider value={value}>
363
+ <View {...props} className={cn('w-full gap-3', className)}>
364
+ {children}
365
+ {showControl ? (
366
+ <Pressable
367
+ accessibilityRole="button"
368
+ accessibilityLabel={userPaused ? playLabel : pauseLabel}
369
+ // In flow and at the end, not floating over the last row. The
370
+ // control belongs to the group, and a group's control that covers
371
+ // one of its members is describing itself as part of that member.
372
+ className="min-h-12 self-end items-center justify-center rounded-full border border-border bg-background px-4"
373
+ onPress={() => {
374
+ const next = !userPaused;
375
+ setUserPaused(next);
376
+ onPlayingChange?.(!next);
377
+ }}
378
+ >
379
+ <Text className="text-xs font-medium text-foreground">
380
+ {userPaused ? playLabel : pauseLabel}
381
+ </Text>
382
+ </Pressable>
383
+ ) : null}
384
+ </View>
385
+ </MarqueeGroupContext.Provider>
386
+ );
387
+ }
388
+ MarqueeGroup.displayName = 'Marquee.Group';
389
+
390
+ MarqueeRoot.displayName = 'Marquee';
391
+
392
+ export const Marquee = Object.assign(MarqueeRoot, {
393
+ Group: MarqueeGroup,
394
+ });
395
+
273
396
  const styles = StyleSheet.create({
274
397
  /* Laid out so it measures, hidden so it does not draw, and behind everything
275
398
  so it can never intercept anything. */
@@ -70,6 +70,7 @@ import { useCSSVariable } from 'uniwind';
70
70
  import { Text } from '../../primitives/text';
71
71
  import { compactNumber, useSeriesColor, wedgePath } from '../../utils/chart';
72
72
  import { cn } from '../../utils/cn';
73
+ import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff';
73
74
 
74
75
  const AnimatedPath = Animated.createAnimatedComponent(Path);
75
76
 
@@ -598,10 +599,17 @@ function PieChartSkeleton({ color }: PieChartSkeletonProps) {
598
599
  const token = useCSSVariable('--color-skeleton');
599
600
  const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
600
601
 
601
- if (status !== 'loading' || radius <= 0) return null;
602
+ // Held through the fade rather than to the frame the data lands, so the band
603
+ // dissolves under the wedges sweeping over it. Cut on `status` alone, the ring
604
+ // vanishes before the first wedge has any sweep to show.
605
+ const { mounted, opacity } = useSkeletonHandoff(status === 'loading');
606
+ const animatedProps = useAnimatedProps(() => ({ opacity: opacity.value }));
607
+
608
+ if (!mounted || radius <= 0) return null;
602
609
 
603
610
  return (
604
- <Path
611
+ <AnimatedPath
612
+ animatedProps={animatedProps}
605
613
  d={wedgePath(size / 2, size / 2, radius, hole, origin, origin + span, 0)}
606
614
  fill={fill}
607
615
  />