panelui-native 0.51.1 → 0.54.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 (46) hide show
  1. package/README.md +4 -1
  2. package/lib/module/components/candlestick-chart/index.js +1161 -0
  3. package/lib/module/components/candlestick-chart/index.js.map +1 -0
  4. package/lib/module/components/combobox/index.js +73 -7
  5. package/lib/module/components/combobox/index.js.map +1 -1
  6. package/lib/module/components/context-menu/index.js +529 -0
  7. package/lib/module/components/context-menu/index.js.map +1 -0
  8. package/lib/module/components/menu/index.js +18 -10
  9. package/lib/module/components/menu/index.js.map +1 -1
  10. package/lib/module/components/popover/index.js +71 -7
  11. package/lib/module/components/popover/index.js.map +1 -1
  12. package/lib/module/components/sortable/index.js +159 -23
  13. package/lib/module/components/sortable/index.js.map +1 -1
  14. package/lib/module/components/tabs/index.js +56 -15
  15. package/lib/module/components/tabs/index.js.map +1 -1
  16. package/lib/module/components/time-picker/index.js +295 -31
  17. package/lib/module/components/time-picker/index.js.map +1 -1
  18. package/lib/module/index.js +3 -1
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/typescript/src/components/candlestick-chart/index.d.ts +278 -0
  21. package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -0
  22. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  23. package/lib/typescript/src/components/context-menu/index.d.ts +270 -0
  24. package/lib/typescript/src/components/context-menu/index.d.ts.map +1 -0
  25. package/lib/typescript/src/components/menu/index.d.ts +20 -20
  26. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  27. package/lib/typescript/src/components/popover/index.d.ts +51 -1
  28. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/sortable/index.d.ts +13 -3
  30. package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/tabs/index.d.ts +34 -2
  32. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  34. package/lib/typescript/src/index.d.ts +3 -1
  35. package/lib/typescript/src/index.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/components/candlestick-chart/index.tsx +1360 -0
  38. package/src/components/combobox/index.tsx +149 -18
  39. package/src/components/context-menu/index.tsx +658 -0
  40. package/src/components/menu/index.tsx +17 -10
  41. package/src/components/popover/index.tsx +94 -6
  42. package/src/components/sortable/index.tsx +278 -36
  43. package/src/components/tabs/index.tsx +82 -16
  44. package/src/components/tag-input/index.tsx +619 -0
  45. package/src/components/time-picker/index.tsx +330 -35
  46. package/src/index.ts +35 -0
@@ -76,6 +76,7 @@ import Animated, {
76
76
  scrollTo,
77
77
  useAnimatedRef,
78
78
  useAnimatedStyle,
79
+ useDerivedValue,
79
80
  useFrameCallback,
80
81
  useReducedMotion,
81
82
  useScrollViewOffset,
@@ -93,8 +94,14 @@ import { impactKnock, selectionTick } from '../../utils/haptics';
93
94
  * Rows getting out of the way of the one being carried. Quick, because they
94
95
  * are answering a finger that has already moved — a neighbour that ambles into
95
96
  * its new slot reads as the list struggling to keep up with the drag.
97
+ *
98
+ * Critically damped, and stiff. Both were wrong before: the spring overshot its
99
+ * slot and spent the rest of a third of a second coming back, so a row the
100
+ * finger had already passed was still visibly moving. A row getting out of the
101
+ * way has nothing to express by bouncing — it is not the thing being carried,
102
+ * and the fastest way to say "your place is free" is to be out of it.
96
103
  */
97
- const DISPLACE = { damping: 22, stiffness: 300, mass: 0.7 } as const;
104
+ const DISPLACE = { damping: 28, stiffness: 400, mass: 0.5 } as const;
98
105
 
99
106
  /**
100
107
  * Settles a row into its slot. Stiffer and less bouncy than the library's
@@ -222,34 +229,114 @@ function slotCenter(
222
229
  }
223
230
 
224
231
  /**
225
- * Where the dragged row belongs now, given where its middle has reached.
232
+ * The order after a row has moved, with pinned rows left where they were.
233
+ *
234
+ * The move is applied first, to the whole list, so a carried row can be dragged
235
+ * *past* a pinned one — refusing the move instead would make a pinned row a
236
+ * wall, and a row that holds its place is not the same as a row nothing may
237
+ * cross. The pinned ids are then put back at the indices they occupy in the
238
+ * laid-out order, and everything else falls into the slots that are left, in
239
+ * the order the move produced.
240
+ *
241
+ * `laid` rather than `list` is what the fixed indices are read from, because
242
+ * that is the one order a pinned row is guaranteed to be correctly placed in:
243
+ * it is where it was rendered, and holding its slot is the whole point.
244
+ */
245
+ function moveWithPinned(
246
+ list: readonly string[],
247
+ laid: readonly string[],
248
+ pinned: Record<string, boolean>,
249
+ id: string,
250
+ from: number,
251
+ to: number
252
+ ): string[] {
253
+ 'worklet';
254
+ const moved = [...list];
255
+ moved.splice(from, 1);
256
+ moved.splice(to, 0, id);
257
+
258
+ const next: (string | undefined)[] = [];
259
+ let anyPinned = false;
260
+ for (let i = 0; i < moved.length; i += 1) next.push(undefined);
261
+ for (let i = 0; i < laid.length && i < next.length; i += 1) {
262
+ const at = laid[i];
263
+ if (at !== undefined && pinned[at]) {
264
+ next[i] = at;
265
+ anyPinned = true;
266
+ }
267
+ }
268
+ if (!anyPinned) return moved;
269
+
270
+ const free: string[] = [];
271
+ for (let i = 0; i < moved.length; i += 1) {
272
+ const at = moved[i];
273
+ if (at !== undefined && !pinned[at]) free.push(at);
274
+ }
275
+
276
+ const result: string[] = [];
277
+ let f = 0;
278
+ for (let i = 0; i < next.length; i += 1) {
279
+ const held = next[i];
280
+ if (held !== undefined) {
281
+ result.push(held);
282
+ continue;
283
+ }
284
+ const take = free[f];
285
+ f += 1;
286
+ if (take !== undefined) result.push(take);
287
+ }
288
+ return result;
289
+ }
290
+
291
+ /**
292
+ * Where the dragged row belongs now, given where its edges have reached.
226
293
  *
227
294
  * It walks outwards from the row's current slot and stops at the first
228
- * neighbour it has *not* passed the middle of, rather than scanning the whole
229
- * list for the nearest slot. The difference shows up with rows of unequal
230
- * height: scanning can hand back a slot two places away that happens to be
231
- * closer, which reads as the row skipping one.
295
+ * neighbour it has not reached, rather than scanning the whole list for the
296
+ * nearest slot. The difference shows up with rows of unequal height: scanning
297
+ * can hand back a slot two places away that happens to be closer, which reads
298
+ * as the row skipping one.
299
+ *
300
+ * What counts as reaching a neighbour is the *leading edge* of the carried row
301
+ * against that neighbour's middle — its bottom edge going down, its top edge
302
+ * going up. Comparing middle against middle, as this used to, means the finger
303
+ * has to travel a whole row before anything happens, because a row's middle
304
+ * starts a whole row away from its neighbour's: the list sat still through the
305
+ * first row of every drag and then moved all at once. Leading edge against
306
+ * middle halves that, and it is also the more natural reading — the rows get
307
+ * out of the way once the row being carried is over them, not once it is past
308
+ * them.
232
309
  */
233
310
  function targetIndex(
234
311
  order: readonly string[],
235
312
  current: number,
236
- center: number,
313
+ top: number,
314
+ height: number,
237
315
  heights: Record<string, number>,
238
316
  gap: number
239
317
  ): number {
240
318
  'worklet';
241
- if (center < slotCenter(order, current, heights, gap)) {
319
+ // Where the carried row's own slot begins, so the direction of travel is
320
+ // read from the row rather than from the sign of a gesture that may have
321
+ // changed its mind since.
322
+ const self = order[current];
323
+ const restingTop =
324
+ slotCenter(order, current, heights, gap) -
325
+ (self === undefined ? 0 : (heights[self] ?? 0)) / 2;
326
+
327
+ if (top < restingTop) {
242
328
  let target = current;
243
329
  for (let i = current - 1; i >= 0; i -= 1) {
244
- if (center >= slotCenter(order, i, heights, gap)) break;
330
+ if (top >= slotCenter(order, i, heights, gap)) break;
245
331
  target = i;
246
332
  }
247
333
  return target;
248
334
  }
249
335
 
336
+ const bottom = top + height;
250
337
  let target = current;
251
338
  for (let i = current + 1; i < order.length; i += 1) {
252
- if (center <= slotCenter(order, i, heights, gap)) break;
339
+ if (bottom <= slotCenter(order, i, heights, gap)) break;
253
340
  target = i;
254
341
  }
255
342
  return target;
@@ -266,6 +353,20 @@ interface SortableContextValue {
266
353
  rendered: SharedValue<string[]>;
267
354
  /** Measured row heights, keyed by id. */
268
355
  heights: SharedValue<Record<string, number>>;
356
+ /** Which ids hold their slot. Read on the UI thread while a drag resolves. */
357
+ pinned: SharedValue<Record<string, boolean>>;
358
+ /**
359
+ * How far each row is from where it was laid out, keyed by id.
360
+ *
361
+ * Derived once per rearrangement rather than worked out by each row for
362
+ * itself. Every row's style worklet re-runs on every frame of a drag — it
363
+ * closes over the value the carried row is riding on — so a row summing the
364
+ * heights above it twice per frame made the list cost the square of its
365
+ * length to drag, which is felt exactly when a list is long enough to be
366
+ * worth reordering by hand. This is invalidated by the same shared values it
367
+ * is built from, so a row changing height still puts it right.
368
+ */
369
+ offsets: SharedValue<Record<string, number>>;
269
370
  /** The row under the finger, or `null`. Also the settling row, until it lands. */
270
371
  activeId: SharedValue<string | null>;
271
372
  /** The active row's offset from where it was laid out. */
@@ -300,6 +401,8 @@ interface SortableContextValue {
300
401
  /** Index of each id in the rendered order. */
301
402
  indexOf: (id: string) => number;
302
403
  measured: (id: string, height: number) => void;
404
+ /** Register or clear a row's hold on its slot. */
405
+ setPinned: (id: string, value: boolean) => void;
303
406
  begin: (id: string) => void;
304
407
  settled: (id: string) => void;
305
408
  /** Move a row by whole slots — the path that is not a gesture. */
@@ -437,6 +540,7 @@ function SortableRoot({
437
540
  const order = useSharedValue<string[]>(value);
438
541
  const rendered = useSharedValue<string[]>(value);
439
542
  const heights = useSharedValue<Record<string, number>>({});
543
+ const pinned = useSharedValue<Record<string, boolean>>({});
440
544
  const activeId = useSharedValue<string | null>(null);
441
545
  const translate = useSharedValue(0);
442
546
  const lift = useSharedValue(0);
@@ -479,14 +583,69 @@ function SortableRoot({
479
583
 
480
584
  const indexOf = useCallback((id: string) => indices.get(id) ?? -1, [indices]);
481
585
 
586
+ /*
587
+ * Both maps are accumulated in a ref and then published, rather than built by
588
+ * reading the shared value back and spreading it. Every row reports its
589
+ * layout in the same batch on mount, and a write to `.value` is not visible
590
+ * to the next read in that batch — so a read-modify-write there has all the
591
+ * rows spreading the same empty map and only the last one surviving. A list
592
+ * that knows one row's height puts every slot a gap apart, and the first
593
+ * drag drops the row at the end of the list.
594
+ */
595
+ const measuredHeights = useRef<Record<string, number>>({});
596
+ const pinnedFlags = useRef<Record<string, boolean>>({});
597
+
482
598
  const measured = useCallback(
483
599
  (id: string, height: number) => {
484
- if (heights.value[id] === height) return;
485
- heights.value = { ...heights.value, [id]: height };
600
+ if (measuredHeights.current[id] === height) return;
601
+ measuredHeights.current = { ...measuredHeights.current, [id]: height };
602
+ heights.value = measuredHeights.current;
486
603
  },
487
604
  [heights]
488
605
  );
489
606
 
607
+ const setPinned = useCallback(
608
+ (id: string, next: boolean) => {
609
+ if (Boolean(pinnedFlags.current[id]) === next) return;
610
+ pinnedFlags.current = { ...pinnedFlags.current, [id]: next };
611
+ pinned.value = pinnedFlags.current;
612
+ },
613
+ [pinned]
614
+ );
615
+
616
+ /*
617
+ * Every row's distance from where it was laid out, in one pass.
618
+ *
619
+ * Two prefix sums — one over the order being dragged, one over the order the
620
+ * children are actually in — and the difference between them per id. Rebuilt
621
+ * when a swap changes `order`, when a drop resets both, or when a row reports
622
+ * a new height, and at no other time; a drag that is only moving the carried
623
+ * row does not touch it at all.
624
+ */
625
+ const offsets = useDerivedValue<Record<string, number>>(() => {
626
+ const map = heights.value;
627
+ const target: Record<string, number> = {};
628
+ const list = order.value;
629
+ let at = 0;
630
+ for (let i = 0; i < list.length; i += 1) {
631
+ const id = list[i];
632
+ if (id === undefined) continue;
633
+ target[id] = at;
634
+ at += (map[id] ?? 0) + gap;
635
+ }
636
+
637
+ const result: Record<string, number> = {};
638
+ const laid = rendered.value;
639
+ at = 0;
640
+ for (let i = 0; i < laid.length; i += 1) {
641
+ const id = laid[i];
642
+ if (id === undefined) continue;
643
+ result[id] = (target[id] ?? at) - at;
644
+ at += (map[id] ?? 0) + gap;
645
+ }
646
+ return result;
647
+ }, [gap]);
648
+
490
649
  /* ---------------------------------------------------------------------- */
491
650
  /* Autoscroll */
492
651
  /* ---------------------------------------------------------------------- */
@@ -607,6 +766,8 @@ function SortableRoot({
607
766
  order,
608
767
  rendered,
609
768
  heights,
769
+ pinned,
770
+ offsets,
610
771
  activeId,
611
772
  translate,
612
773
  lift,
@@ -623,6 +784,7 @@ function SortableRoot({
623
784
  activeItem,
624
785
  indexOf,
625
786
  measured,
787
+ setPinned,
626
788
  begin,
627
789
  settled,
628
790
  step,
@@ -632,6 +794,8 @@ function SortableRoot({
632
794
  order,
633
795
  rendered,
634
796
  heights,
797
+ pinned,
798
+ offsets,
635
799
  activeId,
636
800
  translate,
637
801
  lift,
@@ -648,6 +812,7 @@ function SortableRoot({
648
812
  activeItem,
649
813
  indexOf,
650
814
  measured,
815
+ setPinned,
651
816
  begin,
652
817
  settled,
653
818
  step,
@@ -686,10 +851,20 @@ export interface SortableItemProps extends Omit<ViewProps, 'children'> {
686
851
  /**
687
852
  * Stop this row being picked up. The others still move past it, because a
688
853
  * row that cannot be dragged is not the same as a row that cannot be
689
- * displaced — a pinned row is a different feature, and pretending this one
690
- * is it would mean silently refusing drops that look like they worked.
854
+ * displaced — that is what `pinned` is for, and conflating the two would mean
855
+ * silently refusing drops that look like they worked.
691
856
  */
692
857
  disabled?: boolean;
858
+ /**
859
+ * Hold this row's place in the list. It cannot be picked up, and — unlike a
860
+ * `disabled` row — nothing else can take its slot either: the rows being
861
+ * dragged reorder among the places left over, and one carried past this row
862
+ * goes around it rather than through it.
863
+ *
864
+ * For the row that means something by being where it is. A header, a total, a
865
+ * step that has to come first.
866
+ */
867
+ pinned?: boolean;
693
868
  /**
694
869
  * Extra classes for the row while it is being carried, applied last. A
695
870
  * lifted row is given an opaque surface and a shadow so it is never drawn
@@ -711,6 +886,7 @@ function SortableItem({
711
886
  id,
712
887
  children,
713
888
  disabled = false,
889
+ pinned = false,
714
890
  ...props
715
891
  }: SortableItemProps) {
716
892
  const root = useSortableRoot('Sortable.Item');
@@ -718,6 +894,8 @@ function SortableItem({
718
894
  order,
719
895
  rendered,
720
896
  heights,
897
+ pinned: pinnedIds,
898
+ offsets,
721
899
  activeId,
722
900
  translate,
723
901
  lift,
@@ -733,13 +911,23 @@ function SortableItem({
733
911
  activeItem,
734
912
  indexOf,
735
913
  measured,
914
+ setPinned,
736
915
  begin,
737
916
  settled,
738
917
  step,
739
918
  } = root;
740
919
 
741
- /** A row is undraggable if either it or the whole list says so. */
742
- const locked = root.disabled || disabled;
920
+ /** A row is undraggable if it, the whole list, or its own pin says so. */
921
+ const locked = root.disabled || disabled || pinned;
922
+
923
+ /*
924
+ * Published to the root so the drag can read it on the UI thread. A pin is
925
+ * resolved while a finger is moving, where the props of a row two places away
926
+ * are not reachable.
927
+ */
928
+ useEffect(() => {
929
+ setPinned(id, pinned);
930
+ }, [id, pinned, setPinned]);
743
931
  const index = indexOf(id);
744
932
  const isActive = activeItem === id;
745
933
 
@@ -811,13 +999,20 @@ function SortableItem({
811
999
  if (current < 0) return;
812
1000
 
813
1001
  const top = slotOffset(rendered.value, id, map, gap) + translate.value;
814
- const center = top + (map[id] ?? 0) / 2;
815
- const to = targetIndex(list, current, center, map, gap);
1002
+ const to = targetIndex(list, current, top, map[id] ?? 0, map, gap);
816
1003
  if (to === current) return;
817
1004
 
818
- const next = [...list];
819
- next.splice(current, 1);
820
- next.splice(to, 0, id);
1005
+ const next = moveWithPinned(
1006
+ list,
1007
+ rendered.value,
1008
+ pinnedIds.value,
1009
+ id,
1010
+ current,
1011
+ to
1012
+ );
1013
+ // A move that only pinned rows could have absorbed leaves the order
1014
+ // exactly as it was, and there is nothing to feel or to redraw.
1015
+ if (next[current] === id) return;
821
1016
  order.value = next;
822
1017
 
823
1018
  if (haptics) runOnJS(notifyTick)();
@@ -850,10 +1045,15 @@ function SortableItem({
850
1045
  * drop unreported; `seq` is what tells the two cases apart, because
851
1046
  * the only interruption that should be ignored is the row being picked
852
1047
  * up again.
1048
+ *
1049
+ * `activeId` is checked as well as `seq` because reporting the drop is
1050
+ * itself what interrupts the spring: the caller applies the reorder,
1051
+ * and the reset that follows puts `translate` back to rest, which ends
1052
+ * the animation and calls this a second time under the same `seq`.
853
1053
  */
854
1054
  const land = () => {
855
1055
  'worklet';
856
- if (dragSeq.value !== seq) return;
1056
+ if (dragSeq.value !== seq || activeId.value === null) return;
857
1057
  activeId.value = null;
858
1058
  runOnJS(notifySettled)(id);
859
1059
  };
@@ -914,9 +1114,14 @@ function SortableItem({
914
1114
  };
915
1115
  }
916
1116
 
917
- const map = heights.value;
918
- const offset =
919
- slotOffset(order.value, id, map, gap) - slotOffset(rendered.value, id, map, gap);
1117
+ /*
1118
+ * Read, not worked out. This worklet re-runs on every frame of a drag —
1119
+ * it closes over the value the carried row rides on — so summing the
1120
+ * heights above this row here, twice, made a list cost the square of its
1121
+ * length to drag. The root derives every row's offset in one pass instead,
1122
+ * and only when the arrangement actually changes.
1123
+ */
1124
+ const offset = offsets.value[id] ?? 0;
920
1125
 
921
1126
  /*
922
1127
  * Only animated while a drag is in flight, and the difference is the whole
@@ -956,17 +1161,27 @@ function SortableItem({
956
1161
  * no way to discover it from the row. Moving by whole slots is published as
957
1162
  * an accessibility action instead, which is the only path to reordering for
958
1163
  * someone who is not dragging anything.
1164
+ *
1165
+ * The actions sit wherever the drag does. A handle list keeps them on the
1166
+ * grip, which is an element in its own right; put here they would never be
1167
+ * offered, because the actions of a view that is not itself an accessibility
1168
+ * element are not reachable, and a row full of text is not one. A long-press
1169
+ * list has no grip and gives the whole row to the drag, so the row becomes
1170
+ * the element — which is what a screen reader wants from a row in any case.
959
1171
  */
960
- const a11y = locked
961
- ? undefined
962
- : [
1172
+ const carriesActions = activation === 'longPress' && !locked;
1173
+
1174
+ const a11y = carriesActions
1175
+ ? [
963
1176
  { name: 'moveUp', label: 'Move up' },
964
1177
  { name: 'moveDown', label: 'Move down' },
965
- ];
1178
+ ]
1179
+ : undefined;
966
1180
 
967
1181
  const row = (
968
1182
  <Animated.View
969
1183
  onLayout={onLayout}
1184
+ accessible={carriesActions}
970
1185
  accessibilityActions={a11y}
971
1186
  onAccessibilityAction={(event) => {
972
1187
  if (event.nativeEvent.actionName === 'moveUp') step(id, -1);
@@ -1038,7 +1253,7 @@ function SortableHandle({
1038
1253
  accessibilityLabel = 'Drag to reorder',
1039
1254
  ...props
1040
1255
  }: SortableHandleProps) {
1041
- const { activation, disabled: rootDisabled } = useSortableRoot('Sortable.Handle');
1256
+ const { activation, disabled: rootDisabled, step } = useSortableRoot('Sortable.Handle');
1042
1257
  const item = useContext(SortableItemContext);
1043
1258
 
1044
1259
  /*
@@ -1049,17 +1264,44 @@ function SortableHandle({
1049
1264
  const muted = useCSSVariable('--color-muted-foreground');
1050
1265
  const tint = typeof muted === 'string' ? muted : undefined;
1051
1266
 
1267
+ const locked = rootDisabled || item?.disabled;
1268
+
1269
+ /*
1270
+ * `adjustable` promises an element that answers a swipe up or down, and the
1271
+ * promise was never kept: the grip published the role and nothing else, so
1272
+ * the one part of a row a screen reader could reach did nothing at all.
1273
+ * Moving by whole slots is what it was always meant to do. The same move is
1274
+ * offered as a named action too, because a swipe says nothing about which
1275
+ * way the row is going to travel.
1276
+ */
1277
+ const move = (delta: number) => {
1278
+ if (locked || !item) return;
1279
+ step(item.id, delta);
1280
+ };
1281
+
1052
1282
  const glyph = (
1053
1283
  <View
1054
1284
  accessible
1055
1285
  accessibilityRole="adjustable"
1056
1286
  accessibilityLabel={accessibilityLabel}
1057
- accessibilityState={{ disabled: rootDisabled || item?.disabled }}
1058
- className={cn(
1059
- 'items-center justify-center px-2 py-1.5',
1060
- (rootDisabled || item?.disabled) && 'opacity-40',
1061
- className
1062
- )}
1287
+ accessibilityState={{ disabled: locked }}
1288
+ accessibilityValue={item ? { text: `Position ${item.index + 1}` } : undefined}
1289
+ accessibilityActions={
1290
+ locked
1291
+ ? undefined
1292
+ : [
1293
+ { name: 'increment' },
1294
+ { name: 'decrement' },
1295
+ { name: 'moveUp', label: 'Move up' },
1296
+ { name: 'moveDown', label: 'Move down' },
1297
+ ]
1298
+ }
1299
+ onAccessibilityAction={(event) => {
1300
+ const action = event.nativeEvent.actionName;
1301
+ if (action === 'increment' || action === 'moveUp') move(-1);
1302
+ if (action === 'decrement' || action === 'moveDown') move(1);
1303
+ }}
1304
+ className={cn('items-center justify-center px-2 py-1.5', locked && 'opacity-40', className)}
1063
1305
  {...props}
1064
1306
  >
1065
1307
  <IconColorProvider color={tint}>
@@ -140,6 +140,16 @@ const MEASURE_WIDTH = 400;
140
140
 
141
141
  export type TabsVariant = 'segmented' | 'underline' | 'pill' | 'expanding';
142
142
 
143
+ /**
144
+ * How much of an inactive panel survives a switch away from it.
145
+ *
146
+ * `false` unmounts it. `true` keeps it mounted but takes it out of layout, so
147
+ * it costs nothing to have around. `'measured'` keeps it laid out at full size
148
+ * as well — the expensive option, and the only one a child that sizes itself
149
+ * from its parent can be built inside while it is hidden.
150
+ */
151
+ export type TabsKeepMounted = boolean | 'measured';
152
+
143
153
  const tabsVariants = tv({
144
154
  slots: {
145
155
  list: 'flex-row',
@@ -246,7 +256,7 @@ interface TabsContextValue {
246
256
  variant: TabsVariant;
247
257
  scrollable: boolean;
248
258
  setScrollable: (scrollable: boolean) => void;
249
- keepMounted: boolean;
259
+ keepMounted: TabsKeepMounted;
250
260
  swipeable: boolean;
251
261
  /**
252
262
  * How far the visible panel is displaced from its resting place, in points.
@@ -292,8 +302,29 @@ export interface TabsProps extends ViewProps {
292
302
  * Keep inactive panels mounted and hidden instead of unmounting them, so a
293
303
  * scroll position or a half-filled form survives a switch away and back.
294
304
  * Costs the render of every panel up front.
305
+ *
306
+ * `true` hides a kept panel with `display: none`, which also takes it out of
307
+ * layout: it is mounted, but it has no size. That is what makes it cheap, and
308
+ * it is enough for a panel whose content sizes itself — a column of views, a
309
+ * form, a `ScrollView` of known children.
310
+ *
311
+ * It is *not* enough for a child that decides what to render by measuring the
312
+ * space it has been given. A virtualised list asks its parent how tall it is
313
+ * and fills that many rows; asked inside a panel of zero height it answers
314
+ * zero rows, and the whole first render still lands on the frame the tab
315
+ * becomes visible — the stall this flag looks like it should have avoided.
316
+ *
317
+ * `'measured'` is for that case. A kept panel stays laid out at the full size
318
+ * of the tab set, and is hidden by not being drawn rather than by being
319
+ * removed from layout: a list inside it measures, renders its rows and
320
+ * settles while it is still hidden, so becoming visible costs nothing.
321
+ *
322
+ * The trade is real and is why it is not the default — every kept panel lays
323
+ * out and draws, up front and on every size change, so a five-tab set builds
324
+ * five panels' worth of rows to show one. Reach for it when a panel is slow
325
+ * to appear and its content is virtualised; leave it at `true` otherwise.
295
326
  */
296
- keepMounted?: boolean;
327
+ keepMounted?: TabsKeepMounted;
297
328
  /**
298
329
  * Move between tabs by dragging sideways on the panel, as well as by
299
330
  * pressing the triggers.
@@ -310,7 +341,9 @@ export interface TabsProps extends ViewProps {
310
341
  * something is moving*, so a panel that is slow to build stops being a pause
311
342
  * before it appears and starts being a stutter in the movement. If a swipe
312
343
  * 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.
344
+ * mount — and the answer is whichever `keepMounted` actually keeps its
345
+ * content built, which for a virtualised list is `'measured'` rather than
346
+ * `true`. Turning this off hides the cost rather than removing it.
314
347
  */
315
348
  swipeable?: boolean;
316
349
  children: ReactNode;
@@ -928,11 +961,41 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
928
961
 
929
962
  if (!active && !context.keepMounted) return null;
930
963
 
964
+ /*
965
+ * Under `keepMounted='measured'` a hidden panel keeps its size instead of
966
+ * losing it. It is taken out of the flow and stretched over the tab set, so
967
+ * it is laid out at the same size as the visible panel without contributing
968
+ * its height to the parent, and it is hidden by being transparent rather
969
+ * than by `display: none`.
970
+ *
971
+ * That distinction is the whole point: `display: none` lays a panel out at
972
+ * zero size, and a child that sizes itself from its parent — a virtualised
973
+ * list deciding how many rows to render — renders nothing at all inside one.
974
+ * Given a real size while still hidden, it builds its rows now rather than on
975
+ * the frame the tab is switched to.
976
+ *
977
+ * `opacity: 0` is set once and never animated, so it costs a composite and
978
+ * not a per-frame redraw; the negative `zIndex` keeps it behind the visible
979
+ * panel rather than over it, whatever order the panels are written in.
980
+ */
981
+ const measured = context.keepMounted === 'measured';
982
+ const hiddenStyle = measured
983
+ ? ({
984
+ position: 'absolute',
985
+ top: 0,
986
+ left: 0,
987
+ right: 0,
988
+ bottom: 0,
989
+ opacity: 0,
990
+ zIndex: -1,
991
+ } as const)
992
+ : ({ display: 'none' } as const);
993
+
931
994
  /*
932
995
  * Hidden rather than unmounted under `keepMounted`, and hidden thoroughly:
933
- * `display: none` takes it out of layout, and the accessibility props take
934
- * it out of the reading order too. A screen reader walking through three
935
- * panels of a tab set it cannot see is worse than no tabs at all.
996
+ * it is not drawn, it takes no touches, and the accessibility props take it
997
+ * out of the reading order too. A screen reader walking through three panels
998
+ * of a tab set it cannot see is worse than no tabs at all.
936
999
  */
937
1000
  const panel = (
938
1001
  <Animated.View
@@ -953,19 +1016,21 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
953
1016
  // window would be most of the way across it.
954
1017
  //
955
1018
  // Zero is ignored. Every panel reports into the same value, and a
956
- // kept-mounted one is `display: none` — it measures as nothing, and
957
- // letting it say so would leave the visible panel with no width.
1019
+ // panel kept with `display: none` measures as nothing — letting it say
1020
+ // so would leave the visible panel with no width. A `'measured'` one
1021
+ // is stretched over the tab set and reports the same width as the
1022
+ // visible panel, so it is agreeing rather than overwriting.
958
1023
  const measured = event.nativeEvent.layout.width;
959
1024
  if (measured > 0) width.value = measured;
960
1025
  }}
961
1026
  /*
962
1027
  * 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.
1028
+ * Under `keepMounted` every other panel is hidden and cannot be seen to
1029
+ * move — but an animated style still subscribes them all to the offset,
1030
+ * so a five-tab set ran five mappers per frame to reposition four views
1031
+ * nobody was looking at.
967
1032
  */
968
- style={[!active && { display: 'none' }, swipeable && active && followStyle, style]}
1033
+ style={[!active && hiddenStyle, swipeable && active && followStyle, style]}
969
1034
  pointerEvents={active ? 'auto' : 'none'}
970
1035
  accessibilityElementsHidden={!active}
971
1036
  importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
@@ -976,9 +1041,10 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
976
1041
  </Animated.View>
977
1042
  );
978
1043
 
979
- // Only the visible panel carries the gesture. A kept-mounted panel is
980
- // `display: none` and takes no touches anyway, but attaching a detector to
981
- // each of them would put several competing recognisers in the same tree.
1044
+ // Only the visible panel carries the gesture. A kept-mounted panel takes no
1045
+ // touches anyway — `pointerEvents` is off on it whichever way it is hidden —
1046
+ // but attaching a detector to each of them would put several competing
1047
+ // recognisers in the same tree.
982
1048
  if (!swipeable || !active) return panel;
983
1049
 
984
1050
  return <GestureDetector gesture={pan}>{panel}</GestureDetector>;