@oxyhq/bloom 0.79.0 → 0.80.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/lib/commonjs/provider/index.js +11 -7
- package/lib/commonjs/provider/index.js.map +1 -1
- package/lib/commonjs/scroll/context.js +96 -0
- package/lib/commonjs/scroll/context.js.map +1 -0
- package/lib/commonjs/scroll/expo-router/index.js +133 -0
- package/lib/commonjs/scroll/expo-router/index.js.map +1 -0
- package/lib/commonjs/scroll/index.js +85 -21
- package/lib/commonjs/scroll/index.js.map +1 -1
- package/lib/commonjs/scroll/index.web.js +221 -121
- package/lib/commonjs/scroll/index.web.js.map +1 -1
- package/lib/commonjs/scroll/scrollable.native.js +58 -0
- package/lib/commonjs/scroll/scrollable.native.js.map +1 -0
- package/lib/commonjs/scroll/scrollable.web.js +9 -3
- package/lib/commonjs/scroll/scrollable.web.js.map +1 -1
- package/lib/commonjs/scroll/store.js +47 -25
- package/lib/commonjs/scroll/store.js.map +1 -1
- package/lib/commonjs/tabs/Tabs.js +263 -63
- package/lib/commonjs/tabs/Tabs.js.map +1 -1
- package/lib/commonjs/tabs/expo-router/RouterTabs.js +215 -0
- package/lib/commonjs/tabs/expo-router/RouterTabs.js.map +1 -0
- package/lib/commonjs/tabs/expo-router/index.js +13 -0
- package/lib/commonjs/tabs/expo-router/index.js.map +1 -0
- package/lib/commonjs/theme/color-policy.js +11 -10
- package/lib/commonjs/theme/color-policy.js.map +1 -1
- package/lib/module/provider/index.js +10 -6
- package/lib/module/provider/index.js.map +1 -1
- package/lib/module/scroll/context.js +90 -0
- package/lib/module/scroll/context.js.map +1 -0
- package/lib/module/scroll/expo-router/index.js +128 -0
- package/lib/module/scroll/expo-router/index.js.map +1 -0
- package/lib/module/scroll/index.js +80 -21
- package/lib/module/scroll/index.js.map +1 -1
- package/lib/module/scroll/index.web.js +218 -122
- package/lib/module/scroll/index.web.js.map +1 -1
- package/lib/module/scroll/scrollable.native.js +54 -0
- package/lib/module/scroll/scrollable.native.js.map +1 -0
- package/lib/module/scroll/scrollable.web.js +9 -3
- package/lib/module/scroll/scrollable.web.js.map +1 -1
- package/lib/module/scroll/store.js +47 -25
- package/lib/module/scroll/store.js.map +1 -1
- package/lib/module/tabs/Tabs.js +271 -65
- package/lib/module/tabs/Tabs.js.map +1 -1
- package/lib/module/tabs/expo-router/RouterTabs.js +210 -0
- package/lib/module/tabs/expo-router/RouterTabs.js.map +1 -0
- package/lib/module/tabs/expo-router/index.js +10 -0
- package/lib/module/tabs/expo-router/index.js.map +1 -0
- package/lib/module/theme/color-policy.js +11 -10
- package/lib/module/theme/color-policy.js.map +1 -1
- package/lib/typescript/commonjs/provider/index.d.ts +7 -5
- package/lib/typescript/commonjs/provider/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/scroll/context.d.ts +14 -0
- package/lib/typescript/commonjs/scroll/context.d.ts.map +1 -0
- package/lib/typescript/commonjs/scroll/expo-router/index.d.ts +7 -0
- package/lib/typescript/commonjs/scroll/expo-router/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/scroll/index.d.ts +14 -23
- package/lib/typescript/commonjs/scroll/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/scroll/index.web.d.ts +17 -19
- package/lib/typescript/commonjs/scroll/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/scroll/scrollable.native.d.ts +23 -0
- package/lib/typescript/commonjs/scroll/scrollable.native.d.ts.map +1 -0
- package/lib/typescript/commonjs/scroll/scrollable.web.d.ts +18 -5
- package/lib/typescript/commonjs/scroll/scrollable.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/scroll/store.d.ts +41 -22
- package/lib/typescript/commonjs/scroll/store.d.ts.map +1 -1
- package/lib/typescript/commonjs/scroll/types.d.ts +82 -12
- package/lib/typescript/commonjs/scroll/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/tabs/Tabs.d.ts +41 -1
- package/lib/typescript/commonjs/tabs/Tabs.d.ts.map +1 -1
- package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts +80 -0
- package/lib/typescript/commonjs/tabs/expo-router/RouterTabs.d.ts.map +1 -0
- package/lib/typescript/commonjs/tabs/expo-router/index.d.ts +9 -0
- package/lib/typescript/commonjs/tabs/expo-router/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/tabs/types.d.ts +48 -4
- package/lib/typescript/commonjs/tabs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
- package/lib/typescript/module/provider/index.d.ts +7 -5
- package/lib/typescript/module/provider/index.d.ts.map +1 -1
- package/lib/typescript/module/scroll/context.d.ts +14 -0
- package/lib/typescript/module/scroll/context.d.ts.map +1 -0
- package/lib/typescript/module/scroll/expo-router/index.d.ts +7 -0
- package/lib/typescript/module/scroll/expo-router/index.d.ts.map +1 -0
- package/lib/typescript/module/scroll/index.d.ts +14 -23
- package/lib/typescript/module/scroll/index.d.ts.map +1 -1
- package/lib/typescript/module/scroll/index.web.d.ts +17 -19
- package/lib/typescript/module/scroll/index.web.d.ts.map +1 -1
- package/lib/typescript/module/scroll/scrollable.native.d.ts +23 -0
- package/lib/typescript/module/scroll/scrollable.native.d.ts.map +1 -0
- package/lib/typescript/module/scroll/scrollable.web.d.ts +18 -5
- package/lib/typescript/module/scroll/scrollable.web.d.ts.map +1 -1
- package/lib/typescript/module/scroll/store.d.ts +41 -22
- package/lib/typescript/module/scroll/store.d.ts.map +1 -1
- package/lib/typescript/module/scroll/types.d.ts +82 -12
- package/lib/typescript/module/scroll/types.d.ts.map +1 -1
- package/lib/typescript/module/tabs/Tabs.d.ts +41 -1
- package/lib/typescript/module/tabs/Tabs.d.ts.map +1 -1
- package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts +80 -0
- package/lib/typescript/module/tabs/expo-router/RouterTabs.d.ts.map +1 -0
- package/lib/typescript/module/tabs/expo-router/index.d.ts +9 -0
- package/lib/typescript/module/tabs/expo-router/index.d.ts.map +1 -0
- package/lib/typescript/module/tabs/types.d.ts +48 -4
- package/lib/typescript/module/tabs/types.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
- package/package.json +29 -1
- package/src/__tests__/BloomProvider.web.test.tsx +8 -15
- package/src/__tests__/Tabs.test.tsx +187 -0
- package/src/__tests__/optional-peer-imports.test.ts +7 -2
- package/src/__tests__/scroll-expo-router-adapter.test.tsx +164 -0
- package/src/__tests__/scroll-native.test.tsx +465 -0
- package/src/__tests__/scroll-store.test.ts +41 -17
- package/src/__tests__/scroll-web.test.tsx +797 -108
- package/src/provider/index.tsx +10 -7
- package/src/scroll/context.tsx +107 -0
- package/src/scroll/expo-router/index.ts +140 -0
- package/src/scroll/index.ts +92 -25
- package/src/scroll/index.web.tsx +255 -162
- package/src/scroll/scrollable.native.ts +64 -0
- package/src/scroll/scrollable.web.ts +30 -8
- package/src/scroll/store.ts +47 -25
- package/src/scroll/types.ts +82 -12
- package/src/tabs/Tabs.tsx +352 -77
- package/src/tabs/expo-router/RouterTabs.tsx +268 -0
- package/src/tabs/expo-router/index.ts +8 -0
- package/src/tabs/types.ts +48 -4
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +5 -5
- package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +11 -11
- package/src/theme/__tests__/policy-legibility.test.ts +10 -7
- package/src/theme/color-policy.ts +11 -10
- package/lib/commonjs/provider/scroll-provider.js +0 -13
- package/lib/commonjs/provider/scroll-provider.js.map +0 -1
- package/lib/commonjs/provider/scroll-provider.web.js +0 -13
- package/lib/commonjs/provider/scroll-provider.web.js.map +0 -1
- package/lib/module/provider/scroll-provider.js +0 -16
- package/lib/module/provider/scroll-provider.js.map +0 -1
- package/lib/module/provider/scroll-provider.web.js +0 -5
- package/lib/module/provider/scroll-provider.web.js.map +0 -1
- package/lib/typescript/commonjs/provider/scroll-provider.d.ts +0 -14
- package/lib/typescript/commonjs/provider/scroll-provider.d.ts.map +0 -1
- package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts +0 -3
- package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts.map +0 -1
- package/lib/typescript/module/provider/scroll-provider.d.ts +0 -14
- package/lib/typescript/module/provider/scroll-provider.d.ts.map +0 -1
- package/lib/typescript/module/provider/scroll-provider.web.d.ts +0 -3
- package/lib/typescript/module/provider/scroll-provider.web.d.ts.map +0 -1
- package/src/__tests__/scroll-native.test.ts +0 -25
- package/src/provider/scroll-provider.ts +0 -13
- package/src/provider/scroll-provider.web.ts +0 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/bloom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.0",
|
|
4
4
|
"packageManager": "bun@1.3.14",
|
|
5
5
|
"description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
|
|
6
6
|
"main": "lib/commonjs/index.js",
|
|
@@ -1154,6 +1154,20 @@
|
|
|
1154
1154
|
"default": "./lib/commonjs/tabs/index.js"
|
|
1155
1155
|
}
|
|
1156
1156
|
},
|
|
1157
|
+
"./tabs/expo-router": {
|
|
1158
|
+
"react-native": {
|
|
1159
|
+
"types": "./lib/typescript/module/tabs/expo-router/index.d.ts",
|
|
1160
|
+
"default": "./src/tabs/expo-router/index.ts"
|
|
1161
|
+
},
|
|
1162
|
+
"import": {
|
|
1163
|
+
"types": "./lib/typescript/module/tabs/expo-router/index.d.ts",
|
|
1164
|
+
"default": "./lib/module/tabs/expo-router/index.js"
|
|
1165
|
+
},
|
|
1166
|
+
"require": {
|
|
1167
|
+
"types": "./lib/typescript/commonjs/tabs/expo-router/index.d.ts",
|
|
1168
|
+
"default": "./lib/commonjs/tabs/expo-router/index.js"
|
|
1169
|
+
}
|
|
1170
|
+
},
|
|
1157
1171
|
"./checkbox": {
|
|
1158
1172
|
"react-native": {
|
|
1159
1173
|
"types": "./lib/typescript/module/checkbox/index.d.ts",
|
|
@@ -1267,6 +1281,20 @@
|
|
|
1267
1281
|
"default": "./lib/commonjs/scroll/index.js"
|
|
1268
1282
|
}
|
|
1269
1283
|
},
|
|
1284
|
+
"./scroll/expo-router": {
|
|
1285
|
+
"react-native": {
|
|
1286
|
+
"types": "./lib/typescript/module/scroll/expo-router/index.d.ts",
|
|
1287
|
+
"default": "./src/scroll/expo-router/index.ts"
|
|
1288
|
+
},
|
|
1289
|
+
"import": {
|
|
1290
|
+
"types": "./lib/typescript/module/scroll/expo-router/index.d.ts",
|
|
1291
|
+
"default": "./lib/module/scroll/expo-router/index.js"
|
|
1292
|
+
},
|
|
1293
|
+
"require": {
|
|
1294
|
+
"types": "./lib/typescript/commonjs/scroll/expo-router/index.d.ts",
|
|
1295
|
+
"default": "./lib/commonjs/scroll/expo-router/index.js"
|
|
1296
|
+
}
|
|
1297
|
+
},
|
|
1270
1298
|
"./content-panel": {
|
|
1271
1299
|
"react-native": {
|
|
1272
1300
|
"types": "./lib/typescript/module/content-panel/index.d.ts",
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
// THROWS outside `ScrollRestorationProvider`, so anything scrollable that an
|
|
8
8
|
// app renders beside — rather than under — that provider crashes the screen.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
10
|
+
// The provider itself is platform-agnostic (it holds a store and a router
|
|
11
|
+
// adapter), so no platform mapping is needed here: the hook under test is
|
|
12
|
+
// imported from the web barrel, and it reads the very context the provider
|
|
13
|
+
// mounts. What BloomProvider still supplies — and what these tests assert
|
|
14
|
+
// reaches the subtree — is the expo-router ADAPTER.
|
|
15
15
|
|
|
16
16
|
import { createElement, type ReactNode } from 'react';
|
|
17
17
|
import { act } from 'react';
|
|
@@ -20,8 +20,8 @@ import { createRoot, type Root } from 'react-dom/client';
|
|
|
20
20
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
|
21
21
|
true;
|
|
22
22
|
|
|
23
|
-
// Only the two navigation hooks `scroll/
|
|
24
|
-
//
|
|
23
|
+
// Only the two navigation hooks `scroll/expo-router` consumes; `virtual` keeps the
|
|
24
|
+
// real (native-heavy) expo-router off the resolver.
|
|
25
25
|
jest.mock(
|
|
26
26
|
'expo-router',
|
|
27
27
|
() => {
|
|
@@ -36,14 +36,7 @@ jest.mock(
|
|
|
36
36
|
{ virtual: true },
|
|
37
37
|
);
|
|
38
38
|
|
|
39
|
-
//
|
|
40
|
-
jest.mock('../provider/scroll-provider', () => ({
|
|
41
|
-
ScrollRestorationProvider:
|
|
42
|
-
jest.requireActual<typeof import('../scroll/index.web')>('../scroll/index.web')
|
|
43
|
-
.ScrollRestorationProvider,
|
|
44
|
-
}));
|
|
45
|
-
|
|
46
|
-
// Imported AFTER the mocks are registered.
|
|
39
|
+
// Imported AFTER the mock is registered.
|
|
47
40
|
import { BloomProvider } from '../provider';
|
|
48
41
|
import { useImageResolver } from '../image-resolver';
|
|
49
42
|
import { useScrollRestoration } from '../scroll/index.web';
|
|
@@ -107,4 +107,191 @@ describe('Tabs', () => {
|
|
|
107
107
|
triggerFor(getByText('Second')).props.accessibilityState.selected,
|
|
108
108
|
).toBe(true);
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The shape a real consumer outside this repo ships, spelled out so a later
|
|
113
|
+
* refactor of the controlled path cannot break it silently. OxyPay's wallet
|
|
114
|
+
* screen (`packages/frontend/app/(tabs)/index.tsx`) renders exactly this: a
|
|
115
|
+
* controlled `value` + `onValueChange`, `variant="underline"`, a `style`
|
|
116
|
+
* override zeroing the bottom border, and label-only triggers.
|
|
117
|
+
*
|
|
118
|
+
* A Bloom bump reaches it, and it has no test of its own here — so this is
|
|
119
|
+
* the only thing standing between that screen and a silent regression.
|
|
120
|
+
*/
|
|
121
|
+
it('supports the controlled call shape a published consumer uses', () => {
|
|
122
|
+
const onValueChange = jest.fn();
|
|
123
|
+
const { getByText, getByTestId } = renderWithTheme(
|
|
124
|
+
<Tabs
|
|
125
|
+
value="overview"
|
|
126
|
+
onValueChange={onValueChange}
|
|
127
|
+
variant="underline"
|
|
128
|
+
style={{ borderBottomWidth: 0 }}
|
|
129
|
+
testID="tabs"
|
|
130
|
+
>
|
|
131
|
+
<TabsTrigger value="overview" label="Overview" />
|
|
132
|
+
<TabsTrigger value="activity" label="Activity" />
|
|
133
|
+
</Tabs>,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
expect(getByTestId('tabs-indicator')).toBeTruthy();
|
|
137
|
+
expect(triggerFor(getByText('Overview')).props.accessibilityState.selected).toBe(true);
|
|
138
|
+
fireEvent.press(getByText('Activity'));
|
|
139
|
+
expect(onValueChange).toHaveBeenCalledWith('activity');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('focus-driven path (router adapter)', () => {
|
|
143
|
+
it('takes selection from isFocused rather than the bar value', () => {
|
|
144
|
+
// No `value` at all — the router owns the selection.
|
|
145
|
+
const { getByText } = renderWithTheme(
|
|
146
|
+
<Tabs testID="tabs">
|
|
147
|
+
<TabsTrigger value="a" label="First" isFocused={false} />
|
|
148
|
+
<TabsTrigger value="b" label="Second" isFocused />
|
|
149
|
+
</Tabs>,
|
|
150
|
+
);
|
|
151
|
+
expect(triggerFor(getByText('First')).props.accessibilityState.selected).toBe(false);
|
|
152
|
+
expect(triggerFor(getByText('Second')).props.accessibilityState.selected).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('does NOT report a selection on press, because the caller navigates', () => {
|
|
156
|
+
// Two writers on one underline is the bug this prevents: the router
|
|
157
|
+
// moves it via `isFocused`, so the bar must not also move it on press.
|
|
158
|
+
const onValueChange = jest.fn();
|
|
159
|
+
const onPress = jest.fn();
|
|
160
|
+
const { getByText } = renderWithTheme(
|
|
161
|
+
<Tabs onValueChange={onValueChange} testID="tabs">
|
|
162
|
+
<TabsTrigger value="a" label="First" isFocused />
|
|
163
|
+
<TabsTrigger value="b" label="Second" isFocused={false} onPress={onPress} />
|
|
164
|
+
</Tabs>,
|
|
165
|
+
);
|
|
166
|
+
fireEvent.press(getByText('Second'));
|
|
167
|
+
expect(onPress).toHaveBeenCalled();
|
|
168
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('still reports a selection on press when isFocused is absent', () => {
|
|
172
|
+
// The discriminator is `isFocused` being supplied AT ALL, not its value —
|
|
173
|
+
// so a controlled bar keeps working exactly as before.
|
|
174
|
+
const onValueChange = jest.fn();
|
|
175
|
+
const { getByText } = renderWithTheme(<Bar value="a" onValueChange={onValueChange} />);
|
|
176
|
+
fireEvent.press(getByText('Second'));
|
|
177
|
+
expect(onValueChange).toHaveBeenCalledWith('b');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('counts', () => {
|
|
182
|
+
it('renders a count beside the label and folds it into the a11y label', () => {
|
|
183
|
+
const { getByText, queryByText } = renderWithTheme(
|
|
184
|
+
<Tabs value="a" onValueChange={() => {}} testID="tabs">
|
|
185
|
+
<TabsTrigger value="a" label="First" count={3} />
|
|
186
|
+
<TabsTrigger value="b" label="Second" count={0} />
|
|
187
|
+
</Tabs>,
|
|
188
|
+
);
|
|
189
|
+
expect(getByText('3')).toBeTruthy();
|
|
190
|
+
// A zero count renders nothing at all rather than a literal "0".
|
|
191
|
+
expect(queryByText('0')).toBeNull();
|
|
192
|
+
expect(triggerFor(getByText('First')).props.accessibilityLabel).toBe('First, 3');
|
|
193
|
+
expect(triggerFor(getByText('Second')).props.accessibilityLabel).toBe('Second');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('caps a large tally so it cannot stretch the tab', () => {
|
|
197
|
+
const { getByText } = renderWithTheme(
|
|
198
|
+
<Tabs value="a" onValueChange={() => {}} testID="tabs">
|
|
199
|
+
<TabsTrigger value="a" label="First" count={100} />
|
|
200
|
+
<TabsTrigger value="b" label="Second" count={99} />
|
|
201
|
+
</Tabs>,
|
|
202
|
+
);
|
|
203
|
+
expect(getByText('99+')).toBeTruthy();
|
|
204
|
+
expect(getByText('99')).toBeTruthy();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* `hasSelection` — a strip that is rendered while NOTHING it lists is showing.
|
|
210
|
+
*
|
|
211
|
+
* Not hypothetical, and the reason it exists: a routed strip lives in the
|
|
212
|
+
* LAYOUT, and a layout renders its non-tab children too. A profile's
|
|
213
|
+
* `/followers` sits beside its tab routes under one layout, so on that route
|
|
214
|
+
* every trigger reports `isFocused={false}` and, without this, the underline
|
|
215
|
+
* stays parked on whichever tab was visited last — asserting a selection that
|
|
216
|
+
* is not there.
|
|
217
|
+
*
|
|
218
|
+
* The assertion is on OPACITY rather than position on purpose: fading is the
|
|
219
|
+
* whole response, and the underline must NOT travel to a sentinel, which
|
|
220
|
+
* would drag it across every tab on the way out.
|
|
221
|
+
*/
|
|
222
|
+
describe('no selection', () => {
|
|
223
|
+
/**
|
|
224
|
+
* Drive the strip and read the underline back.
|
|
225
|
+
*
|
|
226
|
+
* The re-render after each step is load-bearing, not ceremony: the
|
|
227
|
+
* reanimated jest mock evaluates `useAnimatedStyle`'s mapper at RENDER time
|
|
228
|
+
* (`useAnimatedStyle: (fn) => fn()`), so a shared value written from a
|
|
229
|
+
* layout callback is not visible in the rendered style until something
|
|
230
|
+
* renders again. Without it every assertion here reads the mount frame and
|
|
231
|
+
* "no selection" and "selection" are indistinguishable.
|
|
232
|
+
*/
|
|
233
|
+
function mount(props: Partial<React.ComponentProps<typeof Bar>> = {}) {
|
|
234
|
+
const utils = renderWithTheme(<Bar value="a" {...props} />);
|
|
235
|
+
const renderOnce = (next: Partial<React.ComponentProps<typeof Bar>>) =>
|
|
236
|
+
utils.rerender(
|
|
237
|
+
<BloomThemeProvider mode="light" colorPreset="teal">
|
|
238
|
+
<Bar value="a" {...props} {...next} />
|
|
239
|
+
</BloomThemeProvider>,
|
|
240
|
+
);
|
|
241
|
+
// Twice, for the same reason the mock forces a re-render at all: a prop
|
|
242
|
+
// change is applied by an EFFECT, which runs after the render that
|
|
243
|
+
// carried it, so the first pass still renders the previous value. The
|
|
244
|
+
// second pass has identical props, so no effect re-runs and nothing is
|
|
245
|
+
// written twice — it only re-evaluates the mapper.
|
|
246
|
+
const render = (next: Partial<React.ComponentProps<typeof Bar>>) => {
|
|
247
|
+
renderOnce(next);
|
|
248
|
+
renderOnce(next);
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
...utils,
|
|
252
|
+
settle: () => render({}),
|
|
253
|
+
update: render,
|
|
254
|
+
measureFirstTrigger() {
|
|
255
|
+
let node: ReactTestInstance | null = triggerFor(utils.getByText('First'));
|
|
256
|
+
while (node && node.props?.onLayout === undefined) node = node.parent;
|
|
257
|
+
if (!node) throw new Error('no onLayout ancestor found for the trigger');
|
|
258
|
+
fireEvent(node, 'layout', { nativeEvent: { layout: { x: 0, width: 80 } } });
|
|
259
|
+
},
|
|
260
|
+
opacity: () =>
|
|
261
|
+
flattenStyle(utils.getByTestId('tabs-indicator').props.style).opacity,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
it('starts hidden so an unmeasured strip never flashes an underline at the origin', () => {
|
|
266
|
+
expect(mount().opacity()).toBe(0);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('shows the underline once the active trigger has been measured', () => {
|
|
270
|
+
const bar = mount();
|
|
271
|
+
bar.measureFirstTrigger();
|
|
272
|
+
bar.settle();
|
|
273
|
+
expect(bar.opacity()).toBe(1);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('fades the underline out when hasSelection is false', () => {
|
|
277
|
+
const bar = mount();
|
|
278
|
+
bar.measureFirstTrigger();
|
|
279
|
+
bar.settle();
|
|
280
|
+
expect(bar.opacity()).toBe(1);
|
|
281
|
+
|
|
282
|
+
bar.update({ hasSelection: false });
|
|
283
|
+
expect(bar.opacity()).toBe(0);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('names the component when a trigger is rendered outside a Tabs', () => {
|
|
288
|
+
// Without a provider the old code read a DEFAULT context and silently did
|
|
289
|
+
// nothing — a strip that renders but never moves. Failing loudly is the
|
|
290
|
+
// point; the message has to name the component to be actionable.
|
|
291
|
+
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
292
|
+
expect(() =>
|
|
293
|
+
renderWithTheme(<TabsTrigger value="a" label="First" />),
|
|
294
|
+
).toThrow(/TabsTrigger must be used within a Tabs/);
|
|
295
|
+
spy.mockRestore();
|
|
296
|
+
});
|
|
110
297
|
});
|
|
@@ -79,8 +79,13 @@ const ALLOWED: { peer: string; file: string; why: string }[] = [
|
|
|
79
79
|
},
|
|
80
80
|
{
|
|
81
81
|
peer: 'expo-router',
|
|
82
|
-
file: 'scroll/index.
|
|
83
|
-
why: '
|
|
82
|
+
file: 'scroll/expo-router/index.ts',
|
|
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
|
+
},
|
|
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`',
|
|
84
89
|
},
|
|
85
90
|
];
|
|
86
91
|
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Exercises `scroll/expo-router` — the only module in the scroll primitive that
|
|
6
|
+
// knows a router exists. What it has to get right is the CONTENT id: two
|
|
7
|
+
// screens that show the same thing must produce the same id however they were
|
|
8
|
+
// reached, and one entry recycled to show something else must not.
|
|
9
|
+
//
|
|
10
|
+
// `expo-router` is mocked (`virtual: true`) so jest never resolves the real,
|
|
11
|
+
// native-heavy package; the mock returns the route object shape the real
|
|
12
|
+
// `useRoute()` returns, which is all the adapter reads.
|
|
13
|
+
|
|
14
|
+
import { createElement, type ReactNode } from 'react';
|
|
15
|
+
import { act } from 'react';
|
|
16
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
17
|
+
|
|
18
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
|
19
|
+
true;
|
|
20
|
+
|
|
21
|
+
interface FakeRoute {
|
|
22
|
+
key: string;
|
|
23
|
+
name: string;
|
|
24
|
+
params?: object;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let currentRoute: FakeRoute = { key: 'k', name: 'index', params: {} };
|
|
28
|
+
|
|
29
|
+
jest.mock(
|
|
30
|
+
'expo-router',
|
|
31
|
+
() => ({
|
|
32
|
+
useRoute: () => currentRoute,
|
|
33
|
+
useFocusEffect: () => undefined,
|
|
34
|
+
}),
|
|
35
|
+
{ virtual: true },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Imported AFTER the mock is registered.
|
|
39
|
+
import { expoRouterScrollAdapter } from '../scroll/expo-router';
|
|
40
|
+
|
|
41
|
+
/** Render the adapter's content-id hook once for a given route object. */
|
|
42
|
+
function contentIdFor(route: FakeRoute): string | null {
|
|
43
|
+
currentRoute = route;
|
|
44
|
+
let captured: string | null | undefined;
|
|
45
|
+
|
|
46
|
+
function Probe(): ReactNode {
|
|
47
|
+
captured = expoRouterScrollAdapter.useScreenContentId();
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const container = document.createElement('div');
|
|
52
|
+
document.body.appendChild(container);
|
|
53
|
+
const root: Root = createRoot(container);
|
|
54
|
+
act(() => {
|
|
55
|
+
root.render(createElement(Probe));
|
|
56
|
+
});
|
|
57
|
+
act(() => {
|
|
58
|
+
root.unmount();
|
|
59
|
+
});
|
|
60
|
+
container.remove();
|
|
61
|
+
|
|
62
|
+
if (captured === undefined) throw new Error('content id was never captured');
|
|
63
|
+
return captured;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('expo-router scroll adapter', () => {
|
|
67
|
+
it('ignores the route key entirely — content is the whole identity', () => {
|
|
68
|
+
// Two different history entries showing the same thing are one screen as
|
|
69
|
+
// far as the offset store is concerned. That is what makes a tab press
|
|
70
|
+
// restore: measured on 56.2.10 and 57.0.9, tapping a tab mints a NEW
|
|
71
|
+
// route key, so an entry-keyed store would miss and open at the top.
|
|
72
|
+
const first = contentIdFor({ key: 'index-A', name: 'index', params: {} });
|
|
73
|
+
const second = contentIdFor({ key: 'index-C', name: 'index', params: {} });
|
|
74
|
+
expect(first).toBe(second);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('distinguishes params under one route name (the recycle)', () => {
|
|
78
|
+
// expo-router reuses one route object — `route.key` included — when
|
|
79
|
+
// `NAVIGATE` targets the current route name and its dynamic segments match,
|
|
80
|
+
// i.e. when only the query changed. Without params in the id the two
|
|
81
|
+
// searches would share an offset.
|
|
82
|
+
const cats = contentIdFor({ key: 'Search-xyz', name: 'search', params: { q: 'cats' } });
|
|
83
|
+
const dogs = contentIdFor({ key: 'Search-xyz', name: 'search', params: { q: 'dogs' } });
|
|
84
|
+
expect(cats).not.toBe(dogs);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('distinguishes content across different routes', () => {
|
|
88
|
+
const a = contentIdFor({ key: 'k', name: 'feeds/index', params: {} });
|
|
89
|
+
const b = contentIdFor({ key: 'k', name: 'saved/index', params: {} });
|
|
90
|
+
expect(a).not.toBe(b);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('is insensitive to param insertion order', () => {
|
|
94
|
+
// expo-router rebuilds the params object on every navigation, so key order
|
|
95
|
+
// is not stable — an order-sensitive id would split one screen in two.
|
|
96
|
+
const first = contentIdFor({
|
|
97
|
+
key: 'k',
|
|
98
|
+
name: 'search',
|
|
99
|
+
params: { q: 'cats', sort: 'top' },
|
|
100
|
+
});
|
|
101
|
+
const second = contentIdFor({
|
|
102
|
+
key: 'k',
|
|
103
|
+
name: 'search',
|
|
104
|
+
params: { sort: 'top', q: 'cats' },
|
|
105
|
+
});
|
|
106
|
+
expect(first).toBe(second);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('ignores the params that describe how a screen was reached, not what it shows', () => {
|
|
110
|
+
// `screen`/`params` are React Navigation's nested-navigator plumbing,
|
|
111
|
+
// `initial` is its anchor flag, and expo-router appends its own
|
|
112
|
+
// `__internal…` params to an ordinary navigation. All four vary with the
|
|
113
|
+
// route taken, not the content shown.
|
|
114
|
+
const plain = contentIdFor({ key: 'k', name: 'p', params: { id: '42' } });
|
|
115
|
+
const decorated = contentIdFor({
|
|
116
|
+
key: 'k',
|
|
117
|
+
name: 'p',
|
|
118
|
+
params: {
|
|
119
|
+
id: '42',
|
|
120
|
+
screen: 'p',
|
|
121
|
+
params: { id: '42' },
|
|
122
|
+
initial: false,
|
|
123
|
+
__internal_expo_router_no_animation: true,
|
|
124
|
+
__internal__expo_router_is_preview_navigation: true,
|
|
125
|
+
__internal_expo_router_zoom_transition_source_id: 'card-42',
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(plain).toBe(decorated);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('separates params that differ only in type or shape', () => {
|
|
133
|
+
const ids = [
|
|
134
|
+
contentIdFor({ key: 'k', name: 'p', params: { id: '1' } }),
|
|
135
|
+
contentIdFor({ key: 'k', name: 'p', params: { id: 1 } }),
|
|
136
|
+
contentIdFor({ key: 'k', name: 'p', params: { id: ['1'] } }),
|
|
137
|
+
contentIdFor({ key: 'k', name: 'p', params: {} }),
|
|
138
|
+
contentIdFor({ key: 'k', name: 'p', params: { id: undefined } }),
|
|
139
|
+
];
|
|
140
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('describes a route with no params at all', () => {
|
|
144
|
+
const withoutParams = contentIdFor({ key: 'k', name: 'settings/index' });
|
|
145
|
+
const withEmptyParams = contentIdFor({
|
|
146
|
+
key: 'k',
|
|
147
|
+
name: 'settings/index',
|
|
148
|
+
params: {},
|
|
149
|
+
});
|
|
150
|
+
expect(withoutParams).toBe(withEmptyParams);
|
|
151
|
+
expect(withoutParams).toContain('settings/index');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('survives a cyclic param object instead of hanging the render', () => {
|
|
155
|
+
// React Navigation expects serializable params but does not enforce it, and
|
|
156
|
+
// a serializer that a cycle can hang has no place on a render path.
|
|
157
|
+
const cyclic: Record<string, unknown> = { name: 'loop' };
|
|
158
|
+
cyclic.self = cyclic;
|
|
159
|
+
|
|
160
|
+
expect(() =>
|
|
161
|
+
contentIdFor({ key: 'k', name: 'p', params: { nested: cyclic } }),
|
|
162
|
+
).not.toThrow();
|
|
163
|
+
});
|
|
164
|
+
});
|