panelui-native 0.44.0 → 0.49.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 (108) hide show
  1. package/README.md +6 -1
  2. package/lib/module/components/accordion/index.js +32 -4
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/button/index.js +83 -15
  5. package/lib/module/components/button/index.js.map +1 -1
  6. package/lib/module/components/button-group/index.js +186 -0
  7. package/lib/module/components/button-group/index.js.map +1 -0
  8. package/lib/module/components/color-picker/index.js +110 -1
  9. package/lib/module/components/color-picker/index.js.map +1 -1
  10. package/lib/module/components/combobox/index.js +7 -1
  11. package/lib/module/components/combobox/index.js.map +1 -1
  12. package/lib/module/components/date-time-picker/index.js +272 -0
  13. package/lib/module/components/date-time-picker/index.js.map +1 -0
  14. package/lib/module/components/fab/index.js +514 -0
  15. package/lib/module/components/fab/index.js.map +1 -0
  16. package/lib/module/components/grid-item/index.js +486 -0
  17. package/lib/module/components/grid-item/index.js.map +1 -0
  18. package/lib/module/components/{kpi-chart → kpi}/index.js +62 -62
  19. package/lib/module/components/kpi/index.js.map +1 -0
  20. package/lib/module/components/line-chart/index.js +92 -10
  21. package/lib/module/components/line-chart/index.js.map +1 -1
  22. package/lib/module/components/markdown-editor/index.js +406 -0
  23. package/lib/module/components/markdown-editor/index.js.map +1 -0
  24. package/lib/module/components/markdown-editor/markdown-transforms.js +243 -0
  25. package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -0
  26. package/lib/module/components/pie-chart/index.js +625 -0
  27. package/lib/module/components/pie-chart/index.js.map +1 -0
  28. package/lib/module/components/questionnaire/index.js +1312 -0
  29. package/lib/module/components/questionnaire/index.js.map +1 -0
  30. package/lib/module/components/scatter-chart/index.js +1173 -0
  31. package/lib/module/components/scatter-chart/index.js.map +1 -0
  32. package/lib/module/components/tabs/index.js +359 -41
  33. package/lib/module/components/tabs/index.js.map +1 -1
  34. package/lib/module/components/time-picker/index.js +34 -6
  35. package/lib/module/components/time-picker/index.js.map +1 -1
  36. package/lib/module/components/tree/index.js +500 -0
  37. package/lib/module/components/tree/index.js.map +1 -0
  38. package/lib/module/icons/index.js +217 -0
  39. package/lib/module/icons/index.js.map +1 -1
  40. package/lib/module/index.js +11 -2
  41. package/lib/module/index.js.map +1 -1
  42. package/lib/module/utils/chart.js +81 -0
  43. package/lib/module/utils/chart.js.map +1 -1
  44. package/lib/typescript/src/components/accordion/index.d.ts +21 -0
  45. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  46. package/lib/typescript/src/components/button/index.d.ts +21 -0
  47. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  48. package/lib/typescript/src/components/button-group/index.d.ts +212 -0
  49. package/lib/typescript/src/components/button-group/index.d.ts.map +1 -0
  50. package/lib/typescript/src/components/color-picker/index.d.ts +82 -1
  51. package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  53. package/lib/typescript/src/components/date-time-picker/index.d.ts +127 -0
  54. package/lib/typescript/src/components/date-time-picker/index.d.ts.map +1 -0
  55. package/lib/typescript/src/components/fab/index.d.ts +285 -0
  56. package/lib/typescript/src/components/fab/index.d.ts.map +1 -0
  57. package/lib/typescript/src/components/grid-item/index.d.ts +292 -0
  58. package/lib/typescript/src/components/grid-item/index.d.ts.map +1 -0
  59. package/lib/typescript/src/components/{kpi-chart → kpi}/index.d.ts +65 -65
  60. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -0
  61. package/lib/typescript/src/components/line-chart/index.d.ts +25 -1
  62. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  63. package/lib/typescript/src/components/markdown-editor/index.d.ts +102 -0
  64. package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -0
  65. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +76 -0
  66. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -0
  67. package/lib/typescript/src/components/pie-chart/index.d.ts +245 -0
  68. package/lib/typescript/src/components/pie-chart/index.d.ts.map +1 -0
  69. package/lib/typescript/src/components/questionnaire/index.d.ts +336 -0
  70. package/lib/typescript/src/components/questionnaire/index.d.ts.map +1 -0
  71. package/lib/typescript/src/components/scatter-chart/index.d.ts +309 -0
  72. package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -0
  73. package/lib/typescript/src/components/tabs/index.d.ts +29 -2
  74. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/time-picker/index.d.ts +19 -1
  76. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/tree/index.d.ts +125 -0
  78. package/lib/typescript/src/components/tree/index.d.ts.map +1 -0
  79. package/lib/typescript/src/icons/index.d.ts +22 -0
  80. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  81. package/lib/typescript/src/index.d.ts +14 -5
  82. package/lib/typescript/src/index.d.ts.map +1 -1
  83. package/lib/typescript/src/utils/chart.d.ts +34 -0
  84. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  85. package/package.json +1 -1
  86. package/src/components/accordion/index.tsx +48 -6
  87. package/src/components/button/index.tsx +97 -15
  88. package/src/components/button-group/index.tsx +199 -0
  89. package/src/components/color-picker/index.tsx +140 -3
  90. package/src/components/combobox/index.tsx +7 -1
  91. package/src/components/date-time-picker/index.tsx +411 -0
  92. package/src/components/fab/index.tsx +583 -0
  93. package/src/components/grid-item/index.tsx +515 -0
  94. package/src/components/{kpi-chart → kpi}/index.tsx +85 -85
  95. package/src/components/line-chart/index.tsx +98 -8
  96. package/src/components/markdown-editor/index.tsx +526 -0
  97. package/src/components/markdown-editor/markdown-transforms.ts +228 -0
  98. package/src/components/pie-chart/index.tsx +863 -0
  99. package/src/components/questionnaire/index.tsx +1615 -0
  100. package/src/components/scatter-chart/index.tsx +1401 -0
  101. package/src/components/tabs/index.tsx +392 -50
  102. package/src/components/time-picker/index.tsx +42 -6
  103. package/src/components/tree/index.tsx +564 -0
  104. package/src/icons/index.tsx +154 -0
  105. package/src/index.ts +142 -18
  106. package/src/utils/chart.ts +110 -0
  107. package/lib/module/components/kpi-chart/index.js.map +0 -1
  108. package/lib/typescript/src/components/kpi-chart/index.d.ts.map +0 -1
@@ -15,6 +15,15 @@
15
15
  * <Tabs.Content value="account">…</Tabs.Content>
16
16
  * </Tabs>
17
17
  * ```
18
+ *
19
+ * **Swiping.** Only the visible panel is mounted, so a drag has nothing behind
20
+ * it to reveal. The panel still tracks the finger one to one and dims as it
21
+ * goes, and the panel arriving comes back up through the same fade — the
22
+ * movement carries the change and the dissolve covers the gap. On release the
23
+ * arriving panel picks up a whole panel's width from wherever the outgoing one
24
+ * was let go, so the two views draw one continuous movement between them. The
25
+ * displacement lives on the root for exactly that reason: a value belonging to
26
+ * either panel would be destroyed at the handover.
18
27
  */
19
28
  import {
20
29
  createContext,
@@ -27,6 +36,7 @@ import {
27
36
  type ReactNode,
28
37
  } from 'react';
29
38
  import {
39
+ Platform,
30
40
  Pressable,
31
41
  ScrollView,
32
42
  View,
@@ -35,11 +45,16 @@ import {
35
45
  } from 'react-native';
36
46
  import { Gesture, GestureDetector } from 'react-native-gesture-handler';
37
47
  import Animated, {
48
+ Easing,
49
+ Extrapolation,
38
50
  FadeIn,
51
+ interpolate,
39
52
  runOnJS,
40
53
  useAnimatedStyle,
54
+ useReducedMotion,
41
55
  useSharedValue,
42
56
  withSpring,
57
+ withTiming,
43
58
  type SharedValue,
44
59
  } from 'react-native-reanimated';
45
60
  import { tv } from 'tailwind-variants';
@@ -64,32 +79,66 @@ const SWIPE_DISTANCE_RATIO = 0.25;
64
79
  const SWIPE_VELOCITY = 500;
65
80
 
66
81
  /**
67
- * How much of the finger's travel the panel actually follows.
82
+ * How much of the finger's travel the panel follows at the ends of the row,
83
+ * where there is nowhere for it to go. Everywhere else it follows all of it.
84
+ */
85
+ const SWIPE_RESISTANCE_AT_END = 0.16;
86
+
87
+ /**
88
+ * How far the panel fades as it is dragged, at a full panel's width.
89
+ *
90
+ * Tracking the finger one to one leaves the space behind the panel empty,
91
+ * because the panels are separate views and only the visible one is mounted.
92
+ * Dimming as it goes is what keeps that from reading as a hole punched in the
93
+ * card: a panel on its way out is on its way out, and the one arriving comes
94
+ * back up through the same fade. The two together read as a dissolve carried
95
+ * by the movement, rather than as a gap between two slides.
68
96
  *
69
- * Not all of it: a panel that tracked one-to-one would look like a pager that
70
- * is about to reveal the next panel, and there is nothing behind it to reveal
71
- * — the panels are separate views, only one of which is mounted. A third of
72
- * the distance is enough to say the gesture was received.
97
+ * **iOS only, and that is not a compromise.** A transform is a property of the
98
+ * layer the panel is drawn into, so moving it costs nothing per frame however
99
+ * much is inside it. An opacity that changes every frame is not: on Android the
100
+ * panel's view group has no offscreen buffer to fade, so the alpha is pushed
101
+ * down into its children and the whole visible subtree is re-drawn on every
102
+ * frame of the drag. Behind a list that is thirty rows of text and images,
103
+ * which is what a tab panel usually is, that is the difference between a swipe
104
+ * that tracks the finger and one that stutters. iOS fades the layer itself and
105
+ * is unaffected.
73
106
  */
74
- const SWIPE_FOLLOW = 0.32;
75
- /** …and a fraction of that again at the ends, where there is nowhere to go. */
76
- const SWIPE_FOLLOW_AT_END = 0.1;
107
+ const SWIPE_FADE = Platform.OS === 'ios' ? 0.75 : 0;
77
108
 
78
109
  /**
79
- * How far off its resting place the arriving panel starts, as a share of the
80
- * panel's width.
110
+ * How far off its resting place a panel arriving by a *press* starts, as a
111
+ * share of the panel's width.
81
112
  *
82
- * A swipe that commits should not look like a swipe that was cancelled. Both
83
- * end with a panel settling at rest, and the only thing that distinguishes
84
- * them is which side it settled *from* — so the incoming panel picks up
85
- * roughly where the outgoing one was let go, on the far side, and travels in.
113
+ * A press has no finger travel to continue from, so it is given a throw of its
114
+ * own. A swipe does not use this: the panel it hands over to picks up exactly
115
+ * one width from wherever the outgoing one has travelled to — see `setValue`.
86
116
  */
87
117
  const SWIPE_ENTER = 0.3;
88
118
 
89
119
  /** The arriving panel is springier than the one being dragged back into place. */
90
120
  const ENTER_SPRING = { damping: 22, stiffness: 240, mass: 0.6 } as const;
91
121
 
92
- export type TabsVariant = 'segmented' | 'underline' | 'pill';
122
+ /** How far the label's reveal is from its own width, in points — the gap after the icon. */
123
+ const LABEL_GAP = 6;
124
+
125
+ /** Points of room over the measured label, so a rounding error cannot truncate it. */
126
+ const LABEL_SLACK = 2;
127
+
128
+ /** Milliseconds for a tab to open its label, and for the one before it to close. */
129
+ const EXPAND_DURATION = 260;
130
+
131
+ /**
132
+ * A width the ghost label is measured inside.
133
+ *
134
+ * A `Text` measures against the box it is in, and the box it is really in is a
135
+ * pill whose width is the thing being measured. So the copy that gets measured
136
+ * sits in a box wide enough not to be the constraint, and reports the width the
137
+ * label actually wants.
138
+ */
139
+ const MEASURE_WIDTH = 400;
140
+
141
+ export type TabsVariant = 'segmented' | 'underline' | 'pill' | 'expanding';
93
142
 
94
143
  const tabsVariants = tv({
95
144
  slots: {
@@ -120,6 +169,22 @@ const tabsVariants = tv({
120
169
  indicator: 'bottom-0 top-0 rounded-full bg-primary',
121
170
  trigger: 'rounded-full py-2',
122
171
  },
172
+ /*
173
+ * A row of icon pills, one of which is open.
174
+ *
175
+ * Every tab draws its own pill here, so there is no indicator to slide
176
+ * between them — the shape that moves is the open tab itself, widening
177
+ * to let its label out and closing again behind it. An indicator sliding
178
+ * under pills that already have backgrounds would be invisible anyway.
179
+ *
180
+ * The open pill is a step further from the page than the closed ones:
181
+ * lighter in a dark theme, darker in a light one, which is what the
182
+ * tertiary surface token means and why it is used rather than a colour.
183
+ */
184
+ expanding: {
185
+ list: 'gap-1.5',
186
+ trigger: 'rounded-full bg-muted px-3.5 py-2.5',
187
+ },
123
188
  },
124
189
  active: {
125
190
  true: { label: 'text-foreground' },
@@ -136,6 +201,11 @@ const tabsVariants = tv({
136
201
  },
137
202
  compoundVariants: [
138
203
  { variant: 'pill', active: true, class: { label: 'text-primary-foreground' } },
204
+ // A pill is as wide as what is inside it. Equal shares would give every
205
+ // closed tab the width of the open one, which is the layout this variant
206
+ // exists to avoid.
207
+ { variant: 'expanding', class: { trigger: 'flex-none' } },
208
+ { variant: 'expanding', active: true, class: { trigger: 'bg-surface-tertiary' } },
139
209
  ],
140
210
  defaultVariants: {
141
211
  variant: 'segmented',
@@ -148,9 +218,19 @@ interface TabLayout {
148
218
  width: number;
149
219
  }
150
220
 
221
+ /**
222
+ * What a swipe hands over to the tab it commits to: where the finger left the
223
+ * outgoing panel, and how fast it was still going. Absent when the tab was
224
+ * changed by pressing a trigger, which has neither.
225
+ */
226
+ interface SwipeHandover {
227
+ /** Points per second at the moment of release, in the panel's own axis. */
228
+ velocity: number;
229
+ }
230
+
151
231
  interface TabsContextValue {
152
232
  value: string;
153
- setValue: (value: string) => void;
233
+ setValue: (value: string, handover?: SwipeHandover) => void;
154
234
  registerLayout: (value: string, layout: TabLayout) => void;
155
235
  layouts: Record<string, TabLayout>;
156
236
  /**
@@ -200,6 +280,12 @@ export interface TabsProps extends ViewProps {
200
280
  /**
201
281
  * `segmented` is a chip travelling inside a recessed track, `underline` is a
202
282
  * rule under the active tab, `pill` is a filled chip on the page.
283
+ *
284
+ * `expanding` is a row of icon pills where only the selected one is open:
285
+ * it widens to let its label out and closes again behind it. For a short row
286
+ * of destinations that are recognisable by their icons, where the labels
287
+ * would otherwise take the whole width to say things nobody rereads. Give
288
+ * every trigger an `icon` — a closed tab has nothing else.
203
289
  */
204
290
  variant?: TabsVariant;
205
291
  /**
@@ -216,6 +302,15 @@ export interface TabsProps extends ViewProps {
216
302
  * already wants a horizontal drag — a carousel, a slider, a row that swipes
217
303
  * open — and the two cannot both have it. Turn it on for panels of ordinary
218
304
  * scrolling content, where it is the gesture people try first.
305
+ *
306
+ * **It does not change what is mounted.** Only `keepMounted` decides that,
307
+ * with or without this — a swipe animates between two panels of which one is
308
+ * being unmounted and the other mounted for the first time, exactly as a
309
+ * press does. What it does change is that the mount now happens *while
310
+ * something is moving*, so a panel that is slow to build stops being a pause
311
+ * before it appears and starts being a stutter in the movement. If a swipe
312
+ * feels heavier than a press on the same tab set, the panel is expensive to
313
+ * mount; `keepMounted` is the answer, not turning this off.
219
314
  */
220
315
  swipeable?: boolean;
221
316
  children: ReactNode;
@@ -244,8 +339,26 @@ function TabsRoot({
244
339
  const isControlled = value !== undefined;
245
340
  const resolvedValue = isControlled ? value : internalValue;
246
341
 
342
+ /*
343
+ * The two things `setValue` reads that change on every switch, held where
344
+ * reading them does not make it a new function.
345
+ *
346
+ * With them in the dependency list, changing tab produced a new `setValue`,
347
+ * therefore a new context, therefore a new `step`, therefore a new pan
348
+ * gesture — which detaches and re-attaches the native recogniser on the same
349
+ * commit that mounts the arriving panel's contents. The busiest frame of the
350
+ * interaction was doing the one piece of work that had no reason to be there.
351
+ */
352
+ const tabsRef = useRef(tabs);
353
+ tabsRef.current = tabs;
354
+ const valueRef = useRef(resolvedValue);
355
+ valueRef.current = resolvedValue;
356
+
247
357
  const setValue = useCallback(
248
- (next: string) => {
358
+ (next: string, handover?: SwipeHandover) => {
359
+ const tabs = tabsRef.current;
360
+ const resolvedValue = valueRef.current;
361
+
249
362
  /*
250
363
  * The arriving panel starts on the side it is arriving from, and travels
251
364
  * in. Done here rather than in the panel because only the root knows
@@ -258,25 +371,37 @@ function TabsRoot({
258
371
  const from = tabs.indexOf(resolvedValue);
259
372
  const to = tabs.indexOf(next);
260
373
  if (from !== -1 && to !== -1 && from !== to) {
261
- swipeOffset.value =
262
- (to > from ? 1 : -1) * sign * panelWidth.value * SWIPE_ENTER;
263
- swipeOffset.value = withSpring(0, ENTER_SPRING);
374
+ const direction = to > from ? 1 : -1;
375
+
376
+ if (handover) {
377
+ /*
378
+ * A swipe hands over mid-movement, so the arriving panel starts a
379
+ * whole panel's width from wherever the outgoing one has got to —
380
+ * which is where it *would* have been all along, had both been
381
+ * mounted. Read live rather than captured, because the outgoing
382
+ * panel is still travelling while this runs: adding to the current
383
+ * displacement rather than replacing it is the whole difference
384
+ * between one continuous movement and a jump at the moment the
385
+ * arriving panel finishes mounting.
386
+ */
387
+ swipeOffset.value += direction * sign * panelWidth.value;
388
+ swipeOffset.value = withSpring(0, {
389
+ ...ENTER_SPRING,
390
+ // Carried across too, so a flick keeps its speed instead of
391
+ // stopping dead and starting again from rest.
392
+ velocity: handover.velocity,
393
+ });
394
+ } else {
395
+ swipeOffset.value = direction * sign * panelWidth.value * SWIPE_ENTER;
396
+ swipeOffset.value = withSpring(0, ENTER_SPRING);
397
+ }
264
398
  }
265
399
  }
266
400
 
267
401
  if (!isControlled) setInternalValue(next);
268
402
  onValueChange?.(next);
269
403
  },
270
- [
271
- isControlled,
272
- onValueChange,
273
- swipeable,
274
- tabs,
275
- resolvedValue,
276
- sign,
277
- swipeOffset,
278
- panelWidth,
279
- ]
404
+ [isControlled, onValueChange, swipeable, sign, swipeOffset, panelWidth]
280
405
  );
281
406
 
282
407
  const registerLayout = useCallback((tab: string, layout: TabLayout) => {
@@ -414,7 +539,9 @@ function TabsList({ className, scrollable = false, children, ...props }: TabsLis
414
539
 
415
540
  const row = (
416
541
  <View accessibilityRole="tablist" className={cn(list(), className)} {...props}>
417
- <TabsIndicator />
542
+ {/* Nothing to slide: in `expanding` every tab draws its own pill, and the
543
+ open one is the shape that moves. */}
544
+ {variant === 'expanding' ? null : <TabsIndicator />}
418
545
  {textChildren(children)}
419
546
  </View>
420
547
  );
@@ -438,7 +565,10 @@ function TabsList({ className, scrollable = false, children, ...props }: TabsLis
438
565
  export interface TabsTriggerProps {
439
566
  className?: string;
440
567
  value: string;
441
- /** Rendered before the label. */
568
+ /**
569
+ * Rendered before the label. Required by `variant="expanding"`, where it is
570
+ * the only thing a closed tab has left to identify it by.
571
+ */
442
572
  icon?: ReactNode;
443
573
  /** Rendered after the label — a count, a dot, a status. */
444
574
  badge?: ReactNode;
@@ -481,6 +611,23 @@ function TabsTrigger({
481
611
  return () => unregisterTab(value);
482
612
  }, [value, registerTab, unregisterTab]);
483
613
 
614
+ if (context.variant === 'expanding') {
615
+ return (
616
+ <ExpandingTrigger
617
+ active={active}
618
+ disabled={disabled}
619
+ icon={icon}
620
+ badge={badge}
621
+ labelClassName={slots.label()}
622
+ className={cn(slots.trigger(), className)}
623
+ onLayout={handleLayout}
624
+ onPress={() => context.setValue(value)}
625
+ >
626
+ {children}
627
+ </ExpandingTrigger>
628
+ );
629
+ }
630
+
484
631
  return (
485
632
  <Pressable
486
633
  accessibilityRole="tab"
@@ -501,6 +648,142 @@ function TabsTrigger({
501
648
  );
502
649
  }
503
650
 
651
+ /**
652
+ * A tab that is an icon until it is selected, and then an icon and its label.
653
+ *
654
+ * The label is never unmounted, only closed over. Two reasons, and both matter:
655
+ * a screen reader walking a row of unlabelled icons has nothing to read out,
656
+ * and a label that mounts on selection has no width to animate *from*, so the
657
+ * pill would jump to its open size and the text would fade in inside it.
658
+ *
659
+ * So the width is animated instead, which needs a number — and the number is
660
+ * the one thing that cannot be measured in place, because the box the label
661
+ * sits in is the box being resized. A second copy, laid out once in a box wide
662
+ * enough not to constrain it, reports the width and is never seen.
663
+ */
664
+ function ExpandingTrigger({
665
+ active,
666
+ disabled,
667
+ icon,
668
+ badge,
669
+ className,
670
+ labelClassName,
671
+ onLayout,
672
+ onPress,
673
+ children,
674
+ }: {
675
+ active: boolean;
676
+ disabled: boolean;
677
+ icon?: ReactNode;
678
+ badge?: ReactNode;
679
+ className: string;
680
+ labelClassName: string;
681
+ onLayout: (event: LayoutChangeEvent) => void;
682
+ onPress: () => void;
683
+ children: ReactNode;
684
+ }) {
685
+ const [labelWidth, setLabelWidth] = useState(0);
686
+ const reducedMotion = useReducedMotion();
687
+ const open = useSharedValue(active ? 1 : 0);
688
+
689
+ useEffect(() => {
690
+ if (reducedMotion) {
691
+ open.value = active ? 1 : 0;
692
+ return;
693
+ }
694
+ /*
695
+ * A curve, not a spring.
696
+ *
697
+ * This animates a *width*, so every frame of it is a layout pass — and the
698
+ * row is centred, so every other pill moves with it. A spring overshoots
699
+ * past its target and settles back, which on a box that is clipping text
700
+ * means the last word slides in, out and in again, and the whole row
701
+ * wobbles with it. The curve leaves quickly and arrives slowly, and it
702
+ * arrives once.
703
+ */
704
+ open.value = withTiming(active ? 1 : 0, {
705
+ duration: EXPAND_DURATION,
706
+ easing: Easing.bezier(0.2, 0, 0, 1),
707
+ });
708
+ }, [active, reducedMotion, open]);
709
+
710
+ const reveal = useAnimatedStyle(() => ({
711
+ width: (labelWidth + LABEL_GAP) * open.value,
712
+ /*
713
+ * Behind the width, not ahead of it. Text drawn into a box narrower than
714
+ * itself is text with its end cut off, and doing that on purpose for the
715
+ * first half of the animation is the difference between a label arriving
716
+ * and a label being wiped on.
717
+ */
718
+ opacity: interpolate(open.value, [0.35, 0.9], [0, 1], Extrapolation.CLAMP),
719
+ }));
720
+
721
+ const label = textChildren(children, (text) => (
722
+ <Text size="sm" weight="medium" numberOfLines={1} className={labelClassName}>
723
+ {text}
724
+ </Text>
725
+ ));
726
+
727
+ return (
728
+ <Pressable
729
+ accessibilityRole="tab"
730
+ accessibilityState={{ selected: active, disabled }}
731
+ // The label may be closed to nothing, and a tab announced as an unlabelled
732
+ // icon is a tab nobody can choose.
733
+ accessibilityLabel={typeof children === 'string' ? children : undefined}
734
+ disabled={disabled}
735
+ onPress={onPress}
736
+ onLayout={onLayout}
737
+ className={cn('flex-row items-center', className)}
738
+ >
739
+ {icon}
740
+ <Animated.View style={[{ overflow: 'hidden' }, reveal]}>
741
+ {/*
742
+ * Pinned to the measured width so the text does not reflow as the box
743
+ * around it closes — a label that rewraps on its way out reads as a
744
+ * glitch rather than as a reveal.
745
+ *
746
+ * The gap after the icon is padding on this box, so it has to be added
747
+ * to the width rather than taken out of it. Set to the measured width
748
+ * alone, the padding comes off the inside and the label is handed six
749
+ * points less than it asked for, which a single-line `Text` answers by
750
+ * truncating: "Inbox" arrives as "Inbo…" and stays that way.
751
+ */}
752
+ <View style={{ width: labelWidth + LABEL_GAP, paddingStart: LABEL_GAP }}>
753
+ {label}
754
+ </View>
755
+ </Animated.View>
756
+ {/* Measured once, never seen, and out of the layout so it cannot widen
757
+ the pill it is measuring. */}
758
+ <View
759
+ pointerEvents="none"
760
+ style={{ position: 'absolute', opacity: 0, width: MEASURE_WIDTH }}
761
+ >
762
+ <View
763
+ style={{ alignSelf: 'flex-start' }}
764
+ onLayout={(event: LayoutChangeEvent) => {
765
+ /*
766
+ * Rounded up and given a couple of points over.
767
+ *
768
+ * The measurement and the box it is put back into are two different
769
+ * layout passes, and a fraction of a point between them is enough
770
+ * for a single-line `Text` to decide it does not fit and truncate —
771
+ * which shows up as a label permanently missing its last letter.
772
+ */
773
+ const measured = Math.ceil(event.nativeEvent.layout.width) + LABEL_SLACK;
774
+ if (measured > LABEL_SLACK && measured !== labelWidth) {
775
+ setLabelWidth(measured);
776
+ }
777
+ }}
778
+ >
779
+ {label}
780
+ </View>
781
+ </View>
782
+ {badge}
783
+ </Pressable>
784
+ );
785
+ }
786
+
504
787
  export interface TabsContentProps extends ViewProps {
505
788
  className?: string;
506
789
  value: string;
@@ -529,13 +812,25 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
529
812
  count.value = tabs.length;
530
813
  }, [position, tabs.length, index, count]);
531
814
 
815
+ /*
816
+ * Same reason as `setValue` on the root: `step` is reached through the pan
817
+ * gesture, and a `step` that changes identity on every switch rebuilds the
818
+ * gesture on every switch. The two facts it needs are read at call time
819
+ * instead, which is when they are wanted anyway.
820
+ */
821
+ const tabsRef = useRef(tabs);
822
+ tabsRef.current = tabs;
823
+ const valueRef = useRef(value);
824
+ valueRef.current = value;
825
+
532
826
  const step = useCallback(
533
- (delta: number) => {
534
- const from = tabs.indexOf(value);
535
- const next = tabs[from + delta];
536
- if (next) setValue(next);
827
+ (delta: number, velocity: number) => {
828
+ const list = tabsRef.current;
829
+ const from = list.indexOf(valueRef.current);
830
+ const next = list[from + delta];
831
+ if (next) setValue(next, { velocity });
537
832
  },
538
- [tabs, value, setValue]
833
+ [setValue]
539
834
  );
540
835
 
541
836
  const pan = useMemo(
@@ -552,8 +847,16 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
552
847
  const atEnd =
553
848
  (travel > 0 && index.value === 0) ||
554
849
  (travel < 0 && index.value >= count.value - 1);
555
- offset.value =
556
- event.translationX * (atEnd ? SWIPE_FOLLOW_AT_END : SWIPE_FOLLOW);
850
+ /*
851
+ * One to one, except at the ends. The panel is under the finger for
852
+ * the whole of the drag, which is what lets the handover on release
853
+ * continue the movement rather than restart it — and a panel that
854
+ * moved a third as far as the finger never felt attached to it in
855
+ * the first place.
856
+ */
857
+ offset.value = atEnd
858
+ ? event.translationX * SWIPE_RESISTANCE_AT_END
859
+ : event.translationX;
557
860
  })
558
861
  .onEnd((event) => {
559
862
  const travel = event.translationX * sign;
@@ -568,16 +871,38 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
568
871
  // Distance and speed can disagree — a flick back the way it came
569
872
  // reads as a cancel — so the direction comes from whichever of the
570
873
  // two crossed its threshold, speed first.
571
- //
572
- // No spring back to zero here: `setValue` on the root throws the
573
- // displacement across to the side the next panel arrives from, and
574
- // springing to rest first would undo it a frame before it happens.
575
- runOnJS(step)(fast ? (speed < 0 ? 1 : -1) : travel < 0 ? 1 : -1);
874
+ const delta = fast ? (speed < 0 ? 1 : -1) : travel < 0 ? 1 : -1;
875
+
876
+ /*
877
+ * The outgoing panel carries on off the edge on the UI thread,
878
+ * immediately, rather than waiting to be told what happened.
879
+ *
880
+ * It used to hold wherever the finger left it until `setValue`
881
+ * landed — which is fine when React commits in a frame, and is a
882
+ * visible freeze when the arriving panel is expensive to mount, a
883
+ * list of any size being the usual case. The panel appeared to
884
+ * stick to the screen for exactly as long as the JS thread was
885
+ * busy, which reads as the swipe having dropped the gesture.
886
+ *
887
+ * `setValue` still places the arriving panel *relative* to this
888
+ * one, reading the offset live at commit time, so continuing the
889
+ * movement here does not desynchronise the handover: the panel
890
+ * that arrives is one width from wherever this one has got to,
891
+ * whenever that turns out to be.
892
+ */
893
+ offset.value = withSpring(-delta * sign * width.value, {
894
+ ...ENTER_SPRING,
895
+ velocity: event.velocityX,
896
+ });
897
+
898
+ runOnJS(step)(delta, event.velocityX);
576
899
  return;
577
900
  }
578
901
 
579
- // Nothing changed hands, so this panel simply comes back.
580
- offset.value = withSpring(0, SPRING);
902
+ // Nothing changed hands, so this panel simply comes back — with the
903
+ // speed it was let go at, so a cancelled flick decelerates rather
904
+ // than stopping and being pulled.
905
+ offset.value = withSpring(0, { ...SPRING, velocity: event.velocityX });
581
906
  })
582
907
  .onFinalize((_event, success) => {
583
908
  // A cancelled gesture never reaches `onEnd`, and would otherwise
@@ -587,9 +912,19 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
587
912
  [sign, step, offset, width, index, count]
588
913
  );
589
914
 
590
- const followStyle = useAnimatedStyle(() => ({
591
- transform: [{ translateX: offset.value }],
592
- }));
915
+ const followStyle = useAnimatedStyle(() => {
916
+ // No `opacity` key at all when there is no fade, rather than a constant 1.
917
+ // Declaring it would still mark the property as animated and hand the view
918
+ // an alpha to composite every frame — the cost this is avoiding.
919
+ if (!SWIPE_FADE) return { transform: [{ translateX: offset.value }] };
920
+
921
+ const span = width.value || 1;
922
+ const travelled = Math.min(1, Math.abs(offset.value) / span);
923
+ return {
924
+ transform: [{ translateX: offset.value }],
925
+ opacity: 1 - SWIPE_FADE * travelled,
926
+ };
927
+ });
593
928
 
594
929
  if (!active && !context.keepMounted) return null;
595
930
 
@@ -623,7 +958,14 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
623
958
  const measured = event.nativeEvent.layout.width;
624
959
  if (measured > 0) width.value = measured;
625
960
  }}
626
- style={[!active && { display: 'none' }, swipeable && followStyle, style]}
961
+ /*
962
+ * The follow style goes on the panel that is moving, and only that one.
963
+ * Under `keepMounted` every other panel is `display: none` and cannot be
964
+ * seen to move — but an animated style still subscribes them all to the
965
+ * offset, so a five-tab set ran five mappers per frame to reposition four
966
+ * views nobody was looking at.
967
+ */
968
+ style={[!active && { display: 'none' }, swipeable && active && followStyle, style]}
627
969
  pointerEvents={active ? 'auto' : 'none'}
628
970
  accessibilityElementsHidden={!active}
629
971
  importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}