@oxyhq/bloom 0.79.1 → 0.80.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 (52) hide show
  1. package/lib/commonjs/scroll/index.web.js +52 -18
  2. package/lib/commonjs/scroll/index.web.js.map +1 -1
  3. package/lib/commonjs/scroll/scrollable.web.js +9 -3
  4. package/lib/commonjs/scroll/scrollable.web.js.map +1 -1
  5. package/lib/commonjs/tabs/Tabs.js +263 -63
  6. package/lib/commonjs/tabs/Tabs.js.map +1 -1
  7. package/lib/commonjs/tabs/expo-router/RouterTabs.js +215 -0
  8. package/lib/commonjs/tabs/expo-router/RouterTabs.js.map +1 -0
  9. package/lib/commonjs/tabs/expo-router/index.js +13 -0
  10. package/lib/commonjs/tabs/expo-router/index.js.map +1 -0
  11. package/lib/module/scroll/index.web.js +52 -18
  12. package/lib/module/scroll/index.web.js.map +1 -1
  13. package/lib/module/scroll/scrollable.web.js +9 -3
  14. package/lib/module/scroll/scrollable.web.js.map +1 -1
  15. package/lib/module/tabs/Tabs.js +271 -65
  16. package/lib/module/tabs/Tabs.js.map +1 -1
  17. package/lib/module/tabs/expo-router/RouterTabs.js +210 -0
  18. package/lib/module/tabs/expo-router/RouterTabs.js.map +1 -0
  19. package/lib/module/tabs/expo-router/index.js +10 -0
  20. package/lib/module/tabs/expo-router/index.js.map +1 -0
  21. package/lib/typescript/commonjs/scroll/index.web.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/scroll/scrollable.web.d.ts +18 -5
  23. package/lib/typescript/commonjs/scroll/scrollable.web.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/tabs/Tabs.d.ts +41 -1
  25. package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts +80 -0
  27. package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts.map +1 -0
  28. package/lib/typescript/commonjs/tabs/expo-router/index.d.ts +9 -0
  29. package/lib/typescript/commonjs/tabs/expo-router/index.d.ts.map +1 -0
  30. package/lib/typescript/commonjs/tabs/types.d.ts +48 -4
  31. package/lib/typescript/commonjs/tabs/types.d.ts.map +1 -1
  32. package/lib/typescript/module/scroll/index.web.d.ts.map +1 -1
  33. package/lib/typescript/module/scroll/scrollable.web.d.ts +18 -5
  34. package/lib/typescript/module/scroll/scrollable.web.d.ts.map +1 -1
  35. package/lib/typescript/module/tabs/Tabs.d.ts +41 -1
  36. package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
  37. package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts +80 -0
  38. package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts.map +1 -0
  39. package/lib/typescript/module/tabs/expo-router/index.d.ts +9 -0
  40. package/lib/typescript/module/tabs/expo-router/index.d.ts.map +1 -0
  41. package/lib/typescript/module/tabs/types.d.ts +48 -4
  42. package/lib/typescript/module/tabs/types.d.ts.map +1 -1
  43. package/package.json +15 -1
  44. package/src/__tests__/Tabs.test.tsx +187 -0
  45. package/src/__tests__/optional-peer-imports.test.ts +5 -0
  46. package/src/__tests__/scroll-web.test.tsx +255 -2
  47. package/src/scroll/index.web.tsx +55 -19
  48. package/src/scroll/scrollable.web.ts +30 -8
  49. package/src/tabs/Tabs.tsx +352 -77
  50. package/src/tabs/expo-router/RouterTabs.tsx +268 -0
  51. package/src/tabs/expo-router/index.ts +8 -0
  52. package/src/tabs/types.ts +48 -4
package/src/tabs/Tabs.tsx CHANGED
@@ -1,9 +1,11 @@
1
1
  import React, {
2
2
  createContext,
3
+ forwardRef,
3
4
  memo,
4
5
  useCallback,
5
6
  useContext,
6
7
  useEffect,
8
+ useImperativeHandle,
7
9
  useMemo,
8
10
  useRef,
9
11
  } from 'react';
@@ -11,13 +13,24 @@ import {
11
13
  View,
12
14
  Text,
13
15
  Pressable,
14
- Animated,
15
- Easing,
16
+ // The press-scale comes from `usePressAnimation`, which owns an RN
17
+ // `Animated.Value`; a reanimated `Animated.View` cannot consume one. The two
18
+ // namespaces coexist on purpose: reanimated drives the SHARED underline (the
19
+ // part that has to animate from a shared value, including on web), RN
20
+ // Animated drives the per-trigger press scale it already drove.
21
+ Animated as RNAnimated,
16
22
  ScrollView,
17
23
  type LayoutRectangle,
18
24
  type ViewStyle,
19
25
  type TextStyle,
20
26
  } from 'react-native';
27
+ import Animated, {
28
+ useAnimatedStyle,
29
+ useSharedValue,
30
+ withSpring,
31
+ withTiming,
32
+ type SharedValue,
33
+ } from 'react-native-reanimated';
21
34
 
22
35
  import { useTheme } from '../theme/use-theme';
23
36
  import { usePressAnimation } from '../hooks/usePressAnimation';
@@ -27,95 +40,297 @@ import type { TabsProps, TabsTriggerProps, TabsContentProps, TabsVariant } from
27
40
 
28
41
  type TriggerLayout = Pick<LayoutRectangle, 'x' | 'width'>;
29
42
 
43
+ /**
44
+ * Underline travel. The same spring the floating `TabBar` highlight uses, so the
45
+ * two strips read as one motion language; stated here rather than imported
46
+ * because they are separate component families and a shared constant would tie
47
+ * their feel together permanently.
48
+ */
49
+ const SLIDE_SPRING = { duration: 420, dampingRatio: 0.82 };
50
+
51
+ /** Visibility, not travel — see {@link TabsContextValue.indicatorOpacity}. */
52
+ const HIGHLIGHT_FADE = { duration: 160 };
53
+
54
+ /**
55
+ * Fraction of the distance to the neighbour a drag must cover before releasing
56
+ * commits. Below it the underline springs home and nothing navigates.
57
+ */
58
+ const COMMIT_THRESHOLD = 0.4;
59
+
60
+ /**
61
+ * How far the underline may stray past the first/last tab, and how heavily that
62
+ * travel is damped. A rubber-band is reserved for the edge, where "there is
63
+ * nothing there" is the true message; used anywhere else it would promise a
64
+ * neighbour that is about to arrive, which is the opposite of what happens.
65
+ */
66
+ const EDGE_RUBBER_BAND_DAMPING = 0.25;
67
+ const EDGE_RUBBER_BAND_MAX = 32;
68
+
69
+ /** Above this the count renders as "99+", so a large tally cannot stretch a tab. */
70
+ const MAX_DISPLAYED_COUNT = 99;
71
+
72
+ function formatCount(count: number): string {
73
+ return count > MAX_DISPLAYED_COUNT ? `${MAX_DISPLAYED_COUNT}+` : String(count);
74
+ }
75
+
30
76
  interface TabsContextValue {
31
- value: string;
32
- onValueChange: (value: string) => void;
77
+ /**
78
+ * The selected value on the CONTROLLED path, `undefined` on the focus-driven
79
+ * one. Which of the two is in play is the whole discriminator — see
80
+ * {@link TabsProps.value} — and it decides who may write the underline's
81
+ * shared values, so there is never more than one writer.
82
+ */
83
+ selectedValue: string | undefined;
84
+ onValueChange: ((value: string) => void) | undefined;
33
85
  variant: TabsVariant;
34
86
  fullWidth: boolean;
35
87
  /** A trigger reports its measured position so the shared underline can track it. */
36
88
  reportTriggerLayout: (value: string, layout: TriggerLayout) => void;
89
+ /** A trigger reports that the ROUTER considers it focused. */
90
+ reportFocused: (value: string) => void;
37
91
  }
38
92
 
39
- const TabsContext = createContext<TabsContextValue>({
40
- value: '',
41
- onValueChange: () => {},
42
- variant: 'underline',
43
- fullWidth: false,
44
- reportTriggerLayout: () => {},
45
- });
93
+ const TabsContext = createContext<TabsContextValue | null>(null);
46
94
 
47
- const TabsBarComponent: React.FC<TabsProps> = ({
48
- value,
49
- onValueChange,
50
- variant = 'underline',
51
- fullWidth = false,
52
- children,
53
- style,
54
- testID,
55
- }) => {
95
+ /**
96
+ * The imperative surface a horizontal drag gesture drives the underline through.
97
+ *
98
+ * It lives here rather than in the gesture's own code because every quantity the
99
+ * drag needs — where the neighbouring trigger is, how wide it is, whether there
100
+ * IS a neighbour — is measured layout the strip already holds and nothing
101
+ * outside it can see. The gesture supplies one number, the finger's delta; the
102
+ * strip decides what that means.
103
+ *
104
+ * Obtained via a `ref` on `Tabs`. The gesture belongs in the LAYOUT beside the
105
+ * strip, never inside a tab screen — a gesture mounted in a screen is destroyed
106
+ * by the navigation it commits, mid-release.
107
+ */
108
+ export interface TabsDragController {
109
+ /**
110
+ * Feed the raw horizontal finger delta (`translationX`).
111
+ *
112
+ * The underline travels OPPOSITE the finger, because that is where the
113
+ * incoming tab is: dragging left reveals the NEXT tab, which sits to the
114
+ * right. Travel is clamped at the neighbour — the underline stops exactly
115
+ * where it would land, so it never promises more than the release delivers —
116
+ * and at the first/last tab it rubber-bands instead.
117
+ *
118
+ * Returns the value that releasing NOW would commit, or `null` for "no
119
+ * commit": either the drag has not passed the threshold, or there is no
120
+ * neighbour in that direction.
121
+ */
122
+ drag(translationX: number): string | null;
123
+ /**
124
+ * End the drag. Pass the value `drag` last returned to keep the underline
125
+ * where the finger left it while the caller navigates; pass `null` to spring
126
+ * it home.
127
+ *
128
+ * On a commit the offset is FOLDED into the underline's base position rather
129
+ * than zeroed, so the focus change that follows springs from where the finger
130
+ * actually let go. Zeroing first would snap backwards for a frame and then
131
+ * animate forwards from the wrong place.
132
+ */
133
+ release(committed: string | null): void;
134
+ }
135
+
136
+ function useTabsContext(component: string): TabsContextValue {
137
+ const ctx = useContext(TabsContext);
138
+ if (!ctx) {
139
+ throw new Error(`${component} must be used within a Tabs`);
140
+ }
141
+ return ctx;
142
+ }
143
+
144
+ const TabsBarComponent = forwardRef<TabsDragController, TabsProps>(function TabsBar(
145
+ {
146
+ value,
147
+ onValueChange,
148
+ hasSelection = true,
149
+ variant = 'underline',
150
+ fullWidth = false,
151
+ children,
152
+ style,
153
+ testID,
154
+ },
155
+ dragRef,
156
+ ) {
56
157
  const theme = useTheme();
57
158
  const isUnderline = variant === 'underline';
58
159
 
59
- // Shared sliding underline: one indicator that translates + resizes between
60
- // triggers. Triggers report their measured {x, width}; the container drives
61
- // these Animated values imperatively (no re-render on measure or slide).
62
- const indicatorX = useRef(new Animated.Value(0)).current;
63
- const indicatorWidth = useRef(new Animated.Value(0)).current;
160
+ // One shared underline that translates + resizes between triggers. Triggers
161
+ // report their measured {x, width}; whoever owns the selection drives these
162
+ // shared values IMPERATIVELY, and `useAnimatedStyle` below only ever READS
163
+ // them. That split is what makes the animation work on web, where returning
164
+ // an animation from inside a mapper silently does nothing.
165
+ const indicatorX = useSharedValue(0);
166
+ const indicatorWidth = useSharedValue(0);
167
+ // Starts hidden: a strip whose active trigger has not been measured yet must
168
+ // not flash an underline at the origin on its first frame.
169
+ const indicatorOpacity = useSharedValue(0);
170
+ // Live drag, added to the settled position/width rather than replacing them,
171
+ // so a release can fold the delta into the base without a visible jump.
172
+ const dragOffset = useSharedValue(0);
173
+ const dragWidthDelta = useSharedValue(0);
174
+
64
175
  const triggerLayoutsRef = useRef<Record<string, TriggerLayout>>({});
65
176
  const indicatorPlacedRef = useRef(false);
177
+ const scrollRef = useRef<ScrollView>(null);
178
+ const viewportWidthRef = useRef(0);
179
+ // The value the underline currently belongs to, from EITHER path. A ref
180
+ // because the layout callback below must see the latest selection without
181
+ // being re-created — and therefore re-firing — on every selection change.
182
+ const selectedValueRef = useRef<string | undefined>(value);
66
183
 
67
184
  const moveIndicator = useCallback(
68
185
  (target: TriggerLayout, animate: boolean) => {
69
186
  if (animate) {
70
- Animated.parallel([
71
- Animated.timing(indicatorX, {
72
- toValue: target.x,
73
- duration: 200,
74
- easing: Easing.out(Easing.cubic),
75
- useNativeDriver: false,
76
- }),
77
- Animated.timing(indicatorWidth, {
78
- toValue: target.width,
79
- duration: 200,
80
- easing: Easing.out(Easing.cubic),
81
- useNativeDriver: false,
82
- }),
83
- ]).start();
187
+ indicatorX.value = withSpring(target.x, SLIDE_SPRING);
188
+ indicatorWidth.value = withSpring(target.width, SLIDE_SPRING);
84
189
  } else {
85
- indicatorX.setValue(target.x);
86
- indicatorWidth.setValue(target.width);
190
+ indicatorX.value = target.x;
191
+ indicatorWidth.value = target.width;
87
192
  }
193
+ indicatorOpacity.value = withTiming(1, HIGHLIGHT_FADE);
194
+ },
195
+ [indicatorX, indicatorWidth, indicatorOpacity],
196
+ );
197
+
198
+ // Keep the active tab in view when the strip overflows its viewport. Centring
199
+ // is what makes a ROUTED strip usable: arriving at a tab that is scrolled out
200
+ // of sight — via a deep link, a Back, or simply a long tab set — must not
201
+ // look like an empty selection.
202
+ const revealTrigger = useCallback(
203
+ (target: TriggerLayout, animate: boolean) => {
204
+ const viewport = viewportWidthRef.current;
205
+ if (fullWidth || viewport <= 0) return;
206
+ const centred = target.x + target.width / 2 - viewport / 2;
207
+ scrollRef.current?.scrollTo({ x: Math.max(0, centred), animated: animate });
208
+ },
209
+ [fullWidth],
210
+ );
211
+
212
+ const applySelection = useCallback(
213
+ (tabValue: string) => {
214
+ const target = triggerLayoutsRef.current[tabValue];
215
+ // Not measured yet — `reportTriggerLayout` places it on arrival.
216
+ if (!target) return;
217
+ const animate = indicatorPlacedRef.current;
218
+ moveIndicator(target, animate);
219
+ revealTrigger(target, animate);
220
+ indicatorPlacedRef.current = true;
88
221
  },
89
- [indicatorX, indicatorWidth],
222
+ [moveIndicator, revealTrigger],
90
223
  );
91
224
 
92
225
  const reportTriggerLayout = useCallback(
93
226
  (tabValue: string, layout: TriggerLayout) => {
94
227
  triggerLayoutsRef.current[tabValue] = layout;
95
- // Snap onto the active trigger the moment it's first measured (mount) or
228
+ // Snap onto the active trigger the moment it is first measured (mount), or
96
229
  // when its size changes — never slide in from the origin.
97
- if (tabValue === value) {
230
+ if (tabValue === selectedValueRef.current) {
98
231
  moveIndicator(layout, false);
232
+ revealTrigger(layout, false);
99
233
  indicatorPlacedRef.current = true;
100
234
  }
101
235
  },
102
- [value, moveIndicator],
236
+ [moveIndicator, revealTrigger],
237
+ );
238
+
239
+ const reportFocused = useCallback(
240
+ (tabValue: string) => {
241
+ selectedValueRef.current = tabValue;
242
+ applySelection(tabValue);
243
+ },
244
+ [applySelection],
103
245
  );
104
246
 
105
- // Slide the indicator to the newly-selected trigger. Syncing an imperative
106
- // Animated value to the controlled `value` prop is a legitimate effect
107
- // (external-system sync); the first placement snaps, later changes animate.
247
+ // CONTROLLED path only: the bar owns the underline when `value` is provided.
248
+ // Syncing an imperative animation to a controlled prop is a legitimate effect
249
+ // (external-system sync). On the focus-driven path each trigger reports
250
+ // instead, so this stays out of the way and there is only ever one writer.
251
+ useEffect(() => {
252
+ if (value === undefined) return;
253
+ selectedValueRef.current = value;
254
+ applySelection(value);
255
+ }, [value, applySelection]);
256
+
257
+ // Nothing is focused — fade out where it stands. Position is deliberately
258
+ // untouched: see `TabsProps.hasSelection`. Placing this AFTER the controlled
259
+ // effect is what lets a later re-selection snap back rather than travel,
260
+ // because `indicatorPlacedRef` is left alone here.
108
261
  useEffect(() => {
109
- if (!isUnderline) return;
110
- const target = triggerLayoutsRef.current[value];
111
- if (!target) return; // not measured yet — reportTriggerLayout will place it
112
- moveIndicator(target, indicatorPlacedRef.current);
113
- indicatorPlacedRef.current = true;
114
- }, [value, isUnderline, moveIndicator]);
262
+ if (hasSelection) return;
263
+ indicatorOpacity.value = withTiming(0, HIGHLIGHT_FADE);
264
+ }, [hasSelection, indicatorOpacity]);
265
+
266
+ useImperativeHandle(
267
+ dragRef,
268
+ (): TabsDragController => ({
269
+ drag(translationX) {
270
+ const current = selectedValueRef.current;
271
+ if (current === undefined) return null;
272
+ // Ordered by measured position, so "the neighbour" means the tab next
273
+ // to it ON SCREEN. Deriving it from child order instead would be wrong
274
+ // the moment a caller reorders or conditionally renders a trigger.
275
+ const ordered = Object.entries(triggerLayoutsRef.current).sort(
276
+ (a, b) => a[1].x - b[1].x,
277
+ );
278
+ const index = ordered.findIndex(([tabValue]) => tabValue === current);
279
+ const from = ordered[index]?.[1];
280
+ if (from === undefined) return null;
281
+
282
+ // Opposite the finger: dragging LEFT reveals the NEXT tab, which is to
283
+ // the right, so that is where the underline goes.
284
+ const travel = -translationX;
285
+ const neighbour = ordered[index + (travel > 0 ? 1 : -1)];
286
+
287
+ if (neighbour === undefined) {
288
+ // First or last tab. Resist, never commit — the rubber-band is the
289
+ // honest signal that there is nothing in that direction.
290
+ const damped = Math.min(
291
+ Math.abs(travel) * EDGE_RUBBER_BAND_DAMPING,
292
+ EDGE_RUBBER_BAND_MAX,
293
+ );
294
+ dragOffset.value = Math.sign(travel) * damped;
295
+ dragWidthDelta.value = 0;
296
+ return null;
297
+ }
298
+
299
+ const [neighbourValue, to] = neighbour;
300
+ const distance = to.x - from.x;
301
+ // Clamped at the neighbour: the underline stops exactly where releasing
302
+ // would leave it, so it never promises travel the commit cannot deliver.
303
+ const progress = Math.min(Math.abs(travel) / Math.abs(distance), 1);
304
+ dragOffset.value = distance * progress;
305
+ dragWidthDelta.value = (to.width - from.width) * progress;
306
+ return progress >= COMMIT_THRESHOLD ? neighbourValue : null;
307
+ },
308
+ release(committed) {
309
+ if (committed === null) {
310
+ dragOffset.value = withSpring(0, SLIDE_SPRING);
311
+ dragWidthDelta.value = withSpring(0, SLIDE_SPRING);
312
+ return;
313
+ }
314
+ // Fold, do not zero — see `TabsDragController.release`.
315
+ indicatorX.value += dragOffset.value;
316
+ indicatorWidth.value += dragWidthDelta.value;
317
+ dragOffset.value = 0;
318
+ dragWidthDelta.value = 0;
319
+ },
320
+ }),
321
+ [dragOffset, dragWidthDelta, indicatorX, indicatorWidth],
322
+ );
115
323
 
116
324
  const contextValue = useMemo(
117
- () => ({ value, onValueChange, variant, fullWidth, reportTriggerLayout }),
118
- [value, onValueChange, variant, fullWidth, reportTriggerLayout],
325
+ (): TabsContextValue => ({
326
+ selectedValue: value,
327
+ onValueChange,
328
+ variant,
329
+ fullWidth,
330
+ reportTriggerLayout,
331
+ reportFocused,
332
+ }),
333
+ [value, onValueChange, variant, fullWidth, reportTriggerLayout, reportFocused],
119
334
  );
120
335
 
121
336
  const containerStyle = useMemo((): ViewStyle => {
@@ -145,19 +360,46 @@ const TabsBarComponent: React.FC<TabsProps> = ({
145
360
  return base;
146
361
  }, [variant, theme]);
147
362
 
363
+ // Deps: every shared value the mapper READS is listed. On web WITHOUT the
364
+ // react-native-worklets babel plugin — the production reality for Bloom's
365
+ // Vite consumers — reanimated cannot auto-detect a worklet's reads and drives
366
+ // the mapper off this array instead: omit one and the mapper runs ONCE and
367
+ // freezes at the first frame while the shared value keeps animating
368
+ // underneath, with no error anywhere. Native (plugin present) auto-tracks and
369
+ // ignores the extras, so listing them is correct on both platforms. Same rule
370
+ // as `TabBarBase` and `BottomSheetBase`. Do NOT strip these.
371
+ const indicatorStyle = useAnimatedStyle(
372
+ () => ({
373
+ // The drag deltas ADD to the settled values rather than replacing them,
374
+ // which is what lets a release fold them into the base with no jump.
375
+ width: Math.max(indicatorWidth.value + dragWidthDelta.value, 0),
376
+ opacity: indicatorOpacity.value,
377
+ transform: [{ translateX: indicatorX.value + dragOffset.value }],
378
+ }),
379
+ [indicatorWidth, dragWidthDelta, indicatorOpacity, indicatorX, dragOffset],
380
+ );
381
+
382
+ // The underline is a sibling of the triggers INSIDE the scrollable content,
383
+ // so the two share one coordinate space: a trigger's `onLayout` x is already
384
+ // relative to the container the underline is absolutely positioned in. That
385
+ // is why nothing here subtracts a scroll offset — an underline parked outside
386
+ // the scroller would have to, and would lag by a frame on every scroll event.
148
387
  const indicator = isUnderline ? (
149
388
  <Animated.View
150
389
  pointerEvents="none"
151
390
  testID={testID ? `${testID}-indicator` : undefined}
152
- style={{
153
- position: 'absolute',
154
- left: 0,
155
- bottom: 0,
156
- height: 2,
157
- backgroundColor: theme.colors.primary,
158
- width: indicatorWidth,
159
- transform: [{ translateX: indicatorX }],
160
- }}
391
+ style={[
392
+ {
393
+ position: 'absolute',
394
+ left: 0,
395
+ bottom: 0,
396
+ height: 2,
397
+ borderTopLeftRadius: 2,
398
+ borderTopRightRadius: 2,
399
+ backgroundColor: theme.colors.primary,
400
+ },
401
+ indicatorStyle,
402
+ ]}
161
403
  />
162
404
  ) : null;
163
405
 
@@ -170,9 +412,13 @@ const TabsBarComponent: React.FC<TabsProps> = ({
170
412
  </View>
171
413
  ) : (
172
414
  <ScrollView
415
+ ref={scrollRef}
173
416
  horizontal
174
417
  showsHorizontalScrollIndicator={false}
175
418
  contentContainerStyle={[containerStyle, style]}
419
+ onLayout={(e) => {
420
+ viewportWidthRef.current = e.nativeEvent.layout.width;
421
+ }}
176
422
  testID={testID}
177
423
  >
178
424
  {children}
@@ -181,32 +427,53 @@ const TabsBarComponent: React.FC<TabsProps> = ({
181
427
  )}
182
428
  </TabsContext.Provider>
183
429
  );
184
- };
430
+ });
185
431
 
186
432
  const TabComponent: React.FC<TabsTriggerProps> = ({
187
433
  value,
188
434
  label,
189
435
  icon,
436
+ count,
437
+ isFocused,
190
438
  disabled = false,
439
+ onPress: onPressProp,
191
440
  style,
192
441
  textStyle,
193
442
  }) => {
194
443
  const theme = useTheme();
195
444
  const {
196
- value: selectedValue,
445
+ selectedValue,
197
446
  onValueChange,
198
447
  variant,
199
448
  fullWidth,
200
449
  reportTriggerLayout,
201
- } = useContext(TabsContext);
202
- const isSelected = value === selectedValue;
450
+ reportFocused,
451
+ } = useTabsContext('TabsTrigger');
452
+ // The two paths meet here: an explicit `isFocused` (router adapter) wins;
453
+ // otherwise selection comes from the bar's controlled `value`.
454
+ const isSelected = isFocused ?? value === selectedValue;
203
455
  const { scaleAnim, onPressIn, onPressOut } = usePressAnimation(0.97);
456
+ const resolvedCount = count ?? 0;
457
+ const showCount = resolvedCount > 0;
458
+
459
+ // FOCUS-DRIVEN path only. This covers programmatic navigation too — a deep
460
+ // link, a browser Back, a back gesture — because nothing here asks HOW the
461
+ // change happened: the trigger simply re-renders focused and the underline
462
+ // follows. On the controlled path the bar drives the underline instead, so
463
+ // this stays out of the way to avoid two writers on one shared value.
464
+ useEffect(() => {
465
+ if (isFocused !== true) return;
466
+ reportFocused(value);
467
+ }, [isFocused, value, reportFocused]);
204
468
 
205
469
  const handlePress = useCallback(() => {
206
- if (!disabled) {
207
- onValueChange(value);
208
- }
209
- }, [value, disabled, onValueChange]);
470
+ if (disabled) return;
471
+ onPressProp?.();
472
+ // On the focus-driven path navigation is the caller's job (the router
473
+ // adapter's trigger performs it), so reporting a selection here as well
474
+ // would fight the router for the same underline.
475
+ if (isFocused === undefined) onValueChange?.(value);
476
+ }, [value, disabled, onValueChange, onPressProp, isFocused]);
210
477
 
211
478
  const tabStyle = useMemo((): ViewStyle => {
212
479
  const base: ViewStyle = {
@@ -260,7 +527,7 @@ const TabComponent: React.FC<TabsTriggerProps> = ({
260
527
  }, [variant, isSelected, theme]);
261
528
 
262
529
  return (
263
- <Animated.View
530
+ <RNAnimated.View
264
531
  onLayout={(e) => {
265
532
  const { x, width } = e.nativeEvent.layout;
266
533
  reportTriggerLayout(value, { x, width });
@@ -274,18 +541,26 @@ const TabComponent: React.FC<TabsTriggerProps> = ({
274
541
  onPressOut={onPressOut}
275
542
  disabled={disabled}
276
543
  accessibilityRole="tab"
544
+ accessibilityLabel={showCount ? `${label}, ${resolvedCount}` : label}
277
545
  accessibilityState={{ selected: isSelected, disabled }}
278
546
  >
279
547
  {icon}
280
548
  <Text style={[labelStyle, textStyle]}>{label}</Text>
549
+ {showCount ? (
550
+ <Text
551
+ style={{ fontSize: 11, fontWeight: '600', color: theme.colors.textTertiary }}
552
+ numberOfLines={1}
553
+ >
554
+ {formatCount(resolvedCount)}
555
+ </Text>
556
+ ) : null}
281
557
  </Pressable>
282
- </Animated.View>
558
+ </RNAnimated.View>
283
559
  );
284
560
  };
285
561
 
286
562
  const TabPanelComponent: React.FC<TabsContentProps> = ({ value, children, style }) => {
287
- const { value: selectedValue } = useContext(TabsContext);
288
-
563
+ const { selectedValue } = useTabsContext('TabsContent');
289
564
  if (value !== selectedValue) return null;
290
565
 
291
566
  return <View style={style}>{children}</View>;