@oxyhq/bloom 1.12.0 → 1.14.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 +1 -1
- package/docs/avatar.mdx +46 -5
- package/docs/index.mdx +1 -1
- package/docs/layout.mdx +21 -35
- package/docs/tab-bar.mdx +35 -0
- package/lib/commonjs/avatar/Avatar.js +24 -8
- package/lib/commonjs/avatar/Avatar.js.map +1 -1
- package/lib/commonjs/avatar/AvatarRing.js +13 -12
- package/lib/commonjs/avatar/AvatarRing.js.map +1 -1
- package/lib/commonjs/avatar/index.js +19 -0
- package/lib/commonjs/avatar/index.js.map +1 -1
- package/lib/commonjs/avatar/resolve-shape.js +43 -0
- package/lib/commonjs/avatar/resolve-shape.js.map +1 -0
- package/lib/commonjs/avatar/shape-paths.js +174 -0
- package/lib/commonjs/avatar/shape-paths.js.map +1 -0
- package/lib/commonjs/fab/Fab.js +4 -55
- package/lib/commonjs/fab/Fab.js.map +1 -1
- package/lib/commonjs/fab/Fab.web.js +1 -29
- package/lib/commonjs/fab/Fab.web.js.map +1 -1
- package/lib/commonjs/layout/bottom-edge.js +47 -92
- package/lib/commonjs/layout/bottom-edge.js.map +1 -1
- package/lib/commonjs/layout/index.js +0 -6
- package/lib/commonjs/layout/index.js.map +1 -1
- package/lib/commonjs/tab-bar/TabBarBase.js +59 -29
- package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
- package/lib/commonjs/tab-bar/expo-router/RouterTabBar.js +5 -0
- package/lib/commonjs/tab-bar/expo-router/RouterTabBar.js.map +1 -1
- package/lib/module/avatar/Avatar.js +24 -8
- package/lib/module/avatar/Avatar.js.map +1 -1
- package/lib/module/avatar/AvatarRing.js +13 -12
- package/lib/module/avatar/AvatarRing.js.map +1 -1
- package/lib/module/avatar/index.js +1 -0
- package/lib/module/avatar/index.js.map +1 -1
- package/lib/module/avatar/resolve-shape.js +40 -0
- package/lib/module/avatar/resolve-shape.js.map +1 -0
- package/lib/module/avatar/shape-paths.js +170 -0
- package/lib/module/avatar/shape-paths.js.map +1 -0
- package/lib/module/fab/Fab.js +6 -57
- package/lib/module/fab/Fab.js.map +1 -1
- package/lib/module/fab/Fab.web.js +2 -30
- package/lib/module/fab/Fab.web.js.map +1 -1
- package/lib/module/layout/bottom-edge.js +47 -91
- package/lib/module/layout/bottom-edge.js.map +1 -1
- package/lib/module/layout/index.js +1 -1
- package/lib/module/layout/index.js.map +1 -1
- package/lib/module/tab-bar/TabBarBase.js +60 -30
- package/lib/module/tab-bar/TabBarBase.js.map +1 -1
- package/lib/module/tab-bar/expo-router/RouterTabBar.js +5 -0
- package/lib/module/tab-bar/expo-router/RouterTabBar.js.map +1 -1
- package/lib/typescript/commonjs/avatar/Avatar.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar/AvatarRing.d.ts +4 -3
- package/lib/typescript/commonjs/avatar/AvatarRing.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar/index.d.ts +3 -1
- package/lib/typescript/commonjs/avatar/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar/resolve-shape.d.ts +17 -0
- package/lib/typescript/commonjs/avatar/resolve-shape.d.ts.map +1 -0
- package/lib/typescript/commonjs/avatar/shape-paths.d.ts +27 -0
- package/lib/typescript/commonjs/avatar/shape-paths.d.ts.map +1 -0
- package/lib/typescript/commonjs/avatar/types.d.ts +30 -3
- package/lib/typescript/commonjs/avatar/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/fab/Fab.d.ts.map +1 -1
- package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/layout/bottom-edge.d.ts +31 -57
- package/lib/typescript/commonjs/layout/bottom-edge.d.ts.map +1 -1
- package/lib/typescript/commonjs/layout/index.d.ts +1 -1
- package/lib/typescript/commonjs/layout/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/expo-router/RouterTabBar.d.ts +5 -0
- package/lib/typescript/commonjs/tab-bar/expo-router/RouterTabBar.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/types.d.ts +36 -5
- package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
- package/lib/typescript/module/avatar/Avatar.d.ts.map +1 -1
- package/lib/typescript/module/avatar/AvatarRing.d.ts +4 -3
- package/lib/typescript/module/avatar/AvatarRing.d.ts.map +1 -1
- package/lib/typescript/module/avatar/index.d.ts +3 -1
- package/lib/typescript/module/avatar/index.d.ts.map +1 -1
- package/lib/typescript/module/avatar/resolve-shape.d.ts +17 -0
- package/lib/typescript/module/avatar/resolve-shape.d.ts.map +1 -0
- package/lib/typescript/module/avatar/shape-paths.d.ts +27 -0
- package/lib/typescript/module/avatar/shape-paths.d.ts.map +1 -0
- package/lib/typescript/module/avatar/types.d.ts +30 -3
- package/lib/typescript/module/avatar/types.d.ts.map +1 -1
- package/lib/typescript/module/fab/Fab.d.ts.map +1 -1
- package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -1
- package/lib/typescript/module/layout/bottom-edge.d.ts +31 -57
- package/lib/typescript/module/layout/bottom-edge.d.ts.map +1 -1
- package/lib/typescript/module/layout/index.d.ts +1 -1
- package/lib/typescript/module/layout/index.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/expo-router/RouterTabBar.d.ts +5 -0
- package/lib/typescript/module/tab-bar/expo-router/RouterTabBar.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/types.d.ts +36 -5
- package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/avatar/Avatar.tsx +27 -10
- package/src/avatar/AvatarRing.tsx +21 -14
- package/src/avatar/index.ts +7 -0
- package/src/avatar/resolve-shape.ts +33 -0
- package/src/avatar/shape-paths.ts +150 -0
- package/src/avatar/types.ts +32 -3
- package/src/fab/Fab.tsx +4 -57
- package/src/fab/Fab.web.tsx +2 -31
- package/src/layout/bottom-edge.tsx +54 -108
- package/src/layout/index.ts +1 -6
- package/src/tab-bar/TabBarBase.tsx +74 -37
- package/src/tab-bar/expo-router/RouterTabBar.tsx +5 -0
- package/src/tab-bar/types.ts +36 -5
- package/src/__tests__/support/collision-fixture-barrel.ts +0 -20
- package/src/__tests__/support/constructed-style-sheets.ts +0 -68
- package/src/__tests__/support/press-host.ts +0 -30
- package/src/__tests__/support/rendered-style.ts +0 -99
- package/src/__tests__/support/unread-hook-fixture.ts +0 -33
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +0 -7682
- package/src/theme/__tests__/fixtures/color-engine-golden.json +0 -1
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
useContext,
|
|
21
21
|
useEffect,
|
|
22
22
|
useMemo,
|
|
23
|
-
useState,
|
|
24
23
|
type ComponentType,
|
|
25
24
|
} from 'react';
|
|
26
25
|
import { Pressable, StyleSheet, useWindowDimensions, View, type ViewStyle } from 'react-native';
|
|
@@ -85,6 +84,13 @@ type BarContextValue = {
|
|
|
85
84
|
theme: TabBarTheme;
|
|
86
85
|
/** Set only on the CONTROLLED path (see `TabBarProps.activeIndex`). */
|
|
87
86
|
activeIndex: number | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* True while a driver owns the highlight's POSITION (see
|
|
89
|
+
* `TabBarProps.activeProgress`). Buttons read it to stand down: their focus
|
|
90
|
+
* effect and their press must not spring `slideIndex` out from under a pager
|
|
91
|
+
* mid-gesture. It says nothing about visibility, which stays where it was.
|
|
92
|
+
*/
|
|
93
|
+
driven: boolean;
|
|
88
94
|
/** Report a selection made by a button press. */
|
|
89
95
|
selectIndex: (index: number) => void;
|
|
90
96
|
};
|
|
@@ -105,6 +111,7 @@ function TabBarBody({
|
|
|
105
111
|
Blur,
|
|
106
112
|
children,
|
|
107
113
|
activeIndex,
|
|
114
|
+
activeProgress,
|
|
108
115
|
onIndexChange,
|
|
109
116
|
onIndexLongPress,
|
|
110
117
|
theme: themeOverrides,
|
|
@@ -191,6 +198,33 @@ function TabBarBody({
|
|
|
191
198
|
// a legitimate effect (external-system sync), the same shape `Tabs` uses for
|
|
192
199
|
// its underline. The focus-driven path is handled per-button instead — see
|
|
193
200
|
// `TabBarButtonBody`.
|
|
201
|
+
// DRIVEN path: a pager owns the highlight's position and writes it every
|
|
202
|
+
// frame, so the position is COPIED rather than sprung — a spring chasing a
|
|
203
|
+
// per-frame value lags behind the finger, which is the whole thing this path
|
|
204
|
+
// exists to avoid.
|
|
205
|
+
//
|
|
206
|
+
// `useAnimatedReaction` cannot be called conditionally, so an unsupplied
|
|
207
|
+
// `activeProgress` is stood in for by a shared value nothing ever writes and
|
|
208
|
+
// the reaction returns early. `driven` changing flips the identity of `source`
|
|
209
|
+
// too, and both are in the deps array, so the reaction re-registers either
|
|
210
|
+
// way.
|
|
211
|
+
//
|
|
212
|
+
// Scrubbing still wins: a finger on the BAR is a direct manipulation of the
|
|
213
|
+
// bar and must not be fought by the pager's settle animation.
|
|
214
|
+
// (Deps: see the CRITICAL note below.)
|
|
215
|
+
const idleProgress = useSharedValue(0);
|
|
216
|
+
const driven = activeProgress !== undefined;
|
|
217
|
+
const source = activeProgress ?? idleProgress;
|
|
218
|
+
useAnimatedReaction(
|
|
219
|
+
() => source.value,
|
|
220
|
+
(value) => {
|
|
221
|
+
if (!driven) return;
|
|
222
|
+
if (isDragging.value) return;
|
|
223
|
+
slideIndex.value = value;
|
|
224
|
+
},
|
|
225
|
+
[source, driven, isDragging, slideIndex],
|
|
226
|
+
);
|
|
227
|
+
|
|
194
228
|
useEffect(() => {
|
|
195
229
|
if (activeIndex === undefined) return;
|
|
196
230
|
// While scrubbing the finger owns the highlight; never fight it.
|
|
@@ -211,10 +245,17 @@ function TabBarBody({
|
|
|
211
245
|
// stale index would animate out of a position the user never saw, and would
|
|
212
246
|
// light up every tab in between on the way. Interrupted mid-fade it is
|
|
213
247
|
// still on screen, so from there it slides as it always does.
|
|
214
|
-
|
|
215
|
-
|
|
248
|
+
//
|
|
249
|
+
// On the DRIVEN path this effect keeps owning visibility and gives up the
|
|
250
|
+
// position: the driver is already writing `slideIndex` every frame, and a
|
|
251
|
+
// spring started here would drag the capsule across the bar between two of
|
|
252
|
+
// its writes.
|
|
253
|
+
if (!driven) {
|
|
254
|
+
slideIndex.value =
|
|
255
|
+
highlightOpacity.value === 0 ? activeIndex : withSpring(activeIndex, SLIDE_SPRING);
|
|
256
|
+
}
|
|
216
257
|
highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
217
|
-
}, [activeIndex, hasSelection, slideIndex, highlightOpacity, isDragging]);
|
|
258
|
+
}, [activeIndex, hasSelection, driven, slideIndex, highlightOpacity, isDragging]);
|
|
218
259
|
|
|
219
260
|
// Scrubbing: the highlight tracks the finger 1:1 while dragging (no spring —
|
|
220
261
|
// it must feel attached), haptic ticks fire on boundary crossings, and
|
|
@@ -294,8 +335,15 @@ function TabBarBody({
|
|
|
294
335
|
const index = Math.round(indexAtX(event.x, progress.value));
|
|
295
336
|
// Same rule as the controlled path above: appear at the tapped tab when
|
|
296
337
|
// hidden, slide to it when already on screen.
|
|
297
|
-
|
|
298
|
-
|
|
338
|
+
//
|
|
339
|
+
// A tap is a discrete writer, so on the DRIVEN path it stands down like
|
|
340
|
+
// the others and the driver carries the highlight to the tapped tab.
|
|
341
|
+
// The scrub is the one gesture that does not: a finger dragging the
|
|
342
|
+
// pill is manipulating the bar itself, not asking for a page.
|
|
343
|
+
if (!driven) {
|
|
344
|
+
slideIndex.value =
|
|
345
|
+
highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
|
|
346
|
+
}
|
|
299
347
|
highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
300
348
|
setMinimized(minimized, 0);
|
|
301
349
|
runOnJS(selectIndex)(index);
|
|
@@ -326,6 +374,7 @@ function TabBarBody({
|
|
|
326
374
|
hasLongPress,
|
|
327
375
|
longPressIndex,
|
|
328
376
|
tick,
|
|
377
|
+
driven,
|
|
329
378
|
isDragging,
|
|
330
379
|
lastTicked,
|
|
331
380
|
slideIndex,
|
|
@@ -424,33 +473,13 @@ function TabBarBody({
|
|
|
424
473
|
// its own layout can never drift from where the bar actually sits.
|
|
425
474
|
const bottomOffset = windowEdgeGap(insets.bottom);
|
|
426
475
|
|
|
427
|
-
// Whether the bar has settled into its minimized size, as REACT state.
|
|
428
|
-
//
|
|
429
|
-
// Reacting on `target` rather than on `progress` is what keeps this cheap:
|
|
430
|
-
// `target` is the binary 0/1 the minimize spring is heading for, so this fires
|
|
431
|
-
// twice per scroll gesture instead of once per frame. Crossing `progress > 0.5`
|
|
432
|
-
// would work too and would cost the same, but it would report the change
|
|
433
|
-
// halfway through the animation rather than when it was decided.
|
|
434
|
-
const [isMinimized, setIsMinimized] = useState(false);
|
|
435
|
-
useAnimatedReaction(
|
|
436
|
-
() => minimized.target.value,
|
|
437
|
-
(target, previous) => {
|
|
438
|
-
if (target !== previous) runOnJS(setIsMinimized)(target === 1);
|
|
439
|
-
},
|
|
440
|
-
[minimized],
|
|
441
|
-
);
|
|
442
|
-
|
|
443
476
|
// Publish what the bar occupies so anything else at this edge stacks above it
|
|
444
|
-
// rather than behind it. Same
|
|
445
|
-
//
|
|
446
|
-
// where the bar actually sits.
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
// space (a list's bottom padding, a toast) must keep room for the full pill or
|
|
451
|
-
// it would jitter on every scroll. The collapse travels as its own STATE — see
|
|
452
|
-
// `layout/bottom-edge` for why it is a boolean and not the live height.
|
|
453
|
-
useClaimBottomEdge(bottomOffset + EXPANDED_HEIGHT, isMinimized);
|
|
477
|
+
// rather than behind it. Same number `useTabBarFootprint` reports, derived from
|
|
478
|
+
// the same two values, so a consumer reading either can never disagree with
|
|
479
|
+
// where the bar actually sits. The EXPANDED height on purpose: the bar
|
|
480
|
+
// minimizes on scroll and re-expands, so claiming the minimized height would
|
|
481
|
+
// drop a FAB onto the pill the moment the user scrolled back up.
|
|
482
|
+
useClaimBottomEdge(bottomOffset + EXPANDED_HEIGHT);
|
|
454
483
|
|
|
455
484
|
// How centring and the animated inset compose: centring is STATIC and belongs
|
|
456
485
|
// to the wrap, the inset stays ANIMATED on the pill inside it. The wrap is
|
|
@@ -466,8 +495,8 @@ function TabBarBody({
|
|
|
466
495
|
const constrainedWrapStyle: ViewStyle | null =
|
|
467
496
|
maxWidth === undefined ? null : { width: barOuterWidth, alignSelf: 'center' };
|
|
468
497
|
const barContext = useMemo(
|
|
469
|
-
() => ({ slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex }),
|
|
470
|
-
[slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex],
|
|
498
|
+
() => ({ slideIndex, highlightOpacity, isDragging, theme, activeIndex, driven, selectIndex }),
|
|
499
|
+
[slideIndex, highlightOpacity, isDragging, theme, activeIndex, driven, selectIndex],
|
|
471
500
|
);
|
|
472
501
|
|
|
473
502
|
return (
|
|
@@ -544,6 +573,10 @@ function TabBarButtonBody({
|
|
|
544
573
|
useEffect(() => {
|
|
545
574
|
if (isFocused === undefined || !isFocused || !bar) return;
|
|
546
575
|
if (bar.isDragging.value) return;
|
|
576
|
+
// A driver owns the position — see `TabBarProps.activeProgress`. It is
|
|
577
|
+
// already tracking this same focus change; springing from here as well would
|
|
578
|
+
// be a second writer on one shared value.
|
|
579
|
+
if (bar.driven) return;
|
|
547
580
|
bar.slideIndex.value = withSpring(index, SLIDE_SPRING);
|
|
548
581
|
}, [isFocused, index, bar]);
|
|
549
582
|
|
|
@@ -611,9 +644,13 @@ function TabBarButtonBody({
|
|
|
611
644
|
// for assistive-technology activation (VoiceOver) and keyboard focus.
|
|
612
645
|
if (bar) {
|
|
613
646
|
// Appear at the tab when hidden, slide to it when visible — the same
|
|
614
|
-
// rule the tap gesture and the controlled path follow.
|
|
615
|
-
|
|
616
|
-
|
|
647
|
+
// rule the tap gesture and the controlled path follow. Skipped on the
|
|
648
|
+
// driven path, where the position belongs to the driver and this
|
|
649
|
+
// press will reach it as an ordinary selection.
|
|
650
|
+
if (!bar.driven) {
|
|
651
|
+
bar.slideIndex.value =
|
|
652
|
+
bar.highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
|
|
653
|
+
}
|
|
617
654
|
bar.highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
618
655
|
}
|
|
619
656
|
setMinimized(minimized, 0);
|
|
@@ -19,6 +19,11 @@ import type { TabBarItem, TabBarProps } from '../types';
|
|
|
19
19
|
* by each trigger's own focus (see `RouterTabBarButton`), which is what keeps it
|
|
20
20
|
* correct through deep links, back gestures and any other programmatic
|
|
21
21
|
* navigation. Setting both would put two writers on one shared value.
|
|
22
|
+
*
|
|
23
|
+
* `activeProgress` is NOT omitted, and that is not an oversight. It answers a
|
|
24
|
+
* different question — where the highlight is, not whether there is one — so a
|
|
25
|
+
* router-focused bar whose screens are PAGED can pass it and keep the focus
|
|
26
|
+
* path for selection. See `TabBarProps.activeProgress`.
|
|
22
27
|
*/
|
|
23
28
|
export type RouterTabBarProps = TabListProps & Omit<TabBarProps, 'activeIndex'>;
|
|
24
29
|
|
package/src/tab-bar/types.ts
CHANGED
|
@@ -4,14 +4,21 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Public API types for `@oxyhq/bloom/tab-bar`.
|
|
6
6
|
*
|
|
7
|
-
* This module
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* three at once. Platform behaviour lives in `surface.native.tsx` /
|
|
11
|
-
* `glyph.native.tsx` / `surface.web.tsx` and the `expo-router/` adapter
|
|
7
|
+
* This module names no PLATFORM package. It is resolved by a consumer's `tsc`,
|
|
8
|
+
* by Metro, and by every web bundler alike, so an import here of
|
|
9
|
+
* expo-glass-effect / expo-symbols / expo-router / react-native-screens would
|
|
10
|
+
* break all three at once. Platform behaviour lives in `surface.native.tsx` /
|
|
11
|
+
* `glyph.native.tsx` / `surface.web.tsx` and the `expo-router/` adapter; the
|
|
12
|
+
* gate is `src/__tests__/TabBarWebFork.test.ts`.
|
|
13
|
+
*
|
|
14
|
+
* `react-native-reanimated` is exempt and always was: it is a REQUIRED peer that
|
|
15
|
+
* every neutral module in this family already imports at runtime
|
|
16
|
+
* (`TabBarBase.tsx`), and the one reference below is `import type`, erased
|
|
17
|
+
* before any bundler sees it.
|
|
12
18
|
*/
|
|
13
19
|
import type { ReactNode } from 'react';
|
|
14
20
|
import type { PressableProps, ViewProps } from 'react-native';
|
|
21
|
+
import type { SharedValue } from 'react-native-reanimated';
|
|
15
22
|
|
|
16
23
|
export type TabBarItem = {
|
|
17
24
|
/** Stable identity for the tab (a route name in router-driven usage). */
|
|
@@ -92,6 +99,30 @@ export type TabBarProps = ViewProps & {
|
|
|
92
99
|
* highlight from its own `isFocused`.
|
|
93
100
|
*/
|
|
94
101
|
activeIndex?: number;
|
|
102
|
+
/**
|
|
103
|
+
* DRIVEN path — the highlight's live position, in TAB UNITS, on the UI thread.
|
|
104
|
+
* A pager writes its own page position here (`1.4` is 40% of the way from the
|
|
105
|
+
* second tab to the third) and the highlight tracks the finger 1:1 instead of
|
|
106
|
+
* springing to a settled index after the navigation commits.
|
|
107
|
+
*
|
|
108
|
+
* It says WHERE the highlight is, never WHETHER there is one. Selection stays
|
|
109
|
+
* with the path already in play: `activeIndex` when the bar is controlled (an
|
|
110
|
+
* index naming no tab still fades the highlight out — see above), each
|
|
111
|
+
* button's `isFocused` when it is not. Passing this alone, with neither, is a
|
|
112
|
+
* bar that is always selected, which is what the focus-driven path already
|
|
113
|
+
* means.
|
|
114
|
+
*
|
|
115
|
+
* PRECEDENCE, because a shared value with two writers is a race: the SCRUB
|
|
116
|
+
* always wins — a finger on the bar is a direct manipulation of the bar, and
|
|
117
|
+
* releasing it reports through `onIndexChange` like any other selection. Below
|
|
118
|
+
* that, this value wins over every discrete writer: while it is supplied, the
|
|
119
|
+
* controlled effect, the focus effect and a `TabBarButton`'s own press all
|
|
120
|
+
* stop writing the position and leave it to the driver. So a consumer that
|
|
121
|
+
* passes this must keep it truthful — spring it to the settled index after a
|
|
122
|
+
* commit, and after any navigation it did not cause (a deep link, a back
|
|
123
|
+
* gesture), or the highlight stays where the last gesture left it.
|
|
124
|
+
*/
|
|
125
|
+
activeProgress?: SharedValue<number>;
|
|
95
126
|
/**
|
|
96
127
|
* Called with the chosen index when a tab is picked by tap, by scrub release,
|
|
97
128
|
* or by keyboard/assistive-technology activation of a `TabBarButton`.
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A barrel that DELIBERATELY offers one name from two declarations, so that
|
|
3
|
-
* `barrel-name-collisions.test.ts` can prove its detector fires. It reproduces
|
|
4
|
-
* the shipped `Item` shape: a star export carrying `Item` from `item/`, and an
|
|
5
|
-
* explicit re-export binding that same name to a different component.
|
|
6
|
-
*
|
|
7
|
-
* Nothing imports this at runtime. bob's `exclude` keeps it out of `lib/`, and
|
|
8
|
-
* `files: ["src", …]` does ship it inside the tarball's `src/` like the other
|
|
9
|
-
* test files there — but no entry point reaches it, so no bundler links it, and
|
|
10
|
-
* `package.json#exports` never names it.
|
|
11
|
-
*
|
|
12
|
-
* The `Card` lines below are the other half of the
|
|
13
|
-
* control: one name offered twice from ONE declaration, which the detector must
|
|
14
|
-
* stay quiet about — otherwise its cheapest fix would be deleting a legitimate
|
|
15
|
-
* redundant re-export.
|
|
16
|
-
*/
|
|
17
|
-
export * from '../../item';
|
|
18
|
-
export { Card as Item } from '../../card';
|
|
19
|
-
export { Card } from '../../card';
|
|
20
|
-
export * from '../../card';
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A spec-shaped stand-in for constructed stylesheets, for the jsdom suites.
|
|
3
|
-
*
|
|
4
|
-
* jsdom 26 ships the `CSSStyleSheet` CONSTRUCTOR but neither `replaceSync` nor
|
|
5
|
-
* `document.adoptedStyleSheets`, so every jsdom test takes Bloom's `<style>`
|
|
6
|
-
* fallback. That makes the whole CSP-safe path — the reason
|
|
7
|
-
* `styles/adopt-style-sheet.ts` exists — invisible to jest unless a test
|
|
8
|
-
* installs the API itself. Without this, a mutation that deleted the adoption
|
|
9
|
-
* branch entirely would leave the suite green.
|
|
10
|
-
*
|
|
11
|
-
* Not collected as a suite: jest's `testMatch` wants `*.test.ts` / `*.spec.ts`.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
export interface FakeStyleSheet {
|
|
15
|
-
/** The CSS most recently handed to `replaceSync`. */
|
|
16
|
-
cssText: string;
|
|
17
|
-
/** How many times the sheet has been re-parsed, so a test can prove it wasn't. */
|
|
18
|
-
replaceSyncCalls: number;
|
|
19
|
-
replaceSync(css: string): void;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface ConstructedStyleSheetsHarness {
|
|
23
|
-
/** The document's adopted sheets, read live (the code reassigns the array). */
|
|
24
|
-
adopted(): readonly FakeStyleSheet[];
|
|
25
|
-
/** Drop the API again, restoring jsdom's own `CSSStyleSheet`. */
|
|
26
|
-
uninstall(): void;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function installConstructedStyleSheets(): ConstructedStyleSheetsHarness {
|
|
30
|
-
class FakeCSSStyleSheet implements FakeStyleSheet {
|
|
31
|
-
cssText = '';
|
|
32
|
-
replaceSyncCalls = 0;
|
|
33
|
-
|
|
34
|
-
replaceSync(css: string): void {
|
|
35
|
-
this.cssText = css;
|
|
36
|
-
this.replaceSyncCalls += 1;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const previousConstructor = Object.getOwnPropertyDescriptor(
|
|
41
|
-
globalThis,
|
|
42
|
-
'CSSStyleSheet',
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
Object.defineProperty(globalThis, 'CSSStyleSheet', {
|
|
46
|
-
value: FakeCSSStyleSheet,
|
|
47
|
-
writable: true,
|
|
48
|
-
configurable: true,
|
|
49
|
-
});
|
|
50
|
-
Object.defineProperty(document, 'adoptedStyleSheets', {
|
|
51
|
-
value: [],
|
|
52
|
-
writable: true,
|
|
53
|
-
configurable: true,
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
return {
|
|
57
|
-
adopted: () =>
|
|
58
|
-
document.adoptedStyleSheets as unknown as readonly FakeStyleSheet[],
|
|
59
|
-
uninstall: () => {
|
|
60
|
-
Reflect.deleteProperty(document, 'adoptedStyleSheets');
|
|
61
|
-
if (previousConstructor) {
|
|
62
|
-
Object.defineProperty(globalThis, 'CSSStyleSheet', previousConstructor);
|
|
63
|
-
} else {
|
|
64
|
-
Reflect.deleteProperty(globalThis, 'CSSStyleSheet');
|
|
65
|
-
}
|
|
66
|
-
},
|
|
67
|
-
};
|
|
68
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pressing a component's OWN host node, and proving the component is what
|
|
3
|
-
* installed the handler being measured.
|
|
4
|
-
*
|
|
5
|
-
* `fireEvent.press` walks UP from the element it is handed to the nearest
|
|
6
|
-
* ancestor carrying an `onPress` prop, and it does not stop at host nodes — a
|
|
7
|
-
* COMPOSITE counts, because `getEventHandler` only reads `element.props`. So
|
|
8
|
-
* `<Thing onPress={fn}>` written in a test's own JSX catches the press itself.
|
|
9
|
-
* Measured on `ProfileCard`: with the handler deleted from the component the
|
|
10
|
-
* card rendered as an inert `View`, and the test still reported exactly one
|
|
11
|
-
* call. Green, and measuring nothing. Nine suites had the same shape.
|
|
12
|
-
*
|
|
13
|
-
* Asserting the host node's own `onPress` FIRST closes both halves:
|
|
14
|
-
*
|
|
15
|
-
* - a component that installs no handler at all fails here, and
|
|
16
|
-
* - a component that installs one which drops the caller's fails on the call
|
|
17
|
-
* count, because `fireEvent` now finds a handler AT the node and never walks
|
|
18
|
-
* past it to the test's own JSX.
|
|
19
|
-
*
|
|
20
|
-
* `host` must therefore be the node the component itself made pressable — the
|
|
21
|
-
* one carrying its `testID` / `accessibilityLabel` — not a `Text` deep inside
|
|
22
|
-
* it, or the walk-up is back and so is the hole.
|
|
23
|
-
*/
|
|
24
|
-
import { fireEvent } from '@testing-library/react-native';
|
|
25
|
-
import type { ReactTestInstance } from 'react-test-renderer';
|
|
26
|
-
|
|
27
|
-
export function pressHost(host: ReactTestInstance): void {
|
|
28
|
-
expect(typeof host.props.onPress).toBe('function');
|
|
29
|
-
fireEvent.press(host);
|
|
30
|
-
}
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reading what actually landed on a rendered node — shared by every suite that
|
|
3
|
-
* asserts the "className lands on the node the parent lays out" rule.
|
|
4
|
-
*
|
|
5
|
-
* Three components had the same two-node defect (`Button`, then `Fab` and
|
|
6
|
-
* `FrostedIconButton`), and asserting it needs the same two things each time:
|
|
7
|
-
* the HOST tree (composites do not appear in `toJSON()`, so a wrapper is only
|
|
8
|
-
* visible there) and a deep flatten of the `style` prop, because react-native-css
|
|
9
|
-
* merges a caller's class in as a `{ $$css: true, className }` descriptor
|
|
10
|
-
* ALONGSIDE the style objects rather than in a prop of its own.
|
|
11
|
-
*/
|
|
12
|
-
import type { ReactTestRendererJSON } from 'react-test-renderer';
|
|
13
|
-
|
|
14
|
-
export type StyleEntry = Record<string, unknown>;
|
|
15
|
-
|
|
16
|
-
export interface HostNode {
|
|
17
|
-
type: string;
|
|
18
|
-
props: Record<string, unknown>;
|
|
19
|
-
children: Array<HostNode | string> | null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Deep-flatten an RN `style` prop (nested arrays, `false`/`null` holes) into the
|
|
24
|
-
* list of style objects that actually reached the node.
|
|
25
|
-
*/
|
|
26
|
-
export function styleEntries(style: unknown, out: StyleEntry[] = []): StyleEntry[] {
|
|
27
|
-
if (!style || typeof style !== 'object') return out;
|
|
28
|
-
if (Array.isArray(style)) {
|
|
29
|
-
for (const entry of style) styleEntries(entry, out);
|
|
30
|
-
return out;
|
|
31
|
-
}
|
|
32
|
-
out.push(style as StyleEntry);
|
|
33
|
-
return out;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Every style key/value that landed on a node, later entries winning. */
|
|
37
|
-
export function resolvedStyle(style: unknown): StyleEntry {
|
|
38
|
-
return Object.assign({}, ...styleEntries(style)) as StyleEntry;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** The class tokens react-native-css accepted for a node, in arrival order. */
|
|
42
|
-
export function classNamesOn(style: unknown): string[] {
|
|
43
|
-
return styleEntries(style)
|
|
44
|
-
.filter((entry) => entry.$$css === true && typeof entry.className === 'string')
|
|
45
|
-
.map((entry) => String(entry.className));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function isHostNode(value: unknown): value is HostNode {
|
|
49
|
-
return (
|
|
50
|
-
typeof value === 'object' && value !== null && typeof (value as HostNode).type === 'string'
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Walk the HOST tree (composites are absent from `toJSON()`) for a testID. */
|
|
55
|
-
export function findHost(
|
|
56
|
-
node: ReactTestRendererJSON | ReactTestRendererJSON[] | unknown,
|
|
57
|
-
testID: string,
|
|
58
|
-
): HostNode | null {
|
|
59
|
-
if (Array.isArray(node)) {
|
|
60
|
-
for (const child of node) {
|
|
61
|
-
const hit = findHost(child, testID);
|
|
62
|
-
if (hit) return hit;
|
|
63
|
-
}
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
if (!isHostNode(node)) return null;
|
|
67
|
-
if (node.props.testID === testID) return node;
|
|
68
|
-
return findHost(node.children, testID);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Every host node in a rendered tree, in document order.
|
|
73
|
-
*
|
|
74
|
-
* The reason to walk `toJSON()` rather than `UNSAFE_root.findAll` is that the
|
|
75
|
-
* latter yields COMPOSITE instances too, so a `memo(fn)` component contributes
|
|
76
|
-
* two matches for one rendered element and every count is silently doubled —
|
|
77
|
-
* which reads as "the component rendered the thing twice", not as an artefact
|
|
78
|
-
* of the query.
|
|
79
|
-
*/
|
|
80
|
-
export function hostNodes(tree: unknown, out: HostNode[] = []): HostNode[] {
|
|
81
|
-
if (Array.isArray(tree)) {
|
|
82
|
-
for (const child of tree) hostNodes(child, out);
|
|
83
|
-
return out;
|
|
84
|
-
}
|
|
85
|
-
if (!isHostNode(tree)) return out;
|
|
86
|
-
out.push(tree);
|
|
87
|
-
return hostNodes(tree.children, out);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* The host nodes a parent laid out. Callers assert the LENGTH themselves —
|
|
92
|
-
* "exactly one" is the property, and a helper that returned only the first would
|
|
93
|
-
* hide the second.
|
|
94
|
-
*/
|
|
95
|
-
export function renderedChildren(tree: unknown, hostTestID: string): HostNode[] {
|
|
96
|
-
const host = findHost(tree, hostTestID);
|
|
97
|
-
if (host === null) throw new Error(`no host rendered for testID "${hostTestID}"`);
|
|
98
|
-
return (host.children ?? []).filter(isHostNode);
|
|
99
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A module that DELIBERATELY throws away one of the values a hook hands back, so
|
|
3
|
-
* `hook-subscriptions-are-read.test.ts` can prove its detector fires. It carries
|
|
4
|
-
* both halves of the control: `unreadFlag` is bound and never read, which the
|
|
5
|
-
* detector must report, and `readFlag` is bound and read, which it must stay
|
|
6
|
-
* quiet about — otherwise the cheapest way to green the gate would be deleting a
|
|
7
|
-
* binding that is doing its job.
|
|
8
|
-
*
|
|
9
|
-
* Nothing imports this at runtime. bob's `exclude` keeps it out of `lib/`, and
|
|
10
|
-
* `files: ["src", …]` does ship it inside the tarball's `src/` like the other
|
|
11
|
-
* 137 test files there — but no entry point reaches it, so no bundler links it.
|
|
12
|
-
* It is a plain `.ts` with no React import because the
|
|
13
|
-
* detector reads source, never types: a call named `use…` bound to a name is all
|
|
14
|
-
* the shape there is.
|
|
15
|
-
*
|
|
16
|
-
* The destructuring RENAMES, which is not decoration — it reproduces the shipped
|
|
17
|
-
* shape (`const { state: pressed, onIn: onPressIn } = useInteractionState()`) and
|
|
18
|
-
* it is what keeps the control honest. A hook declared in the same file as its
|
|
19
|
-
* caller mentions its own property names in its return type and its return
|
|
20
|
-
* object, and the detector counts every occurrence of a spelling as a possible
|
|
21
|
-
* read, so an unrenamed fixture would be silently unreportable for a reason no
|
|
22
|
-
* real call site has.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
/** Stands in for a real subscription — a hook by name, with two return values. */
|
|
26
|
-
function useFixtureInteractionState(): { first: boolean; second: boolean } {
|
|
27
|
-
return { first: false, second: false };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function fixtureComponentBody(): boolean {
|
|
31
|
-
const { first: readFlag, second: unreadFlag } = useFixtureInteractionState();
|
|
32
|
-
return readFlag;
|
|
33
|
-
}
|