@oxyhq/bloom 0.53.0 → 0.54.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.
@@ -22,7 +22,7 @@ import {
22
22
  useMemo,
23
23
  type ComponentType,
24
24
  } from 'react';
25
- import { Pressable, StyleSheet, useWindowDimensions, View } from 'react-native';
25
+ import { Pressable, StyleSheet, useWindowDimensions, View, type ViewStyle } from 'react-native';
26
26
  import { Gesture, GestureDetector } from 'react-native-gesture-handler';
27
27
  import Animated, {
28
28
  Extrapolation,
@@ -32,6 +32,7 @@ import Animated, {
32
32
  useAnimatedStyle,
33
33
  useSharedValue,
34
34
  withSpring,
35
+ withTiming,
35
36
  type SharedValue,
36
37
  } from 'react-native-reanimated';
37
38
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -44,6 +45,7 @@ import {
44
45
  BLUR_BLEED,
45
46
  EXPANDED_HEIGHT,
46
47
  HIGHLIGHT_EXPANDED,
48
+ HIGHLIGHT_FADE,
47
49
  HIGHLIGHT_MINIMIZED,
48
50
  ICON_SIZE,
49
51
  ITEM_GAP,
@@ -69,6 +71,12 @@ import type { TabBarButtonProps, TabBarProps, TabBarTheme } from './types';
69
71
  type BarContextValue = {
70
72
  /** Live highlight position, in tab units. Fractional while scrubbing. */
71
73
  slideIndex: SharedValue<number>;
74
+ /**
75
+ * Highlight visibility, 0–1. Below 1 only when the bar has NO selection (an
76
+ * `activeIndex` that names no tab). Buttons fold it into their own active
77
+ * tint so a faded-out capsule leaves no glyph and no label lit.
78
+ */
79
+ highlightOpacity: SharedValue<number>;
72
80
  /** True while a scrub is in progress — the finger owns the highlight. */
73
81
  isDragging: SharedValue<boolean>;
74
82
  theme: TabBarTheme;
@@ -98,6 +106,8 @@ function TabBarBody({
98
106
  onIndexLongPress,
99
107
  theme: themeOverrides,
100
108
  haptics = true,
109
+ blur = true,
110
+ maxWidth,
101
111
  style,
102
112
  ...viewProps
103
113
  }: TabBarBodyProps) {
@@ -105,13 +115,53 @@ function TabBarBody({
105
115
  const { width: windowWidth } = useWindowDimensions();
106
116
  const minimized = useMinimizeState();
107
117
  const progress = minimized.progress;
118
+ const tabCount = Math.max(Children.count(children), 1);
119
+
120
+ // Is a tab selected at all?
121
+ //
122
+ // The two states this distinguishes are NOT the same thing, and collapsing
123
+ // them would silently kill the highlight for every router consumer:
124
+ //
125
+ // - `activeIndex === undefined` is the FOCUS-DRIVEN path. The bar is not
126
+ // the writer at all — each `TabBarButton` supplies `isFocused` and drives
127
+ // the highlight itself (that is how the adapter keeps it correct through
128
+ // deep links and back gestures), so from the bar's side there is always a
129
+ // selection and the highlight is always visible.
130
+ // - NO SELECTION is `activeIndex` being a number that names no tab:
131
+ // negative, past the last tab, or fractional. Every consumer whose route
132
+ // set is larger than its tab set produces one — a `usePathname()`-derived
133
+ // index is -1 on every screen that is not a tab — and the highlight must
134
+ // then be gone rather than parked outside the pill.
135
+ const hasSelection =
136
+ activeIndex === undefined ||
137
+ (Number.isInteger(activeIndex) && activeIndex >= 0 && activeIndex < tabCount);
138
+
108
139
  const slideIndex = useSharedValue(0);
140
+ // Seeded from the CURRENT state, not from 0: a bar that mounts with a
141
+ // selection must show its highlight on the first frame exactly as it always
142
+ // has, and one that mounts without a selection must never flash it.
143
+ const highlightOpacity = useSharedValue(hasSelection ? 1 : 0);
109
144
  const isDragging = useSharedValue(false);
110
145
  const lastTicked = useSharedValue(-1);
111
- const tabCount = Math.max(Children.count(children), 1);
112
146
  const theme = useTabBarTheme(themeOverrides);
113
147
  const impact = useHaptics();
114
148
 
149
+ // The pill's OUTER width (the box the animated minimize inset is applied
150
+ // inside), and the single source of truth for it: the wrap's own layout, the
151
+ // highlight geometry and the `indexAtX` scrub worklet all derive from this one
152
+ // number. They have to, or a tap lands on a tab the highlight is not under —
153
+ // the exact failure `maxWidth` exists to prevent, since narrowing the bar from
154
+ // the outside with a `style` override moves only the pixels.
155
+ //
156
+ // Unconstrained it is what the stretched wrap already measures
157
+ // (`windowWidth - BAR_MARGIN * 2`), so an existing consumer's geometry is
158
+ // unchanged to the pixel. `maxWidth` is a ceiling, never a floor: on a window
159
+ // narrower than it, the bar stays full-bleed.
160
+ const barOuterWidth =
161
+ maxWidth === undefined
162
+ ? windowWidth - BAR_MARGIN * 2
163
+ : Math.min(windowWidth - BAR_MARGIN * 2, maxWidth);
164
+
115
165
  // Picker-style tick while the highlight crosses tab boundaries mid-drag.
116
166
  // `useHaptics` already no-ops on web, when the optional `expo-haptics` peer
117
167
  // is absent, and when a `BloomHapticsProvider` has haptics turned off — so
@@ -142,8 +192,26 @@ function TabBarBody({
142
192
  if (activeIndex === undefined) return;
143
193
  // While scrubbing the finger owns the highlight; never fight it.
144
194
  if (isDragging.value) return;
145
- slideIndex.value = withSpring(activeIndex, SLIDE_SPRING);
146
- }, [activeIndex, slideIndex, isDragging]);
195
+ if (!hasSelection) {
196
+ // Fade out where it stands. `slideIndex` is deliberately left alone: it
197
+ // is the highlight's POSITION, and there is no position that means
198
+ // "nowhere" — springing it to a sentinel would drag the capsule across
199
+ // the bar on its way out, dragging the active tint over every tab it
200
+ // passed. Visibility is the thing that changed, so visibility is the only
201
+ // thing that animates.
202
+ highlightOpacity.value = withTiming(0, HIGHLIGHT_FADE);
203
+ return;
204
+ }
205
+ // Coming back from fully hidden the capsule APPEARS at the new tab instead
206
+ // of travelling to it: a slide says "the selection moved from here to
207
+ // there", and while it was invisible there was no "here" — sliding from the
208
+ // stale index would animate out of a position the user never saw, and would
209
+ // light up every tab in between on the way. Interrupted mid-fade it is
210
+ // still on screen, so from there it slides as it always does.
211
+ slideIndex.value =
212
+ highlightOpacity.value === 0 ? activeIndex : withSpring(activeIndex, SLIDE_SPRING);
213
+ highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
214
+ }, [activeIndex, hasSelection, slideIndex, highlightOpacity, isDragging]);
147
215
 
148
216
  // Scrubbing: the highlight tracks the finger 1:1 while dragging (no spring —
149
217
  // it must feel attached), haptic ticks fire on boundary crossings, and
@@ -159,7 +227,13 @@ function TabBarBody({
159
227
  [0, MINIMIZED_INSET],
160
228
  Extrapolation.CLAMP,
161
229
  );
162
- const barWidth = windowWidth - BAR_MARGIN * 2 - sideInset * 2;
230
+ // `event.x` is measured from the left edge of the view the detector is
231
+ // attached to — the animated pill itself, NOT the window and not the wrap
232
+ // around it. Its width is therefore the outer width minus the two
233
+ // animated margins, which is exactly what this computes, constrained or
234
+ // not. (Margins sit outside a view's own box, so x = 0 is the pill's left
235
+ // border edge either way.)
236
+ const barWidth = barOuterWidth - sideInset * 2;
163
237
  const itemWidth = (barWidth - ROW_PAD_H * 2) / tabCount;
164
238
  const raw = (x - ROW_PAD_H) / itemWidth - 0.5;
165
239
  return Math.min(Math.max(raw, 0), tabCount - 1);
@@ -168,8 +242,17 @@ function TabBarBody({
168
242
  const pan = Gesture.Pan()
169
243
  .activeOffsetX([-6, 6])
170
244
  .failOffsetY([-14, 14])
171
- .onStart(() => {
245
+ .onStart((event) => {
172
246
  isDragging.value = true;
247
+ // Arming from the no-selection state: a hidden capsule has no position
248
+ // the finger can pick up, so it starts under the finger rather than at
249
+ // the index it faded out on — which would also make the first boundary
250
+ // tick fire against a phantom position. Mid-fade it is still visible,
251
+ // so it keeps the position it is at, exactly as it always has.
252
+ if (highlightOpacity.value === 0) {
253
+ slideIndex.value = indexAtX(event.x, progress.value);
254
+ }
255
+ highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
173
256
  lastTicked.value = Math.round(slideIndex.value);
174
257
  // Scrubbing is a deliberate bar interaction — surface the labels.
175
258
  setMinimized(minimized, 0);
@@ -206,7 +289,11 @@ function TabBarBody({
206
289
  return;
207
290
  }
208
291
  const index = Math.round(indexAtX(event.x, progress.value));
209
- slideIndex.value = withSpring(index, SLIDE_SPRING);
292
+ // Same rule as the controlled path above: appear at the tapped tab when
293
+ // hidden, slide to it when already on screen.
294
+ slideIndex.value =
295
+ highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
296
+ highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
210
297
  setMinimized(minimized, 0);
211
298
  runOnJS(selectIndex)(index);
212
299
  });
@@ -230,7 +317,7 @@ function TabBarBody({
230
317
 
231
318
  return Gesture.Race(pan, tap, longPress);
232
319
  }, [
233
- windowWidth,
320
+ barOuterWidth,
234
321
  tabCount,
235
322
  selectIndex,
236
323
  hasLongPress,
@@ -239,6 +326,7 @@ function TabBarBody({
239
326
  isDragging,
240
327
  lastTicked,
241
328
  slideIndex,
329
+ highlightOpacity,
242
330
  minimized,
243
331
  progress,
244
332
  ]);
@@ -311,39 +399,70 @@ function TabBarBody({
311
399
  [0, MINIMIZED_INSET],
312
400
  Extrapolation.CLAMP,
313
401
  );
314
- const barWidth = windowWidth - BAR_MARGIN * 2 - sideInset * 2;
402
+ // Same `barOuterWidth` the scrub worklet resolves an index from see the
403
+ // note where it is computed.
404
+ const barWidth = barOuterWidth - sideInset * 2;
315
405
  const itemWidth = (barWidth - ROW_PAD_H * 2) / tabCount;
316
406
  return {
317
407
  height,
318
408
  width: itemWidth,
319
409
  borderRadius: height / 2,
320
410
  top: (barHeight - height) / 2,
411
+ // With no selection there is nothing to highlight, and the capsule has to
412
+ // stop being drawn: `slideIndex` is a position in tab units, so an
413
+ // out-of-range one is a real place — one item-width to the LEFT of the
414
+ // first tab, i.e. half outside the pill — not an absence.
415
+ opacity: highlightOpacity.value,
321
416
  transform: [{ translateX: ROW_PAD_H + itemWidth * slideIndex.value }],
322
417
  };
323
- }, [progress, slideIndex, windowWidth, tabCount]);
418
+ }, [progress, slideIndex, highlightOpacity, barOuterWidth, tabCount]);
324
419
 
325
420
  // Shared with `useTabBarFootprint`, so a consumer accounting for the bar in
326
421
  // its own layout can never drift from where the bar actually sits.
327
422
  const bottomOffset = tabBarBottomGap(insets.bottom);
423
+
424
+ // How centring and the animated inset compose: centring is STATIC and belongs
425
+ // to the wrap, the inset stays ANIMATED on the pill inside it. The wrap is
426
+ // centred once by layout at a definite width, and the pill's two equal
427
+ // margins keep it centred within that wrap at every point of the minimize
428
+ // animation — neither has to know about the other. Centring the animated view
429
+ // itself would instead mean `alignSelf: 'center'` on a node with no width of
430
+ // its own (its width comes from those margins), which Yoga then sizes from its
431
+ // CONTENT rather than from the constraint.
432
+ //
433
+ // Applied only when `maxWidth` is set: with no width and no `alignSelf` the
434
+ // wrap stretches, which is the original full-bleed behaviour.
435
+ const constrainedWrapStyle: ViewStyle | null =
436
+ maxWidth === undefined ? null : { width: barOuterWidth, alignSelf: 'center' };
328
437
  const barContext = useMemo(
329
- () => ({ slideIndex, isDragging, theme, activeIndex, selectIndex }),
330
- [slideIndex, isDragging, theme, activeIndex, selectIndex],
438
+ () => ({ slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex }),
439
+ [slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex],
331
440
  );
332
441
 
333
442
  return (
334
443
  <View {...viewProps} style={[styles.root, style]}>
335
- {/* Progressive blur rising from the screen's bottom edge behind the pill. */}
336
- <Blur
337
- direction="bottom"
338
- style={{
339
- position: 'absolute',
340
- left: 0,
341
- right: 0,
342
- bottom: 0,
343
- height: bottomOffset + EXPANDED_HEIGHT + BLUR_BLEED,
344
- }}
345
- />
346
- <View style={[styles.barWrap, { marginBottom: bottomOffset }]}>
444
+ {/* Progressive blur rising from the screen's bottom edge behind the pill.
445
+ Rendered CONDITIONALLY, and as nothing at all when off: the band is
446
+ full-bleed and 114pt tall at a zero bottom inset, so it blurs whatever
447
+ a screen floats near the bottom edge (a scrubber, a FAB), and leaving
448
+ an empty absolutely-positioned view behind would keep a node — and a
449
+ rect — that exists to do nothing. Stays full-bleed under `maxWidth`:
450
+ it is the screen-edge scrim content dissolves into, not part of the
451
+ pill. */}
452
+ {blur !== false && (
453
+ <Blur
454
+ direction="bottom"
455
+ intensity={typeof blur === 'object' ? blur.intensity : undefined}
456
+ style={{
457
+ position: 'absolute',
458
+ left: 0,
459
+ right: 0,
460
+ bottom: 0,
461
+ height: bottomOffset + EXPANDED_HEIGHT + BLUR_BLEED,
462
+ }}
463
+ />
464
+ )}
465
+ <View style={[styles.barWrap, { marginBottom: bottomOffset }, constrainedWrapStyle]}>
347
466
  <GestureDetector gesture={gesture}>
348
467
  <Animated.View style={barStyle}>
349
468
  <Surface theme={theme} style={shapeStyle} />
@@ -381,6 +500,7 @@ function TabBarButtonBody({
381
500
  const standaloneTheme = useTabBarTheme();
382
501
  const theme = bar?.theme ?? standaloneTheme;
383
502
  const slideIndex = bar?.slideIndex;
503
+ const highlightOpacity = bar?.highlightOpacity;
384
504
  // The two paths meet here: an explicit `isFocused` (router adapter) wins;
385
505
  // otherwise focus comes from the bar's controlled `activeIndex`.
386
506
  const focused = isFocused ?? (bar?.activeIndex === index);
@@ -399,29 +519,35 @@ function TabBarButtonBody({
399
519
  // Tint follows the sliding highlight, not navigation focus: whatever the pill
400
520
  // is over lights up — live while scrubbing, traveling on taps. Without a bar
401
521
  // there is nothing to follow, so it falls back to plain focus.
522
+ //
523
+ // Scaled by the highlight's own visibility, so a bar with NO selection leaves
524
+ // nothing lit: distance alone would keep the tab the capsule faded out on
525
+ // fully tinted, which is the same bug as a stray capsule wearing a different
526
+ // hat — a tab that looks selected while nothing is.
402
527
  // (Deps: see the CRITICAL note in `TabBarBody`.)
403
- const activeGlyphStyle = useAnimatedStyle(
404
- () => ({
405
- opacity: slideIndex ? 1 - Math.min(Math.abs(slideIndex.value - index), 1) : focused ? 1 : 0,
406
- }),
407
- [slideIndex, index, focused],
408
- );
528
+ const activeGlyphStyle = useAnimatedStyle(() => {
529
+ if (!slideIndex || !highlightOpacity) return { opacity: focused ? 1 : 0 };
530
+ const proximity = 1 - Math.min(Math.abs(slideIndex.value - index), 1);
531
+ return { opacity: highlightOpacity.value * proximity };
532
+ }, [slideIndex, highlightOpacity, index, focused]);
409
533
 
410
- const labelStyle = useAnimatedStyle(
411
- () => ({
412
- opacity: interpolate(progress.value, [0, 0.4], [1, 0], Extrapolation.CLAMP),
413
- color: slideIndex
414
- ? interpolateColor(
415
- Math.min(Math.abs(slideIndex.value - index), 1),
416
- [0, 1],
417
- [theme.activeTint, theme.inactiveTint],
418
- )
419
- : focused
420
- ? theme.activeTint
421
- : theme.inactiveTint,
422
- }),
423
- [progress, slideIndex, index, focused, theme],
424
- );
534
+ const labelStyle = useAnimatedStyle(() => {
535
+ const opacity = interpolate(progress.value, [0, 0.4], [1, 0], Extrapolation.CLAMP);
536
+ if (!slideIndex || !highlightOpacity) {
537
+ return { opacity, color: focused ? theme.activeTint : theme.inactiveTint };
538
+ }
539
+ // Same quantity as the glyph crossfade above, so a label can never disagree
540
+ // with the icon it sits under.
541
+ const proximity = 1 - Math.min(Math.abs(slideIndex.value - index), 1);
542
+ return {
543
+ opacity,
544
+ color: interpolateColor(
545
+ highlightOpacity.value * proximity,
546
+ [0, 1],
547
+ [theme.inactiveTint, theme.activeTint],
548
+ ),
549
+ };
550
+ }, [progress, slideIndex, highlightOpacity, index, focused, theme]);
425
551
 
426
552
  // Height is animated EXPLICITLY (not derived from children) so the icon stays
427
553
  // perfectly centered every frame — layout-driven sizing lags a frame behind
@@ -449,7 +575,13 @@ function TabBarButtonBody({
449
575
  onPress={(event) => {
450
576
  // The bar's GestureDetector normally consumes touches; this still fires
451
577
  // for assistive-technology activation (VoiceOver) and keyboard focus.
452
- if (bar) bar.slideIndex.value = withSpring(index, SLIDE_SPRING);
578
+ if (bar) {
579
+ // Appear at the tab when hidden, slide to it when visible — the same
580
+ // rule the tap gesture and the controlled path follow.
581
+ bar.slideIndex.value =
582
+ bar.highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
583
+ bar.highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
584
+ }
453
585
  setMinimized(minimized, 0);
454
586
  // Controlled path only. On the focus-driven path the trigger's own
455
587
  // `onPress` below performs the navigation, so reporting the selection
@@ -79,6 +79,18 @@ export function tabBarBottomGap(bottomInset: number): number {
79
79
  */
80
80
  export const SLIDE_SPRING = { duration: 420, dampingRatio: 0.82 };
81
81
 
82
+ /**
83
+ * Fade for the highlight coming and going when the selection does — an
84
+ * `activeIndex` that names no tab (see `TabBarProps.activeIndex`).
85
+ *
86
+ * A timing, not a spring, and deliberately shorter than {@link SLIDE_SPRING}:
87
+ * appearing and disappearing is a change of STATE, not a movement, so it must
88
+ * not read as a second animation racing the slide. The capsule fades where it
89
+ * stands — moving it out of view instead would drag it the length of the bar on
90
+ * its way out and sweep the active tint across every tab it passed.
91
+ */
92
+ export const HIGHLIGHT_FADE = { duration: 160 };
93
+
82
94
  /**
83
95
  * How long a press must be held before `onIndexLongPress` fires.
84
96
  *
@@ -78,6 +78,18 @@ export type TabBarProps = ViewProps & {
78
78
  * highlight and springs it here whenever this changes. Use this when there is
79
79
  * no router in play; the router adapter uses the per-button `isFocused` path
80
80
  * instead (see {@link TabBarButtonProps.isFocused}) and must NOT pass this.
81
+ *
82
+ * An index that names NO tab — negative, past the last tab, or fractional —
83
+ * means no selection: the highlight fades out where it stands and no tab is
84
+ * left tinted. Pass one whenever the current screen is not a tab; an index
85
+ * derived from the route (`TABS.findIndex(…)`) is `-1` on every such screen
86
+ * and is correct as-is. Returning to a real index fades the highlight back in
87
+ * AT that tab rather than sliding to it — while it was invisible there was no
88
+ * position to travel from — and scrubbing still arms it under the finger.
89
+ *
90
+ * OMITTING this prop is a different thing entirely and does not hide
91
+ * anything: that is the focus-driven path, where each button drives the
92
+ * highlight from its own `isFocused`.
81
93
  */
82
94
  activeIndex?: number;
83
95
  /**
@@ -107,6 +119,44 @@ export type TabBarProps = ViewProps & {
107
119
  * optional `expo-haptics` peer is absent.
108
120
  */
109
121
  haptics?: boolean;
122
+ /**
123
+ * The progressive blur rising from the bottom edge of the screen behind the
124
+ * pill. Defaults to `true`; `{ intensity }` tunes its strength.
125
+ *
126
+ * Pass `false` to remove it entirely — no blur, and no node left behind. The
127
+ * band is tall (the bar's own bottom gap plus its expanded height plus the
128
+ * bleed above it, 114pt at a zero bottom inset) and full-bleed, so anything a
129
+ * screen floats near the bottom edge — a video scrubber, a FAB — sits INSIDE
130
+ * it and is blurred. That cannot be undone from the outside: `zIndex` only
131
+ * orders siblings within one stacking context, and a screen's FAB is inside
132
+ * an earlier sibling of the bar's host, so no z-order a consumer can write
133
+ * lifts it above this band. Turning the blur off is the only fix that does
134
+ * not also change where the FAB paints relative to everything else.
135
+ *
136
+ * On native the band is ten stacked `expo-blur` layers plus a tail gradient;
137
+ * on web it is one masked `backdrop-filter`. `false` skips both.
138
+ */
139
+ blur?: boolean | { intensity?: number };
140
+ /**
141
+ * Maximum width of the pill, in points. No default — omit it and the bar
142
+ * spans the window exactly as it always has.
143
+ *
144
+ * A CEILING, never a floor: on a window narrower than this the bar keeps its
145
+ * full-bleed width, so a phone layout is untouched by any value big enough to
146
+ * matter on a tablet. When it binds, the pill is constrained to this width and
147
+ * CENTRED, and the ITEM geometry follows it — the item width, the sliding
148
+ * highlight's position and the tap/scrub hit-testing all derive from the same
149
+ * constrained width, so a tap still lands on the tab it is visibly over.
150
+ *
151
+ * That last part is why this cannot be done from the outside. Narrowing the
152
+ * bar with a `style` override moves the pixels only: the highlight and the
153
+ * scrub worklet would still divide the WINDOW width by the tab count, leaving
154
+ * the highlight sized and positioned for a bar that is no longer there. On an
155
+ * iPad the unconstrained bar is the reason to reach for this at all — 810pt at
156
+ * 11" portrait, 1342pt in landscape, leaving 21pt glyphs adrift in cells
157
+ * hundreds of points wide.
158
+ */
159
+ maxWidth?: number;
110
160
  };
111
161
 
112
162
  export type TabBarButtonProps = PressableProps & {