@oxyhq/bloom 0.54.0 → 0.55.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 (81) hide show
  1. package/lib/commonjs/tab-bar/TabBarBase.js +101 -16
  2. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  3. package/lib/commonjs/tab-bar/shared.js +15 -1
  4. package/lib/commonjs/tab-bar/shared.js.map +1 -1
  5. package/lib/commonjs/toast/ToastRow.js +35 -10
  6. package/lib/commonjs/toast/ToastRow.js.map +1 -1
  7. package/lib/commonjs/toast/ToastSwipeHandler.js +15 -5
  8. package/lib/commonjs/toast/ToastSwipeHandler.js.map +1 -1
  9. package/lib/commonjs/toast/constants.js +18 -1
  10. package/lib/commonjs/toast/constants.js.map +1 -1
  11. package/lib/commonjs/toast/toast-store.js +39 -1
  12. package/lib/commonjs/toast/toast-store.js.map +1 -1
  13. package/lib/commonjs/toast/use-stack-hover.js +175 -0
  14. package/lib/commonjs/toast/use-stack-hover.js.map +1 -0
  15. package/lib/commonjs/toast/use-stack-hover.native.js +42 -0
  16. package/lib/commonjs/toast/use-stack-hover.native.js.map +1 -0
  17. package/lib/module/tab-bar/TabBarBase.js +103 -18
  18. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  19. package/lib/module/tab-bar/shared.js +14 -0
  20. package/lib/module/tab-bar/shared.js.map +1 -1
  21. package/lib/module/toast/ToastRow.js +35 -10
  22. package/lib/module/toast/ToastRow.js.map +1 -1
  23. package/lib/module/toast/ToastSwipeHandler.js +16 -4
  24. package/lib/module/toast/ToastSwipeHandler.js.map +1 -1
  25. package/lib/module/toast/constants.js +18 -1
  26. package/lib/module/toast/constants.js.map +1 -1
  27. package/lib/module/toast/toast-store.js +39 -1
  28. package/lib/module/toast/toast-store.js.map +1 -1
  29. package/lib/module/toast/use-stack-hover.js +168 -0
  30. package/lib/module/toast/use-stack-hover.js.map +1 -0
  31. package/lib/module/toast/use-stack-hover.native.js +35 -0
  32. package/lib/module/toast/use-stack-hover.native.js.map +1 -0
  33. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/tab-bar/shared.d.ts +13 -0
  35. package/lib/typescript/commonjs/tab-bar/shared.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/tab-bar/types.d.ts +12 -0
  37. package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/toast/ToastRow.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts +4 -0
  40. package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts.map +1 -1
  41. package/lib/typescript/commonjs/toast/constants.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/toast/toast-store.d.ts +15 -0
  43. package/lib/typescript/commonjs/toast/toast-store.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/toast/use-stack-hover.d.ts +55 -0
  45. package/lib/typescript/commonjs/toast/use-stack-hover.d.ts.map +1 -0
  46. package/lib/typescript/commonjs/toast/use-stack-hover.native.d.ts +26 -0
  47. package/lib/typescript/commonjs/toast/use-stack-hover.native.d.ts.map +1 -0
  48. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  49. package/lib/typescript/module/tab-bar/shared.d.ts +13 -0
  50. package/lib/typescript/module/tab-bar/shared.d.ts.map +1 -1
  51. package/lib/typescript/module/tab-bar/types.d.ts +12 -0
  52. package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
  53. package/lib/typescript/module/toast/ToastRow.d.ts.map +1 -1
  54. package/lib/typescript/module/toast/ToastSwipeHandler.d.ts +4 -0
  55. package/lib/typescript/module/toast/ToastSwipeHandler.d.ts.map +1 -1
  56. package/lib/typescript/module/toast/constants.d.ts.map +1 -1
  57. package/lib/typescript/module/toast/toast-store.d.ts +15 -0
  58. package/lib/typescript/module/toast/toast-store.d.ts.map +1 -1
  59. package/lib/typescript/module/toast/use-stack-hover.d.ts +55 -0
  60. package/lib/typescript/module/toast/use-stack-hover.d.ts.map +1 -0
  61. package/lib/typescript/module/toast/use-stack-hover.native.d.ts +26 -0
  62. package/lib/typescript/module/toast/use-stack-hover.native.d.ts.map +1 -0
  63. package/package.json +1 -1
  64. package/src/__tests__/TabBarNoSelection.test.tsx +458 -0
  65. package/src/__tests__/TabBarReanimatedDeps.test.ts +28 -4
  66. package/src/__tests__/Toaster.test.tsx +554 -0
  67. package/src/__tests__/animated-style-deps.test.ts +30 -6
  68. package/src/__tests__/toast-position-utils.test.ts +28 -0
  69. package/src/__tests__/toast-stack-hover.test.ts +92 -0
  70. package/src/__tests__/toast-store.test.ts +72 -0
  71. package/src/tab-bar/TabBar.stories.tsx +52 -1
  72. package/src/tab-bar/TabBarBase.tsx +113 -31
  73. package/src/tab-bar/shared.ts +12 -0
  74. package/src/tab-bar/types.ts +12 -0
  75. package/src/toast/Toast.stories.tsx +10 -0
  76. package/src/toast/ToastRow.tsx +33 -10
  77. package/src/toast/ToastSwipeHandler.tsx +14 -2
  78. package/src/toast/constants.ts +18 -1
  79. package/src/toast/toast-store.ts +39 -1
  80. package/src/toast/use-stack-hover.native.ts +34 -0
  81. package/src/toast/use-stack-hover.ts +182 -0
@@ -0,0 +1,92 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { renderHook } from '@testing-library/react-native';
4
+
5
+ import {
6
+ isStackHovered as isStackHoveredNative,
7
+ useStackHover as useStackHoverNative,
8
+ } from '../toast/use-stack-hover.native';
9
+ import { isStackHovered, useStackHover } from '../toast/use-stack-hover';
10
+
11
+ /**
12
+ * The hover trigger's platform split. jest resolves the WEB file by default (as
13
+ * every non-Metro bundler and a consumer's `tsc` do), so `Toaster.test.tsx` covers
14
+ * the web behaviour through real rows; this file covers the NATIVE fork, which has
15
+ * no behaviour to exercise, plus the source properties that keep the split honest.
16
+ */
17
+ const SRC = join(__dirname, '..');
18
+ const read = (rel: string) => readFileSync(join(SRC, rel), 'utf8');
19
+ const code = (rel: string) =>
20
+ read(rel)
21
+ .replace(/\/\*[\s\S]*?\*\//g, '')
22
+ .replace(/^\s*\/\/.*$/gm, '');
23
+
24
+ describe('stack hover platform split', () => {
25
+ it('native hands back no handlers at all, for either stacking value', () => {
26
+ const stacked = renderHook(() => useStackHoverNative({ enableStacking: true }));
27
+ const flat = renderHook(() => useStackHoverNative({ enableStacking: false }));
28
+
29
+ expect(Object.keys(stacked.result.current)).toEqual([]);
30
+ expect(Object.keys(flat.result.current)).toEqual([]);
31
+ });
32
+
33
+ it('native returns ONE object identity, so the row box props never churn', () => {
34
+ const { result, rerender } = renderHook(() =>
35
+ useStackHoverNative({ enableStacking: true }),
36
+ );
37
+ const first = result.current;
38
+ rerender(undefined);
39
+
40
+ expect(result.current).toBe(first);
41
+ });
42
+
43
+ it('nothing ever hovers on native', () => {
44
+ expect(isStackHoveredNative()).toBe(false);
45
+ });
46
+
47
+ it('web hands back both pointer handlers', () => {
48
+ const { result } = renderHook(() => useStackHover({ enableStacking: true }));
49
+
50
+ expect(typeof result.current.onPointerEnter).toBe('function');
51
+ expect(typeof result.current.onPointerLeave).toBe('function');
52
+ });
53
+
54
+ it('web is not hovered until a pointer says so', () => {
55
+ expect(isStackHovered()).toBe(false);
56
+ });
57
+
58
+ it('the native fork carries no hover state, no timer and no store import', () => {
59
+ const native = code('toast/use-stack-hover.native.ts');
60
+ // A native bundle must not ship the web file's module-level latch or its
61
+ // pending-leave timeout, which is the whole reason this is a file split rather
62
+ // than a `Platform.OS` branch.
63
+ expect(native).not.toMatch(/setTimeout/);
64
+ expect(native).not.toMatch(/from '\.\/toast-store'/);
65
+ expect(native).not.toMatch(/pointerType/);
66
+ });
67
+
68
+ it('the web fork gates on a mouse pointer', () => {
69
+ const web = code('toast/use-stack-hover.ts');
70
+ // Touch fires enter before pointerdown and leave after pointerup, so without
71
+ // this every tap on a touch-capable web device would expand then collapse.
72
+ expect(web).toMatch(/pointerType === 'mouse'/);
73
+ });
74
+
75
+ it('the web fork drives the store rather than a second expansion mechanism', () => {
76
+ const web = code('toast/use-stack-hover.ts');
77
+ expect(web).toMatch(/toastStore\.expand\(\)/);
78
+ expect(web).toMatch(/toastStore\.collapse\(\)/);
79
+ // With stacking off there is nothing to expand, so the pointer pauses directly.
80
+ expect(web).toMatch(/toastStore\.pauseAllTimers\(\)/);
81
+ expect(web).toMatch(/toastStore\.resumeAllTimers\(\)/);
82
+ // No parallel state: expansion lives in the store and nowhere else.
83
+ expect(web).not.toMatch(/useState/);
84
+ expect(web).not.toMatch(/isExpanded\s*=/);
85
+ });
86
+
87
+ it('the row box is the hover target and the press toggle stands down for it', () => {
88
+ expect(code('toast/ToastSwipeHandler.tsx')).toMatch(/\{\.\.\.hoverProps\}/);
89
+ // Hover owns expansion on web; a click must not toggle it shut again.
90
+ expect(code('toast/ToastRow.tsx')).toMatch(/else if \(!isStackHovered\(\)\)/);
91
+ });
92
+ });
@@ -17,6 +17,9 @@ describe('toastStore', () => {
17
17
  });
18
18
 
19
19
  afterEach(() => {
20
+ // The expansion hold is registered by whoever owns hovering, so a test that
21
+ // installs one must not leak it into the next.
22
+ toastStore.setExpansionHold(null);
20
23
  toastStore.dismissToast(undefined);
21
24
  // Drains the hide-overlay timeout so the next test starts from a clean state.
22
25
  jest.runOnlyPendingTimers();
@@ -573,6 +576,14 @@ describe('toastStore', () => {
573
576
  expect(toastStore.getSnapshot().isExpanded).toBe(true);
574
577
  });
575
578
 
579
+ /**
580
+ * The auto-collapse rule. A lone row renders identically expanded or collapsed
581
+ * (`toast-position-utils.test.ts`: "a single row is unmoved by expansion"), while
582
+ * `isExpanded` keeps every timer paused — so collapsing at one row is invisible
583
+ * and is the only thing that stops that last toast hanging on screen forever.
584
+ * The exception is a stack something is HOLDING open, which on web is a pointer
585
+ * resting on it.
586
+ */
576
587
  it('auto-collapses once only one toast is left', () => {
577
588
  const first = toastStore.addToast({ title: 'a' });
578
589
  toastStore.addToast({ title: 'b' });
@@ -581,6 +592,67 @@ describe('toastStore', () => {
581
592
  toastStore.dismissToast(first, 'onDismiss');
582
593
  expect(toastStore.getSnapshot().isExpanded).toBe(false);
583
594
  });
595
+
596
+ it('resumes the surviving toast on that auto-collapse, so it cannot hang', () => {
597
+ const first = toastStore.addToast({ title: 'a', duration: 1000 });
598
+ toastStore.addToast({ title: 'b', duration: 1000 });
599
+ toastStore.expand();
600
+
601
+ toastStore.dismissToast(first, 'onDismiss');
602
+ jest.advanceTimersByTime(ENTERING_ANIMATION_DURATION + 1000);
603
+
604
+ expect(toastStore.getSnapshot().toasts).toHaveLength(0);
605
+ });
606
+
607
+ it('stays expanded while more than one toast is left', () => {
608
+ const first = toastStore.addToast({ title: 'a' });
609
+ toastStore.addToast({ title: 'b' });
610
+ toastStore.addToast({ title: 'c' });
611
+ toastStore.expand();
612
+
613
+ toastStore.dismissToast(first, 'onDismiss');
614
+ expect(toastStore.getSnapshot().isExpanded).toBe(true);
615
+ });
616
+
617
+ it('does not auto-collapse a stack something is holding open', () => {
618
+ toastStore.setExpansionHold(() => true);
619
+ const first = toastStore.addToast({ title: 'a', duration: 1000 });
620
+ toastStore.addToast({ title: 'b', duration: 1000 });
621
+ toastStore.expand();
622
+
623
+ toastStore.dismissToast(first, 'onDismiss');
624
+
625
+ expect(toastStore.getSnapshot().isExpanded).toBe(true);
626
+ // Still paused: resuming here is what would let a hovered toast expire under
627
+ // the cursor.
628
+ jest.advanceTimersByTime(1_000_000);
629
+ expect(toastStore.getSnapshot().toasts).toHaveLength(1);
630
+ });
631
+
632
+ it('resets an EMPTY stack even while held', () => {
633
+ // Nothing can hold open a stack that no longer exists, and once the rows
634
+ // unmount no `pointerleave` ever arrives to release the hold — so this must
635
+ // not depend on it.
636
+ toastStore.setExpansionHold(() => true);
637
+ const only = toastStore.addToast({ title: 'a' });
638
+ toastStore.expand();
639
+
640
+ toastStore.dismissToast(only, 'onDismiss');
641
+
642
+ expect(toastStore.getSnapshot().toasts).toHaveLength(0);
643
+ expect(toastStore.getSnapshot().isExpanded).toBe(false);
644
+ });
645
+
646
+ it('stops consulting a hold once it is cleared', () => {
647
+ toastStore.setExpansionHold(() => true);
648
+ toastStore.setExpansionHold(null);
649
+ const first = toastStore.addToast({ title: 'a' });
650
+ toastStore.addToast({ title: 'b' });
651
+ toastStore.expand();
652
+
653
+ toastStore.dismissToast(first, 'onDismiss');
654
+ expect(toastStore.getSnapshot().isExpanded).toBe(false);
655
+ });
584
656
  });
585
657
 
586
658
  describe('subscription', () => {
@@ -1,5 +1,5 @@
1
1
  import React, { useState, type PropsWithChildren } from 'react';
2
- import { ScrollView, Text, View } from 'react-native';
2
+ import { Pressable, ScrollView, Text, View } from 'react-native';
3
3
  import { GestureHandlerRootView } from 'react-native-gesture-handler';
4
4
  import Animated from 'react-native-reanimated';
5
5
  import { SafeAreaProvider } from 'react-native-safe-area-context';
@@ -177,6 +177,52 @@ function MinimizeScreen() {
177
177
  );
178
178
  }
179
179
 
180
+ /**
181
+ * Body of {@link NoSelection} — the consumer shape that exposed the bug.
182
+ *
183
+ * An index derived from the current route is `-1` on every screen outside the
184
+ * tab set, and the capsule used to slide one item-width to the LEFT of the first
185
+ * tab and poke out of the pill. What to watch for, none of which jest can see:
186
+ * it fades out WHERE IT STANDS rather than travelling off, no glyph or label
187
+ * stays lit while it is gone, and picking a tab again fades it back in AT that
188
+ * tab instead of sliding across the bar to reach it. Scrubbing from the
189
+ * off-tab state arms it under the finger.
190
+ */
191
+ function NoSelectionScreen() {
192
+ const { colors } = useTheme();
193
+ const [activeIndex, setActiveIndex] = useState(0);
194
+ const offTab = activeIndex < 0;
195
+ return (
196
+ <Screen>
197
+ <ScrollView contentContainerStyle={CONTENT_STYLE}>
198
+ <Pressable
199
+ onPress={() => setActiveIndex(offTab ? 0 : -1)}
200
+ style={{
201
+ padding: 16,
202
+ borderRadius: 16,
203
+ borderWidth: 1,
204
+ borderColor: colors.border,
205
+ backgroundColor: colors.backgroundSecondary,
206
+ }}
207
+ >
208
+ <Text style={{ color: colors.text, fontSize: 15, fontWeight: '600' }}>
209
+ {offTab ? 'Back to the Home tab' : 'Open a screen that is not a tab (index -1)'}
210
+ </Text>
211
+ </Pressable>
212
+ <Feed
213
+ heading={offTab ? 'Not a tab' : (ITEMS[activeIndex]?.label ?? 'Feed')}
214
+ caption={
215
+ offTab
216
+ ? 'No tab is selected: the highlight has faded out where it stood and nothing is tinted. Tap a tab — it fades back in there rather than sliding across — or drag across the bar to arm it under the finger.'
217
+ : 'Open the screen above to leave the tab set, the way a post detail or a settings route does.'
218
+ }
219
+ />
220
+ </ScrollView>
221
+ <ControlledBar activeIndex={activeIndex} onIndexChange={setActiveIndex} />
222
+ </Screen>
223
+ );
224
+ }
225
+
180
226
  function ThemeOverrideScreen() {
181
227
  const { colors } = useTheme();
182
228
  const [activeIndex, setActiveIndex] = useState(1);
@@ -211,6 +257,11 @@ export const MinimizeOnScroll: Story = {
211
257
  ),
212
258
  };
213
259
 
260
+ /** No tab selected (`activeIndex` naming no tab): the highlight fades out in place. */
261
+ export const NoSelection: Story = {
262
+ render: () => <NoSelectionScreen />,
263
+ };
264
+
214
265
  /** A partial `theme` override on top of the token-derived defaults. */
215
266
  export const ThemeOverride: Story = {
216
267
  render: () => <ThemeOverrideScreen />,
@@ -32,6 +32,7 @@ import Animated, {
32
32
  useAnimatedStyle,
33
33
  useSharedValue,
34
34
  withSpring,
35
+ withTiming,
35
36
  type SharedValue,
36
37
  } from 'react-native-reanimated';
37
38
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -44,6 +45,7 @@ import {
44
45
  BLUR_BLEED,
45
46
  EXPANDED_HEIGHT,
46
47
  HIGHLIGHT_EXPANDED,
48
+ HIGHLIGHT_FADE,
47
49
  HIGHLIGHT_MINIMIZED,
48
50
  ICON_SIZE,
49
51
  ITEM_GAP,
@@ -69,6 +71,12 @@ import type { TabBarButtonProps, TabBarProps, TabBarTheme } from './types';
69
71
  type BarContextValue = {
70
72
  /** Live highlight position, in tab units. Fractional while scrubbing. */
71
73
  slideIndex: SharedValue<number>;
74
+ /**
75
+ * Highlight visibility, 0–1. Below 1 only when the bar has NO selection (an
76
+ * `activeIndex` that names no tab). Buttons fold it into their own active
77
+ * tint so a faded-out capsule leaves no glyph and no label lit.
78
+ */
79
+ highlightOpacity: SharedValue<number>;
72
80
  /** True while a scrub is in progress — the finger owns the highlight. */
73
81
  isDragging: SharedValue<boolean>;
74
82
  theme: TabBarTheme;
@@ -107,10 +115,34 @@ function TabBarBody({
107
115
  const { width: windowWidth } = useWindowDimensions();
108
116
  const minimized = useMinimizeState();
109
117
  const progress = minimized.progress;
118
+ const tabCount = Math.max(Children.count(children), 1);
119
+
120
+ // Is a tab selected at all?
121
+ //
122
+ // The two states this distinguishes are NOT the same thing, and collapsing
123
+ // them would silently kill the highlight for every router consumer:
124
+ //
125
+ // - `activeIndex === undefined` is the FOCUS-DRIVEN path. The bar is not
126
+ // the writer at all — each `TabBarButton` supplies `isFocused` and drives
127
+ // the highlight itself (that is how the adapter keeps it correct through
128
+ // deep links and back gestures), so from the bar's side there is always a
129
+ // selection and the highlight is always visible.
130
+ // - NO SELECTION is `activeIndex` being a number that names no tab:
131
+ // negative, past the last tab, or fractional. Every consumer whose route
132
+ // set is larger than its tab set produces one — a `usePathname()`-derived
133
+ // index is -1 on every screen that is not a tab — and the highlight must
134
+ // then be gone rather than parked outside the pill.
135
+ const hasSelection =
136
+ activeIndex === undefined ||
137
+ (Number.isInteger(activeIndex) && activeIndex >= 0 && activeIndex < tabCount);
138
+
110
139
  const slideIndex = useSharedValue(0);
140
+ // Seeded from the CURRENT state, not from 0: a bar that mounts with a
141
+ // selection must show its highlight on the first frame exactly as it always
142
+ // has, and one that mounts without a selection must never flash it.
143
+ const highlightOpacity = useSharedValue(hasSelection ? 1 : 0);
111
144
  const isDragging = useSharedValue(false);
112
145
  const lastTicked = useSharedValue(-1);
113
- const tabCount = Math.max(Children.count(children), 1);
114
146
  const theme = useTabBarTheme(themeOverrides);
115
147
  const impact = useHaptics();
116
148
 
@@ -160,8 +192,26 @@ function TabBarBody({
160
192
  if (activeIndex === undefined) return;
161
193
  // While scrubbing the finger owns the highlight; never fight it.
162
194
  if (isDragging.value) return;
163
- slideIndex.value = withSpring(activeIndex, SLIDE_SPRING);
164
- }, [activeIndex, slideIndex, isDragging]);
195
+ if (!hasSelection) {
196
+ // Fade out where it stands. `slideIndex` is deliberately left alone: it
197
+ // is the highlight's POSITION, and there is no position that means
198
+ // "nowhere" — springing it to a sentinel would drag the capsule across
199
+ // the bar on its way out, dragging the active tint over every tab it
200
+ // passed. Visibility is the thing that changed, so visibility is the only
201
+ // thing that animates.
202
+ highlightOpacity.value = withTiming(0, HIGHLIGHT_FADE);
203
+ return;
204
+ }
205
+ // Coming back from fully hidden the capsule APPEARS at the new tab instead
206
+ // of travelling to it: a slide says "the selection moved from here to
207
+ // there", and while it was invisible there was no "here" — sliding from the
208
+ // stale index would animate out of a position the user never saw, and would
209
+ // light up every tab in between on the way. Interrupted mid-fade it is
210
+ // still on screen, so from there it slides as it always does.
211
+ slideIndex.value =
212
+ highlightOpacity.value === 0 ? activeIndex : withSpring(activeIndex, SLIDE_SPRING);
213
+ highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
214
+ }, [activeIndex, hasSelection, slideIndex, highlightOpacity, isDragging]);
165
215
 
166
216
  // Scrubbing: the highlight tracks the finger 1:1 while dragging (no spring —
167
217
  // it must feel attached), haptic ticks fire on boundary crossings, and
@@ -192,8 +242,17 @@ function TabBarBody({
192
242
  const pan = Gesture.Pan()
193
243
  .activeOffsetX([-6, 6])
194
244
  .failOffsetY([-14, 14])
195
- .onStart(() => {
245
+ .onStart((event) => {
196
246
  isDragging.value = true;
247
+ // Arming from the no-selection state: a hidden capsule has no position
248
+ // the finger can pick up, so it starts under the finger rather than at
249
+ // the index it faded out on — which would also make the first boundary
250
+ // tick fire against a phantom position. Mid-fade it is still visible,
251
+ // so it keeps the position it is at, exactly as it always has.
252
+ if (highlightOpacity.value === 0) {
253
+ slideIndex.value = indexAtX(event.x, progress.value);
254
+ }
255
+ highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
197
256
  lastTicked.value = Math.round(slideIndex.value);
198
257
  // Scrubbing is a deliberate bar interaction — surface the labels.
199
258
  setMinimized(minimized, 0);
@@ -230,7 +289,11 @@ function TabBarBody({
230
289
  return;
231
290
  }
232
291
  const index = Math.round(indexAtX(event.x, progress.value));
233
- slideIndex.value = withSpring(index, SLIDE_SPRING);
292
+ // Same rule as the controlled path above: appear at the tapped tab when
293
+ // hidden, slide to it when already on screen.
294
+ slideIndex.value =
295
+ highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
296
+ highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
234
297
  setMinimized(minimized, 0);
235
298
  runOnJS(selectIndex)(index);
236
299
  });
@@ -263,6 +326,7 @@ function TabBarBody({
263
326
  isDragging,
264
327
  lastTicked,
265
328
  slideIndex,
329
+ highlightOpacity,
266
330
  minimized,
267
331
  progress,
268
332
  ]);
@@ -344,9 +408,14 @@ function TabBarBody({
344
408
  width: itemWidth,
345
409
  borderRadius: height / 2,
346
410
  top: (barHeight - height) / 2,
411
+ // With no selection there is nothing to highlight, and the capsule has to
412
+ // stop being drawn: `slideIndex` is a position in tab units, so an
413
+ // out-of-range one is a real place — one item-width to the LEFT of the
414
+ // first tab, i.e. half outside the pill — not an absence.
415
+ opacity: highlightOpacity.value,
347
416
  transform: [{ translateX: ROW_PAD_H + itemWidth * slideIndex.value }],
348
417
  };
349
- }, [progress, slideIndex, barOuterWidth, tabCount]);
418
+ }, [progress, slideIndex, highlightOpacity, barOuterWidth, tabCount]);
350
419
 
351
420
  // Shared with `useTabBarFootprint`, so a consumer accounting for the bar in
352
421
  // its own layout can never drift from where the bar actually sits.
@@ -366,8 +435,8 @@ function TabBarBody({
366
435
  const constrainedWrapStyle: ViewStyle | null =
367
436
  maxWidth === undefined ? null : { width: barOuterWidth, alignSelf: 'center' };
368
437
  const barContext = useMemo(
369
- () => ({ slideIndex, isDragging, theme, activeIndex, selectIndex }),
370
- [slideIndex, isDragging, theme, activeIndex, selectIndex],
438
+ () => ({ slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex }),
439
+ [slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex],
371
440
  );
372
441
 
373
442
  return (
@@ -431,6 +500,7 @@ function TabBarButtonBody({
431
500
  const standaloneTheme = useTabBarTheme();
432
501
  const theme = bar?.theme ?? standaloneTheme;
433
502
  const slideIndex = bar?.slideIndex;
503
+ const highlightOpacity = bar?.highlightOpacity;
434
504
  // The two paths meet here: an explicit `isFocused` (router adapter) wins;
435
505
  // otherwise focus comes from the bar's controlled `activeIndex`.
436
506
  const focused = isFocused ?? (bar?.activeIndex === index);
@@ -449,29 +519,35 @@ function TabBarButtonBody({
449
519
  // Tint follows the sliding highlight, not navigation focus: whatever the pill
450
520
  // is over lights up — live while scrubbing, traveling on taps. Without a bar
451
521
  // there is nothing to follow, so it falls back to plain focus.
522
+ //
523
+ // Scaled by the highlight's own visibility, so a bar with NO selection leaves
524
+ // nothing lit: distance alone would keep the tab the capsule faded out on
525
+ // fully tinted, which is the same bug as a stray capsule wearing a different
526
+ // hat — a tab that looks selected while nothing is.
452
527
  // (Deps: see the CRITICAL note in `TabBarBody`.)
453
- const activeGlyphStyle = useAnimatedStyle(
454
- () => ({
455
- opacity: slideIndex ? 1 - Math.min(Math.abs(slideIndex.value - index), 1) : focused ? 1 : 0,
456
- }),
457
- [slideIndex, index, focused],
458
- );
459
-
460
- const labelStyle = useAnimatedStyle(
461
- () => ({
462
- opacity: interpolate(progress.value, [0, 0.4], [1, 0], Extrapolation.CLAMP),
463
- color: slideIndex
464
- ? interpolateColor(
465
- Math.min(Math.abs(slideIndex.value - index), 1),
466
- [0, 1],
467
- [theme.activeTint, theme.inactiveTint],
468
- )
469
- : focused
470
- ? theme.activeTint
471
- : theme.inactiveTint,
472
- }),
473
- [progress, slideIndex, index, focused, theme],
474
- );
528
+ const activeGlyphStyle = useAnimatedStyle(() => {
529
+ if (!slideIndex || !highlightOpacity) return { opacity: focused ? 1 : 0 };
530
+ const proximity = 1 - Math.min(Math.abs(slideIndex.value - index), 1);
531
+ return { opacity: highlightOpacity.value * proximity };
532
+ }, [slideIndex, highlightOpacity, index, focused]);
533
+
534
+ const labelStyle = useAnimatedStyle(() => {
535
+ const opacity = interpolate(progress.value, [0, 0.4], [1, 0], Extrapolation.CLAMP);
536
+ if (!slideIndex || !highlightOpacity) {
537
+ return { opacity, color: focused ? theme.activeTint : theme.inactiveTint };
538
+ }
539
+ // Same quantity as the glyph crossfade above, so a label can never disagree
540
+ // with the icon it sits under.
541
+ const proximity = 1 - Math.min(Math.abs(slideIndex.value - index), 1);
542
+ return {
543
+ opacity,
544
+ color: interpolateColor(
545
+ highlightOpacity.value * proximity,
546
+ [0, 1],
547
+ [theme.inactiveTint, theme.activeTint],
548
+ ),
549
+ };
550
+ }, [progress, slideIndex, highlightOpacity, index, focused, theme]);
475
551
 
476
552
  // Height is animated EXPLICITLY (not derived from children) so the icon stays
477
553
  // perfectly centered every frame — layout-driven sizing lags a frame behind
@@ -499,7 +575,13 @@ function TabBarButtonBody({
499
575
  onPress={(event) => {
500
576
  // The bar's GestureDetector normally consumes touches; this still fires
501
577
  // for assistive-technology activation (VoiceOver) and keyboard focus.
502
- if (bar) bar.slideIndex.value = withSpring(index, SLIDE_SPRING);
578
+ if (bar) {
579
+ // Appear at the tab when hidden, slide to it when visible — the same
580
+ // rule the tap gesture and the controlled path follow.
581
+ bar.slideIndex.value =
582
+ bar.highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
583
+ bar.highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
584
+ }
503
585
  setMinimized(minimized, 0);
504
586
  // Controlled path only. On the focus-driven path the trigger's own
505
587
  // `onPress` below performs the navigation, so reporting the selection
@@ -79,6 +79,18 @@ export function tabBarBottomGap(bottomInset: number): number {
79
79
  */
80
80
  export const SLIDE_SPRING = { duration: 420, dampingRatio: 0.82 };
81
81
 
82
+ /**
83
+ * Fade for the highlight coming and going when the selection does — an
84
+ * `activeIndex` that names no tab (see `TabBarProps.activeIndex`).
85
+ *
86
+ * A timing, not a spring, and deliberately shorter than {@link SLIDE_SPRING}:
87
+ * appearing and disappearing is a change of STATE, not a movement, so it must
88
+ * not read as a second animation racing the slide. The capsule fades where it
89
+ * stands — moving it out of view instead would drag it the length of the bar on
90
+ * its way out and sweep the active tint across every tab it passed.
91
+ */
92
+ export const HIGHLIGHT_FADE = { duration: 160 };
93
+
82
94
  /**
83
95
  * How long a press must be held before `onIndexLongPress` fires.
84
96
  *
@@ -78,6 +78,18 @@ export type TabBarProps = ViewProps & {
78
78
  * highlight and springs it here whenever this changes. Use this when there is
79
79
  * no router in play; the router adapter uses the per-button `isFocused` path
80
80
  * instead (see {@link TabBarButtonProps.isFocused}) and must NOT pass this.
81
+ *
82
+ * An index that names NO tab — negative, past the last tab, or fractional —
83
+ * means no selection: the highlight fades out where it stands and no tab is
84
+ * left tinted. Pass one whenever the current screen is not a tab; an index
85
+ * derived from the route (`TABS.findIndex(…)`) is `-1` on every such screen
86
+ * and is correct as-is. Returning to a real index fades the highlight back in
87
+ * AT that tab rather than sliding to it — while it was invisible there was no
88
+ * position to travel from — and scrubbing still arms it under the finger.
89
+ *
90
+ * OMITTING this prop is a different thing entirely and does not hide
91
+ * anything: that is the focus-driven path, where each button drives the
92
+ * highlight from its own `isFocused`.
81
93
  */
82
94
  activeIndex?: number;
83
95
  /**
@@ -26,6 +26,14 @@ import type { ToasterProps } from './types';
26
26
  * unstacked they must be ~54px apart (46px measured row + 8px gap), collapsed-
27
27
  * stack ~8px apart. Equal `y` values mean the rows are drawn on top of each
28
28
  * other — the failure this shape exists to catch.
29
+ *
30
+ * `Sequential*` gives you BOTH durations for a reason. The `One`/`Two` buttons use
31
+ * `duration: Infinity` so a row stays put while you inspect it — but that starts no
32
+ * auto-close timer at all, so nothing driven from those buttons can see the timer
33
+ * path. `One 3s`/`Two 3s` fire at the outlet's real duration; drive those when the
34
+ * question is a row's LIFETIME rather than its position. (`Toaster.test.tsx`'s
35
+ * "sequential toasts at the default duration" block covers the render set there;
36
+ * only a browser can judge the geometry.)
29
37
  */
30
38
  const meta: Meta = {
31
39
  title: 'Components/Toast',
@@ -249,6 +257,8 @@ function SequentialDemo({ outlet }: { outlet?: ToasterProps }) {
249
257
  <Demo outlet={outlet}>
250
258
  <Button onPress={() => toast('Row one', { duration: Infinity })}>One</Button>
251
259
  <Button onPress={() => toast('Row two', { duration: Infinity })}>Two</Button>
260
+ <Button onPress={() => toast('Row one')}>One 3s</Button>
261
+ <Button onPress={() => toast('Row two')}>Two 3s</Button>
252
262
  </Demo>
253
263
  );
254
264
  }
@@ -60,6 +60,7 @@ import {
60
60
  } from './constants';
61
61
  import { useDynamicToastContext, useToastContext } from './context';
62
62
  import { calculateStackScaleX } from './position-utils';
63
+ import { isStackHovered } from './use-stack-hover';
63
64
  import { useAnimatedTarget } from './use-animated-target';
64
65
  import { ToastContent } from './ToastContent';
65
66
  import { ToastSwipeHandler } from './ToastSwipeHandler';
@@ -354,18 +355,41 @@ export const ToastRow = React.forwardRef<ToastRef, ToastRowProps>(
354
355
  }
355
356
  }, [id, isExpanded]);
356
357
 
358
+ /**
359
+ * INVARIANT: NO TAP ON A STACKED ROW IS INERT. Every one of them dismisses,
360
+ * expands or collapses.
361
+ *
362
+ * The close-button strip dismisses only when a ✕ is actually THERE to aim at,
363
+ * which is exactly `closeButton && dismissible` — the same condition
364
+ * `ToastContent` renders it under, and nothing to do with expansion. Anything
365
+ * else in the strip falls through to the row's own behaviour rather than being
366
+ * swallowed: on a collapsed stack it expands, on an expanded one it collapses.
367
+ *
368
+ * Guarding the strip on `isExpanded` (as this did until now) made two taps do
369
+ * nothing at all — the whole strip at default config, where `closeButton` is
370
+ * `false` so the dismiss branch is unreachable, and the VISIBLE ✕ on a
371
+ * collapsed stack's front row. On Android that second one has no fallback:
372
+ * the RNGH tap wins the race against `ToastContent`'s nested close Pressable,
373
+ * so the ✕ never got its own press either.
374
+ *
375
+ * `x` is relative to the row, which is CAPPED at `TOAST_MAX_ROW_WIDTH`.
376
+ * Measuring the strip from the window edge would put it outside the row
377
+ * entirely on a wide viewport — do not "simplify" this back to `screenWidth`.
378
+ *
379
+ * W13 — the expansion TOGGLE stands down while a mouse is hovering the stack,
380
+ * because hover already owns expansion there (`use-stack-hover`). Without this
381
+ * a desktop click would collapse the stack under the cursor and resume the
382
+ * timers hover had just paused. Dismissing is unaffected, `onPress` always
383
+ * runs, and `isStackHovered()` is always false on native and on any touch web
384
+ * device — so press remains the only trigger wherever there is no pointer.
385
+ */
357
386
  const onSwipePress = React.useCallback(
358
387
  ({ x }: { x: number; y: number }) => {
359
388
  if (enableStacking && numberOfToasts > 1 && position !== 'center') {
360
- // On Android the RNGH tap wins the race against the nested Pressable,
361
- // so a press in the close-button strip has to dismiss explicitly.
362
- // `x` is relative to the row, which is capped — measuring the strip
363
- // from the window edge would put it outside the row on a wide viewport.
364
- if (x > rowWidth - CLOSE_BUTTON_HIT_AREA) {
365
- if (isExpanded && closeButton && dismissible) {
366
- onDismiss(id);
367
- }
368
- } else {
389
+ const inCloseStrip = x > rowWidth - CLOSE_BUTTON_HIT_AREA;
390
+ if (inCloseStrip && closeButton && dismissible) {
391
+ onDismiss(id);
392
+ } else if (!isStackHovered()) {
369
393
  toggleExpand();
370
394
  }
371
395
  }
@@ -376,7 +400,6 @@ export const ToastRow = React.forwardRef<ToastRef, ToastRowProps>(
376
400
  numberOfToasts,
377
401
  position,
378
402
  rowWidth,
379
- isExpanded,
380
403
  closeButton,
381
404
  dismissible,
382
405
  onDismiss,