@oxyhq/bloom 1.13.0 → 1.14.1
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/media-flight.mdx +15 -6
- 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/media-flight/MediaSurface.js +51 -10
- package/lib/commonjs/media-flight/MediaSurface.js.map +1 -1
- package/lib/commonjs/media-flight/constants.js +32 -7
- package/lib/commonjs/media-flight/constants.js.map +1 -1
- package/lib/commonjs/tab-bar/TabBarBase.js +87 -13
- 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/commonjs/theme/color-scope/ColorScope.js +42 -12
- package/lib/commonjs/theme/color-scope/ColorScope.js.map +1 -1
- package/lib/commonjs/theme/color-scope/ColorScope.web.js +2 -8
- package/lib/commonjs/theme/color-scope/ColorScope.web.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/media-flight/MediaSurface.js +52 -11
- package/lib/module/media-flight/MediaSurface.js.map +1 -1
- package/lib/module/media-flight/constants.js +31 -6
- package/lib/module/media-flight/constants.js.map +1 -1
- package/lib/module/tab-bar/TabBarBase.js +89 -15
- 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/module/theme/color-scope/ColorScope.js +45 -13
- package/lib/module/theme/color-scope/ColorScope.js.map +1 -1
- package/lib/module/theme/color-scope/ColorScope.web.js +3 -9
- package/lib/module/theme/color-scope/ColorScope.web.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/media-flight/MediaSurface.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/constants.d.ts +30 -6
- package/lib/typescript/commonjs/media-flight/constants.d.ts.map +1 -1
- package/lib/typescript/commonjs/media-flight/types.d.ts +7 -2
- package/lib/typescript/commonjs/media-flight/types.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/commonjs/theme/color-scope/ColorScope.d.ts +36 -2
- package/lib/typescript/commonjs/theme/color-scope/ColorScope.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-scope/ColorScope.web.d.ts +6 -2
- package/lib/typescript/commonjs/theme/color-scope/ColorScope.web.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/media-flight/MediaSurface.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/constants.d.ts +30 -6
- package/lib/typescript/module/media-flight/constants.d.ts.map +1 -1
- package/lib/typescript/module/media-flight/types.d.ts +7 -2
- package/lib/typescript/module/media-flight/types.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/lib/typescript/module/theme/color-scope/ColorScope.d.ts +36 -2
- package/lib/typescript/module/theme/color-scope/ColorScope.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-scope/ColorScope.web.d.ts +6 -2
- package/lib/typescript/module/theme/color-scope/ColorScope.web.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/media-flight/MediaSurface.tsx +60 -14
- package/src/media-flight/constants.ts +31 -6
- package/src/media-flight/types.ts +7 -2
- package/src/tab-bar/TabBarBase.tsx +110 -17
- package/src/tab-bar/expo-router/RouterTabBar.tsx +5 -0
- package/src/tab-bar/types.ts +36 -5
- package/src/theme/color-scope/ColorScope.tsx +53 -12
- package/src/theme/color-scope/ColorScope.web.tsx +10 -8
|
@@ -22,13 +22,21 @@ import {
|
|
|
22
22
|
useMemo,
|
|
23
23
|
type ComponentType,
|
|
24
24
|
} from 'react';
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
Platform,
|
|
27
|
+
Pressable,
|
|
28
|
+
StyleSheet,
|
|
29
|
+
useWindowDimensions,
|
|
30
|
+
View,
|
|
31
|
+
type ViewStyle,
|
|
32
|
+
} from 'react-native';
|
|
26
33
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
27
34
|
import Animated, {
|
|
28
35
|
Extrapolation,
|
|
29
36
|
interpolate,
|
|
30
37
|
interpolateColor,
|
|
31
38
|
runOnJS,
|
|
39
|
+
useAnimatedReaction,
|
|
32
40
|
useAnimatedStyle,
|
|
33
41
|
useSharedValue,
|
|
34
42
|
withSpring,
|
|
@@ -83,6 +91,13 @@ type BarContextValue = {
|
|
|
83
91
|
theme: TabBarTheme;
|
|
84
92
|
/** Set only on the CONTROLLED path (see `TabBarProps.activeIndex`). */
|
|
85
93
|
activeIndex: number | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* True while a driver owns the highlight's POSITION (see
|
|
96
|
+
* `TabBarProps.activeProgress`). Buttons read it to stand down: their focus
|
|
97
|
+
* effect and their press must not spring `slideIndex` out from under a pager
|
|
98
|
+
* mid-gesture. It says nothing about visibility, which stays where it was.
|
|
99
|
+
*/
|
|
100
|
+
driven: boolean;
|
|
86
101
|
/** Report a selection made by a button press. */
|
|
87
102
|
selectIndex: (index: number) => void;
|
|
88
103
|
};
|
|
@@ -103,6 +118,7 @@ function TabBarBody({
|
|
|
103
118
|
Blur,
|
|
104
119
|
children,
|
|
105
120
|
activeIndex,
|
|
121
|
+
activeProgress,
|
|
106
122
|
onIndexChange,
|
|
107
123
|
onIndexLongPress,
|
|
108
124
|
theme: themeOverrides,
|
|
@@ -189,6 +205,33 @@ function TabBarBody({
|
|
|
189
205
|
// a legitimate effect (external-system sync), the same shape `Tabs` uses for
|
|
190
206
|
// its underline. The focus-driven path is handled per-button instead — see
|
|
191
207
|
// `TabBarButtonBody`.
|
|
208
|
+
// DRIVEN path: a pager owns the highlight's position and writes it every
|
|
209
|
+
// frame, so the position is COPIED rather than sprung — a spring chasing a
|
|
210
|
+
// per-frame value lags behind the finger, which is the whole thing this path
|
|
211
|
+
// exists to avoid.
|
|
212
|
+
//
|
|
213
|
+
// `useAnimatedReaction` cannot be called conditionally, so an unsupplied
|
|
214
|
+
// `activeProgress` is stood in for by a shared value nothing ever writes and
|
|
215
|
+
// the reaction returns early. `driven` changing flips the identity of `source`
|
|
216
|
+
// too, and both are in the deps array, so the reaction re-registers either
|
|
217
|
+
// way.
|
|
218
|
+
//
|
|
219
|
+
// Scrubbing still wins: a finger on the BAR is a direct manipulation of the
|
|
220
|
+
// bar and must not be fought by the pager's settle animation.
|
|
221
|
+
// (Deps: see the CRITICAL note below.)
|
|
222
|
+
const idleProgress = useSharedValue(0);
|
|
223
|
+
const driven = activeProgress !== undefined;
|
|
224
|
+
const source = activeProgress ?? idleProgress;
|
|
225
|
+
useAnimatedReaction(
|
|
226
|
+
() => source.value,
|
|
227
|
+
(value) => {
|
|
228
|
+
if (!driven) return;
|
|
229
|
+
if (isDragging.value) return;
|
|
230
|
+
slideIndex.value = value;
|
|
231
|
+
},
|
|
232
|
+
[source, driven, isDragging, slideIndex],
|
|
233
|
+
);
|
|
234
|
+
|
|
192
235
|
useEffect(() => {
|
|
193
236
|
if (activeIndex === undefined) return;
|
|
194
237
|
// While scrubbing the finger owns the highlight; never fight it.
|
|
@@ -209,16 +252,29 @@ function TabBarBody({
|
|
|
209
252
|
// stale index would animate out of a position the user never saw, and would
|
|
210
253
|
// light up every tab in between on the way. Interrupted mid-fade it is
|
|
211
254
|
// still on screen, so from there it slides as it always does.
|
|
212
|
-
|
|
213
|
-
|
|
255
|
+
//
|
|
256
|
+
// On the DRIVEN path this effect keeps owning visibility and gives up the
|
|
257
|
+
// position: the driver is already writing `slideIndex` every frame, and a
|
|
258
|
+
// spring started here would drag the capsule across the bar between two of
|
|
259
|
+
// its writes.
|
|
260
|
+
if (!driven) {
|
|
261
|
+
slideIndex.value =
|
|
262
|
+
highlightOpacity.value === 0 ? activeIndex : withSpring(activeIndex, SLIDE_SPRING);
|
|
263
|
+
}
|
|
214
264
|
highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
215
|
-
}, [activeIndex, hasSelection, slideIndex, highlightOpacity, isDragging]);
|
|
265
|
+
}, [activeIndex, hasSelection, driven, slideIndex, highlightOpacity, isDragging]);
|
|
216
266
|
|
|
217
267
|
// Scrubbing: the highlight tracks the finger 1:1 while dragging (no spring —
|
|
218
268
|
// it must feel attached), haptic ticks fire on boundary crossings, and
|
|
219
269
|
// navigation happens only on release. Taps are handled by a Tap gesture
|
|
220
|
-
// racing the pan
|
|
221
|
-
//
|
|
270
|
+
// racing the pan.
|
|
271
|
+
//
|
|
272
|
+
// THE DETECTOR DOES NOT CONSUME THE PRESS, which this file assumed for a long
|
|
273
|
+
// time. A `Tap` gesture activates on RELEASE, by which point the inner
|
|
274
|
+
// `Pressable` has already fired `onPress`, and RNGH's cancellation of the RN
|
|
275
|
+
// responder comes too late to stop it. Measured on a Pixel 10 Pro: ONE tap
|
|
276
|
+
// produced both, ~600ms apart on a busy JS thread, so every consumer got two
|
|
277
|
+
// selections per tap. `TabBarButtonBody` is where that is resolved.
|
|
222
278
|
const gesture = useMemo(() => {
|
|
223
279
|
const indexAtX = (x: number, minimizedValue: number) => {
|
|
224
280
|
'worklet';
|
|
@@ -292,8 +348,15 @@ function TabBarBody({
|
|
|
292
348
|
const index = Math.round(indexAtX(event.x, progress.value));
|
|
293
349
|
// Same rule as the controlled path above: appear at the tapped tab when
|
|
294
350
|
// hidden, slide to it when already on screen.
|
|
295
|
-
|
|
296
|
-
|
|
351
|
+
//
|
|
352
|
+
// A tap is a discrete writer, so on the DRIVEN path it stands down like
|
|
353
|
+
// the others and the driver carries the highlight to the tapped tab.
|
|
354
|
+
// The scrub is the one gesture that does not: a finger dragging the
|
|
355
|
+
// pill is manipulating the bar itself, not asking for a page.
|
|
356
|
+
if (!driven) {
|
|
357
|
+
slideIndex.value =
|
|
358
|
+
highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
|
|
359
|
+
}
|
|
297
360
|
highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
298
361
|
setMinimized(minimized, 0);
|
|
299
362
|
runOnJS(selectIndex)(index);
|
|
@@ -324,6 +387,7 @@ function TabBarBody({
|
|
|
324
387
|
hasLongPress,
|
|
325
388
|
longPressIndex,
|
|
326
389
|
tick,
|
|
390
|
+
driven,
|
|
327
391
|
isDragging,
|
|
328
392
|
lastTicked,
|
|
329
393
|
slideIndex,
|
|
@@ -444,8 +508,8 @@ function TabBarBody({
|
|
|
444
508
|
const constrainedWrapStyle: ViewStyle | null =
|
|
445
509
|
maxWidth === undefined ? null : { width: barOuterWidth, alignSelf: 'center' };
|
|
446
510
|
const barContext = useMemo(
|
|
447
|
-
() => ({ slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex }),
|
|
448
|
-
[slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex],
|
|
511
|
+
() => ({ slideIndex, highlightOpacity, isDragging, theme, activeIndex, driven, selectIndex }),
|
|
512
|
+
[slideIndex, highlightOpacity, isDragging, theme, activeIndex, driven, selectIndex],
|
|
449
513
|
);
|
|
450
514
|
|
|
451
515
|
return (
|
|
@@ -493,6 +557,9 @@ interface TabBarButtonBodyProps extends TabBarButtonProps {
|
|
|
493
557
|
}
|
|
494
558
|
|
|
495
559
|
/** One tab trigger: icon + label that fades out when the bar minimizes. */
|
|
560
|
+
/** Stable identity: a new array each render would re-send the actions to the OS. */
|
|
561
|
+
const ACTIVATE_ACTIONS = [{ name: 'activate' as const }];
|
|
562
|
+
|
|
496
563
|
function TabBarButtonBody({
|
|
497
564
|
Glyph,
|
|
498
565
|
item,
|
|
@@ -522,6 +589,10 @@ function TabBarButtonBody({
|
|
|
522
589
|
useEffect(() => {
|
|
523
590
|
if (isFocused === undefined || !isFocused || !bar) return;
|
|
524
591
|
if (bar.isDragging.value) return;
|
|
592
|
+
// A driver owns the position — see `TabBarProps.activeProgress`. It is
|
|
593
|
+
// already tracking this same focus change; springing from here as well would
|
|
594
|
+
// be a second writer on one shared value.
|
|
595
|
+
if (bar.driven) return;
|
|
525
596
|
bar.slideIndex.value = withSpring(index, SLIDE_SPRING);
|
|
526
597
|
}, [isFocused, index, bar]);
|
|
527
598
|
|
|
@@ -584,21 +655,43 @@ function TabBarButtonBody({
|
|
|
584
655
|
aria-selected={focused}
|
|
585
656
|
accessibilityLabel={item.label}
|
|
586
657
|
{...pressableProps}
|
|
658
|
+
// ASSISTIVE TECHNOLOGY, on native, is the one activation the bar's gesture
|
|
659
|
+
// cannot see: TalkBack and VoiceOver activate a view without ever
|
|
660
|
+
// producing a touch, so the `Tap` gesture never runs and the selection has
|
|
661
|
+
// to come from here.
|
|
662
|
+
accessibilityActions={ACTIVATE_ACTIONS}
|
|
663
|
+
onAccessibilityAction={(event) => {
|
|
664
|
+
if (event.nativeEvent.actionName !== 'activate') return;
|
|
665
|
+
if (isFocused === undefined) bar?.selectIndex(index);
|
|
666
|
+
}}
|
|
587
667
|
onPress={(event) => {
|
|
588
668
|
// The bar's GestureDetector normally consumes touches; this still fires
|
|
589
669
|
// for assistive-technology activation (VoiceOver) and keyboard focus.
|
|
590
670
|
if (bar) {
|
|
591
671
|
// Appear at the tab when hidden, slide to it when visible — the same
|
|
592
|
-
// rule the tap gesture and the controlled path follow.
|
|
593
|
-
|
|
594
|
-
|
|
672
|
+
// rule the tap gesture and the controlled path follow. Skipped on the
|
|
673
|
+
// driven path, where the position belongs to the driver and this
|
|
674
|
+
// press will reach it as an ordinary selection.
|
|
675
|
+
if (!bar.driven) {
|
|
676
|
+
bar.slideIndex.value =
|
|
677
|
+
bar.highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
|
|
678
|
+
}
|
|
595
679
|
bar.highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
596
680
|
}
|
|
597
681
|
setMinimized(minimized, 0);
|
|
598
|
-
// Controlled path only. On the focus-driven path the
|
|
599
|
-
// `onPress` below performs the navigation, so reporting
|
|
600
|
-
// here as well would navigate twice.
|
|
601
|
-
|
|
682
|
+
// Controlled path only, and NOT on Android. On the focus-driven path the
|
|
683
|
+
// trigger's own `onPress` below performs the navigation, so reporting
|
|
684
|
+
// the selection here as well would navigate twice.
|
|
685
|
+
//
|
|
686
|
+
// ANDROID IS THE ONE PLATFORM THAT DOUBLES. RNGH sets
|
|
687
|
+
// `cancelsTouchesInView` on iOS, so a recognised tap cancels this press
|
|
688
|
+
// and the gesture above is the only reporter there; react-native-web
|
|
689
|
+
// likewise routes a keyboard Enter or Space through this press and
|
|
690
|
+
// nothing else. Android cancels nothing — measured on a Pixel 10 Pro,
|
|
691
|
+
// one tap produced `PRESSABLE selectIndex(1)` AND `GESTURE tap
|
|
692
|
+
// selectIndex(1)` ~600ms apart on a busy JS thread — so there, and only
|
|
693
|
+
// there, this press stands down.
|
|
694
|
+
if (isFocused === undefined && Platform.OS !== 'android') bar?.selectIndex(index);
|
|
602
695
|
onPress?.(event);
|
|
603
696
|
}}
|
|
604
697
|
// `Pressable`'s `style` also accepts a function of the press state; both
|
|
@@ -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`.
|
|
@@ -6,10 +6,25 @@ import { buildTheme } from '../build-theme';
|
|
|
6
6
|
import type { AppColorName } from '../color-presets';
|
|
7
7
|
import { buildNativePresetStyle, buildScopeVars, getVariableContextProvider } from './style-builder';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The variables a scope publishes when it carries no preset of its own.
|
|
11
|
+
*
|
|
12
|
+
* Empty and shared at module scope, both deliberately: react-native-css's
|
|
13
|
+
* `VariableContextProvider` MERGES its value over the inherited one, so an empty
|
|
14
|
+
* record leaves the parent scope's variables exactly as they were, and one
|
|
15
|
+
* shared identity keeps that provider's `useMemo` from recomputing every render.
|
|
16
|
+
*/
|
|
17
|
+
const NO_VARS: Record<string, string> = Object.freeze({});
|
|
18
|
+
|
|
9
19
|
export interface BloomColorScopeProps {
|
|
10
20
|
/**
|
|
11
|
-
* Preset to apply within this subtree. When `undefined`, the scope
|
|
12
|
-
*
|
|
21
|
+
* Preset to apply within this subtree. When `undefined`, the scope publishes
|
|
22
|
+
* nothing of its own and children inherit the parent scope's preset unchanged.
|
|
23
|
+
*
|
|
24
|
+
* It is a no-op in what it PUBLISHES, never in what it RENDERS — see the
|
|
25
|
+
* component's own comment: the element tree this returns is identical whether
|
|
26
|
+
* or not a preset is set, because a preset that arrives late must not remount
|
|
27
|
+
* the subtree.
|
|
13
28
|
*/
|
|
14
29
|
colorPreset: AppColorName | undefined;
|
|
15
30
|
/**
|
|
@@ -26,6 +41,35 @@ interface StyleableProps {
|
|
|
26
41
|
style?: StyleProp<ViewStyle>;
|
|
27
42
|
}
|
|
28
43
|
|
|
44
|
+
/**
|
|
45
|
+
* A subtree themed by one color preset.
|
|
46
|
+
*
|
|
47
|
+
* THE SHAPE OF WHAT THIS RETURNS DOES NOT DEPEND ON `colorPreset`. Both
|
|
48
|
+
* providers are mounted unconditionally, and an absent preset is expressed as
|
|
49
|
+
* pass-through VALUES ({@link NO_VARS}, the parent's own theme) rather than as a
|
|
50
|
+
* shorter tree. That is a correctness requirement, not a tidiness one, and it
|
|
51
|
+
* cost a real bug to learn:
|
|
52
|
+
*
|
|
53
|
+
* The no-preset path used to be `return <>{children}</>` — two provider levels
|
|
54
|
+
* shallower than the preset path. React reconciles by POSITION, so the first
|
|
55
|
+
* render after a preset arrives finds different element types at that position
|
|
56
|
+
* and unmounts the whole subtree to mount a new one. Every consumer that resolves
|
|
57
|
+
* its preset asynchronously — a profile screen learning the account's color a
|
|
58
|
+
* few hundred milliseconds after it opens — therefore remounted everything below
|
|
59
|
+
* the scope, including its own navigator.
|
|
60
|
+
*
|
|
61
|
+
* In Mention that scope sits above the app's route stack: the remount rebuilt
|
|
62
|
+
* the `[username]` navigator from scratch, which dropped the pushed child route
|
|
63
|
+
* AND its `username` param, and the profile a reader had just tapped rendered
|
|
64
|
+
* itself as "Profile not found" (mention#…, reproduced from the reel on a Pixel
|
|
65
|
+
* 10 Pro; the A/B was pinning `colorPreset` and watching the collapse stop).
|
|
66
|
+
* Remounting also throws away scroll positions, video players and in-flight
|
|
67
|
+
* state, so the same bug was costing far more than the one visible symptom.
|
|
68
|
+
*
|
|
69
|
+
* If a future change wants the no-preset case to render less, it has to keep the
|
|
70
|
+
* element types at this position identical across a preset change, or the
|
|
71
|
+
* remount comes back.
|
|
72
|
+
*/
|
|
29
73
|
export function BloomColorScope({
|
|
30
74
|
colorPreset,
|
|
31
75
|
asChild = false,
|
|
@@ -33,10 +77,9 @@ export function BloomColorScope({
|
|
|
33
77
|
children,
|
|
34
78
|
}: BloomColorScopeProps) {
|
|
35
79
|
// All hooks are called UNCONDITIONALLY, in the same order on every render —
|
|
36
|
-
// never gate a hook behind an early return (rules of hooks). The
|
|
37
|
-
//
|
|
38
|
-
// the
|
|
39
|
-
// is absent (that render path throws below anyway).
|
|
80
|
+
// never gate a hook behind an early return (rules of hooks). The throw below
|
|
81
|
+
// happens AFTER every hook has run. `resolvedMode` falls back harmlessly when
|
|
82
|
+
// the provider is absent (that render path throws anyway).
|
|
40
83
|
const parent = useContext(BloomThemeContext);
|
|
41
84
|
const resolvedMode = parent?.theme.mode ?? 'light';
|
|
42
85
|
|
|
@@ -58,10 +101,6 @@ export function BloomColorScope({
|
|
|
58
101
|
if (!parent) {
|
|
59
102
|
throw new Error('BloomColorScope must be used within a <BloomThemeProvider>');
|
|
60
103
|
}
|
|
61
|
-
// `colorPreset` undefined => the scope is a no-op; children inherit the
|
|
62
|
-
// parent scope. With `parent` present, `contextValue`/`nativeVars` are null
|
|
63
|
-
// iff `colorPreset` is absent, so this guard also narrows them to non-null.
|
|
64
|
-
if (!contextValue || !nativeVars) return <>{children}</>;
|
|
65
104
|
|
|
66
105
|
const VariableProvider = getVariableContextProvider();
|
|
67
106
|
|
|
@@ -85,12 +124,14 @@ export function BloomColorScope({
|
|
|
85
124
|
// Wrap the subtree in the variable provider when NativeWind is available;
|
|
86
125
|
// otherwise render as-is so we never crash on web/non-NativeWind hosts.
|
|
87
126
|
const scoped = VariableProvider ? (
|
|
88
|
-
<VariableProvider value={nativeVars}>{content}</VariableProvider>
|
|
127
|
+
<VariableProvider value={nativeVars ?? NO_VARS}>{content}</VariableProvider>
|
|
89
128
|
) : (
|
|
90
129
|
content
|
|
91
130
|
);
|
|
92
131
|
|
|
93
|
-
return
|
|
132
|
+
return (
|
|
133
|
+
<BloomThemeContext.Provider value={contextValue ?? parent}>{scoped}</BloomThemeContext.Provider>
|
|
134
|
+
);
|
|
94
135
|
}
|
|
95
136
|
|
|
96
137
|
/**
|
|
@@ -20,8 +20,12 @@ function buildWebScopeVars(
|
|
|
20
20
|
|
|
21
21
|
export interface BloomColorScopeProps {
|
|
22
22
|
/**
|
|
23
|
-
* Preset to apply within this subtree. When `undefined`, the scope
|
|
24
|
-
*
|
|
23
|
+
* Preset to apply within this subtree. When `undefined`, the scope publishes
|
|
24
|
+
* nothing of its own and children inherit the parent scope's preset unchanged.
|
|
25
|
+
*
|
|
26
|
+
* It is a no-op in what it PUBLISHES, never in what it RENDERS — the element
|
|
27
|
+
* tree is identical either way, so a preset that arrives late cannot remount
|
|
28
|
+
* the subtree. `ColorScope.tsx` carries the full account.
|
|
25
29
|
*/
|
|
26
30
|
colorPreset: AppColorName | undefined;
|
|
27
31
|
/**
|
|
@@ -91,10 +95,6 @@ export function BloomColorScope({
|
|
|
91
95
|
if (!parent) {
|
|
92
96
|
throw new Error('BloomColorScope must be used within a <BloomThemeProvider>');
|
|
93
97
|
}
|
|
94
|
-
// `colorPreset` undefined => the scope is a no-op; children inherit the
|
|
95
|
-
// parent scope. With `parent` present, `contextValue`/`varsStyle` are null
|
|
96
|
-
// iff `colorPreset` is absent, so this guard also narrows them to non-null.
|
|
97
|
-
if (!contextValue || !varsStyle) return <>{children}</>;
|
|
98
98
|
|
|
99
99
|
let content: React.ReactNode;
|
|
100
100
|
if (asChild) {
|
|
@@ -111,7 +111,7 @@ export function BloomColorScope({
|
|
|
111
111
|
const childStyle = child.props.style;
|
|
112
112
|
const childIsRnStyled = Array.isArray(childStyle) || typeof childStyle === 'number';
|
|
113
113
|
const mergedStyle: WebStyle = childIsRnStyled
|
|
114
|
-
? [varsStyle, style, childStyle]
|
|
114
|
+
? [varsStyle ?? undefined, style, childStyle]
|
|
115
115
|
: { ...varsStyle, ...style, ...(childStyle || undefined) };
|
|
116
116
|
content = cloneElement(child, { style: mergedStyle });
|
|
117
117
|
} else {
|
|
@@ -121,7 +121,9 @@ export function BloomColorScope({
|
|
|
121
121
|
content = <div style={mergedStyle}>{children}</div>;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
return
|
|
124
|
+
return (
|
|
125
|
+
<BloomThemeContext.Provider value={contextValue ?? parent}>{content}</BloomThemeContext.Provider>
|
|
126
|
+
);
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
/**
|