@oxyhq/bloom 0.79.1 → 0.81.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 (99) 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 +409 -71
  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/commonjs/theme/color-engine/index.js +6 -0
  12. package/lib/commonjs/theme/color-engine/index.js.map +1 -1
  13. package/lib/commonjs/theme/color-engine/scheme-variants.js +29 -1
  14. package/lib/commonjs/theme/color-engine/scheme-variants.js.map +1 -1
  15. package/lib/commonjs/theme/color-policy.js +54 -10
  16. package/lib/commonjs/theme/color-policy.js.map +1 -1
  17. package/lib/commonjs/theme/color-presets.js +14 -2
  18. package/lib/commonjs/theme/color-presets.js.map +1 -1
  19. package/lib/commonjs/theme/color-scope/seed-scope.js +5 -1
  20. package/lib/commonjs/theme/color-scope/seed-scope.js.map +1 -1
  21. package/lib/module/scroll/index.web.js +52 -18
  22. package/lib/module/scroll/index.web.js.map +1 -1
  23. package/lib/module/scroll/scrollable.web.js +9 -3
  24. package/lib/module/scroll/scrollable.web.js.map +1 -1
  25. package/lib/module/tabs/Tabs.js +417 -73
  26. package/lib/module/tabs/Tabs.js.map +1 -1
  27. package/lib/module/tabs/expo-router/RouterTabs.js +210 -0
  28. package/lib/module/tabs/expo-router/RouterTabs.js.map +1 -0
  29. package/lib/module/tabs/expo-router/index.js +10 -0
  30. package/lib/module/tabs/expo-router/index.js.map +1 -0
  31. package/lib/module/theme/color-engine/index.js +1 -1
  32. package/lib/module/theme/color-engine/index.js.map +1 -1
  33. package/lib/module/theme/color-engine/scheme-variants.js +28 -1
  34. package/lib/module/theme/color-engine/scheme-variants.js.map +1 -1
  35. package/lib/module/theme/color-policy.js +53 -10
  36. package/lib/module/theme/color-policy.js.map +1 -1
  37. package/lib/module/theme/color-presets.js +14 -2
  38. package/lib/module/theme/color-presets.js.map +1 -1
  39. package/lib/module/theme/color-scope/seed-scope.js +6 -2
  40. package/lib/module/theme/color-scope/seed-scope.js.map +1 -1
  41. package/lib/typescript/commonjs/scroll/index.web.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/scroll/scrollable.web.d.ts +18 -5
  43. package/lib/typescript/commonjs/scroll/scrollable.web.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/tabs/Tabs.d.ts +41 -1
  45. package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts +80 -0
  47. package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts.map +1 -0
  48. package/lib/typescript/commonjs/tabs/expo-router/index.d.ts +9 -0
  49. package/lib/typescript/commonjs/tabs/expo-router/index.d.ts.map +1 -0
  50. package/lib/typescript/commonjs/tabs/types.d.ts +48 -4
  51. package/lib/typescript/commonjs/tabs/types.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/theme/color-engine/index.d.ts +1 -1
  53. package/lib/typescript/commonjs/theme/color-engine/index.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts +13 -1
  55. package/lib/typescript/commonjs/theme/color-engine/scheme-variants.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/theme/color-policy.d.ts +6 -0
  57. package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/theme/color-presets.d.ts +1 -1
  59. package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/theme/color-scope/seed-scope.d.ts.map +1 -1
  61. package/lib/typescript/module/scroll/index.web.d.ts.map +1 -1
  62. package/lib/typescript/module/scroll/scrollable.web.d.ts +18 -5
  63. package/lib/typescript/module/scroll/scrollable.web.d.ts.map +1 -1
  64. package/lib/typescript/module/tabs/Tabs.d.ts +41 -1
  65. package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
  66. package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts +80 -0
  67. package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts.map +1 -0
  68. package/lib/typescript/module/tabs/expo-router/index.d.ts +9 -0
  69. package/lib/typescript/module/tabs/expo-router/index.d.ts.map +1 -0
  70. package/lib/typescript/module/tabs/types.d.ts +48 -4
  71. package/lib/typescript/module/tabs/types.d.ts.map +1 -1
  72. package/lib/typescript/module/theme/color-engine/index.d.ts +1 -1
  73. package/lib/typescript/module/theme/color-engine/index.d.ts.map +1 -1
  74. package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts +13 -1
  75. package/lib/typescript/module/theme/color-engine/scheme-variants.d.ts.map +1 -1
  76. package/lib/typescript/module/theme/color-policy.d.ts +6 -0
  77. package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
  78. package/lib/typescript/module/theme/color-presets.d.ts +1 -1
  79. package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
  80. package/lib/typescript/module/theme/color-scope/seed-scope.d.ts.map +1 -1
  81. package/package.json +15 -1
  82. package/src/__tests__/Tabs.test.tsx +470 -7
  83. package/src/__tests__/optional-peer-imports.test.ts +5 -0
  84. package/src/__tests__/scroll-web.test.tsx +255 -2
  85. package/src/__tests__/theme.test.ts +2 -1
  86. package/src/scroll/index.web.tsx +55 -19
  87. package/src/scroll/scrollable.web.ts +30 -8
  88. package/src/tabs/Tabs.tsx +518 -83
  89. package/src/tabs/expo-router/RouterTabs.tsx +268 -0
  90. package/src/tabs/expo-router/index.ts +8 -0
  91. package/src/tabs/types.ts +48 -4
  92. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +120 -0
  93. package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +70 -0
  94. package/src/theme/__tests__/policy-legibility.test.ts +12 -4
  95. package/src/theme/color-engine/index.ts +1 -1
  96. package/src/theme/color-engine/scheme-variants.ts +38 -1
  97. package/src/theme/color-policy.ts +68 -14
  98. package/src/theme/color-presets.ts +11 -2
  99. package/src/theme/color-scope/seed-scope.ts +6 -2
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,427 @@ import type { TabsProps, TabsTriggerProps, TabsContentProps, TabsVariant } from
27
40
 
28
41
  type TriggerLayout = Pick<LayoutRectangle, 'x' | 'width'>;
29
42
 
43
+ /**
44
+ * Re-read ONE trigger's geometry from the platform and report it back.
45
+ *
46
+ * A callback rather than a return value because measuring is asynchronous on
47
+ * both platforms: react-native-web defers to a `setTimeout(0)` inside
48
+ * `UIManager.measure`, and native hops to the UI thread. Each trigger keeps its
49
+ * own host ref and hands the strip this instead, so the registry says exactly
50
+ * what it needs — a position on demand — and nothing about what a trigger
51
+ * renders.
52
+ */
53
+ type TriggerMeasure = (report: (layout: TriggerLayout) => void) => void;
54
+
55
+ /**
56
+ * Underline travel. The same spring the floating `TabBar` highlight uses, so the
57
+ * two strips read as one motion language; stated here rather than imported
58
+ * because they are separate component families and a shared constant would tie
59
+ * their feel together permanently.
60
+ */
61
+ const SLIDE_SPRING = { duration: 420, dampingRatio: 0.82 };
62
+
63
+ /** Visibility, not travel — the underline fades, it does not slide, in and out. */
64
+ const HIGHLIGHT_FADE = { duration: 160 };
65
+
66
+ /**
67
+ * Fraction of the distance to the neighbour a drag must cover before releasing
68
+ * commits. Below it the underline springs home and nothing navigates.
69
+ */
70
+ const COMMIT_THRESHOLD = 0.4;
71
+
72
+ /**
73
+ * How far the underline may stray past the first/last tab, and how heavily that
74
+ * travel is damped. A rubber-band is reserved for the edge, where "there is
75
+ * nothing there" is the true message; used anywhere else it would promise a
76
+ * neighbour that is about to arrive, which is the opposite of what happens.
77
+ */
78
+ const EDGE_RUBBER_BAND_DAMPING = 0.25;
79
+ const EDGE_RUBBER_BAND_MAX = 32;
80
+
81
+ /** Above this the count renders as "99+", so a large tally cannot stretch a tab. */
82
+ const MAX_DISPLAYED_COUNT = 99;
83
+
84
+ function formatCount(count: number): string {
85
+ return count > MAX_DISPLAYED_COUNT ? `${MAX_DISPLAYED_COUNT}+` : String(count);
86
+ }
87
+
30
88
  interface TabsContextValue {
31
- value: string;
32
- onValueChange: (value: string) => void;
89
+ /**
90
+ * The selected value on the CONTROLLED path, `undefined` on the focus-driven
91
+ * one. Which of the two is in play is the whole discriminator — see
92
+ * {@link TabsProps.value} — and it decides who may write the underline's
93
+ * shared values, so there is never more than one writer.
94
+ */
95
+ selectedValue: string | undefined;
96
+ onValueChange: ((value: string) => void) | undefined;
33
97
  variant: TabsVariant;
34
98
  fullWidth: boolean;
35
- /** A trigger reports its measured position so the shared underline can track it. */
99
+ /**
100
+ * A trigger hands the strip a way to RE-READ its own geometry, and takes it
101
+ * back on unmount. See `remeasureTriggers` for why the strip cannot simply
102
+ * keep whatever `onLayout` last reported.
103
+ */
104
+ registerTrigger: (value: string, measure: TriggerMeasure) => () => void;
105
+ /** A trigger reports the geometry its own `onLayout` just handed it. */
36
106
  reportTriggerLayout: (value: string, layout: TriggerLayout) => void;
107
+ /** A trigger reports that the ROUTER considers it focused. */
108
+ reportFocused: (value: string) => void;
37
109
  }
38
110
 
39
- const TabsContext = createContext<TabsContextValue>({
40
- value: '',
41
- onValueChange: () => {},
42
- variant: 'underline',
43
- fullWidth: false,
44
- reportTriggerLayout: () => {},
45
- });
111
+ const TabsContext = createContext<TabsContextValue | null>(null);
46
112
 
47
- const TabsBarComponent: React.FC<TabsProps> = ({
48
- value,
49
- onValueChange,
50
- variant = 'underline',
51
- fullWidth = false,
52
- children,
53
- style,
54
- testID,
55
- }) => {
113
+ /**
114
+ * The imperative surface a horizontal drag gesture drives the underline through.
115
+ *
116
+ * It lives here rather than in the gesture's own code because every quantity the
117
+ * drag needs — where the neighbouring trigger is, how wide it is, whether there
118
+ * IS a neighbour — is measured layout the strip already holds and nothing
119
+ * outside it can see. The gesture supplies one number, the finger's delta; the
120
+ * strip decides what that means.
121
+ *
122
+ * Obtained via a `ref` on `Tabs`. The gesture belongs in the LAYOUT beside the
123
+ * strip, never inside a tab screen — a gesture mounted in a screen is destroyed
124
+ * by the navigation it commits, mid-release.
125
+ */
126
+ export interface TabsDragController {
127
+ /**
128
+ * Feed the raw horizontal finger delta (`translationX`).
129
+ *
130
+ * The underline travels OPPOSITE the finger, because that is where the
131
+ * incoming tab is: dragging left reveals the NEXT tab, which sits to the
132
+ * right. Travel is clamped at the neighbour — the underline stops exactly
133
+ * where it would land, so it never promises more than the release delivers —
134
+ * and at the first/last tab it rubber-bands instead.
135
+ *
136
+ * Returns the value that releasing NOW would commit, or `null` for "no
137
+ * commit": either the drag has not passed the threshold, or there is no
138
+ * neighbour in that direction.
139
+ */
140
+ drag(translationX: number): string | null;
141
+ /**
142
+ * End the drag. Pass the value `drag` last returned to keep the underline
143
+ * where the finger left it while the caller navigates; pass `null` to spring
144
+ * it home.
145
+ *
146
+ * On a commit the offset is FOLDED into the underline's base position rather
147
+ * than zeroed, so the focus change that follows springs from where the finger
148
+ * actually let go. Zeroing first would snap backwards for a frame and then
149
+ * animate forwards from the wrong place.
150
+ */
151
+ release(committed: string | null): void;
152
+ }
153
+
154
+ function useTabsContext(component: string): TabsContextValue {
155
+ const ctx = useContext(TabsContext);
156
+ if (!ctx) {
157
+ throw new Error(`${component} must be used within a Tabs`);
158
+ }
159
+ return ctx;
160
+ }
161
+
162
+ const TabsBarComponent = forwardRef<TabsDragController, TabsProps>(function TabsBar(
163
+ {
164
+ value,
165
+ onValueChange,
166
+ hasSelection = true,
167
+ variant = 'underline',
168
+ fullWidth = false,
169
+ children,
170
+ style,
171
+ testID,
172
+ },
173
+ dragRef,
174
+ ) {
56
175
  const theme = useTheme();
57
176
  const isUnderline = variant === 'underline';
58
177
 
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;
178
+ // One shared underline that translates + resizes between triggers. Triggers
179
+ // report their measured {x, width}; whoever owns the selection drives these
180
+ // shared values IMPERATIVELY, and `useAnimatedStyle` below only ever READS
181
+ // them. That split is what makes the animation work on web, where returning
182
+ // an animation from inside a mapper silently does nothing.
183
+ const indicatorX = useSharedValue(0);
184
+ const indicatorWidth = useSharedValue(0);
185
+ // Starts hidden: a strip whose active trigger has not been measured yet must
186
+ // not flash an underline at the origin on its first frame.
187
+ const indicatorOpacity = useSharedValue(0);
188
+ // Live drag, added to the settled position/width rather than replacing them,
189
+ // so a release can fold the delta into the base without a visible jump.
190
+ const dragOffset = useSharedValue(0);
191
+ const dragWidthDelta = useSharedValue(0);
192
+
64
193
  const triggerLayoutsRef = useRef<Record<string, TriggerLayout>>({});
194
+ // How to ask each trigger where it is NOW, keyed by value. Registration order
195
+ // carries no meaning — everything that consumes the geometry orders itself by
196
+ // measured `x`, which is the only ordering that survives a reorder.
197
+ const triggerMeasuresRef = useRef(new Map<string, TriggerMeasure>());
198
+ const remeasureScheduledRef = useRef(false);
65
199
  const indicatorPlacedRef = useRef(false);
200
+ const scrollRef = useRef<ScrollView>(null);
201
+ const viewportWidthRef = useRef(0);
202
+ // The value the underline currently belongs to, from EITHER path. A ref
203
+ // because the layout callback below must see the latest selection without
204
+ // being re-created — and therefore re-firing — on every selection change.
205
+ const selectedValueRef = useRef<string | undefined>(value);
66
206
 
67
207
  const moveIndicator = useCallback(
68
208
  (target: TriggerLayout, animate: boolean) => {
69
209
  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();
210
+ indicatorX.value = withSpring(target.x, SLIDE_SPRING);
211
+ indicatorWidth.value = withSpring(target.width, SLIDE_SPRING);
84
212
  } else {
85
- indicatorX.setValue(target.x);
86
- indicatorWidth.setValue(target.width);
213
+ indicatorX.value = target.x;
214
+ indicatorWidth.value = target.width;
87
215
  }
216
+ // Reveal only while something IS selected. Geometry keeps arriving while
217
+ // a non-tab sibling route is showing — the measurement pass below is
218
+ // precisely what makes it arrive — and fading the underline back in there
219
+ // would re-assert a tab the reader has left. Read from the closure rather
220
+ // than a ref: a ref written in an effect is still stale at this point,
221
+ // because a child's effect runs before its parent's.
222
+ if (!hasSelection) return;
223
+ indicatorOpacity.value = withTiming(1, HIGHLIGHT_FADE);
88
224
  },
89
- [indicatorX, indicatorWidth],
225
+ [indicatorX, indicatorWidth, indicatorOpacity, hasSelection],
90
226
  );
91
227
 
92
- const reportTriggerLayout = useCallback(
228
+ // Keep the active tab in view when the strip overflows its viewport. Centring
229
+ // is what makes a ROUTED strip usable: arriving at a tab that is scrolled out
230
+ // of sight — via a deep link, a Back, or simply a long tab set — must not
231
+ // look like an empty selection.
232
+ const revealTrigger = useCallback(
233
+ (target: TriggerLayout, animate: boolean) => {
234
+ const viewport = viewportWidthRef.current;
235
+ if (fullWidth || viewport <= 0) return;
236
+ const centred = target.x + target.width / 2 - viewport / 2;
237
+ scrollRef.current?.scrollTo({ x: Math.max(0, centred), animated: animate });
238
+ },
239
+ [fullWidth],
240
+ );
241
+
242
+ const applySelection = useCallback(
243
+ (tabValue: string) => {
244
+ const target = triggerLayoutsRef.current[tabValue];
245
+ // Not measured yet — `reportTriggerLayout` places it on arrival.
246
+ if (!target) return;
247
+ const animate = indicatorPlacedRef.current;
248
+ moveIndicator(target, animate);
249
+ revealTrigger(target, animate);
250
+ indicatorPlacedRef.current = true;
251
+ },
252
+ [moveIndicator, revealTrigger],
253
+ );
254
+
255
+ /**
256
+ * Record where a trigger is, from whichever source measured it, and keep the
257
+ * underline glued to it.
258
+ *
259
+ * Placement here never animates: the tab did not become SELECTED, it MOVED,
260
+ * and it moved instantly because nothing animates a strip's reflow. Sliding
261
+ * the underline across would read as a selection change that never happened.
262
+ */
263
+ const applyTriggerLayout = useCallback(
93
264
  (tabValue: string, layout: TriggerLayout) => {
265
+ const previous = triggerLayoutsRef.current[tabValue];
266
+ // Unchanged geometry must be a no-op, not a re-place: a measurement pass
267
+ // runs after every render, and re-placing would cancel a selection slide
268
+ // mid-flight for no reason.
269
+ if (previous !== undefined && previous.x === layout.x && previous.width === layout.width) {
270
+ return;
271
+ }
94
272
  triggerLayoutsRef.current[tabValue] = layout;
95
- // Snap onto the active trigger the moment it's first measured (mount) or
273
+ if (tabValue !== selectedValueRef.current) return;
274
+ moveIndicator(layout, false);
275
+ revealTrigger(layout, false);
276
+ indicatorPlacedRef.current = true;
277
+ },
278
+ [moveIndicator, revealTrigger],
279
+ );
280
+
281
+ /**
282
+ * Re-read EVERY trigger's position from the platform.
283
+ *
284
+ * This exists because `onLayout` cannot be trusted to report a MOVE. On
285
+ * react-native-web it is backed by a single `ResizeObserver`
286
+ * (`modules/useElementLayout`), which fires for a SIZE change and never for a
287
+ * position-only one — so a trigger inserted, removed or reordered after first
288
+ * layout shifts every trigger after it while not one of them re-reports, and
289
+ * the underline stays where the stale numbers put it: silently, one tab off,
290
+ * only on the first paint after an async tab list lands. A tab whose own
291
+ * width changes has the same effect on its neighbours.
292
+ *
293
+ * So `onLayout` is demoted to a change SIGNAL and the geometry is read back
294
+ * explicitly, through the `measure` both platforms put on a host view ref. It
295
+ * answers in the view's PARENT coordinate space, which is the space the
296
+ * underline is positioned in and the same one `onLayout` reports — the two
297
+ * sources cannot disagree about what they mean.
298
+ */
299
+ const remeasureTriggers = useCallback(() => {
300
+ for (const [tabValue, measure] of triggerMeasuresRef.current) {
301
+ measure((layout) => {
302
+ // A strip that is mounted but not laid out — a `display: none` ancestor
303
+ // on web, an unmeasured subtree on native — measures as zero, and a
304
+ // trigger cannot genuinely be zero-wide (it always carries horizontal
305
+ // padding). Recording that would collapse the underline and throw away
306
+ // the real geometry, so the last known position stands until it is on
307
+ // screen again.
308
+ if (layout.width <= 0) return;
309
+ applyTriggerLayout(tabValue, layout);
310
+ });
311
+ }
312
+ }, [applyTriggerLayout]);
313
+
314
+ const scheduleRemeasure = useCallback(() => {
315
+ // One pass per turn, however many signals arrive: a mount registers N
316
+ // triggers and reports N layouts, which is N+1 reasons to measure the same
317
+ // frame. A microtask, so the pass is queued before the browser paints the
318
+ // frame the change landed in.
319
+ if (remeasureScheduledRef.current) return;
320
+ remeasureScheduledRef.current = true;
321
+ queueMicrotask(() => {
322
+ remeasureScheduledRef.current = false;
323
+ remeasureTriggers();
324
+ });
325
+ }, [remeasureTriggers]);
326
+
327
+ const registerTrigger = useCallback((tabValue: string, measure: TriggerMeasure) => {
328
+ triggerMeasuresRef.current.set(tabValue, measure);
329
+ return () => {
330
+ triggerMeasuresRef.current.delete(tabValue);
331
+ // A tab that is gone must not keep a position in the geometry the drag
332
+ // controller orders itself by, or a swipe can commit to a trigger that is
333
+ // no longer on screen.
334
+ delete triggerLayoutsRef.current[tabValue];
335
+ };
336
+ }, []);
337
+
338
+ const reportTriggerLayout = useCallback(
339
+ (tabValue: string, layout: TriggerLayout) => {
340
+ // Snap onto the active trigger the moment it is first measured (mount), or
96
341
  // when its size changes — never slide in from the origin.
97
- if (tabValue === value) {
98
- moveIndicator(layout, false);
99
- indicatorPlacedRef.current = true;
100
- }
342
+ applyTriggerLayout(tabValue, layout);
343
+ // One trigger changing size moves every trigger after it, and not one of
344
+ // them will say so.
345
+ scheduleRemeasure();
346
+ },
347
+ [applyTriggerLayout, scheduleRemeasure],
348
+ );
349
+
350
+ const reportFocused = useCallback(
351
+ (tabValue: string) => {
352
+ selectedValueRef.current = tabValue;
353
+ applySelection(tabValue);
101
354
  },
102
- [value, moveIndicator],
355
+ [applySelection],
103
356
  );
104
357
 
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.
358
+ // CONTROLLED path only: the bar owns the underline when `value` is provided.
359
+ // Syncing an imperative animation to a controlled prop is a legitimate effect
360
+ // (external-system sync). On the focus-driven path each trigger reports
361
+ // instead, so this stays out of the way and there is only ever one writer.
362
+ useEffect(() => {
363
+ if (value === undefined) return;
364
+ selectedValueRef.current = value;
365
+ applySelection(value);
366
+ }, [value, applySelection]);
367
+
368
+ // Nothing is focused — fade out where it stands. Position is deliberately
369
+ // untouched: see `TabsProps.hasSelection`. Placing this AFTER the controlled
370
+ // effect is what lets a later re-selection snap back rather than travel,
371
+ // because `indicatorPlacedRef` is left alone here.
108
372
  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]);
373
+ if (hasSelection) return;
374
+ indicatorOpacity.value = withTiming(0, HIGHLIGHT_FADE);
375
+ }, [hasSelection, indicatorOpacity]);
376
+
377
+ // Every render of the strip is a render in which its contents may have MOVED:
378
+ // a trigger inserted, removed or reordered, or one re-rendered at a new size.
379
+ // Unconditional on purpose, rather than keyed on a signature of `children` —
380
+ // any such signature has to be built from React keys or child props, both of
381
+ // which the caller controls and neither of which is obliged to change on a
382
+ // reorder. A pass writes nothing when nothing moved, so being wrong in this
383
+ // direction costs a handful of reads and being wrong in the other direction
384
+ // is the bug.
385
+ useEffect(scheduleRemeasure);
386
+
387
+ useImperativeHandle(
388
+ dragRef,
389
+ (): TabsDragController => ({
390
+ drag(translationX) {
391
+ const current = selectedValueRef.current;
392
+ if (current === undefined) return null;
393
+ // Ordered by measured position, so "the neighbour" means the tab next
394
+ // to it ON SCREEN. Deriving it from child order instead would be wrong
395
+ // the moment a caller reorders or conditionally renders a trigger.
396
+ const ordered = Object.entries(triggerLayoutsRef.current).sort(
397
+ (a, b) => a[1].x - b[1].x,
398
+ );
399
+ const index = ordered.findIndex(([tabValue]) => tabValue === current);
400
+ const from = ordered[index]?.[1];
401
+ if (from === undefined) return null;
402
+
403
+ // Opposite the finger: dragging LEFT reveals the NEXT tab, which is to
404
+ // the right, so that is where the underline goes.
405
+ const travel = -translationX;
406
+ const neighbour = ordered[index + (travel > 0 ? 1 : -1)];
407
+
408
+ if (neighbour === undefined) {
409
+ // First or last tab. Resist, never commit — the rubber-band is the
410
+ // honest signal that there is nothing in that direction.
411
+ const damped = Math.min(
412
+ Math.abs(travel) * EDGE_RUBBER_BAND_DAMPING,
413
+ EDGE_RUBBER_BAND_MAX,
414
+ );
415
+ dragOffset.value = Math.sign(travel) * damped;
416
+ dragWidthDelta.value = 0;
417
+ return null;
418
+ }
419
+
420
+ const [neighbourValue, to] = neighbour;
421
+ const distance = to.x - from.x;
422
+ // Clamped at the neighbour: the underline stops exactly where releasing
423
+ // would leave it, so it never promises travel the commit cannot deliver.
424
+ const progress = Math.min(Math.abs(travel) / Math.abs(distance), 1);
425
+ dragOffset.value = distance * progress;
426
+ dragWidthDelta.value = (to.width - from.width) * progress;
427
+ return progress >= COMMIT_THRESHOLD ? neighbourValue : null;
428
+ },
429
+ release(committed) {
430
+ if (committed === null) {
431
+ dragOffset.value = withSpring(0, SLIDE_SPRING);
432
+ dragWidthDelta.value = withSpring(0, SLIDE_SPRING);
433
+ return;
434
+ }
435
+ // Fold, do not zero — see `TabsDragController.release`.
436
+ indicatorX.value += dragOffset.value;
437
+ indicatorWidth.value += dragWidthDelta.value;
438
+ dragOffset.value = 0;
439
+ dragWidthDelta.value = 0;
440
+ },
441
+ }),
442
+ [dragOffset, dragWidthDelta, indicatorX, indicatorWidth],
443
+ );
115
444
 
116
445
  const contextValue = useMemo(
117
- () => ({ value, onValueChange, variant, fullWidth, reportTriggerLayout }),
118
- [value, onValueChange, variant, fullWidth, reportTriggerLayout],
446
+ (): TabsContextValue => ({
447
+ selectedValue: value,
448
+ onValueChange,
449
+ variant,
450
+ fullWidth,
451
+ registerTrigger,
452
+ reportTriggerLayout,
453
+ reportFocused,
454
+ }),
455
+ [
456
+ value,
457
+ onValueChange,
458
+ variant,
459
+ fullWidth,
460
+ registerTrigger,
461
+ reportTriggerLayout,
462
+ reportFocused,
463
+ ],
119
464
  );
120
465
 
121
466
  const containerStyle = useMemo((): ViewStyle => {
@@ -145,19 +490,46 @@ const TabsBarComponent: React.FC<TabsProps> = ({
145
490
  return base;
146
491
  }, [variant, theme]);
147
492
 
493
+ // Deps: every shared value the mapper READS is listed. On web WITHOUT the
494
+ // react-native-worklets babel plugin — the production reality for Bloom's
495
+ // Vite consumers — reanimated cannot auto-detect a worklet's reads and drives
496
+ // the mapper off this array instead: omit one and the mapper runs ONCE and
497
+ // freezes at the first frame while the shared value keeps animating
498
+ // underneath, with no error anywhere. Native (plugin present) auto-tracks and
499
+ // ignores the extras, so listing them is correct on both platforms. Same rule
500
+ // as `TabBarBase` and `BottomSheetBase`. Do NOT strip these.
501
+ const indicatorStyle = useAnimatedStyle(
502
+ () => ({
503
+ // The drag deltas ADD to the settled values rather than replacing them,
504
+ // which is what lets a release fold them into the base with no jump.
505
+ width: Math.max(indicatorWidth.value + dragWidthDelta.value, 0),
506
+ opacity: indicatorOpacity.value,
507
+ transform: [{ translateX: indicatorX.value + dragOffset.value }],
508
+ }),
509
+ [indicatorWidth, dragWidthDelta, indicatorOpacity, indicatorX, dragOffset],
510
+ );
511
+
512
+ // The underline is a sibling of the triggers INSIDE the scrollable content,
513
+ // so the two share one coordinate space: a trigger's `onLayout` x is already
514
+ // relative to the container the underline is absolutely positioned in. That
515
+ // is why nothing here subtracts a scroll offset — an underline parked outside
516
+ // the scroller would have to, and would lag by a frame on every scroll event.
148
517
  const indicator = isUnderline ? (
149
518
  <Animated.View
150
519
  pointerEvents="none"
151
520
  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
- }}
521
+ style={[
522
+ {
523
+ position: 'absolute',
524
+ left: 0,
525
+ bottom: 0,
526
+ height: 2,
527
+ borderTopLeftRadius: 2,
528
+ borderTopRightRadius: 2,
529
+ backgroundColor: theme.colors.primary,
530
+ },
531
+ indicatorStyle,
532
+ ]}
161
533
  />
162
534
  ) : null;
163
535
 
@@ -170,9 +542,13 @@ const TabsBarComponent: React.FC<TabsProps> = ({
170
542
  </View>
171
543
  ) : (
172
544
  <ScrollView
545
+ ref={scrollRef}
173
546
  horizontal
174
547
  showsHorizontalScrollIndicator={false}
175
548
  contentContainerStyle={[containerStyle, style]}
549
+ onLayout={(e) => {
550
+ viewportWidthRef.current = e.nativeEvent.layout.width;
551
+ }}
176
552
  testID={testID}
177
553
  >
178
554
  {children}
@@ -181,32 +557,72 @@ const TabsBarComponent: React.FC<TabsProps> = ({
181
557
  )}
182
558
  </TabsContext.Provider>
183
559
  );
184
- };
560
+ });
185
561
 
186
562
  const TabComponent: React.FC<TabsTriggerProps> = ({
187
563
  value,
188
564
  label,
189
565
  icon,
566
+ count,
567
+ isFocused,
190
568
  disabled = false,
569
+ onPress: onPressProp,
191
570
  style,
192
571
  textStyle,
193
572
  }) => {
194
573
  const theme = useTheme();
195
574
  const {
196
- value: selectedValue,
575
+ selectedValue,
197
576
  onValueChange,
198
577
  variant,
199
578
  fullWidth,
579
+ registerTrigger,
200
580
  reportTriggerLayout,
201
- } = useContext(TabsContext);
202
- const isSelected = value === selectedValue;
581
+ reportFocused,
582
+ } = useTabsContext('TabsTrigger');
583
+ // The two paths meet here: an explicit `isFocused` (router adapter) wins;
584
+ // otherwise selection comes from the bar's controlled `value`.
585
+ const isSelected = isFocused ?? value === selectedValue;
203
586
  const { scaleAnim, onPressIn, onPressOut } = usePressAnimation(0.97);
587
+ const resolvedCount = count ?? 0;
588
+ const showCount = resolvedCount > 0;
589
+
590
+ // The trigger's own host view. It stays here rather than in the strip because
591
+ // the strip has no way to reach a child it did not create — it receives them
592
+ // as `children` — and because a trigger is the only thing that knows its own
593
+ // value, which is what the geometry has to be keyed by.
594
+ const nodeRef = useRef<View | null>(null);
595
+ const measureSelf = useCallback<TriggerMeasure>((report) => {
596
+ nodeRef.current?.measure((x, _y, width) => {
597
+ report({ x, width });
598
+ });
599
+ }, []);
600
+
601
+ // Registration IS the insertion/removal signal the strip acts on, so it must
602
+ // outlive nothing: the cleanup drops both the measure hook and the geometry.
603
+ useEffect(
604
+ () => registerTrigger(value, measureSelf),
605
+ [registerTrigger, value, measureSelf],
606
+ );
607
+
608
+ // FOCUS-DRIVEN path only. This covers programmatic navigation too — a deep
609
+ // link, a browser Back, a back gesture — because nothing here asks HOW the
610
+ // change happened: the trigger simply re-renders focused and the underline
611
+ // follows. On the controlled path the bar drives the underline instead, so
612
+ // this stays out of the way to avoid two writers on one shared value.
613
+ useEffect(() => {
614
+ if (isFocused !== true) return;
615
+ reportFocused(value);
616
+ }, [isFocused, value, reportFocused]);
204
617
 
205
618
  const handlePress = useCallback(() => {
206
- if (!disabled) {
207
- onValueChange(value);
208
- }
209
- }, [value, disabled, onValueChange]);
619
+ if (disabled) return;
620
+ onPressProp?.();
621
+ // On the focus-driven path navigation is the caller's job (the router
622
+ // adapter's trigger performs it), so reporting a selection here as well
623
+ // would fight the router for the same underline.
624
+ if (isFocused === undefined) onValueChange?.(value);
625
+ }, [value, disabled, onValueChange, onPressProp, isFocused]);
210
626
 
211
627
  const tabStyle = useMemo((): ViewStyle => {
212
628
  const base: ViewStyle = {
@@ -260,7 +676,8 @@ const TabComponent: React.FC<TabsTriggerProps> = ({
260
676
  }, [variant, isSelected, theme]);
261
677
 
262
678
  return (
263
- <Animated.View
679
+ <RNAnimated.View
680
+ ref={nodeRef}
264
681
  onLayout={(e) => {
265
682
  const { x, width } = e.nativeEvent.layout;
266
683
  reportTriggerLayout(value, { x, width });
@@ -274,18 +691,36 @@ const TabComponent: React.FC<TabsTriggerProps> = ({
274
691
  onPressOut={onPressOut}
275
692
  disabled={disabled}
276
693
  accessibilityRole="tab"
277
- accessibilityState={{ selected: isSelected, disabled }}
694
+ accessibilityLabel={showCount ? `${label}, ${resolvedCount}` : label}
695
+ // `aria-selected`, and NOT a web-only spelling of `accessibilityState`.
696
+ // react-native-web's `createDOMProps` reads `aria-selected` (or the
697
+ // deprecated `accessibilitySelected`) and does not look at
698
+ // `accessibilityState` at all, so a strip that set only the latter
699
+ // announced no selection on web at all — every tab equally current.
700
+ // React Native's own `Pressable` folds `aria-selected` back into
701
+ // `accessibilityState.selected`, so this one prop serves both platforms
702
+ // and there is no second place for the answer to disagree. `disabled`
703
+ // needs no counterpart: both Pressables already derive that state from
704
+ // the `disabled` prop above.
705
+ aria-selected={isSelected}
278
706
  >
279
707
  {icon}
280
708
  <Text style={[labelStyle, textStyle]}>{label}</Text>
709
+ {showCount ? (
710
+ <Text
711
+ style={{ fontSize: 11, fontWeight: '600', color: theme.colors.textTertiary }}
712
+ numberOfLines={1}
713
+ >
714
+ {formatCount(resolvedCount)}
715
+ </Text>
716
+ ) : null}
281
717
  </Pressable>
282
- </Animated.View>
718
+ </RNAnimated.View>
283
719
  );
284
720
  };
285
721
 
286
722
  const TabPanelComponent: React.FC<TabsContentProps> = ({ value, children, style }) => {
287
- const { value: selectedValue } = useContext(TabsContext);
288
-
723
+ const { selectedValue } = useTabsContext('TabsContent');
289
724
  if (value !== selectedValue) return null;
290
725
 
291
726
  return <View style={style}>{children}</View>;