@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
@@ -1,9 +1,10 @@
1
1
  import React from 'react';
2
2
  import type { ReactTestInstance } from 'react-test-renderer';
3
- import { fireEvent, render } from '@testing-library/react-native';
3
+ import { act, fireEvent, render } from '@testing-library/react-native';
4
4
 
5
5
  import { BloomThemeProvider } from '../theme/BloomThemeProvider';
6
6
  import { Tabs, TabsTrigger } from '../tabs';
7
+ import type { TabsDragController } from '../tabs/Tabs';
7
8
 
8
9
  function renderWithTheme(ui: React.ReactElement) {
9
10
  return render(
@@ -34,6 +35,18 @@ function triggerFor(node: ReactTestInstance): ReactTestInstance {
34
35
  return current;
35
36
  }
36
37
 
38
+ /**
39
+ * The selected state as it actually reaches a platform.
40
+ *
41
+ * `aria-selected` rather than `accessibilityState.selected` because
42
+ * react-native-web's `createDOMProps` reads only the former (the latter appears
43
+ * nowhere in it), while React Native's `Pressable` folds `aria-selected` back
44
+ * into `accessibilityState` — so this is the ONE spelling both platforms honour.
45
+ */
46
+ function selectedState(node: ReactTestInstance): unknown {
47
+ return node.props['aria-selected'];
48
+ }
49
+
37
50
  function Bar({
38
51
  value,
39
52
  onValueChange = () => {},
@@ -98,13 +111,463 @@ describe('Tabs', () => {
98
111
  }
99
112
  });
100
113
 
114
+ /**
115
+ * A tab strip that does not say WHICH tab is current is unusable with a
116
+ * screen reader, and the failure is invisible to everyone else: the underline
117
+ * is drawn, so the strip looks correct while announcing nothing.
118
+ *
119
+ * The assertion is on `aria-selected` specifically. Setting only
120
+ * `accessibilityState={{selected}}` — which reads like the React Native
121
+ * answer and is what this component shipped — produces no `aria-selected` on
122
+ * web at all, because react-native-web stopped consulting
123
+ * `accessibilityState` and now maps `aria-*` props directly.
124
+ */
101
125
  it('marks the active trigger as selected for accessibility', () => {
102
126
  const { getByText } = renderWithTheme(<Bar value="b" />);
103
- expect(
104
- triggerFor(getByText('First')).props.accessibilityState.selected,
105
- ).toBe(false);
106
- expect(
107
- triggerFor(getByText('Second')).props.accessibilityState.selected,
108
- ).toBe(true);
127
+ expect(selectedState(triggerFor(getByText('First')))).toBe(false);
128
+ expect(selectedState(triggerFor(getByText('Second')))).toBe(true);
129
+ });
130
+
131
+ it('leaves the disabled state to the disabled prop, which both platforms map', () => {
132
+ // Neither Pressable needs telling twice: React Native's folds a non-null
133
+ // `disabled` into `accessibilityState`, react-native-web's emits
134
+ // `aria-disabled` from it. A second copy on the trigger could only ever
135
+ // disagree with this one.
136
+ const { getByText } = renderWithTheme(
137
+ <Tabs value="a" onValueChange={() => {}} testID="tabs">
138
+ <TabsTrigger value="a" label="First" />
139
+ <TabsTrigger value="b" label="Second" disabled />
140
+ </Tabs>,
141
+ );
142
+ expect(triggerFor(getByText('Second')).props.disabled).toBe(true);
143
+ expect(triggerFor(getByText('First')).props.disabled).toBe(false);
144
+ });
145
+
146
+ /**
147
+ * The shape a real consumer outside this repo ships, spelled out so a later
148
+ * refactor of the controlled path cannot break it silently. OxyPay's wallet
149
+ * screen (`packages/frontend/app/(tabs)/index.tsx`) renders exactly this: a
150
+ * controlled `value` + `onValueChange`, `variant="underline"`, a `style`
151
+ * override zeroing the bottom border, and label-only triggers.
152
+ *
153
+ * A Bloom bump reaches it, and it has no test of its own here — so this is
154
+ * the only thing standing between that screen and a silent regression.
155
+ */
156
+ it('supports the controlled call shape a published consumer uses', () => {
157
+ const onValueChange = jest.fn();
158
+ const { getByText, getByTestId } = renderWithTheme(
159
+ <Tabs
160
+ value="overview"
161
+ onValueChange={onValueChange}
162
+ variant="underline"
163
+ style={{ borderBottomWidth: 0 }}
164
+ testID="tabs"
165
+ >
166
+ <TabsTrigger value="overview" label="Overview" />
167
+ <TabsTrigger value="activity" label="Activity" />
168
+ </Tabs>,
169
+ );
170
+
171
+ expect(getByTestId('tabs-indicator')).toBeTruthy();
172
+ expect(selectedState(triggerFor(getByText('Overview')))).toBe(true);
173
+ fireEvent.press(getByText('Activity'));
174
+ expect(onValueChange).toHaveBeenCalledWith('activity');
175
+ });
176
+
177
+ describe('focus-driven path (router adapter)', () => {
178
+ it('takes selection from isFocused rather than the bar value', () => {
179
+ // No `value` at all — the router owns the selection.
180
+ const { getByText } = renderWithTheme(
181
+ <Tabs testID="tabs">
182
+ <TabsTrigger value="a" label="First" isFocused={false} />
183
+ <TabsTrigger value="b" label="Second" isFocused />
184
+ </Tabs>,
185
+ );
186
+ expect(selectedState(triggerFor(getByText('First')))).toBe(false);
187
+ expect(selectedState(triggerFor(getByText('Second')))).toBe(true);
188
+ });
189
+
190
+ it('does NOT report a selection on press, because the caller navigates', () => {
191
+ // Two writers on one underline is the bug this prevents: the router
192
+ // moves it via `isFocused`, so the bar must not also move it on press.
193
+ const onValueChange = jest.fn();
194
+ const onPress = jest.fn();
195
+ const { getByText } = renderWithTheme(
196
+ <Tabs onValueChange={onValueChange} testID="tabs">
197
+ <TabsTrigger value="a" label="First" isFocused />
198
+ <TabsTrigger value="b" label="Second" isFocused={false} onPress={onPress} />
199
+ </Tabs>,
200
+ );
201
+ fireEvent.press(getByText('Second'));
202
+ expect(onPress).toHaveBeenCalled();
203
+ expect(onValueChange).not.toHaveBeenCalled();
204
+ });
205
+
206
+ it('still reports a selection on press when isFocused is absent', () => {
207
+ // The discriminator is `isFocused` being supplied AT ALL, not its value —
208
+ // so a controlled bar keeps working exactly as before.
209
+ const onValueChange = jest.fn();
210
+ const { getByText } = renderWithTheme(<Bar value="a" onValueChange={onValueChange} />);
211
+ fireEvent.press(getByText('Second'));
212
+ expect(onValueChange).toHaveBeenCalledWith('b');
213
+ });
214
+ });
215
+
216
+ describe('counts', () => {
217
+ it('renders a count beside the label and folds it into the a11y label', () => {
218
+ const { getByText, queryByText } = renderWithTheme(
219
+ <Tabs value="a" onValueChange={() => {}} testID="tabs">
220
+ <TabsTrigger value="a" label="First" count={3} />
221
+ <TabsTrigger value="b" label="Second" count={0} />
222
+ </Tabs>,
223
+ );
224
+ expect(getByText('3')).toBeTruthy();
225
+ // A zero count renders nothing at all rather than a literal "0".
226
+ expect(queryByText('0')).toBeNull();
227
+ expect(triggerFor(getByText('First')).props.accessibilityLabel).toBe('First, 3');
228
+ expect(triggerFor(getByText('Second')).props.accessibilityLabel).toBe('Second');
229
+ });
230
+
231
+ it('caps a large tally so it cannot stretch the tab', () => {
232
+ const { getByText } = renderWithTheme(
233
+ <Tabs value="a" onValueChange={() => {}} testID="tabs">
234
+ <TabsTrigger value="a" label="First" count={100} />
235
+ <TabsTrigger value="b" label="Second" count={99} />
236
+ </Tabs>,
237
+ );
238
+ expect(getByText('99+')).toBeTruthy();
239
+ expect(getByText('99')).toBeTruthy();
240
+ });
241
+ });
242
+
243
+ /**
244
+ * `hasSelection` — a strip that is rendered while NOTHING it lists is showing.
245
+ *
246
+ * Not hypothetical, and the reason it exists: a routed strip lives in the
247
+ * LAYOUT, and a layout renders its non-tab children too. A profile's
248
+ * `/followers` sits beside its tab routes under one layout, so on that route
249
+ * every trigger reports `isFocused={false}` and, without this, the underline
250
+ * stays parked on whichever tab was visited last — asserting a selection that
251
+ * is not there.
252
+ *
253
+ * The assertion is on OPACITY rather than position on purpose: fading is the
254
+ * whole response, and the underline must NOT travel to a sentinel, which
255
+ * would drag it across every tab on the way out.
256
+ */
257
+ describe('no selection', () => {
258
+ /**
259
+ * Drive the strip and read the underline back.
260
+ *
261
+ * The re-render after each step is load-bearing, not ceremony: the
262
+ * reanimated jest mock evaluates `useAnimatedStyle`'s mapper at RENDER time
263
+ * (`useAnimatedStyle: (fn) => fn()`), so a shared value written from a
264
+ * layout callback is not visible in the rendered style until something
265
+ * renders again. Without it every assertion here reads the mount frame and
266
+ * "no selection" and "selection" are indistinguishable.
267
+ */
268
+ function mount(props: Partial<React.ComponentProps<typeof Bar>> = {}) {
269
+ const utils = renderWithTheme(<Bar value="a" {...props} />);
270
+ const renderOnce = (next: Partial<React.ComponentProps<typeof Bar>>) =>
271
+ utils.rerender(
272
+ <BloomThemeProvider mode="light" colorPreset="teal">
273
+ <Bar value="a" {...props} {...next} />
274
+ </BloomThemeProvider>,
275
+ );
276
+ // Twice, for the same reason the mock forces a re-render at all: a prop
277
+ // change is applied by an EFFECT, which runs after the render that
278
+ // carried it, so the first pass still renders the previous value. The
279
+ // second pass has identical props, so no effect re-runs and nothing is
280
+ // written twice — it only re-evaluates the mapper.
281
+ const render = (next: Partial<React.ComponentProps<typeof Bar>>) => {
282
+ renderOnce(next);
283
+ renderOnce(next);
284
+ };
285
+ return {
286
+ ...utils,
287
+ settle: () => render({}),
288
+ update: render,
289
+ measureFirstTrigger() {
290
+ let node: ReactTestInstance | null = triggerFor(utils.getByText('First'));
291
+ while (node && node.props?.onLayout === undefined) node = node.parent;
292
+ if (!node) throw new Error('no onLayout ancestor found for the trigger');
293
+ fireEvent(node, 'layout', { nativeEvent: { layout: { x: 0, width: 80 } } });
294
+ },
295
+ opacity: () =>
296
+ flattenStyle(utils.getByTestId('tabs-indicator').props.style).opacity,
297
+ };
298
+ }
299
+
300
+ it('starts hidden so an unmeasured strip never flashes an underline at the origin', () => {
301
+ expect(mount().opacity()).toBe(0);
302
+ });
303
+
304
+ it('shows the underline once the active trigger has been measured', () => {
305
+ const bar = mount();
306
+ bar.measureFirstTrigger();
307
+ bar.settle();
308
+ expect(bar.opacity()).toBe(1);
309
+ });
310
+
311
+ it('fades the underline out when hasSelection is false', () => {
312
+ const bar = mount();
313
+ bar.measureFirstTrigger();
314
+ bar.settle();
315
+ expect(bar.opacity()).toBe(1);
316
+
317
+ bar.update({ hasSelection: false });
318
+ expect(bar.opacity()).toBe(0);
319
+ });
320
+ });
321
+
322
+ it('names the component when a trigger is rendered outside a Tabs', () => {
323
+ // Without a provider the old code read a DEFAULT context and silently did
324
+ // nothing — a strip that renders but never moves. Failing loudly is the
325
+ // point; the message has to name the component to be actionable.
326
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
327
+ expect(() =>
328
+ renderWithTheme(<TabsTrigger value="a" label="First" />),
329
+ ).toThrow(/TabsTrigger must be used within a Tabs/);
330
+ spy.mockRestore();
331
+ });
332
+
333
+ /**
334
+ * A trigger set that CHANGES after the strip has already been laid out.
335
+ *
336
+ * This is the case `onLayout` alone cannot serve, and it is worth being
337
+ * precise about why, because the component looks correct without it. On
338
+ * react-native-web `onLayout` is one shared `ResizeObserver`
339
+ * (`react-native-web/dist/modules/useElementLayout`), so it fires for a SIZE
340
+ * change and never for a position-only move. Insert a tab and every trigger
341
+ * after it slides right without one of them re-reporting; the underline stays
342
+ * on the stale numbers, one tab to the left of the tab that is actually
343
+ * showing. Measured on production `mention.earth`, where a profile's lane tab
344
+ * arrives from a separate query after first paint: `/@nate/boosts` underlined
345
+ * "Likes".
346
+ *
347
+ * Every test below therefore reports layout for the changed triggers ONLY —
348
+ * modelling exactly what the platform does — and asserts the underline anyway.
349
+ * Reporting layout for all of them would hide the bug completely.
350
+ */
351
+ describe('a trigger set that changes after first layout', () => {
352
+ interface Box {
353
+ x: number;
354
+ width: number;
355
+ }
356
+
357
+ /**
358
+ * Which tab an element belongs to, or `undefined` if it is not a trigger.
359
+ *
360
+ * The element the strip measures is a trigger's animated wrapper, which is
361
+ * anonymous; the tab it stands for is named by the Pressable inside it.
362
+ */
363
+ function tabLabelOf(element: unknown): string | undefined {
364
+ // `createNodeMock` is handed a plain `{type, props}` pair rather than a
365
+ // real element — `React.isValidElement` is false for it — so the outer
366
+ // hop is narrowed by hand. The CHILD is a genuine element.
367
+ if (typeof element !== 'object' || element === null || !('props' in element)) {
368
+ return undefined;
369
+ }
370
+ const props = element.props;
371
+ if (typeof props !== 'object' || props === null || !('children' in props)) {
372
+ return undefined;
373
+ }
374
+ const child = props.children;
375
+ if (
376
+ !React.isValidElement<{ accessibilityRole?: string; accessibilityLabel?: string }>(
377
+ child,
378
+ )
379
+ ) {
380
+ return undefined;
381
+ }
382
+ if (child.props.accessibilityRole !== 'tab') return undefined;
383
+ return child.props.accessibilityLabel;
384
+ }
385
+
386
+ /**
387
+ * Stand in for a host view.
388
+ *
389
+ * react-test-renderer hands `null` to every host ref unless a
390
+ * `createNodeMock` is supplied, so without this a trigger has no node to
391
+ * measure and the strip is back to trusting `onLayout` — which is the
392
+ * behaviour under test, not a background detail. Geometry is read from a
393
+ * live table so the test can move the tabs between renders, exactly as a
394
+ * reflow would.
395
+ */
396
+ function nodeMockFor(geometry: Map<string, Box>) {
397
+ return (element: React.ReactElement): unknown => {
398
+ const label = tabLabelOf(element);
399
+ if (label === undefined) return {};
400
+ return {
401
+ measure: (
402
+ report: (x: number, y: number, width: number, height: number) => void,
403
+ ) => {
404
+ const box = geometry.get(label);
405
+ if (box) report(box.x, 0, box.width, 40);
406
+ },
407
+ };
408
+ };
409
+ }
410
+
411
+ function mountStrip(tabs: string[], focused: string, geometry: Map<string, Box>) {
412
+ const dragRef = React.createRef<TabsDragController>();
413
+ const tree = (next: { tabs: string[]; focused: string }) => (
414
+ <BloomThemeProvider mode="light" colorPreset="teal">
415
+ <Tabs testID="tabs" ref={dragRef} hasSelection>
416
+ {next.tabs.map((tab) => (
417
+ <TabsTrigger
418
+ key={tab}
419
+ value={tab}
420
+ label={tab}
421
+ isFocused={tab === next.focused}
422
+ />
423
+ ))}
424
+ </Tabs>
425
+ </BloomThemeProvider>
426
+ );
427
+ const utils = render(tree({ tabs, focused }), {
428
+ createNodeMock: nodeMockFor(geometry),
429
+ });
430
+
431
+ /** Fire the `onLayout` a trigger's own node would fire. */
432
+ const reportLayout = (label: string) => {
433
+ const box = geometry.get(label);
434
+ if (!box) throw new Error(`no geometry for "${label}"`);
435
+ let node: ReactTestInstance | null = triggerFor(utils.getByText(label));
436
+ while (node && node.props?.onLayout === undefined) node = node.parent;
437
+ if (!node) throw new Error(`no onLayout ancestor for "${label}"`);
438
+ fireEvent(node, 'layout', { nativeEvent: { layout: box } });
439
+ };
440
+
441
+ /**
442
+ * Let the measurement pass run and re-render so the mapper is evaluated
443
+ * against the shared values it wrote — the reanimated mock computes
444
+ * `useAnimatedStyle` at render time, so nothing written from a callback is
445
+ * visible in the rendered style until something renders again.
446
+ */
447
+ const settle = async (next?: { tabs: string[]; focused: string }) => {
448
+ await act(async () => {
449
+ utils.rerender(tree(next ?? { tabs, focused }));
450
+ });
451
+ await act(async () => {
452
+ utils.rerender(tree(next ?? { tabs, focused }));
453
+ });
454
+ };
455
+
456
+ const indicator = () => {
457
+ const style = flattenStyle(utils.getByTestId('tabs-indicator').props.style);
458
+ const transform = style.transform as { translateX?: number }[] | undefined;
459
+ return {
460
+ x: transform?.[0]?.translateX,
461
+ width: style.width,
462
+ };
463
+ };
464
+
465
+ return { ...utils, dragRef, reportLayout, settle, indicator };
466
+ }
467
+
468
+ it('follows the active tab when one is INSERTED before it', async () => {
469
+ const geometry = new Map<string, Box>([
470
+ ['posts', { x: 0, width: 80 }],
471
+ ['likes', { x: 80, width: 80 }],
472
+ ['boosts', { x: 160, width: 80 }],
473
+ ]);
474
+ const bar = mountStrip(['posts', 'likes', 'boosts'], 'boosts', geometry);
475
+ for (const tab of ['posts', 'likes', 'boosts']) bar.reportLayout(tab);
476
+ await bar.settle();
477
+ expect(bar.indicator()).toEqual({ x: 160, width: 80 });
478
+
479
+ // The lane tab lands, and everything after it slides right by its width.
480
+ geometry.set('lane', { x: 0, width: 100 });
481
+ geometry.set('posts', { x: 100, width: 80 });
482
+ geometry.set('likes', { x: 180, width: 80 });
483
+ geometry.set('boosts', { x: 260, width: 80 });
484
+ await bar.settle({ tabs: ['lane', 'posts', 'likes', 'boosts'], focused: 'boosts' });
485
+ // Only the NEW node is newly observed, so only it reports.
486
+ bar.reportLayout('lane');
487
+ await bar.settle({ tabs: ['lane', 'posts', 'likes', 'boosts'], focused: 'boosts' });
488
+
489
+ expect(bar.indicator()).toEqual({ x: 260, width: 80 });
490
+ });
491
+
492
+ it('follows the active tab when one is REMOVED before it', async () => {
493
+ const geometry = new Map<string, Box>([
494
+ ['lane', { x: 0, width: 100 }],
495
+ ['posts', { x: 100, width: 80 }],
496
+ ['boosts', { x: 180, width: 80 }],
497
+ ]);
498
+ const bar = mountStrip(['lane', 'posts', 'boosts'], 'boosts', geometry);
499
+ for (const tab of ['lane', 'posts', 'boosts']) bar.reportLayout(tab);
500
+ await bar.settle();
501
+ expect(bar.indicator()).toEqual({ x: 180, width: 80 });
502
+
503
+ geometry.set('posts', { x: 0, width: 80 });
504
+ geometry.set('boosts', { x: 80, width: 80 });
505
+ // Nothing resized and the removed node is gone, so NOTHING reports here.
506
+ await bar.settle({ tabs: ['posts', 'boosts'], focused: 'boosts' });
507
+
508
+ expect(bar.indicator()).toEqual({ x: 80, width: 80 });
509
+ });
510
+
511
+ it('follows the active tab when the set is REORDERED', async () => {
512
+ const geometry = new Map<string, Box>([
513
+ ['posts', { x: 0, width: 80 }],
514
+ ['likes', { x: 80, width: 80 }],
515
+ ['lane', { x: 160, width: 100 }],
516
+ ]);
517
+ const bar = mountStrip(['posts', 'likes', 'lane'], 'lane', geometry);
518
+ for (const tab of ['posts', 'likes', 'lane']) bar.reportLayout(tab);
519
+ await bar.settle();
520
+ expect(bar.indicator()).toEqual({ x: 160, width: 100 });
521
+
522
+ // Same tabs, same sizes, new order — the case no size-change notification
523
+ // can ever report, on either platform.
524
+ geometry.set('lane', { x: 0, width: 100 });
525
+ geometry.set('posts', { x: 100, width: 80 });
526
+ geometry.set('likes', { x: 180, width: 80 });
527
+ await bar.settle({ tabs: ['lane', 'posts', 'likes'], focused: 'lane' });
528
+
529
+ expect(bar.indicator()).toEqual({ x: 0, width: 100 });
530
+ });
531
+
532
+ it('forgets a removed tab, so a swipe cannot commit to one that is gone', async () => {
533
+ const geometry = new Map<string, Box>([
534
+ ['posts', { x: 0, width: 80 }],
535
+ ['likes', { x: 80, width: 80 }],
536
+ ['lane', { x: 160, width: 100 }],
537
+ ]);
538
+ const bar = mountStrip(['posts', 'likes', 'lane'], 'likes', geometry);
539
+ for (const tab of ['posts', 'likes', 'lane']) bar.reportLayout(tab);
540
+ await bar.settle();
541
+ // Dragging left reveals the tab to the RIGHT, so this commits `lane`.
542
+ expect(bar.dragRef.current?.drag(-1000)).toBe('lane');
543
+
544
+ await bar.settle({ tabs: ['posts', 'likes'], focused: 'likes' });
545
+
546
+ // `likes` is now last. A phantom `lane` left behind in the geometry would
547
+ // still be found here, and releasing would navigate to a tab that is not
548
+ // on screen.
549
+ expect(bar.dragRef.current?.drag(-1000)).toBeNull();
550
+ });
551
+
552
+ it('ignores a zero measurement, which means "not laid out" and not "empty"', async () => {
553
+ const geometry = new Map<string, Box>([
554
+ ['posts', { x: 0, width: 80 }],
555
+ ['likes', { x: 80, width: 80 }],
556
+ ]);
557
+ const bar = mountStrip(['posts', 'likes'], 'likes', geometry);
558
+ for (const tab of ['posts', 'likes']) bar.reportLayout(tab);
559
+ await bar.settle();
560
+ expect(bar.indicator()).toEqual({ x: 80, width: 80 });
561
+
562
+ // What a `display: none` ancestor reports on web. A trigger always
563
+ // carries horizontal padding, so it can never genuinely be zero-wide —
564
+ // taking this at face value would collapse the underline and lose the
565
+ // real geometry it has to come back to.
566
+ geometry.set('posts', { x: 0, width: 0 });
567
+ geometry.set('likes', { x: 0, width: 0 });
568
+ await bar.settle();
569
+
570
+ expect(bar.indicator()).toEqual({ x: 80, width: 80 });
571
+ });
109
572
  });
110
573
  });
@@ -82,6 +82,11 @@ const ALLOWED: { peer: string; file: string; why: string }[] = [
82
82
  file: 'scroll/expo-router/index.ts',
83
83
  why: 'the whole point of this file is to BE the expo-router binding — it is reachable only from `@oxyhq/bloom/scroll/expo-router` and from BloomProvider, both of which are expo-router-only by construction; the scroll core itself no longer imports a router (see AGENTS.md "App Root Provider")',
84
84
  },
85
+ {
86
+ peer: 'expo-router',
87
+ file: 'tabs/expo-router/RouterTabs.tsx',
88
+ why: 'same shape as the scroll binding above — this file exists to BE the expo-router binding for the tab strip and is reachable only from `@oxyhq/bloom/tabs/expo-router`; the tabs core imports no router, so a consumer on a different router (or none) still gets `@oxyhq/bloom/tabs`',
89
+ },
85
90
  ];
86
91
 
87
92
  /**