panelui-native 0.59.0 → 0.60.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.
@@ -18,17 +18,25 @@
18
18
  * </Tabs>
19
19
  * ```
20
20
  *
21
- * **Swiping.** Only the visible panel is mounted, so a drag has nothing behind
22
- * it to reveal. The panel still tracks the finger one to one and dims as it
23
- * goes, and the panel arriving comes back up through the same fade — the
24
- * movement carries the change and the dissolve covers the gap. On release the
25
- * arriving panel picks up a whole panel's width from wherever the outgoing one
26
- * was let go, so the two views draw one continuous movement between them. The
27
- * displacement lives on the root for exactly that reason: a value belonging to
28
- * either panel would be destroyed at the handover.
21
+ * **Swiping puts the panels in a row.** With `swipeable`, the panels are laid
22
+ * out side by side in a strip as wide as all of them, inside a viewport that
23
+ * shows one at a time, and moving between tabs is that strip translating. The
24
+ * neighbours are therefore already built and already the right size before the
25
+ * finger arrives at them, which is the whole point: a panel that has to be
26
+ * mounted and measured at the moment it becomes visible is a panel that stalls
27
+ * there, and it stalls for exactly as long as it takes to build.
28
+ *
29
+ * One shared value carries the strip's position, in panels rather than points,
30
+ * and it is the only thing that decides where the strip is. A press springs it,
31
+ * a drag sets it, and neither waits for React: the value the tab set reports is
32
+ * updated alongside the movement, not ahead of it.
33
+ *
34
+ * A swipeable tab set therefore needs a height to fill, the same as any pager.
35
+ * Give it one — `flex-1` on the tab set, or a fixed height — or the strip has
36
+ * nothing to lay its panels out in.
29
37
  */
30
- import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
31
- import { Platform, Pressable, ScrollView, View } from 'react-native';
38
+ import { Children, createContext, Fragment, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
39
+ import { Pressable, ScrollView, View } from 'react-native';
32
40
  import { Gesture, GestureDetector } from 'react-native-gesture-handler';
33
41
  import Animated, { Easing, Extrapolation, FadeIn, interpolate, runOnJS, useAnimatedStyle, useReducedMotion, useSharedValue, withSpring, withTiming } from 'react-native-reanimated';
34
42
  import { tv } from 'tailwind-variants';
@@ -51,50 +59,19 @@ const SPRING = {
51
59
  const SWIPE_ACTIVATE_X = 12;
52
60
  const SWIPE_FAIL_Y = 8;
53
61
 
54
- /** A swipe past this share of the panel's width changes tab on release. */
62
+ /** A swipe past this share of a panel's width changes tab on release. */
55
63
  const SWIPE_DISTANCE_RATIO = 0.25;
56
- /** …or past this speed, however short it was. */
64
+ /** …or past this speed, however short it was, in points per second. */
57
65
  const SWIPE_VELOCITY = 500;
58
66
 
59
67
  /**
60
- * How much of the finger's travel the panel follows at the ends of the row,
61
- * where there is nowhere for it to go. Everywhere else it follows all of it.
68
+ * How much of the finger's travel the strip follows at the ends of the row,
69
+ * where there is no further panel to bring on. Everywhere else it follows all
70
+ * of it.
62
71
  */
63
72
  const SWIPE_RESISTANCE_AT_END = 0.16;
64
73
 
65
- /**
66
- * How far the panel fades as it is dragged, at a full panel's width.
67
- *
68
- * Tracking the finger one to one leaves the space behind the panel empty,
69
- * because the panels are separate views and only the visible one is mounted.
70
- * Dimming as it goes is what keeps that from reading as a hole punched in the
71
- * card: a panel on its way out is on its way out, and the one arriving comes
72
- * back up through the same fade. The two together read as a dissolve carried
73
- * by the movement, rather than as a gap between two slides.
74
- *
75
- * **iOS only, and that is not a compromise.** A transform is a property of the
76
- * layer the panel is drawn into, so moving it costs nothing per frame however
77
- * much is inside it. An opacity that changes every frame is not: on Android the
78
- * panel's view group has no offscreen buffer to fade, so the alpha is pushed
79
- * down into its children and the whole visible subtree is re-drawn on every
80
- * frame of the drag. Behind a list that is thirty rows of text and images,
81
- * which is what a tab panel usually is, that is the difference between a swipe
82
- * that tracks the finger and one that stutters. iOS fades the layer itself and
83
- * is unaffected.
84
- */
85
- const SWIPE_FADE = Platform.OS === 'ios' ? 0.75 : 0;
86
-
87
- /**
88
- * How far off its resting place a panel arriving by a *press* starts, as a
89
- * share of the panel's width.
90
- *
91
- * A press has no finger travel to continue from, so it is given a throw of its
92
- * own. A swipe does not use this: the panel it hands over to picks up exactly
93
- * one width from wherever the outgoing one has travelled to — see `setValue`.
94
- */
95
- const SWIPE_ENTER = 0.3;
96
-
97
- /** The arriving panel is springier than the one being dragged back into place. */
74
+ /** The spring the strip settles on, whether it was thrown or pressed. */
98
75
  const ENTER_SPRING = {
99
76
  damping: 22,
100
77
  stiffness: 240,
@@ -123,10 +100,21 @@ const MEASURE_WIDTH = 400;
123
100
  /**
124
101
  * How much of an inactive panel survives a switch away from it.
125
102
  *
126
- * `false` unmounts it. `true` keeps it mounted but takes it out of layout, so
127
- * it costs nothing to have around. `'measured'` keeps it laid out at full size
128
- * as well — the expensive option, and the only one a child that sizes itself
129
- * from its parent can be built inside while it is hidden.
103
+ * `false` unmounts it. `true` keeps it mounted.
104
+ *
105
+ * `'measured'` meant "keep it mounted *and* laid out at a real size", which was
106
+ * a distinction only a tab set of separately hidden panels had to make. In a
107
+ * swipeable tab set every panel in the strip is laid out at a real size
108
+ * already, so it is the same as `true` there and is kept only so that passing
109
+ * it does not break.
110
+ *
111
+ * @see TabsProps.keepMounted
112
+ */
113
+
114
+ /**
115
+ * `'disable-all'` turns off every animation in the tab set — the indicator, the
116
+ * strip, and an expanding tab's reveal — including the ones its parts run
117
+ * themselves.
130
118
  */
131
119
 
132
120
  const tabsVariants = tv({
@@ -225,13 +213,6 @@ const tabsVariants = tv({
225
213
  scrollable: false
226
214
  }
227
215
  });
228
-
229
- /**
230
- * What a swipe hands over to the tab it commits to: where the finger left the
231
- * outgoing panel, and how fast it was still going. Absent when the tab was
232
- * changed by pressing a trigger, which has neither.
233
- */
234
-
235
216
  const TabsContext = /*#__PURE__*/createContext(null);
236
217
  function useTabs(component) {
237
218
  const context = useContext(TabsContext);
@@ -240,6 +221,45 @@ function useTabs(component) {
240
221
  }
241
222
  return context;
242
223
  }
224
+ /**
225
+ * Pulls the panels out of the children, keeping everything else where it was.
226
+ *
227
+ * A pager has to lay its panels out together, and they are written wherever
228
+ * they read best — usually after the list, sometimes inside a fragment from a
229
+ * `map`. So they are found rather than required to be somewhere: fragments are
230
+ * flattened through, `Tabs.Content` elements are collected in the order they
231
+ * appear, and every other child is left exactly where it was written.
232
+ *
233
+ * A panel that is *not* reachable this way — wrapped in a component of your own
234
+ * — is not found, and the tab set falls back to showing one panel at a time.
235
+ * Silently losing it would be worse than not paging it.
236
+ */
237
+ function collectPanels(children) {
238
+ const rest = [];
239
+ const panels = [];
240
+ const walk = nodes => {
241
+ Children.forEach(nodes, child => {
242
+ if (! /*#__PURE__*/isValidElement(child)) {
243
+ if (child !== null && child !== undefined && child !== false) rest.push(child);
244
+ return;
245
+ }
246
+ if (child.type === Fragment) {
247
+ walk(child.props.children);
248
+ return;
249
+ }
250
+ if (child.type === TabsContent) {
251
+ panels.push(child);
252
+ return;
253
+ }
254
+ rest.push(child);
255
+ });
256
+ };
257
+ walk(children);
258
+ return {
259
+ rest,
260
+ panels
261
+ };
262
+ }
243
263
  function TabsRoot({
244
264
  className,
245
265
  value,
@@ -248,79 +268,22 @@ function TabsRoot({
248
268
  variant = 'segmented',
249
269
  keepMounted = false,
250
270
  swipeable = false,
271
+ animation,
251
272
  children,
252
273
  ...props
253
274
  }) {
254
275
  const [internalValue, setInternalValue] = useState(defaultValue);
255
276
  const [layouts, setLayouts] = useState({});
256
- const [tabs, setTabs] = useState([]);
257
- const swipeOffset = useSharedValue(0);
258
- const panelWidth = useSharedValue(0);
259
- const sign = useDirectionSign();
260
277
  // Published by the List rather than the root, because it is the List that
261
278
  // decides whether it scrolls — but the Triggers below it need to know.
262
279
  const [scrollable, setScrollable] = useState(false);
263
280
  const isControlled = value !== undefined;
264
281
  const resolvedValue = isControlled ? value : internalValue;
265
-
266
- /*
267
- * The two things `setValue` reads that change on every switch, held where
268
- * reading them does not make it a new function.
269
- *
270
- * With them in the dependency list, changing tab produced a new `setValue`,
271
- * therefore a new context, therefore a new `step`, therefore a new pan
272
- * gesture — which detaches and re-attaches the native recogniser on the same
273
- * commit that mounts the arriving panel's contents. The busiest frame of the
274
- * interaction was doing the one piece of work that had no reason to be there.
275
- */
276
- const tabsRef = useRef(tabs);
277
- tabsRef.current = tabs;
278
- const valueRef = useRef(resolvedValue);
279
- valueRef.current = resolvedValue;
280
- const setValue = useCallback((next, handover) => {
281
- const tabs = tabsRef.current;
282
- const resolvedValue = valueRef.current;
283
-
284
- /*
285
- * The arriving panel starts on the side it is arriving from, and travels
286
- * in. Done here rather than in the panel because only the root knows
287
- * both the tab being left and the tab being gone to — a panel knows
288
- * which one it is, not which one it replaced — and because a tab changed
289
- * by pressing a trigger deserves the same movement as one changed by
290
- * swiping to it. Without it the two read as different features.
291
- */
292
- if (swipeable) {
293
- const from = tabs.indexOf(resolvedValue);
294
- const to = tabs.indexOf(next);
295
- if (from !== -1 && to !== -1 && from !== to) {
296
- const direction = to > from ? 1 : -1;
297
- if (handover) {
298
- /*
299
- * A swipe hands over mid-movement, so the arriving panel starts a
300
- * whole panel's width from wherever the outgoing one has got to —
301
- * which is where it *would* have been all along, had both been
302
- * mounted. Read live rather than captured, because the outgoing
303
- * panel is still travelling while this runs: adding to the current
304
- * displacement rather than replacing it is the whole difference
305
- * between one continuous movement and a jump at the moment the
306
- * arriving panel finishes mounting.
307
- */
308
- swipeOffset.value += direction * sign * panelWidth.value;
309
- swipeOffset.value = withSpring(0, {
310
- ...ENTER_SPRING,
311
- // Carried across too, so a flick keeps its speed instead of
312
- // stopping dead and starting again from rest.
313
- velocity: handover.velocity
314
- });
315
- } else {
316
- swipeOffset.value = direction * sign * panelWidth.value * SWIPE_ENTER;
317
- swipeOffset.value = withSpring(0, ENTER_SPRING);
318
- }
319
- }
320
- }
282
+ const animationDisabled = animation === 'disable-all';
283
+ const setValue = useCallback(next => {
321
284
  if (!isControlled) setInternalValue(next);
322
285
  onValueChange?.(next);
323
- }, [isControlled, onValueChange, swipeable, sign, swipeOffset, panelWidth]);
286
+ }, [isControlled, onValueChange]);
324
287
  const registerLayout = useCallback((tab, layout) => {
325
288
  setLayouts(current => {
326
289
  const existing = current[tab];
@@ -335,45 +298,286 @@ function TabsRoot({
335
298
  }, []);
336
299
 
337
300
  /*
338
- * Triggers add themselves on mount, so the order is React's child order —
339
- * the order they are written in, which is the order they are read in.
301
+ * The panels are the sequence.
302
+ *
303
+ * They are read straight out of the children, in the order they are written,
304
+ * which is available on the first render and cannot disagree with what is on
305
+ * screen. The triggers used to register themselves to build this, which meant
306
+ * the order arrived a commit late and every mount and unmount of a trigger
307
+ * re-rendered every panel.
340
308
  */
341
- const registerTab = useCallback(tab => {
342
- setTabs(current => current.includes(tab) ? current : [...current, tab]);
343
- }, []);
344
- const unregisterTab = useCallback(tab => {
345
- setTabs(current => current.filter(entry => entry !== tab));
346
- }, []);
309
+ const {
310
+ rest,
311
+ panels
312
+ } = useMemo(() => collectPanels(children), [children]);
313
+ const paged = swipeable && panels.length > 0;
347
314
  const context = useMemo(() => ({
348
315
  value: resolvedValue,
349
316
  setValue,
350
317
  registerLayout,
351
318
  layouts,
352
- tabs,
353
- registerTab,
354
- unregisterTab,
355
319
  variant,
356
320
  scrollable,
357
321
  setScrollable,
358
322
  keepMounted,
359
- swipeable,
360
- swipeOffset,
361
- panelWidth
362
- }), [resolvedValue, setValue, registerLayout, layouts, tabs, registerTab, unregisterTab, variant, scrollable, keepMounted, swipeable, swipeOffset, panelWidth]);
323
+ pager: paged,
324
+ animationDisabled
325
+ }), [resolvedValue, setValue, registerLayout, layouts, variant, scrollable, keepMounted, paged, animationDisabled]);
363
326
  return /*#__PURE__*/_jsx(TabsContext.Provider, {
364
327
  value: context,
365
- children: /*#__PURE__*/_jsx(View, {
328
+ children: /*#__PURE__*/_jsxs(View, {
366
329
  className: cn('gap-3', className),
367
330
  ...props,
368
- children: textChildren(children)
331
+ children: [textChildren(paged ? rest : children), paged ? /*#__PURE__*/_jsx(TabsPager, {
332
+ panels: panels,
333
+ value: resolvedValue,
334
+ setValue: setValue,
335
+ keepMounted: !!keepMounted,
336
+ animationDisabled: animationDisabled
337
+ }) : null]
369
338
  })
370
339
  });
371
340
  }
341
+
342
+ /**
343
+ * The panels, side by side, behind a window one panel wide.
344
+ *
345
+ * Everything about the movement lives on `position`, measured in panels rather
346
+ * than points: the strip is at `-position × width`, a drag sets it, a press
347
+ * springs it, and it is the only thing that says where the strip is. Nothing
348
+ * here waits for React to commit before moving, which is what the old
349
+ * arrangement did — it mounted the arriving panel and animated it in on the
350
+ * same frame, so the movement was only as smooth as the mount was quick.
351
+ */
352
+ function TabsPager({
353
+ panels,
354
+ value,
355
+ setValue,
356
+ keepMounted,
357
+ animationDisabled
358
+ }) {
359
+ const sign = useDirectionSign();
360
+ const reducedMotion = useReducedMotion();
361
+ const still = animationDisabled || reducedMotion;
362
+ const order = useMemo(() => panels.map(panel => panel.props.value), [panels]);
363
+ const count = panels.length;
364
+ // An unknown value shows the first panel rather than none of them: a tab set
365
+ // with nothing in it is a harder thing to debug than one showing the wrong tab.
366
+ const active = Math.max(0, order.indexOf(value));
367
+ const [width, setWidth] = useState(0);
368
+ const position = useSharedValue(active);
369
+ const widthValue = useSharedValue(0);
370
+ const countValue = useSharedValue(count);
371
+ const start = useSharedValue(active);
372
+ /** 1 between a drag activating and it being finalised, 0 otherwise. */
373
+ const dragging = useSharedValue(0);
374
+ useEffect(() => {
375
+ widthValue.value = width;
376
+ countValue.value = count;
377
+ }, [width, count, widthValue, countValue]);
378
+
379
+ /*
380
+ * Which panels have been built, and it only ever grows.
381
+ *
382
+ * A panel that has been reached stays mounted for the life of the tab set,
383
+ * so a tab is slow at most once. That is what `keepMounted` was reached for
384
+ * and could not deliver, because it decided mounting and hiding together and
385
+ * the hiding took the panel's size away.
386
+ */
387
+ const [reached, setReached] = useState(() => [active]);
388
+ if (!reached.includes(active)) {
389
+ // During render, not in an effect: a press on a far tab has to have its
390
+ // panel in this commit, or the strip travels to an empty box.
391
+ setReached(current => current.includes(active) ? current : [...current, active]);
392
+ }
393
+ useEffect(() => {
394
+ const wanted = keepMounted ? Array.from({
395
+ length: count
396
+ }, (_, index) => index) : [active - 1, active + 1].filter(index => index >= 0 && index < count);
397
+
398
+ /*
399
+ * The neighbours arrive a tick late, on purpose.
400
+ *
401
+ * They are what makes a swipe cost nothing — the panel you are swiping
402
+ * towards is already built — but mounting them in the same commit as the
403
+ * active one puts three panels' worth of work on the frame the tab set
404
+ * first appears. A timeout of zero is enough to let that frame out.
405
+ */
406
+ const timer = setTimeout(() => {
407
+ setReached(current => {
408
+ const missing = wanted.filter(index => !current.includes(index));
409
+ return missing.length > 0 ? [...current, ...missing] : current;
410
+ });
411
+ }, 0);
412
+ return () => clearTimeout(timer);
413
+ }, [active, count, keepMounted]);
414
+
415
+ /*
416
+ * Which tab the strip has already been sprung to by a swipe.
417
+ *
418
+ * A swipe moves the strip and *then* reports the change, so by the time the
419
+ * value arrives the movement is under way with the flick's speed in it.
420
+ * Springing again from the effect below would restart it from rest, which is
421
+ * the flick visibly losing its throw halfway across.
422
+ *
423
+ * The index rather than a flag, so a change that never came back — a
424
+ * controlled parent that ignored the swipe — cannot swallow the next press.
425
+ */
426
+ const sprungTo = useRef(null);
427
+ const orderRef = useRef(order);
428
+ orderRef.current = order;
429
+ const valueRef = useRef(value);
430
+ valueRef.current = value;
431
+ useEffect(() => {
432
+ const already = sprungTo.current;
433
+ sprungTo.current = null;
434
+ if (already === active) return;
435
+ if (still || width === 0) {
436
+ position.value = active;
437
+ return;
438
+ }
439
+ position.value = withSpring(active, ENTER_SPRING);
440
+ }, [active, still, width, position]);
441
+ const commit = useCallback(index => {
442
+ const next = orderRef.current[index];
443
+ if (next === undefined || next === valueRef.current) return;
444
+ sprungTo.current = index;
445
+ setValue(next);
446
+ }, [setValue]);
447
+ const pan = useMemo(() => Gesture.Pan()
448
+ // Sideways past the threshold takes the gesture; any real vertical
449
+ // travel hands it back, so a panel that scrolls still scrolls.
450
+ .activeOffsetX([-SWIPE_ACTIVATE_X, SWIPE_ACTIVATE_X]).failOffsetY([-SWIPE_FAIL_Y, SWIPE_FAIL_Y]).onStart(() => {
451
+ // On activation rather than on touch-down, so a tap that never
452
+ // becomes a drag never claims a starting point. Rounded, so a drag
453
+ // begun while the last one is still settling starts from the tab it
454
+ // is settling on.
455
+ dragging.value = 1;
456
+ start.value = Math.round(position.value);
457
+ }).onUpdate(event => {
458
+ const span = widthValue.value;
459
+ if (span === 0) return;
460
+ const last = countValue.value - 1;
461
+ const raw = start.value - event.translationX * sign / span;
462
+
463
+ // Past either end there is no panel to bring on, so the strip gives
464
+ // a little and then stops, rather than pulling a blank into view.
465
+ if (raw < 0) position.value = raw * SWIPE_RESISTANCE_AT_END;else if (raw > last) position.value = last + (raw - last) * SWIPE_RESISTANCE_AT_END;else position.value = raw;
466
+ }).onEnd(event => {
467
+ const span = widthValue.value;
468
+ if (span === 0) return;
469
+ const last = countValue.value - 1;
470
+ const from = start.value;
471
+ const moved = position.value - from;
472
+ // Points per second becomes panels per second, which is the unit the
473
+ // spring that finishes the movement is working in.
474
+ const speed = -event.velocityX * sign / span;
475
+ let target = from;
476
+ // Speed first: distance and speed can disagree, and a flick back the
477
+ // way it came reads as a cancel however far it had already got.
478
+ if (Math.abs(event.velocityX) > SWIPE_VELOCITY) {
479
+ target = from + (speed > 0 ? 1 : -1);
480
+ } else if (Math.abs(moved) > SWIPE_DISTANCE_RATIO) {
481
+ target = from + (moved > 0 ? 1 : -1);
482
+ }
483
+ if (target < 0) target = 0;
484
+ if (target > last) target = last;
485
+ position.value = withSpring(target, {
486
+ ...ENTER_SPRING,
487
+ velocity: speed
488
+ });
489
+ if (target !== from) runOnJS(commit)(target);
490
+ }).onFinalize((_event, success) => {
491
+ // A cancelled gesture never reaches `onEnd`, and would otherwise
492
+ // leave the strip wherever the finger abandoned it. Only for a drag
493
+ // that actually started: this also runs for every touch that never
494
+ // became one, and springing to the rounded position there would
495
+ // interrupt a press's own movement with a tap on the panel.
496
+ if (dragging.value === 1 && !success) {
497
+ position.value = withSpring(start.value, ENTER_SPRING);
498
+ }
499
+ dragging.value = 0;
500
+ }), [sign, commit, position, start, dragging, widthValue, countValue]);
501
+ const strip = useAnimatedStyle(() => ({
502
+ transform: [{
503
+ translateX: -position.value * widthValue.value * sign
504
+ }]
505
+ }));
506
+ const onLayout = useCallback(event => {
507
+ const measured = event.nativeEvent.layout;
508
+ if (measured.width > 0) setWidth(measured.width);
509
+ if (__DEV__ && measured.width > 0 && measured.height === 0) {
510
+ console.warn('[PanelUI] <Tabs swipeable> has no height to fill, so its panels have nowhere ' + 'to be laid out. Give the tab set a height — `className="flex-1"` on <Tabs>, ' + 'or a fixed height — the same as any pager needs.');
511
+ }
512
+ }, []);
513
+
514
+ /*
515
+ * A strip cannot be laid out before the width of one panel is known, so the
516
+ * first render is the active panel on its own, filling the window. The strip
517
+ * takes over on the next frame, and every frame after it.
518
+ */
519
+ if (width === 0) {
520
+ return /*#__PURE__*/_jsx(View, {
521
+ style: PAGER_VIEWPORT,
522
+ onLayout: onLayout,
523
+ children: panels[active]
524
+ });
525
+ }
526
+ return /*#__PURE__*/_jsx(View, {
527
+ style: PAGER_VIEWPORT,
528
+ onLayout: onLayout,
529
+ children: /*#__PURE__*/_jsx(GestureDetector, {
530
+ gesture: pan,
531
+ children: /*#__PURE__*/_jsx(Animated.View, {
532
+ style: [FILL, {
533
+ flexDirection: 'row',
534
+ width: width * count
535
+ }, strip],
536
+ children: panels.map((panel, index) => /*#__PURE__*/_jsx(View, {
537
+ style: [FILL, {
538
+ width
539
+ }],
540
+ children: reached.includes(index) ? panel : null
541
+ }, order[index]))
542
+ })
543
+ })
544
+ });
545
+ }
546
+
547
+ /**
548
+ * Fill a height that is offered, and take the content's own when none is.
549
+ *
550
+ * `flexBasis: 'auto'` rather than the `0` that `flex: 1` sets, and it is on
551
+ * every box between the tab set and a panel — viewport, strip, panel — because
552
+ * one `flex: 1` anywhere in that chain breaks both cases at once. A `flex: 1`
553
+ * box inside a parent of indefinite height resolves to *nothing*: its basis is
554
+ * zero and there is no free space to grow into. So the tab set that had not
555
+ * been given a height would collapse, and take every panel with it — which is
556
+ * the same zero-height failure that made a kept panel useless to a list, one
557
+ * level up.
558
+ *
559
+ * With `auto` the chain resolves both ways. Given a height, the panels fill it
560
+ * and a virtualised list inside one has a real size to build against. Given
561
+ * none, the strip is as tall as its tallest panel and every panel stretches to
562
+ * match, so switching tabs does not change the tab set's height either.
563
+ */
564
+ const FILL = {
565
+ flexGrow: 1,
566
+ flexShrink: 1,
567
+ flexBasis: 'auto'
568
+ };
569
+
570
+ /** The window the strip moves behind, one panel wide. */
571
+ const PAGER_VIEWPORT = {
572
+ ...FILL,
573
+ overflow: 'hidden'
574
+ };
372
575
  function TabsIndicator() {
373
576
  const {
374
577
  value,
375
578
  layouts,
376
- variant
579
+ variant,
580
+ animationDisabled
377
581
  } = useTabs('Tabs.List');
378
582
  const x = useSharedValue(0);
379
583
  const width = useSharedValue(0);
@@ -390,7 +594,7 @@ function TabsIndicator() {
390
594
  // duplicated when React re-renders.
391
595
  useEffect(() => {
392
596
  if (!layout) return;
393
- if (initialized.value === 0) {
597
+ if (initialized.value === 0 || animationDisabled) {
394
598
  // First measurement snaps into place; there is nothing to animate from.
395
599
  x.value = layout.x;
396
600
  width.value = layout.width;
@@ -399,7 +603,7 @@ function TabsIndicator() {
399
603
  x.value = withSpring(layout.x, SPRING);
400
604
  width.value = withSpring(layout.width, SPRING);
401
605
  }
402
- }, [layout?.x, layout?.width, x, width, initialized, layout]);
606
+ }, [layout?.x, layout?.width, x, width, initialized, animationDisabled, layout]);
403
607
  const style = useAnimatedStyle(() => ({
404
608
  opacity: initialized.value,
405
609
  transform: [{
@@ -492,24 +696,13 @@ function TabsTrigger({
492
696
  width
493
697
  });
494
698
  }, [context, value]);
495
-
496
- // Separate from the layout registration above, and earlier than it: a swipe
497
- // needs to know the sequence, which is known at mount, not the positions,
498
- // which are not known until the row has been laid out.
499
- const {
500
- registerTab,
501
- unregisterTab
502
- } = context;
503
- useEffect(() => {
504
- registerTab(value);
505
- return () => unregisterTab(value);
506
- }, [value, registerTab, unregisterTab]);
507
699
  if (context.variant === 'expanding') {
508
700
  return /*#__PURE__*/_jsx(ExpandingTrigger, {
509
701
  active: active,
510
702
  disabled: disabled,
511
703
  icon: icon,
512
704
  badge: badge,
705
+ still: context.animationDisabled,
513
706
  labelClassName: slots.label(),
514
707
  className: cn(slots.trigger(), className),
515
708
  onLayout: handleLayout,
@@ -554,6 +747,7 @@ function ExpandingTrigger({
554
747
  disabled,
555
748
  icon,
556
749
  badge,
750
+ still,
557
751
  className,
558
752
  labelClassName,
559
753
  onLayout,
@@ -564,7 +758,7 @@ function ExpandingTrigger({
564
758
  const reducedMotion = useReducedMotion();
565
759
  const open = useSharedValue(active ? 1 : 0);
566
760
  useEffect(() => {
567
- if (reducedMotion) {
761
+ if (reducedMotion || still) {
568
762
  open.value = active ? 1 : 0;
569
763
  return;
570
764
  }
@@ -582,7 +776,7 @@ function ExpandingTrigger({
582
776
  duration: EXPAND_DURATION,
583
777
  easing: Easing.bezier(0.2, 0, 0, 1)
584
778
  });
585
- }, [active, reducedMotion, open]);
779
+ }, [active, reducedMotion, still, open]);
586
780
  const reveal = useAnimatedStyle(() => ({
587
781
  width: (labelWidth + LABEL_GAP) * open.value,
588
782
  /*
@@ -664,196 +858,44 @@ function TabsContent({
664
858
  }) {
665
859
  const context = useTabs('Tabs.Content');
666
860
  const active = context.value === value;
667
- const {
668
- tabs,
669
- setValue,
670
- swipeable,
671
- swipeOffset: offset,
672
- panelWidth: width
673
- } = context;
674
- const sign = useDirectionSign();
675
- // Read on the UI thread while the finger is down, so the resistance at the
676
- // ends is known without a round trip to JavaScript.
677
- const index = useSharedValue(0);
678
- const count = useSharedValue(0);
679
- const position = tabs.indexOf(value);
680
- useEffect(() => {
681
- index.value = position;
682
- count.value = tabs.length;
683
- }, [position, tabs.length, index, count]);
684
861
 
685
862
  /*
686
- * Same reason as `setValue` on the root: `step` is reached through the pan
687
- * gesture, and a `step` that changes identity on every switch rebuilds the
688
- * gesture on every switch. The two facts it needs are read at call time
689
- * instead, which is when they are wanted anyway.
863
+ * In a strip, a panel does no hiding and no moving.
864
+ *
865
+ * It is positioned by the strip and sized by the box it was put in, so all
866
+ * that is left to it is to fill that box and to stay out of the screen
867
+ * reader's way while it is off screen. Everything else this used to do — the
868
+ * displacement, the fade, the gesture, the two ways of being hidden — was
869
+ * work to make one panel stand in for a row of them, and the row is real now.
690
870
  */
691
- const tabsRef = useRef(tabs);
692
- tabsRef.current = tabs;
693
- const valueRef = useRef(value);
694
- valueRef.current = value;
695
- const step = useCallback((delta, velocity) => {
696
- const list = tabsRef.current;
697
- const from = list.indexOf(valueRef.current);
698
- const next = list[from + delta];
699
- if (next) setValue(next, {
700
- velocity
701
- });
702
- }, [setValue]);
703
- const pan = useMemo(() => Gesture.Pan()
704
- // Sideways past the threshold takes the gesture; any real vertical
705
- // travel hands it back, so a panel that scrolls still scrolls.
706
- .activeOffsetX([-SWIPE_ACTIVATE_X, SWIPE_ACTIVATE_X]).failOffsetY([-SWIPE_FAIL_Y, SWIPE_FAIL_Y]).onUpdate(event => {
707
- // Positive is "towards the previous tab" in reading order, which is
708
- // rightwards under LTR and leftwards under RTL.
709
- const travel = event.translationX * sign;
710
- const atEnd = travel > 0 && index.value === 0 || travel < 0 && index.value >= count.value - 1;
711
- /*
712
- * One to one, except at the ends. The panel is under the finger for
713
- * the whole of the drag, which is what lets the handover on release
714
- * continue the movement rather than restart it — and a panel that
715
- * moved a third as far as the finger never felt attached to it in
716
- * the first place.
717
- */
718
- offset.value = atEnd ? event.translationX * SWIPE_RESISTANCE_AT_END : event.translationX;
719
- }).onEnd(event => {
720
- const travel = event.translationX * sign;
721
- const speed = event.velocityX * sign;
722
- const far = Math.abs(travel) > width.value * SWIPE_DISTANCE_RATIO;
723
- const fast = Math.abs(speed) > SWIPE_VELOCITY;
724
- const atEnd = travel > 0 && index.value === 0 || travel < 0 && index.value >= count.value - 1;
725
- if ((far || fast) && !atEnd) {
726
- // Distance and speed can disagree — a flick back the way it came
727
- // reads as a cancel — so the direction comes from whichever of the
728
- // two crossed its threshold, speed first.
729
- const delta = fast ? speed < 0 ? 1 : -1 : travel < 0 ? 1 : -1;
730
-
731
- /*
732
- * The outgoing panel carries on off the edge on the UI thread,
733
- * immediately, rather than waiting to be told what happened.
734
- *
735
- * It used to hold wherever the finger left it until `setValue`
736
- * landed — which is fine when React commits in a frame, and is a
737
- * visible freeze when the arriving panel is expensive to mount, a
738
- * list of any size being the usual case. The panel appeared to
739
- * stick to the screen for exactly as long as the JS thread was
740
- * busy, which reads as the swipe having dropped the gesture.
741
- *
742
- * `setValue` still places the arriving panel *relative* to this
743
- * one, reading the offset live at commit time, so continuing the
744
- * movement here does not desynchronise the handover: the panel
745
- * that arrives is one width from wherever this one has got to,
746
- * whenever that turns out to be.
747
- */
748
- offset.value = withSpring(-delta * sign * width.value, {
749
- ...ENTER_SPRING,
750
- velocity: event.velocityX
751
- });
752
- runOnJS(step)(delta, event.velocityX);
753
- return;
754
- }
755
-
756
- // Nothing changed hands, so this panel simply comes back — with the
757
- // speed it was let go at, so a cancelled flick decelerates rather
758
- // than stopping and being pulled.
759
- offset.value = withSpring(0, {
760
- ...SPRING,
761
- velocity: event.velocityX
871
+ if (context.pager) {
872
+ return /*#__PURE__*/_jsx(View, {
873
+ style: [PAGER_PANEL, style],
874
+ accessibilityElementsHidden: !active,
875
+ importantForAccessibility: active ? 'auto' : 'no-hide-descendants',
876
+ className: className,
877
+ ...props,
878
+ children: textChildren(children)
762
879
  });
763
- }).onFinalize((_event, success) => {
764
- // A cancelled gesture never reaches `onEnd`, and would otherwise
765
- // leave the panel wherever the finger abandoned it.
766
- if (!success) offset.value = withSpring(0, SPRING);
767
- }), [sign, step, offset, width, index, count]);
768
- const followStyle = useAnimatedStyle(() => {
769
- // No `opacity` key at all when there is no fade, rather than a constant 1.
770
- // Declaring it would still mark the property as animated and hand the view
771
- // an alpha to composite every frame — the cost this is avoiding.
772
- if (!SWIPE_FADE) return {
773
- transform: [{
774
- translateX: offset.value
775
- }]
776
- };
777
- const span = width.value || 1;
778
- const travelled = Math.min(1, Math.abs(offset.value) / span);
779
- return {
780
- transform: [{
781
- translateX: offset.value
782
- }],
783
- opacity: 1 - SWIPE_FADE * travelled
784
- };
785
- });
880
+ }
786
881
  if (!active && !context.keepMounted) return null;
787
882
 
788
- /*
789
- * Under `keepMounted='measured'` a hidden panel keeps its size instead of
790
- * losing it. It is taken out of the flow and stretched over the tab set, so
791
- * it is laid out at the same size as the visible panel without contributing
792
- * its height to the parent, and it is hidden by being transparent rather
793
- * than by `display: none`.
794
- *
795
- * That distinction is the whole point: `display: none` lays a panel out at
796
- * zero size, and a child that sizes itself from its parent — a virtualised
797
- * list deciding how many rows to render — renders nothing at all inside one.
798
- * Given a real size while still hidden, it builds its rows now rather than on
799
- * the frame the tab is switched to.
800
- *
801
- * `opacity: 0` is set once and never animated, so it costs a composite and
802
- * not a per-frame redraw; the negative `zIndex` keeps it behind the visible
803
- * panel rather than over it, whatever order the panels are written in.
804
- */
805
- const measured = context.keepMounted === 'measured';
806
- const hiddenStyle = measured ? {
807
- position: 'absolute',
808
- top: 0,
809
- left: 0,
810
- right: 0,
811
- bottom: 0,
812
- opacity: 0,
813
- zIndex: -1
814
- } : {
815
- display: 'none'
816
- };
817
-
818
883
  /*
819
884
  * Hidden rather than unmounted under `keepMounted`, and hidden thoroughly:
820
885
  * it is not drawn, it takes no touches, and the accessibility props take it
821
886
  * out of the reading order too. A screen reader walking through three panels
822
887
  * of a tab set it cannot see is worse than no tabs at all.
888
+ *
889
+ * `display: none` takes it out of layout as well, so a kept panel costs
890
+ * nothing to have around — and can hold nothing that needs a size while it is
891
+ * hidden. That is what `swipeable` is for: in a strip every panel has one.
823
892
  */
824
- const panel = /*#__PURE__*/_jsx(Animated.View, {
825
- /*
826
- * Only worth animating when the panel is genuinely arriving. A kept
827
- * panel is already there; fading it in every time it is revealed would
828
- * undo the point of keeping it.
829
- *
830
- * And never alongside the swipe: an entering layout animation owns the
831
- * view's style for the length of it and will overwrite an animated style
832
- * touching the same view, so the fade would eat the slide. When panels
833
- * move, the movement is the entrance.
834
- */
835
- entering: context.keepMounted || swipeable ? undefined : FadeIn.duration(150),
836
- onLayout: event => {
837
- // The thresholds are a share of the panel, not of the screen: a tab
838
- // set inside a card is narrower than the window, and a quarter of the
839
- // window would be most of the way across it.
840
- //
841
- // Zero is ignored. Every panel reports into the same value, and a
842
- // panel kept with `display: none` measures as nothing — letting it say
843
- // so would leave the visible panel with no width. A `'measured'` one
844
- // is stretched over the tab set and reports the same width as the
845
- // visible panel, so it is agreeing rather than overwriting.
846
- const measured = event.nativeEvent.layout.width;
847
- if (measured > 0) width.value = measured;
848
- }
849
- /*
850
- * The follow style goes on the panel that is moving, and only that one.
851
- * Under `keepMounted` every other panel is hidden and cannot be seen to
852
- * move — but an animated style still subscribes them all to the offset,
853
- * so a five-tab set ran five mappers per frame to reposition four views
854
- * nobody was looking at.
855
- */,
856
- style: [!active && hiddenStyle, swipeable && active && followStyle, style],
893
+ return /*#__PURE__*/_jsx(Animated.View, {
894
+ // Only worth animating when the panel is genuinely arriving. A kept panel
895
+ // is already there; fading it in every time it is revealed would undo the
896
+ // point of keeping it.
897
+ entering: context.keepMounted || context.animationDisabled ? undefined : FadeIn.duration(150),
898
+ style: [!active && HIDDEN_PANEL, style],
857
899
  pointerEvents: active ? 'auto' : 'none',
858
900
  accessibilityElementsHidden: !active,
859
901
  importantForAccessibility: active ? 'auto' : 'no-hide-descendants',
@@ -861,17 +903,18 @@ function TabsContent({
861
903
  ...props,
862
904
  children: textChildren(children)
863
905
  });
864
-
865
- // Only the visible panel carries the gesture. A kept-mounted panel takes no
866
- // touches anyway — `pointerEvents` is off on it whichever way it is hidden —
867
- // but attaching a detector to each of them would put several competing
868
- // recognisers in the same tree.
869
- if (!swipeable || !active) return panel;
870
- return /*#__PURE__*/_jsx(GestureDetector, {
871
- gesture: pan,
872
- children: panel
873
- });
874
906
  }
907
+
908
+ /** A panel in the strip fills the box the strip put it in. */
909
+ const PAGER_PANEL = {
910
+ ...FILL,
911
+ width: '100%'
912
+ };
913
+
914
+ /** …and one that is kept without a strip is mounted, but takes up no room. */
915
+ const HIDDEN_PANEL = {
916
+ display: 'none'
917
+ };
875
918
  export const Tabs = Object.assign(TabsRoot, {
876
919
  List: TabsList,
877
920
  Trigger: TabsTrigger,