@oxyhq/bloom 0.51.0 → 0.52.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 (122) hide show
  1. package/lib/commonjs/frosted-icon-button/shared.js +11 -1
  2. package/lib/commonjs/frosted-icon-button/shared.js.map +1 -1
  3. package/lib/commonjs/tab-bar/TabBarBase.js +30 -5
  4. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  5. package/lib/commonjs/tab-bar/glyph.js +9 -4
  6. package/lib/commonjs/tab-bar/glyph.js.map +1 -1
  7. package/lib/commonjs/tab-bar/glyph.native.js +7 -2
  8. package/lib/commonjs/tab-bar/glyph.native.js.map +1 -1
  9. package/lib/commonjs/tab-bar/index.js +7 -0
  10. package/lib/commonjs/tab-bar/index.js.map +1 -1
  11. package/lib/commonjs/tab-bar/index.web.js +7 -0
  12. package/lib/commonjs/tab-bar/index.web.js.map +1 -1
  13. package/lib/commonjs/tab-bar/shared.js +32 -1
  14. package/lib/commonjs/tab-bar/shared.js.map +1 -1
  15. package/lib/commonjs/tab-bar/surface-solid.js +43 -0
  16. package/lib/commonjs/tab-bar/surface-solid.js.map +1 -0
  17. package/lib/commonjs/tab-bar/surface.js +5 -42
  18. package/lib/commonjs/tab-bar/surface.js.map +1 -1
  19. package/lib/commonjs/tab-bar/surface.native.js +65 -8
  20. package/lib/commonjs/tab-bar/surface.native.js.map +1 -1
  21. package/lib/commonjs/tab-bar/use-reserved-space.js +50 -0
  22. package/lib/commonjs/tab-bar/use-reserved-space.js.map +1 -0
  23. package/lib/module/frosted-icon-button/shared.js +11 -1
  24. package/lib/module/frosted-icon-button/shared.js.map +1 -1
  25. package/lib/module/tab-bar/TabBarBase.js +31 -6
  26. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  27. package/lib/module/tab-bar/glyph.js +9 -4
  28. package/lib/module/tab-bar/glyph.js.map +1 -1
  29. package/lib/module/tab-bar/glyph.native.js +7 -2
  30. package/lib/module/tab-bar/glyph.native.js.map +1 -1
  31. package/lib/module/tab-bar/index.js +1 -0
  32. package/lib/module/tab-bar/index.js.map +1 -1
  33. package/lib/module/tab-bar/index.web.js +1 -0
  34. package/lib/module/tab-bar/index.web.js.map +1 -1
  35. package/lib/module/tab-bar/shared.js +30 -0
  36. package/lib/module/tab-bar/shared.js.map +1 -1
  37. package/lib/module/tab-bar/surface-solid.js +37 -0
  38. package/lib/module/tab-bar/surface-solid.js.map +1 -0
  39. package/lib/module/tab-bar/surface.js +10 -23
  40. package/lib/module/tab-bar/surface.js.map +1 -1
  41. package/lib/module/tab-bar/surface.native.js +68 -9
  42. package/lib/module/tab-bar/surface.native.js.map +1 -1
  43. package/lib/module/tab-bar/use-reserved-space.js +46 -0
  44. package/lib/module/tab-bar/use-reserved-space.js.map +1 -0
  45. package/lib/typescript/commonjs/benefit-list/types.d.ts +2 -1
  46. package/lib/typescript/commonjs/benefit-list/types.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/fab/types.d.ts +4 -2
  48. package/lib/typescript/commonjs/fab/types.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts +11 -1
  50. package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/frosted-icon-button/types.d.ts +2 -1
  52. package/lib/typescript/commonjs/frosted-icon-button/types.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/tab-bar/glyph.d.ts +1 -1
  55. package/lib/typescript/commonjs/tab-bar/glyph.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts +1 -1
  57. package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/tab-bar/index.d.ts +1 -0
  59. package/lib/typescript/commonjs/tab-bar/index.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/tab-bar/index.web.d.ts +1 -0
  61. package/lib/typescript/commonjs/tab-bar/index.web.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/tab-bar/shared.d.ts +25 -0
  63. package/lib/typescript/commonjs/tab-bar/shared.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/tab-bar/surface-solid.d.ts +6 -0
  65. package/lib/typescript/commonjs/tab-bar/surface-solid.d.ts.map +1 -0
  66. package/lib/typescript/commonjs/tab-bar/surface.d.ts +28 -5
  67. package/lib/typescript/commonjs/tab-bar/surface.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/tab-bar/surface.native.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/tab-bar/types.d.ts +43 -4
  70. package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/tab-bar/use-reserved-space.d.ts +25 -0
  72. package/lib/typescript/commonjs/tab-bar/use-reserved-space.d.ts.map +1 -0
  73. package/lib/typescript/module/benefit-list/types.d.ts +2 -1
  74. package/lib/typescript/module/benefit-list/types.d.ts.map +1 -1
  75. package/lib/typescript/module/fab/types.d.ts +4 -2
  76. package/lib/typescript/module/fab/types.d.ts.map +1 -1
  77. package/lib/typescript/module/frosted-icon-button/shared.d.ts +11 -1
  78. package/lib/typescript/module/frosted-icon-button/shared.d.ts.map +1 -1
  79. package/lib/typescript/module/frosted-icon-button/types.d.ts +2 -1
  80. package/lib/typescript/module/frosted-icon-button/types.d.ts.map +1 -1
  81. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  82. package/lib/typescript/module/tab-bar/glyph.d.ts +1 -1
  83. package/lib/typescript/module/tab-bar/glyph.d.ts.map +1 -1
  84. package/lib/typescript/module/tab-bar/glyph.native.d.ts +1 -1
  85. package/lib/typescript/module/tab-bar/glyph.native.d.ts.map +1 -1
  86. package/lib/typescript/module/tab-bar/index.d.ts +1 -0
  87. package/lib/typescript/module/tab-bar/index.d.ts.map +1 -1
  88. package/lib/typescript/module/tab-bar/index.web.d.ts +1 -0
  89. package/lib/typescript/module/tab-bar/index.web.d.ts.map +1 -1
  90. package/lib/typescript/module/tab-bar/shared.d.ts +25 -0
  91. package/lib/typescript/module/tab-bar/shared.d.ts.map +1 -1
  92. package/lib/typescript/module/tab-bar/surface-solid.d.ts +6 -0
  93. package/lib/typescript/module/tab-bar/surface-solid.d.ts.map +1 -0
  94. package/lib/typescript/module/tab-bar/surface.d.ts +28 -5
  95. package/lib/typescript/module/tab-bar/surface.d.ts.map +1 -1
  96. package/lib/typescript/module/tab-bar/surface.native.d.ts.map +1 -1
  97. package/lib/typescript/module/tab-bar/types.d.ts +43 -4
  98. package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
  99. package/lib/typescript/module/tab-bar/use-reserved-space.d.ts +25 -0
  100. package/lib/typescript/module/tab-bar/use-reserved-space.d.ts.map +1 -0
  101. package/package.json +2 -8
  102. package/src/__tests__/TabBar.test.tsx +82 -2
  103. package/src/__tests__/TabBarGlassFallback.test.tsx +234 -0
  104. package/src/__tests__/TabBarLongPress.test.tsx +258 -0
  105. package/src/__tests__/TabBarReservedSpace.test.tsx +150 -0
  106. package/src/__tests__/TabBarWebFork.test.ts +47 -0
  107. package/src/__tests__/icon-references.test.ts +243 -0
  108. package/src/benefit-list/types.ts +2 -1
  109. package/src/fab/types.ts +4 -2
  110. package/src/frosted-icon-button/shared.ts +11 -1
  111. package/src/frosted-icon-button/types.ts +2 -1
  112. package/src/tab-bar/TabBarBase.tsx +39 -5
  113. package/src/tab-bar/glyph.native.tsx +5 -2
  114. package/src/tab-bar/glyph.tsx +8 -4
  115. package/src/tab-bar/index.ts +1 -0
  116. package/src/tab-bar/index.web.tsx +1 -0
  117. package/src/tab-bar/shared.ts +38 -0
  118. package/src/tab-bar/surface-solid.tsx +40 -0
  119. package/src/tab-bar/surface.native.tsx +77 -15
  120. package/src/tab-bar/surface.tsx +10 -29
  121. package/src/tab-bar/types.ts +43 -4
  122. package/src/tab-bar/use-reserved-space.ts +44 -0
@@ -0,0 +1,234 @@
1
+ import React from 'react';
2
+ import type { ReactTestInstance } from 'react-test-renderer';
3
+ import { render } from '@testing-library/react-native';
4
+
5
+ import type { TabBarSurfaceProps } from '../tab-bar/shared';
6
+ import type { TabBarTheme } from '../tab-bar/types';
7
+
8
+ /**
9
+ * The native capsule surface must DEGRADE, never crash, when `expo-glass-effect`
10
+ * is not really usable.
11
+ *
12
+ * This is the one thing the rest of the tab-bar suite structurally cannot see.
13
+ * `TabBar.test.tsx` renders the native surface against the manual mock in
14
+ * `__mocks__/expo-glass-effect.ts`, which is a HEALTHY install reporting
15
+ * `isLiquidGlassAvailable() === false` — so it only ever exercises the branch a
16
+ * correctly-packaged Android device takes. Every failure mode below is a
17
+ * different install: the module resolved but handed back no exports, or the JS
18
+ * package is there while the native module is not.
19
+ *
20
+ * Each case is re-mocked into its own module registry because both hazards live at
21
+ * MODULE scope. `Animated.createAnimatedComponent(GlassView)` runs on import, and
22
+ * on iOS `isLiquidGlassAvailable()` reaches a native module through
23
+ * `requireNativeModule`, which throws by contract when it is absent. Mocking after
24
+ * the module under test was imported would test a render branch and miss both.
25
+ *
26
+ * The two counter-tests at the end are what keep the rest honest: a guard that
27
+ * always fell through to the solid surface would satisfy every other assertion
28
+ * here while silently deleting the glass look on every iOS 26 device.
29
+ */
30
+
31
+ /** Every export may be absent — that is the whole point of these fixtures. */
32
+ interface GlassEffectModule {
33
+ GlassView?: unknown;
34
+ isLiquidGlassAvailable?: () => boolean;
35
+ isGlassEffectAPIAvailable?: () => boolean;
36
+ }
37
+
38
+ /**
39
+ * A host tag stands in for the real `GlassView` component: under the reanimated
40
+ * mock `createAnimatedComponent` is the identity, so whatever the package exports
41
+ * IS the element type, and a string host is the cheapest valid one to assert on.
42
+ * It matches the tag the manual mock renders.
43
+ */
44
+ const GLASS_HOST = 'GlassView';
45
+
46
+ const THEME: TabBarTheme = {
47
+ activeTint: 'rgb(20 20 20)',
48
+ inactiveTint: 'rgb(120 120 120)',
49
+ highlight: 'rgb(230 230 230)',
50
+ glassTint: 'rgba(255, 255, 255, 0.55)',
51
+ solidFallback: 'rgb(246 246 246)',
52
+ };
53
+
54
+ const STYLE = { borderRadius: 29 };
55
+
56
+ function flattenStyle(style: unknown): Record<string, unknown> {
57
+ const out: Record<string, unknown> = {};
58
+ const visit = (s: unknown): void => {
59
+ if (Array.isArray(s)) s.forEach(visit);
60
+ else if (s && typeof s === 'object') Object.assign(out, s);
61
+ };
62
+ visit(style);
63
+ return out;
64
+ }
65
+
66
+ /** Every host node rendered as `tag`. */
67
+ function hosts(root: ReactTestInstance, tag: string): ReactTestInstance[] {
68
+ return root.findAll((node) => typeof node.type === 'string' && node.type === tag);
69
+ }
70
+
71
+ /**
72
+ * Render `surface.native.tsx` against a given `expo-glass-effect`.
73
+ *
74
+ * Only the surface comes from the isolated registry — testing-library registers
75
+ * suite-level hooks when it loads, so it has to be imported at the top of the file
76
+ * rather than inside a test. Nothing in the rendered tree calls a hook, and the
77
+ * surface renders host strings from the reanimated mock, so the two registries
78
+ * never have to agree on a React instance. `times` re-renders within one registry,
79
+ * for the assertion about module-scoped memoization.
80
+ */
81
+ function renderNativeSurface(glassEffect: GlassEffectModule, times = 1): ReactTestInstance {
82
+ let root: ReactTestInstance | undefined;
83
+
84
+ jest.isolateModules(() => {
85
+ jest.doMock('expo-glass-effect', () => glassEffect);
86
+
87
+ const { TabBarSurface } = require('../tab-bar/surface.native') as {
88
+ TabBarSurface: React.ComponentType<TabBarSurfaceProps>;
89
+ };
90
+
91
+ for (let i = 0; i < times; i += 1) {
92
+ root = render(<TabBarSurface theme={THEME} style={STYLE} />).UNSAFE_root;
93
+ }
94
+ });
95
+
96
+ if (!root) throw new Error('the native surface did not render');
97
+ return root;
98
+ }
99
+
100
+ /** Assert the tree is the opaque non-glass surface, filled from the theme. */
101
+ function expectSolidFallback(root: ReactTestInstance): void {
102
+ expect(hosts(root, GLASS_HOST)).toHaveLength(0);
103
+ const surface = hosts(root, 'Animated.View')[0];
104
+ expect(surface).toBeTruthy();
105
+ expect(flattenStyle(surface?.props.style).backgroundColor).toBe(THEME.solidFallback);
106
+ // The animated capsule radius must survive the fallback — the pill still
107
+ // reshapes as the bar minimizes.
108
+ expect(flattenStyle(surface?.props.style).borderRadius).toBe(STYLE.borderRadius);
109
+ }
110
+
111
+ describe('TabBarSurface (native) — degrading when glass is unusable', () => {
112
+ let warn: jest.SpyInstance<void, Parameters<typeof console.warn>>;
113
+
114
+ beforeEach(() => {
115
+ jest.resetModules();
116
+ warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
117
+ });
118
+
119
+ afterEach(() => {
120
+ warn.mockRestore();
121
+ });
122
+
123
+ it('falls back when the module resolved without GlassView', () => {
124
+ // `createAnimatedComponent(undefined)` produces an element type React rejects
125
+ // with "Element type is invalid … but got: undefined" — and it happens at
126
+ // import, so the whole screen the bar is on goes down with it.
127
+ expectSolidFallback(
128
+ renderNativeSurface({ GlassView: undefined, isLiquidGlassAvailable: () => true }),
129
+ );
130
+ });
131
+
132
+ it('falls back when the module delivered no exports at all', () => {
133
+ // A stub, an alias, or a resolver that landed on a platform-specific file:
134
+ // there is no `isLiquidGlassAvailable` to call either, so calling it blindly
135
+ // is a TypeError.
136
+ expectSolidFallback(renderNativeSurface({}));
137
+ });
138
+
139
+ it('falls back when the native module is missing from the build', () => {
140
+ // On iOS `isLiquidGlassAvailable()` resolves `ExpoGlassEffect` through
141
+ // `requireNativeModule`, which THROWS by contract when the module is not in
142
+ // the binary — an OTA JS update landing on a binary built before the package
143
+ // was linked reaches exactly this state.
144
+ expectSolidFallback(
145
+ renderNativeSurface({
146
+ GlassView: GLASS_HOST,
147
+ isLiquidGlassAvailable: () => {
148
+ throw new Error("Cannot find native module 'ExpoGlassEffect'");
149
+ },
150
+ }),
151
+ );
152
+ });
153
+
154
+ it('reports the missing native module instead of swallowing it', () => {
155
+ renderNativeSurface({
156
+ GlassView: GLASS_HOST,
157
+ isLiquidGlassAvailable: () => {
158
+ throw new Error("Cannot find native module 'ExpoGlassEffect'");
159
+ },
160
+ });
161
+
162
+ expect(warn).toHaveBeenCalledTimes(1);
163
+ const [message, detail] = warn.mock.calls[0] ?? [];
164
+ expect(String(message)).toContain('expo-glass-effect');
165
+ expect(String(detail)).toContain("Cannot find native module 'ExpoGlassEffect'");
166
+ });
167
+
168
+ it('falls back when the platform has no glass API (iOS 26 betas)', () => {
169
+ // Upstream's own guard: on those builds `isLiquidGlassAvailable()` answers
170
+ // true while touching `GlassView` crashes NATIVELY, below JS.
171
+ expectSolidFallback(
172
+ renderNativeSurface({
173
+ GlassView: GLASS_HOST,
174
+ isLiquidGlassAvailable: () => true,
175
+ isGlassEffectAPIAvailable: () => false,
176
+ }),
177
+ );
178
+ });
179
+
180
+ it('renders real glass when the package is fully there', () => {
181
+ const root = renderNativeSurface({
182
+ GlassView: GLASS_HOST,
183
+ isLiquidGlassAvailable: () => true,
184
+ isGlassEffectAPIAvailable: () => true,
185
+ });
186
+
187
+ const glass = hosts(root, GLASS_HOST)[0];
188
+ expect(glass).toBeTruthy();
189
+ expect(glass?.props.glassEffectStyle).toBe('regular');
190
+ expect(flattenStyle(glass?.props.style).backgroundColor).toBe(THEME.glassTint);
191
+ expect(warn).not.toHaveBeenCalled();
192
+ });
193
+
194
+ it('renders glass on a version that predates isGlassEffectAPIAvailable', () => {
195
+ // The probe first shipped in 0.1.9, so the `>=0.1.9` peer floor guarantees it
196
+ // — but a peer range only WARNS, so a consumer can still resolve an older
197
+ // copy. A missing probe means "not applicable", never "unavailable"; treating
198
+ // it as the latter would delete the glass look on every working device.
199
+ expect(
200
+ hosts(
201
+ renderNativeSurface({ GlassView: GLASS_HOST, isLiquidGlassAvailable: () => true }),
202
+ GLASS_HOST,
203
+ ),
204
+ ).toHaveLength(1);
205
+ });
206
+
207
+ it('probes availability once per session, not once per render', () => {
208
+ let probes = 0;
209
+ renderNativeSurface(
210
+ {
211
+ GlassView: GLASS_HOST,
212
+ isLiquidGlassAvailable: () => {
213
+ probes += 1;
214
+ return true;
215
+ },
216
+ },
217
+ 3,
218
+ );
219
+ expect(probes).toBe(1);
220
+ });
221
+
222
+ it('reuses the neutral surface rather than a second implementation', () => {
223
+ // Two copies of the fallback would drift: the neutral variant is what web
224
+ // bundlers and a consumer's `tsc` resolve, and it must paint exactly what an
225
+ // Android device paints.
226
+ jest.isolateModules(() => {
227
+ const { TabBarSurface } = require('../tab-bar/surface') as { TabBarSurface: unknown };
228
+ const { SolidTabBarSurface } = require('../tab-bar/surface-solid') as {
229
+ SolidTabBarSurface: unknown;
230
+ };
231
+ expect(TabBarSurface).toBe(SolidTabBarSurface);
232
+ });
233
+ });
234
+ });
@@ -0,0 +1,258 @@
1
+ import React from 'react';
2
+ import { act, render } from '@testing-library/react-native';
3
+
4
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
5
+ import { TabBar, TabBarButton } from '../tab-bar';
6
+ import type { TabBarItem } from '../tab-bar/types';
7
+
8
+ /**
9
+ * `onIndexLongPress` — the bar's only reachable long press.
10
+ *
11
+ * `TabBarButtonProps extends PressableProps`, so a consumer's `onLongPress`
12
+ * type-checks and silently does nothing: the bar's `GestureDetector` consumes
13
+ * the touches (that is what makes scrubbing possible), so the inner `Pressable`
14
+ * only ever fires for assistive technology and keyboard activation. A real held
15
+ * finger fails the tap gesture at 400ms, never activates the pan, and dies in
16
+ * `onFinalize`'s early return. The gesture below is the only path in.
17
+ *
18
+ * Like `BottomSheetGesture.test.tsx`, this file replaces the shared
19
+ * gesture-handler mock (which discards every registered callback) with a
20
+ * RECORDING one, so the gestures the bar builds can be inspected and their
21
+ * worklets invoked directly. What that CAN prove: which gestures are composed
22
+ * into the race, the duration windows they are configured with, and that the
23
+ * long-press worklet resolves the same index from the same x as the tap
24
+ * worklet. What it cannot prove is gesture-handler's own arbitration — that a
25
+ * held finger really reaches this handler on a device. The 400ms/500ms split
26
+ * asserted below is what makes that arbitration decidable rather than a race,
27
+ * and the behaviour itself is verified on a real build.
28
+ */
29
+
30
+ interface MockGestureEvent {
31
+ x: number;
32
+ }
33
+
34
+ interface MockGesture {
35
+ kind: string;
36
+ /** Values captured from the builder chain. */
37
+ minDurationMs?: number;
38
+ maxDurationMs?: number;
39
+ /** Worklets captured from the builder chain. */
40
+ start?: (event: MockGestureEvent) => void;
41
+ update?: (event: MockGestureEvent) => void;
42
+ end?: (event: MockGestureEvent, success: boolean) => void;
43
+ /** Members of a composed `Gesture.Race(…)`; empty for a leaf gesture. */
44
+ members: MockGesture[];
45
+ /** Builder chain — every method returns the same object. */
46
+ activeOffsetX: (range: number[]) => MockGesture;
47
+ failOffsetY: (range: number[]) => MockGesture;
48
+ maxDistance: (px: number) => MockGesture;
49
+ maxDuration: (ms: number) => MockGesture;
50
+ minDuration: (ms: number) => MockGesture;
51
+ onStart: (worklet: (event: MockGestureEvent) => void) => MockGesture;
52
+ onUpdate: (worklet: (event: MockGestureEvent) => void) => MockGesture;
53
+ onEnd: (worklet: (event: MockGestureEvent, success: boolean) => void) => MockGesture;
54
+ onFinalize: (worklet: () => void) => MockGesture;
55
+ }
56
+
57
+ // Every gesture built since the last reset. Must be `mock`-prefixed so the
58
+ // hoisted `jest.mock` factory may reference it.
59
+ const mockGestures: MockGesture[] = [];
60
+
61
+ jest.mock('react-native-gesture-handler', () => {
62
+ const build = (kind: string): MockGesture => {
63
+ const gesture: MockGesture = {
64
+ kind,
65
+ members: [],
66
+ activeOffsetX: () => gesture,
67
+ failOffsetY: () => gesture,
68
+ maxDistance: () => gesture,
69
+ maxDuration: (ms) => {
70
+ gesture.maxDurationMs = ms;
71
+ return gesture;
72
+ },
73
+ minDuration: (ms) => {
74
+ gesture.minDurationMs = ms;
75
+ return gesture;
76
+ },
77
+ onStart: (worklet) => {
78
+ gesture.start = worklet;
79
+ return gesture;
80
+ },
81
+ onUpdate: (worklet) => {
82
+ gesture.update = worklet;
83
+ return gesture;
84
+ },
85
+ onEnd: (worklet) => {
86
+ gesture.end = worklet;
87
+ return gesture;
88
+ },
89
+ onFinalize: () => gesture,
90
+ };
91
+ mockGestures.push(gesture);
92
+ return gesture;
93
+ };
94
+
95
+ return {
96
+ Gesture: {
97
+ Pan: () => build('Pan'),
98
+ Tap: () => build('Tap'),
99
+ LongPress: () => build('LongPress'),
100
+ Race: (...members: MockGesture[]) => {
101
+ const race = build('Race');
102
+ race.members.push(...members);
103
+ return race;
104
+ },
105
+ },
106
+ GestureDetector: ({ children }: { children: React.ReactNode }) => children,
107
+ GestureHandlerRootView: ({ children }: { children: React.ReactNode }) => children,
108
+ };
109
+ });
110
+
111
+ const ITEMS: TabBarItem[] = [
112
+ { name: 'home', label: 'Home', icon: null },
113
+ { name: 'search', label: 'Search', icon: null },
114
+ { name: 'you', label: 'You', icon: null },
115
+ ];
116
+
117
+ /**
118
+ * Touch x positions inside the bar, in the 375pt window the react-native mock
119
+ * reports. Named by the tab they land on rather than by the geometry that puts
120
+ * them there: an edge touch belongs to the first/last tab and a centered one to
121
+ * the middle tab whatever the exact item width turns out to be.
122
+ */
123
+ const NEAR_LEFT_EDGE = 8;
124
+ const WINDOW_CENTER = 187;
125
+ const NEAR_RIGHT_EDGE = 340;
126
+
127
+ function renderBar(props: {
128
+ onIndexChange?: (index: number) => void;
129
+ onIndexLongPress?: (index: number) => void;
130
+ }) {
131
+ return render(
132
+ <BloomThemeProvider mode="light" colorPreset="teal">
133
+ <TabBar activeIndex={0} {...props}>
134
+ {ITEMS.map((item, index) => (
135
+ <TabBarButton key={item.name} item={item} index={index} />
136
+ ))}
137
+ </TabBar>
138
+ </BloomThemeProvider>,
139
+ );
140
+ }
141
+
142
+ const gesturesOfKind = (kind: string) => mockGestures.filter((gesture) => gesture.kind === kind);
143
+
144
+ /** The gesture the detector was actually given — the last race constructed. */
145
+ function race(): MockGesture {
146
+ const races = gesturesOfKind('Race');
147
+ const last = races[races.length - 1];
148
+ if (!last) throw new Error('The bar composed no gesture race');
149
+ return last;
150
+ }
151
+
152
+ function member(kind: string): MockGesture {
153
+ const found = race().members.find((gesture) => gesture.kind === kind);
154
+ if (!found) throw new Error(`No ${kind} gesture in the bar's race`);
155
+ return found;
156
+ }
157
+
158
+ /** Hold a finger at `x` until the long press activates. */
159
+ function longPressAt(x: number) {
160
+ act(() => {
161
+ member('LongPress').start?.({ x });
162
+ });
163
+ }
164
+
165
+ /** A tap that succeeded at `x`. */
166
+ function tapAt(x: number) {
167
+ act(() => {
168
+ member('Tap').end?.({ x }, true);
169
+ });
170
+ }
171
+
172
+ describe('TabBar long press', () => {
173
+ beforeEach(() => {
174
+ mockGestures.length = 0;
175
+ });
176
+
177
+ it('composes no long-press gesture when no handler is given', () => {
178
+ // An always-armed long press would cancel the pan for anyone who rests a
179
+ // finger before scrubbing — a regression for every existing consumer.
180
+ renderBar({});
181
+ expect(gesturesOfKind('LongPress')).toHaveLength(0);
182
+ expect(race().members.map((gesture) => gesture.kind)).toEqual(['Pan', 'Tap']);
183
+ });
184
+
185
+ it('adds one to the race, alongside the pan and the tap, when a handler is given', () => {
186
+ renderBar({ onIndexLongPress: jest.fn() });
187
+ expect(race().members.map((gesture) => gesture.kind)).toEqual(['Pan', 'Tap', 'LongPress']);
188
+ });
189
+
190
+ it('arms it after the tap gesture has already given up', () => {
191
+ // The two windows must not overlap, or arming a long press would sometimes
192
+ // cost the bar a tap. The tap gives up at 400ms; the long press activates
193
+ // at 500ms, so at no instant are both candidates.
194
+ renderBar({ onIndexLongPress: jest.fn() });
195
+ expect(member('LongPress').minDurationMs).toBe(500);
196
+ expect(member('Tap').maxDurationMs).toBe(400);
197
+ expect(member('Tap').maxDurationMs ?? 0).toBeLessThan(member('LongPress').minDurationMs ?? 0);
198
+ });
199
+
200
+ it('reports the index of the tab under the finger', () => {
201
+ const onIndexLongPress = jest.fn();
202
+ renderBar({ onIndexLongPress });
203
+
204
+ longPressAt(NEAR_LEFT_EDGE);
205
+ longPressAt(WINDOW_CENTER);
206
+ longPressAt(NEAR_RIGHT_EDGE);
207
+
208
+ expect(onIndexLongPress.mock.calls).toEqual([[0], [1], [2]]);
209
+ });
210
+
211
+ it('resolves the same index a tap at the same point would', () => {
212
+ // Long press, tap and scrub all share one `indexAtX` worklet; this is what
213
+ // keeps a long press from ever acting on a different tab than the one the
214
+ // finger is visibly on.
215
+ const onIndexChange = jest.fn();
216
+ const onIndexLongPress = jest.fn();
217
+ renderBar({ onIndexChange, onIndexLongPress });
218
+
219
+ for (const x of [NEAR_LEFT_EDGE, 100, WINDOW_CENTER, 250, NEAR_RIGHT_EDGE]) {
220
+ tapAt(x);
221
+ longPressAt(x);
222
+ }
223
+
224
+ expect(onIndexLongPress.mock.calls).toEqual(onIndexChange.mock.calls);
225
+ });
226
+
227
+ it('does not select the tab it long-presses', () => {
228
+ // It is a secondary action (an account sheet, say) — navigating as well
229
+ // would take the user off the screen they opened it from.
230
+ const onIndexChange = jest.fn();
231
+ renderBar({ onIndexChange, onIndexLongPress: jest.fn() });
232
+
233
+ longPressAt(NEAR_RIGHT_EDGE);
234
+
235
+ expect(onIndexChange).not.toHaveBeenCalled();
236
+ });
237
+
238
+ it('still selects on a short tap while the long press is armed', () => {
239
+ const onIndexChange = jest.fn();
240
+ const onIndexLongPress = jest.fn();
241
+ renderBar({ onIndexChange, onIndexLongPress });
242
+
243
+ tapAt(NEAR_RIGHT_EDGE);
244
+
245
+ expect(onIndexChange).toHaveBeenCalledTimes(1);
246
+ expect(onIndexChange).toHaveBeenCalledWith(2);
247
+ expect(onIndexLongPress).not.toHaveBeenCalled();
248
+ });
249
+
250
+ it('keeps the scrub gesture in the race', () => {
251
+ // The pan is what makes the highlight scrubbable; a long press must not
252
+ // have displaced it.
253
+ renderBar({ onIndexLongPress: jest.fn() });
254
+ // Its scrub worklet, not merely a pan object: this is the handler that
255
+ // drags the highlight under the finger.
256
+ expect(typeof member('Pan').update).toBe('function');
257
+ });
258
+ });
@@ -0,0 +1,150 @@
1
+ import React from 'react';
2
+ import type { ReactTestInstance } from 'react-test-renderer';
3
+ import { render } from '@testing-library/react-native';
4
+
5
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
6
+ import { TabBar, TabBarButton, useTabBarReservedSpace } from '../tab-bar';
7
+ import type { TabBarItem } from '../tab-bar/types';
8
+
9
+ /**
10
+ * `useTabBarReservedSpace()` — the number a screen pads its content by so a
11
+ * list does not end underneath the floating bar.
12
+ *
13
+ * The bar is absolutely positioned, so nothing reserves that space
14
+ * automatically, and the geometry it is built from (expanded height, bottom
15
+ * gap) is private. Without the hook a consumer hardcodes both — which drifts
16
+ * silently the first time the bar changes by a pixel — and, worse, tends to add
17
+ * `insets.bottom` on top, because that is what reserving space for bottom
18
+ * chrome normally requires. The bar already folds the inset into its own gap,
19
+ * so that double-counts the home indicator.
20
+ *
21
+ * The suite therefore asserts three separate things: the absolute pixel values
22
+ * (so a change to the constants must be deliberate), the inset relationship (so
23
+ * the double-count can never creep back in), and equality with the bar's own
24
+ * rendered geometry (so the hook cannot drift from where the bar actually sits).
25
+ */
26
+
27
+ // Mutated per test; must be `mock`-prefixed for the hoisted factory.
28
+ const mockInsets = { top: 0, right: 0, bottom: 0, left: 0 };
29
+
30
+ jest.mock('react-native-safe-area-context', () => ({
31
+ ...jest.requireActual('react-native-safe-area-context'),
32
+ useSafeAreaInsets: () => mockInsets,
33
+ }));
34
+
35
+ const ITEMS: TabBarItem[] = [
36
+ { name: 'home', label: 'Home', icon: null },
37
+ { name: 'search', label: 'Search', icon: null },
38
+ ];
39
+
40
+ /** A device with a home indicator (iPhone-class bottom inset). */
41
+ const HOME_INDICATOR_INSET = 34;
42
+
43
+ function ReservedProbe({ onValue }: { onValue: (space: number) => void }) {
44
+ onValue(useTabBarReservedSpace());
45
+ return null;
46
+ }
47
+
48
+ function withTheme(ui: React.ReactElement) {
49
+ return (
50
+ <BloomThemeProvider mode="light" colorPreset="teal">
51
+ {ui}
52
+ </BloomThemeProvider>
53
+ );
54
+ }
55
+
56
+ /** The space the hook reserves on a device whose bottom inset is `inset`. */
57
+ function reservedFor(inset: number): number {
58
+ mockInsets.bottom = inset;
59
+ let space: number | undefined;
60
+ render(withTheme(<ReservedProbe onValue={(value) => (space = value)} />));
61
+ if (space === undefined) throw new Error('ReservedProbe never ran');
62
+ return space;
63
+ }
64
+
65
+ function flattenStyle(style: unknown): Record<string, unknown> {
66
+ const out: Record<string, unknown> = {};
67
+ const visit = (value: unknown): void => {
68
+ if (Array.isArray(value)) value.forEach(visit);
69
+ else if (value && typeof value === 'object') Object.assign(out, value);
70
+ };
71
+ visit(style);
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * The flattened style of every styled node in the tree.
77
+ *
78
+ * Restricted to HOST nodes: the RN mock renders each component as a composite
79
+ * wrapping a host element of the same name, and both carry the same `style`, so
80
+ * counting all of them would double every match.
81
+ */
82
+ function styledNodes(root: ReactTestInstance): Record<string, unknown>[] {
83
+ return root
84
+ .findAll((node) => typeof node.type === 'string' && node.props?.style !== undefined)
85
+ .map((node) => flattenStyle(node.props.style));
86
+ }
87
+
88
+ describe('useTabBarReservedSpace', () => {
89
+ beforeEach(() => {
90
+ mockInsets.bottom = 0;
91
+ });
92
+
93
+ it('reserves the bar plus its gap where there is no bottom inset', () => {
94
+ // 58pt bar + the 12pt floor the gap never goes below.
95
+ expect(reservedFor(0)).toBe(70);
96
+ });
97
+
98
+ it('reserves more on a device with a home indicator', () => {
99
+ expect(reservedFor(HOME_INDICATOR_INSET)).toBe(76);
100
+ });
101
+
102
+ it('already includes the inset — adding insets.bottom would double-count it', () => {
103
+ // The bar absorbs 16pt of the inset into its own gap, so the space grows by
104
+ // the REMAINDER, never by the whole inset. A consumer writing
105
+ // `reserved + insets.bottom` would reserve 110pt for a 76pt bar.
106
+ const grown = reservedFor(HOME_INDICATOR_INSET) - reservedFor(0);
107
+ expect(grown).toBe(6);
108
+ expect(grown).toBeLessThan(HOME_INDICATOR_INSET);
109
+ });
110
+
111
+ it('does not shrink below the floor for a small inset', () => {
112
+ // An inset under what the gap absorbs must not pull the bar down toward the
113
+ // window edge — the gap is clamped, and the reserved space with it.
114
+ expect(reservedFor(8)).toBe(reservedFor(0));
115
+ });
116
+
117
+ it('equals the space the rendered bar actually occupies', () => {
118
+ // The invariant that makes the hook worth exporting: its number is the bar's
119
+ // own geometry (the gap it holds off the window edge plus its expanded
120
+ // height), not a second copy that can drift.
121
+ mockInsets.bottom = HOME_INDICATOR_INSET;
122
+ let reserved: number | undefined;
123
+ const { UNSAFE_root } = render(
124
+ withTheme(
125
+ <>
126
+ <ReservedProbe onValue={(value) => (reserved = value)} />
127
+ <TabBar activeIndex={0}>
128
+ {ITEMS.map((item, index) => (
129
+ <TabBarButton key={item.name} item={item} index={index} />
130
+ ))}
131
+ </TabBar>
132
+ </>,
133
+ ),
134
+ );
135
+
136
+ const styles = styledNodes(UNSAFE_root);
137
+ // The wrapper holding the pill off the bottom edge — the only node with a
138
+ // bottom margin.
139
+ const gaps = styles.filter((style) => typeof style.marginBottom === 'number');
140
+ expect(gaps).toHaveLength(1);
141
+ // The pill itself: the animated capsule sizes BOTH its height and its
142
+ // horizontal inset, which nothing else in the bar does.
143
+ const pills = styles.filter(
144
+ (style) => typeof style.height === 'number' && typeof style.marginHorizontal === 'number',
145
+ );
146
+ expect(pills).toHaveLength(1);
147
+
148
+ expect(reserved).toBe(Number(gaps[0]?.marginBottom) + Number(pills[0]?.height));
149
+ });
150
+ });