panelui-native 0.59.0 → 0.61.1

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 (42) hide show
  1. package/README.md +3 -1
  2. package/lib/module/components/area-chart/index.js +86 -1
  3. package/lib/module/components/area-chart/index.js.map +1 -1
  4. package/lib/module/components/bar-chart/index.js +112 -2
  5. package/lib/module/components/bar-chart/index.js.map +1 -1
  6. package/lib/module/components/fab/index.js +63 -30
  7. package/lib/module/components/fab/index.js.map +1 -1
  8. package/lib/module/components/selection-mode/index.js +805 -0
  9. package/lib/module/components/selection-mode/index.js.map +1 -0
  10. package/lib/module/components/tabs/index.js +384 -341
  11. package/lib/module/components/tabs/index.js.map +1 -1
  12. package/lib/module/components/treemap-chart/index.js +957 -0
  13. package/lib/module/components/treemap-chart/index.js.map +1 -0
  14. package/lib/module/index.js +2 -0
  15. package/lib/module/index.js.map +1 -1
  16. package/lib/module/utils/chart.js +131 -4
  17. package/lib/module/utils/chart.js.map +1 -1
  18. package/lib/typescript/src/components/area-chart/index.d.ts +28 -1
  19. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  20. package/lib/typescript/src/components/bar-chart/index.d.ts +33 -3
  21. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
  22. package/lib/typescript/src/components/fab/index.d.ts +11 -5
  23. package/lib/typescript/src/components/fab/index.d.ts.map +1 -1
  24. package/lib/typescript/src/components/selection-mode/index.d.ts +498 -0
  25. package/lib/typescript/src/components/selection-mode/index.d.ts.map +1 -0
  26. package/lib/typescript/src/components/tabs/index.d.ts +64 -46
  27. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  28. package/lib/typescript/src/components/treemap-chart/index.d.ts +317 -0
  29. package/lib/typescript/src/components/treemap-chart/index.d.ts.map +1 -0
  30. package/lib/typescript/src/index.d.ts +3 -1
  31. package/lib/typescript/src/index.d.ts.map +1 -1
  32. package/lib/typescript/src/utils/chart.d.ts +26 -1
  33. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/components/area-chart/index.tsx +86 -1
  36. package/src/components/bar-chart/index.tsx +115 -4
  37. package/src/components/fab/index.tsx +66 -31
  38. package/src/components/selection-mode/index.tsx +1027 -0
  39. package/src/components/tabs/index.tsx +452 -413
  40. package/src/components/treemap-chart/index.tsx +1175 -0
  41. package/src/index.ts +30 -0
  42. package/src/utils/chart.ts +158 -4
@@ -52,15 +52,17 @@ import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-
52
52
  import { Gesture, GestureDetector } from 'react-native-gesture-handler';
53
53
  import Animated, {
54
54
  Easing,
55
+ cancelAnimation,
55
56
  runOnJS,
56
57
  useAnimatedProps,
57
58
  useAnimatedStyle,
58
59
  useReducedMotion,
59
60
  useSharedValue,
61
+ withRepeat,
60
62
  withTiming,
61
63
  type SharedValue,
62
64
  } from 'react-native-reanimated';
63
- import Svg, { G, Line as SvgLine, Path } from 'react-native-svg';
65
+ import Svg, { Defs, G, Line as SvgLine, LinearGradient, Path, Stop } from 'react-native-svg';
64
66
  import { useCSSVariable } from 'uniwind';
65
67
  import { Text } from '../../primitives/text';
66
68
  import {
@@ -75,6 +77,7 @@ import {
75
77
  import { cn } from '../../utils/cn';
76
78
 
77
79
  const AnimatedPath = Animated.createAnimatedComponent(Path);
80
+ const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
78
81
 
79
82
  /** Room left around the plot for the axis labels. */
80
83
  const PADDING = { top: 12, right: 10, bottom: 22, left: 10 };
@@ -163,9 +166,10 @@ export interface BarChartProps extends ViewProps {
163
166
  /** Key holding the category label. Used by the axis and the readout. */
164
167
  xDataKey?: string;
165
168
  /**
166
- * `loading` draws a row of flat placeholder bars and grows them into the real
167
- * ones when it turns `ready`. One component throughout, rather than a spinner
168
- * swapped for a chart — swapping loses the transition.
169
+ * `loading` holds the bars at the baseline and grows them into the real ones
170
+ * when it turns `ready`. One component throughout, rather than a spinner
171
+ * swapped for a chart — swapping loses the transition. Add a
172
+ * `BarChart.Skeleton` for something to stand in the plot meanwhile.
169
173
  */
170
174
  status?: BarChartStatus;
171
175
  /** Width ÷ height. `2` is the wide card shape. */
@@ -738,6 +742,112 @@ function BarChartBar({ dataKey, color, colorIndex = 1, cornerRadius }: BarChartB
738
742
  BarChartBar.displayName = 'BarChart.Bar';
739
743
  BarChartBar.layer = 'svg' as Layer;
740
744
 
745
+ /** How much of the value axis a placeholder bar takes. */
746
+ const SKELETON_LENGTH = 0.22;
747
+
748
+ /** Bands to draw when there is no data yet to count them from. */
749
+ const SKELETON_BARS = 7;
750
+
751
+ export interface BarChartSkeletonProps {
752
+ /**
753
+ * How many placeholder bars to draw. Defaults to one per row, and to seven
754
+ * when the data has not arrived — the count is the one thing a loading
755
+ * chart can be honest about only if it already has the rows.
756
+ */
757
+ bars?: number;
758
+ /** Milliseconds for one pass of the sweep. */
759
+ duration?: number;
760
+ color?: string;
761
+ }
762
+
763
+ /**
764
+ * The loading state: a row of short, equal stubs on the baseline, with a
765
+ * highlight travelling across them.
766
+ *
767
+ * Equal on purpose. Placeholder bars of differing heights are a distribution
768
+ * the reader has no way to tell from the real one until it changes under them,
769
+ * so these say only how many bars there will be and where the baseline is.
770
+ *
771
+ * The sweep is the part that carries the meaning. Without it a chart waiting
772
+ * for data and a chart whose values are all zero draw the same picture, and
773
+ * the reader is left to guess which one they are looking at.
774
+ */
775
+ function BarChartSkeleton({ bars, duration = 1400, color }: BarChartSkeletonProps) {
776
+ const { plot, status, orientation, data, barGap, barWidth, cornerRadius } =
777
+ useChart('BarChart.Skeleton');
778
+ const token = useCSSVariable('--color-skeleton');
779
+ const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
780
+ const highlight = useSeriesColor(undefined, 1);
781
+
782
+ const sweep = useSharedValue(0);
783
+ const reducedMotion = useReducedMotion();
784
+ const loading = status === 'loading';
785
+
786
+ useEffect(() => {
787
+ if (!loading || reducedMotion) {
788
+ cancelAnimation(sweep);
789
+ sweep.value = 0;
790
+ return;
791
+ }
792
+ sweep.value = 0;
793
+ sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);
794
+ return () => cancelAnimation(sweep);
795
+ }, [loading, reducedMotion, duration, sweep]);
796
+
797
+ // The band travels by moving the gradient's own endpoints, so the whole
798
+ // effect is two numbers changing on the UI thread.
799
+ const animatedProps = useAnimatedProps(() => ({
800
+ x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,
801
+ x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,
802
+ }));
803
+
804
+ const horizontal = orientation === 'horizontal';
805
+ const total = Math.max(1, bars ?? (data.length || SKELETON_BARS));
806
+
807
+ const d = useMemo(() => {
808
+ if (plot.width <= 0 || plot.height <= 0) return '';
809
+
810
+ // The same two axes the bars use: `along` is the category one, `across`
811
+ // the value one, so a sideways chart is this code with the pair swapped.
812
+ const along = horizontal ? plot.height : plot.width;
813
+ const alongStart = horizontal ? plot.top : plot.left;
814
+ const across = horizontal ? plot.width : plot.height;
815
+
816
+ const band = along / total;
817
+ const usable = band * (1 - barGap);
818
+ const thickness = Math.min(barWidth ?? usable, usable);
819
+ const length = across * SKELETON_LENGTH;
820
+
821
+ let path = '';
822
+ for (let i = 0; i < total; i += 1) {
823
+ const lead = alongStart + i * band + (band - thickness) / 2;
824
+ path += horizontal
825
+ ? barPath(plot.left, lead, length, thickness, cornerRadius, 'right')
826
+ : barPath(lead, plot.top + plot.height - length, thickness, length, cornerRadius, 'up');
827
+ }
828
+ return path;
829
+ }, [plot, horizontal, total, barGap, barWidth, cornerRadius]);
830
+
831
+ if (!loading || !d) return null;
832
+
833
+ const gradientId = 'panelui-bar-skeleton';
834
+
835
+ return (
836
+ <G>
837
+ <Defs>
838
+ <AnimatedLinearGradient id={gradientId} animatedProps={animatedProps} y1="0" y2="0">
839
+ <Stop offset="0" stopColor={base} />
840
+ <Stop offset="0.5" stopColor={highlight} stopOpacity={0.55} />
841
+ <Stop offset="1" stopColor={base} />
842
+ </AnimatedLinearGradient>
843
+ </Defs>
844
+ <Path d={d} fill={`url(#${gradientId})`} />
845
+ </G>
846
+ );
847
+ }
848
+ BarChartSkeleton.displayName = 'BarChart.Skeleton';
849
+ BarChartSkeleton.layer = 'svg' as Layer;
850
+
741
851
  /* -------------------------------------------------------------------------- */
742
852
  /* Overlay layer */
743
853
  /* -------------------------------------------------------------------------- */
@@ -1201,6 +1311,7 @@ export const BarChart = Object.assign(BarChartRoot, {
1201
1311
  Header: BarChartHeader,
1202
1312
  Grid: BarChartGrid,
1203
1313
  Bar: BarChartBar,
1314
+ Skeleton: BarChartSkeleton,
1204
1315
  XAxis: BarChartXAxis,
1205
1316
  YAxis: BarChartYAxis,
1206
1317
  Tooltip: BarChartTooltip,
@@ -48,14 +48,21 @@
48
48
  * modal — the next tap either picks something or closes it — and a scrim is what
49
49
  * says so, as well as what catches the tap that closes it.
50
50
  *
51
- * A group draws itself through a portal, above everything else — the scrim and
52
- * the buttons together, so the backdrop cannot end up on top of the dial it is
53
- * behind. That means its `offset` is measured from the screen's edges rather
54
- * than from whatever it is written inside, which is what lets it be declared
55
- * next to the content it belongs to instead of at the root of the screen.
51
+ * ## Where a group has to be written
52
+ *
53
+ * A `Fab.Group` draws its scrim and its buttons as two absolutely positioned
54
+ * siblings in whatever it is written inside, so put it in the screen's root
55
+ * container. That is where its `offset` is measured from, and it is what the
56
+ * scrim covers.
57
+ *
58
+ * It stays in the screen's own view tree rather than being lifted out of it,
59
+ * which is the part that matters after navigation: pushing a screen over this
60
+ * one hides the dial with everything else on it. A group lifted to the top of
61
+ * the app would still be drawn over the screen that replaced it.
56
62
  */
57
63
  import {
58
64
  Children,
65
+ cloneElement,
59
66
  createContext,
60
67
  forwardRef,
61
68
  isValidElement,
@@ -63,6 +70,7 @@ import {
63
70
  useContext,
64
71
  useMemo,
65
72
  useState,
73
+ type ReactElement,
66
74
  type ReactNode,
67
75
  } from 'react';
68
76
  import { Pressable, StyleSheet, View, type ViewProps } from 'react-native';
@@ -76,8 +84,8 @@ import Animated, {
76
84
  import { tv, type VariantProps } from 'tailwind-variants';
77
85
  import { useCSSVariable } from 'uniwind';
78
86
  import { IconColorProvider } from '../../icons';
87
+ import { useBackHandler } from '../../hooks/use-back-handler';
79
88
  import { AnimatedPressable } from '../../primitives/animated-pressable';
80
- import { Portal } from '../../primitives/portal';
81
89
  import { Scrim } from '../../primitives/scrim';
82
90
  import { Text } from '../../primitives/text';
83
91
  import { cn } from '../../utils/cn';
@@ -271,6 +279,9 @@ FabRoot.displayName = 'Fab';
271
279
  * Group — the speed dial
272
280
  * -------------------------------------------------------------------------- */
273
281
 
282
+ /** Whatever was written as a dial child, and the one prop a slot reaches for. */
283
+ type PressableChild = ReactElement<{ onPress?: () => void }>;
284
+
274
285
  interface FabGroupContextValue {
275
286
  /** 0 closed, 1 open. Every action reads it and its own index off it. */
276
287
  progress: SharedValue<number>;
@@ -324,10 +335,11 @@ export interface FabGroupProps extends Omit<ViewProps, 'children'> {
324
335
  /**
325
336
  * A trigger with actions behind it.
326
337
  *
327
- * The scrim goes through a portal so it covers the screen rather than the
328
- * group's own corner of it; the actions do not, because they belong to the
329
- * button and have to stay above the scrim in the same coordinate space as the
330
- * trigger they unfold from.
338
+ * The scrim and the dial are two absolutely positioned siblings written into
339
+ * the group's own parent — the scrim first, so the dial is drawn over it. Both
340
+ * are laid out against that parent, which is why a group belongs in the
341
+ * screen's root container: it is what `offset` is measured from and what the
342
+ * scrim covers.
331
343
  */
332
344
  const FabGroup = forwardRef<View, FabGroupProps>(
333
345
  (
@@ -363,7 +375,7 @@ const FabGroup = forwardRef<View, FabGroupProps>(
363
375
  [openProp, onOpenChange]
364
376
  );
365
377
 
366
- const actions = Children.toArray(children).filter(isValidElement);
378
+ const actions = Children.toArray(children).filter(isValidElement) as PressableChild[];
367
379
  /*
368
380
  * The count, pulled out as a plain number before any worklet sees it.
369
381
  *
@@ -406,28 +418,32 @@ const FabGroup = forwardRef<View, FabGroupProps>(
406
418
  setOpen(!open);
407
419
  }, [haptics, open, setOpen]);
408
420
 
421
+ // An open dial owns the back button: back should shut it, not leave it
422
+ // standing over the screen underneath.
423
+ useBackHandler(open, close);
424
+
409
425
  /*
410
- * The scrim and the buttons go through the same portal, in that order.
426
+ * Two absolutely positioned siblings, scrim first, both in the group's own
427
+ * parent — which is why a group belongs in the screen's root container.
411
428
  *
412
- * They have to travel together. Portalled content is drawn above
413
- * everything in the ordinary tree, so a portalled scrim over an
414
- * in-place trigger covers the trigger and its actions — the dial opens
415
- * and immediately disappears behind its own backdrop. One portal, scrim
416
- * first, keeps the stacking honest: within it they are siblings, and the
417
- * later sibling wins.
429
+ * This used to go through a portal, and the portal was the bug. Portalled
430
+ * content is mounted at the app root, above the router, and is removed only
431
+ * when the component that declared it unmounts. A stack keeps the screen
432
+ * you pushed from mounted, so a group declared on that screen carried on
433
+ * drawing over every screen after it — open or closed, since the trigger
434
+ * travelled through the portal too.
418
435
  *
419
- * The provider goes inside the portalled subtree for the same reason
420
- * every other overlay here re-provides its context — the portal renders
421
- * its children at the host, which is nowhere below this component, so a
422
- * `Fab.Action` looking for the dial would not find it.
436
+ * Ordering is the reason the portal was reached for in the first place: a
437
+ * scrim that covers the screen must not cover the dial it belongs to. As
438
+ * siblings it comes out right for free — the scrim is written first and the
439
+ * dial after it, and the later sibling draws on top.
423
440
  *
424
- * One consequence worth knowing: `offset` is measured from the screen's
425
- * edges rather than from whatever this is written inside. That is what a
426
- * floating button wants, and it is why a group can be declared next to
427
- * the content it belongs to instead of at the root of the screen.
441
+ * Nothing remounts when the dial opens. The trigger is in the same place in
442
+ * the tree either way, so opening adds the scrim and the actions and leaves
443
+ * the button alone.
428
444
  */
429
445
  return (
430
- <Portal>
446
+ <>
431
447
  {open ? (
432
448
  // Catches the tap that closes the dial, and says it is modal.
433
449
  <Pressable
@@ -478,7 +494,7 @@ const FabGroup = forwardRef<View, FabGroupProps>(
478
494
  </FabRoot>
479
495
  </View>
480
496
  </FabGroupContext.Provider>
481
- </Portal>
497
+ </>
482
498
  );
483
499
  }
484
500
  );
@@ -491,8 +507,27 @@ FabGroup.displayName = 'Fab.Group';
491
507
  * The stagger runs bottom-up: the action nearest the trigger arrives first,
492
508
  * which is the order a hand travelling away from the button meets them in.
493
509
  */
494
- function FabActionSlot({ index, children }: { index: number; children: ReactNode }) {
495
- const { progress, count } = useFabGroup('Fab.Action');
510
+ function FabActionSlot({ index, children }: { index: number; children: PressableChild }) {
511
+ const { progress, count, close } = useFabGroup('Fab.Action');
512
+
513
+ /*
514
+ * Whatever is in the slot closes the dial when it is pressed.
515
+ *
516
+ * `Fab.Action` does this itself, but a plain `Fab` written as a child is a
517
+ * perfectly reasonable thing to reach for and knows nothing about the dial it
518
+ * is in. Left alone it runs its action — navigating, usually — with the dial
519
+ * still open behind it. Wrapping the handler here covers both, and closing
520
+ * twice is closing once.
521
+ */
522
+ const { onPress } = children.props;
523
+ const child = onPress
524
+ ? cloneElement(children, {
525
+ onPress: () => {
526
+ close();
527
+ onPress();
528
+ },
529
+ })
530
+ : children;
496
531
 
497
532
  const style = useAnimatedStyle(() => {
498
533
  const steps = Math.max(1, count);
@@ -505,7 +540,7 @@ function FabActionSlot({ index, children }: { index: number; children: ReactNode
505
540
  };
506
541
  });
507
542
 
508
- return <Animated.View style={style}>{children}</Animated.View>;
543
+ return <Animated.View style={style}>{child}</Animated.View>;
509
544
  }
510
545
 
511
546
  /* -------------------------------------------------------------------------- *