@oxyhq/bloom 0.51.1 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/lib/commonjs/frosted-icon-button/shared.js +10 -0
  2. package/lib/commonjs/frosted-icon-button/shared.js.map +1 -1
  3. package/lib/commonjs/tab-bar/TabBarBase.js +30 -5
  4. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  5. package/lib/commonjs/tab-bar/glyph.js +9 -4
  6. package/lib/commonjs/tab-bar/glyph.js.map +1 -1
  7. package/lib/commonjs/tab-bar/glyph.native.js +7 -2
  8. package/lib/commonjs/tab-bar/glyph.native.js.map +1 -1
  9. package/lib/commonjs/tab-bar/index.js +7 -0
  10. package/lib/commonjs/tab-bar/index.js.map +1 -1
  11. package/lib/commonjs/tab-bar/index.web.js +7 -0
  12. package/lib/commonjs/tab-bar/index.web.js.map +1 -1
  13. package/lib/commonjs/tab-bar/shared.js +32 -1
  14. package/lib/commonjs/tab-bar/shared.js.map +1 -1
  15. package/lib/commonjs/tab-bar/use-reserved-space.js +50 -0
  16. package/lib/commonjs/tab-bar/use-reserved-space.js.map +1 -0
  17. package/lib/module/frosted-icon-button/shared.js +10 -0
  18. package/lib/module/frosted-icon-button/shared.js.map +1 -1
  19. package/lib/module/tab-bar/TabBarBase.js +31 -6
  20. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  21. package/lib/module/tab-bar/glyph.js +9 -4
  22. package/lib/module/tab-bar/glyph.js.map +1 -1
  23. package/lib/module/tab-bar/glyph.native.js +7 -2
  24. package/lib/module/tab-bar/glyph.native.js.map +1 -1
  25. package/lib/module/tab-bar/index.js +1 -0
  26. package/lib/module/tab-bar/index.js.map +1 -1
  27. package/lib/module/tab-bar/index.web.js +1 -0
  28. package/lib/module/tab-bar/index.web.js.map +1 -1
  29. package/lib/module/tab-bar/shared.js +30 -0
  30. package/lib/module/tab-bar/shared.js.map +1 -1
  31. package/lib/module/tab-bar/use-reserved-space.js +46 -0
  32. package/lib/module/tab-bar/use-reserved-space.js.map +1 -0
  33. package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts +10 -0
  34. package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/tab-bar/glyph.d.ts +1 -1
  37. package/lib/typescript/commonjs/tab-bar/glyph.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts +1 -1
  39. package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts.map +1 -1
  40. package/lib/typescript/commonjs/tab-bar/index.d.ts +1 -0
  41. package/lib/typescript/commonjs/tab-bar/index.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/tab-bar/index.web.d.ts +1 -0
  43. package/lib/typescript/commonjs/tab-bar/index.web.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/tab-bar/shared.d.ts +25 -0
  45. package/lib/typescript/commonjs/tab-bar/shared.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/tab-bar/types.d.ts +37 -1
  47. package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/tab-bar/use-reserved-space.d.ts +25 -0
  49. package/lib/typescript/commonjs/tab-bar/use-reserved-space.d.ts.map +1 -0
  50. package/lib/typescript/module/frosted-icon-button/shared.d.ts +10 -0
  51. package/lib/typescript/module/frosted-icon-button/shared.d.ts.map +1 -1
  52. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  53. package/lib/typescript/module/tab-bar/glyph.d.ts +1 -1
  54. package/lib/typescript/module/tab-bar/glyph.d.ts.map +1 -1
  55. package/lib/typescript/module/tab-bar/glyph.native.d.ts +1 -1
  56. package/lib/typescript/module/tab-bar/glyph.native.d.ts.map +1 -1
  57. package/lib/typescript/module/tab-bar/index.d.ts +1 -0
  58. package/lib/typescript/module/tab-bar/index.d.ts.map +1 -1
  59. package/lib/typescript/module/tab-bar/index.web.d.ts +1 -0
  60. package/lib/typescript/module/tab-bar/index.web.d.ts.map +1 -1
  61. package/lib/typescript/module/tab-bar/shared.d.ts +25 -0
  62. package/lib/typescript/module/tab-bar/shared.d.ts.map +1 -1
  63. package/lib/typescript/module/tab-bar/types.d.ts +37 -1
  64. package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
  65. package/lib/typescript/module/tab-bar/use-reserved-space.d.ts +25 -0
  66. package/lib/typescript/module/tab-bar/use-reserved-space.d.ts.map +1 -0
  67. package/package.json +1 -1
  68. package/src/__tests__/TabBar.test.tsx +82 -2
  69. package/src/__tests__/TabBarLongPress.test.tsx +258 -0
  70. package/src/__tests__/TabBarReservedSpace.test.tsx +150 -0
  71. package/src/__tests__/TabBarWebFork.test.ts +1 -0
  72. package/src/frosted-icon-button/shared.ts +10 -0
  73. package/src/tab-bar/TabBarBase.tsx +39 -5
  74. package/src/tab-bar/glyph.native.tsx +5 -2
  75. package/src/tab-bar/glyph.tsx +8 -4
  76. package/src/tab-bar/index.ts +1 -0
  77. package/src/tab-bar/index.web.tsx +1 -0
  78. package/src/tab-bar/shared.ts +38 -0
  79. package/src/tab-bar/types.ts +37 -1
  80. package/src/tab-bar/use-reserved-space.ts +44 -0
@@ -0,0 +1,150 @@
1
+ import React from 'react';
2
+ import type { ReactTestInstance } from 'react-test-renderer';
3
+ import { render } from '@testing-library/react-native';
4
+
5
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
6
+ import { TabBar, TabBarButton, useTabBarReservedSpace } from '../tab-bar';
7
+ import type { TabBarItem } from '../tab-bar/types';
8
+
9
+ /**
10
+ * `useTabBarReservedSpace()` — the number a screen pads its content by so a
11
+ * list does not end underneath the floating bar.
12
+ *
13
+ * The bar is absolutely positioned, so nothing reserves that space
14
+ * automatically, and the geometry it is built from (expanded height, bottom
15
+ * gap) is private. Without the hook a consumer hardcodes both — which drifts
16
+ * silently the first time the bar changes by a pixel — and, worse, tends to add
17
+ * `insets.bottom` on top, because that is what reserving space for bottom
18
+ * chrome normally requires. The bar already folds the inset into its own gap,
19
+ * so that double-counts the home indicator.
20
+ *
21
+ * The suite therefore asserts three separate things: the absolute pixel values
22
+ * (so a change to the constants must be deliberate), the inset relationship (so
23
+ * the double-count can never creep back in), and equality with the bar's own
24
+ * rendered geometry (so the hook cannot drift from where the bar actually sits).
25
+ */
26
+
27
+ // Mutated per test; must be `mock`-prefixed for the hoisted factory.
28
+ const mockInsets = { top: 0, right: 0, bottom: 0, left: 0 };
29
+
30
+ jest.mock('react-native-safe-area-context', () => ({
31
+ ...jest.requireActual('react-native-safe-area-context'),
32
+ useSafeAreaInsets: () => mockInsets,
33
+ }));
34
+
35
+ const ITEMS: TabBarItem[] = [
36
+ { name: 'home', label: 'Home', icon: null },
37
+ { name: 'search', label: 'Search', icon: null },
38
+ ];
39
+
40
+ /** A device with a home indicator (iPhone-class bottom inset). */
41
+ const HOME_INDICATOR_INSET = 34;
42
+
43
+ function ReservedProbe({ onValue }: { onValue: (space: number) => void }) {
44
+ onValue(useTabBarReservedSpace());
45
+ return null;
46
+ }
47
+
48
+ function withTheme(ui: React.ReactElement) {
49
+ return (
50
+ <BloomThemeProvider mode="light" colorPreset="teal">
51
+ {ui}
52
+ </BloomThemeProvider>
53
+ );
54
+ }
55
+
56
+ /** The space the hook reserves on a device whose bottom inset is `inset`. */
57
+ function reservedFor(inset: number): number {
58
+ mockInsets.bottom = inset;
59
+ let space: number | undefined;
60
+ render(withTheme(<ReservedProbe onValue={(value) => (space = value)} />));
61
+ if (space === undefined) throw new Error('ReservedProbe never ran');
62
+ return space;
63
+ }
64
+
65
+ function flattenStyle(style: unknown): Record<string, unknown> {
66
+ const out: Record<string, unknown> = {};
67
+ const visit = (value: unknown): void => {
68
+ if (Array.isArray(value)) value.forEach(visit);
69
+ else if (value && typeof value === 'object') Object.assign(out, value);
70
+ };
71
+ visit(style);
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * The flattened style of every styled node in the tree.
77
+ *
78
+ * Restricted to HOST nodes: the RN mock renders each component as a composite
79
+ * wrapping a host element of the same name, and both carry the same `style`, so
80
+ * counting all of them would double every match.
81
+ */
82
+ function styledNodes(root: ReactTestInstance): Record<string, unknown>[] {
83
+ return root
84
+ .findAll((node) => typeof node.type === 'string' && node.props?.style !== undefined)
85
+ .map((node) => flattenStyle(node.props.style));
86
+ }
87
+
88
+ describe('useTabBarReservedSpace', () => {
89
+ beforeEach(() => {
90
+ mockInsets.bottom = 0;
91
+ });
92
+
93
+ it('reserves the bar plus its gap where there is no bottom inset', () => {
94
+ // 58pt bar + the 12pt floor the gap never goes below.
95
+ expect(reservedFor(0)).toBe(70);
96
+ });
97
+
98
+ it('reserves more on a device with a home indicator', () => {
99
+ expect(reservedFor(HOME_INDICATOR_INSET)).toBe(76);
100
+ });
101
+
102
+ it('already includes the inset — adding insets.bottom would double-count it', () => {
103
+ // The bar absorbs 16pt of the inset into its own gap, so the space grows by
104
+ // the REMAINDER, never by the whole inset. A consumer writing
105
+ // `reserved + insets.bottom` would reserve 110pt for a 76pt bar.
106
+ const grown = reservedFor(HOME_INDICATOR_INSET) - reservedFor(0);
107
+ expect(grown).toBe(6);
108
+ expect(grown).toBeLessThan(HOME_INDICATOR_INSET);
109
+ });
110
+
111
+ it('does not shrink below the floor for a small inset', () => {
112
+ // An inset under what the gap absorbs must not pull the bar down toward the
113
+ // window edge — the gap is clamped, and the reserved space with it.
114
+ expect(reservedFor(8)).toBe(reservedFor(0));
115
+ });
116
+
117
+ it('equals the space the rendered bar actually occupies', () => {
118
+ // The invariant that makes the hook worth exporting: its number is the bar's
119
+ // own geometry (the gap it holds off the window edge plus its expanded
120
+ // height), not a second copy that can drift.
121
+ mockInsets.bottom = HOME_INDICATOR_INSET;
122
+ let reserved: number | undefined;
123
+ const { UNSAFE_root } = render(
124
+ withTheme(
125
+ <>
126
+ <ReservedProbe onValue={(value) => (reserved = value)} />
127
+ <TabBar activeIndex={0}>
128
+ {ITEMS.map((item, index) => (
129
+ <TabBarButton key={item.name} item={item} index={index} />
130
+ ))}
131
+ </TabBar>
132
+ </>,
133
+ ),
134
+ );
135
+
136
+ const styles = styledNodes(UNSAFE_root);
137
+ // The wrapper holding the pill off the bottom edge — the only node with a
138
+ // bottom margin.
139
+ const gaps = styles.filter((style) => typeof style.marginBottom === 'number');
140
+ expect(gaps).toHaveLength(1);
141
+ // The pill itself: the animated capsule sizes BOTH its height and its
142
+ // horizontal inset, which nothing else in the bar does.
143
+ const pills = styles.filter(
144
+ (style) => typeof style.height === 'number' && typeof style.marginHorizontal === 'number',
145
+ );
146
+ expect(pills).toHaveLength(1);
147
+
148
+ expect(reserved).toBe(Number(gaps[0]?.marginBottom) + Number(pills[0]?.height));
149
+ });
150
+ });
@@ -49,6 +49,7 @@ const NEUTRAL_AND_WEB_FILES = [
49
49
  'tab-bar/shared.ts',
50
50
  'tab-bar/TabBarBase.tsx',
51
51
  'tab-bar/minimize-context.tsx',
52
+ 'tab-bar/use-reserved-space.ts',
52
53
  'tab-bar/surface.tsx',
53
54
  'tab-bar/surface-solid.tsx',
54
55
  'tab-bar/surface.web.tsx',
@@ -109,6 +109,16 @@ type FillableElement = ReactElement<{ fill?: unknown }>;
109
109
  * so any bare Bloom icon element gets the theme-aware color for free while an
110
110
  * explicitly-colored icon is never overridden. Non-element children (plain
111
111
  * text/strings) pass through unchanged.
112
+ *
113
+ * KNOWN LIMIT, deliberately not papered over: an icon component that paints from
114
+ * a `color` PROP rather than from `fill`/`style.color` ignores this entirely, and
115
+ * does so SILENTLY — nothing throws, the glyph renders, it simply never takes the
116
+ * tint. `color` is not injected alongside `fill` because the prop is not ours to
117
+ * claim: components use it for semantic variants (`color="danger"`), so writing an
118
+ * `rgb(…)` string into it can produce a worse failure than the one it fixes, and
119
+ * an icon already carrying its own `color` would be clobbered. Consumers with such
120
+ * a set pre-color the element themselves; the tab bar additionally offers
121
+ * `TabBarItem.activeIcon`, which carries selection by shape instead of by tint.
112
122
  */
113
123
  export function applyIconColor(node: ReactNode, color: string): ReactNode {
114
124
  if (!isValidElement(node)) return node;
@@ -49,10 +49,12 @@ import {
49
49
  ITEM_GAP,
50
50
  ITEM_PAD_V,
51
51
  LABEL_FONT_SIZE,
52
+ LONG_PRESS_MIN_DURATION,
52
53
  MINIMIZED_HEIGHT,
53
54
  MINIMIZED_INSET,
54
55
  ROW_PAD_H,
55
56
  SLIDE_SPRING,
57
+ tabBarBottomGap,
56
58
  useTabBarTheme,
57
59
  type TabBarGlyphProps,
58
60
  type TabBarSurfaceProps,
@@ -93,6 +95,7 @@ function TabBarBody({
93
95
  children,
94
96
  activeIndex,
95
97
  onIndexChange,
98
+ onIndexLongPress,
96
99
  theme: themeOverrides,
97
100
  haptics = true,
98
101
  style,
@@ -121,6 +124,14 @@ function TabBarBody({
121
124
  // scrubbing makes the content jump under the finger.
122
125
  const selectIndex = useCallback((index: number) => onIndexChange?.(index), [onIndexChange]);
123
126
 
127
+ // Long press is reported through the same shape, and deliberately does NOT
128
+ // select: it is a secondary action on the tab under the finger.
129
+ const longPressIndex = useCallback(
130
+ (index: number) => onIndexLongPress?.(index),
131
+ [onIndexLongPress],
132
+ );
133
+ const hasLongPress = onIndexLongPress !== undefined;
134
+
124
135
  // CONTROLLED path: the bar owns the highlight when `activeIndex` is provided,
125
136
  // so it tracks the prop even when a consumer renders custom children instead
126
137
  // of `TabBarButton`s. Syncing an imperative animation to a controlled prop is
@@ -200,11 +211,30 @@ function TabBarBody({
200
211
  runOnJS(selectIndex)(index);
201
212
  });
202
213
 
203
- return Gesture.Race(pan, tap);
214
+ if (!hasLongPress) return Gesture.Race(pan, tap);
215
+
216
+ // Long press: the ONLY path to one, because the detector consumes the
217
+ // bar's touches and a button's inner `Pressable` therefore never sees a
218
+ // real finger (see `TabBarButtonBody`). Added to the race only when a
219
+ // handler was supplied — an always-armed long press would cancel the pan
220
+ // for anyone who rests a finger before scrubbing, and no existing consumer
221
+ // asked for that. It cannot cost a tap either: the tap gesture gives up at
222
+ // 400ms, before this one can activate at 500ms, so the two are never
223
+ // candidates at the same instant. Resolves its index through the same
224
+ // `indexAtX` worklet as tap and pan, so all three agree on the geometry.
225
+ const longPress = Gesture.LongPress()
226
+ .minDuration(LONG_PRESS_MIN_DURATION)
227
+ .onStart((event) => {
228
+ runOnJS(longPressIndex)(Math.round(indexAtX(event.x, progress.value)));
229
+ });
230
+
231
+ return Gesture.Race(pan, tap, longPress);
204
232
  }, [
205
233
  windowWidth,
206
234
  tabCount,
207
235
  selectIndex,
236
+ hasLongPress,
237
+ longPressIndex,
208
238
  tick,
209
239
  isDragging,
210
240
  lastTicked,
@@ -287,7 +317,9 @@ function TabBarBody({
287
317
  };
288
318
  }, [progress, slideIndex, windowWidth, tabCount]);
289
319
 
290
- const bottomOffset = Math.max(insets.bottom - 16, 12);
320
+ // Shared with `useTabBarReservedSpace`, so a consumer reserving layout space
321
+ // for the bar can never drift from where the bar actually sits.
322
+ const bottomOffset = tabBarBottomGap(insets.bottom);
291
323
  const barContext = useMemo(
292
324
  () => ({ slideIndex, isDragging, theme, activeIndex, selectIndex }),
293
325
  [slideIndex, isDragging, theme, activeIndex, selectIndex],
@@ -430,11 +462,13 @@ function TabBarButtonBody({
430
462
  }
431
463
  >
432
464
  <Animated.View style={[styles.itemBox, boxStyle]}>
433
- {/* Inactive glyph underneath, active glyph crossfading on top. */}
465
+ {/* Inactive glyph underneath, active glyph crossfading on top. `active`
466
+ tells each layer which one it is, so an item with an `activeIcon`
467
+ crossfades between two different nodes and not just two tints. */}
434
468
  <View>
435
- <Glyph item={item} tint={theme.inactiveTint} size={ICON_SIZE} />
469
+ <Glyph item={item} tint={theme.inactiveTint} size={ICON_SIZE} active={false} />
436
470
  <Animated.View style={[StyleSheet.absoluteFill, styles.glyphLayer, activeGlyphStyle]}>
437
- <Glyph item={item} tint={theme.activeTint} size={ICON_SIZE} />
471
+ <Glyph item={item} tint={theme.activeTint} size={ICON_SIZE} active />
438
472
  </Animated.View>
439
473
  </View>
440
474
  {/* Fades out and is clipped by the shrinking box — no layout anim. */}
@@ -17,7 +17,7 @@ import { Platform, View } from 'react-native';
17
17
  import { applyIconColor } from '../frosted-icon-button/shared';
18
18
  import type { TabBarGlyphProps } from './shared';
19
19
 
20
- export function TabBarGlyph({ item, tint, size }: TabBarGlyphProps) {
20
+ export function TabBarGlyph({ item, tint, size, active }: TabBarGlyphProps) {
21
21
  if (item.sfSymbol && Platform.OS === 'ios') {
22
22
  // `TabBarItem.sfSymbol` is a plain `string` because `types.ts` may import
23
23
  // from react/react-native only — pulling `expo-symbols`' `SFSymbol` union
@@ -36,9 +36,12 @@ export function TabBarGlyph({ item, tint, size }: TabBarGlyphProps) {
36
36
  );
37
37
  }
38
38
 
39
+ // Same node-swapping crossfade as the neutral variant: the active layer takes
40
+ // `activeIcon` when the item has one. An SF Symbol never reaches here, and is
41
+ // tinted natively above, so the tint crossfade remains its correct expression.
39
42
  return (
40
43
  <View style={{ height: size, alignItems: 'center', justifyContent: 'center' }}>
41
- {applyIconColor(item.icon, tint)}
44
+ {applyIconColor(active ? (item.activeIcon ?? item.icon) : item.icon, tint)}
42
45
  </View>
43
46
  );
44
47
  }
@@ -2,8 +2,8 @@
2
2
  * Ported from expo-glass-tabs v0.1.1 — src/glass-tab-bar.tsx
3
3
  * (MIT © 2026 David Mokos). See the top-level NOTICE.
4
4
  *
5
- * NEUTRAL default variant of a tab glyph: renders the item's own `icon` node,
6
- * tinted for this crossfade layer.
5
+ * NEUTRAL default variant of a tab glyph: renders the item's own `icon` node
6
+ * or its `activeIcon` on the active layer — tinted for this crossfade layer.
7
7
  *
8
8
  * `item.icon` is the primary icon API for the whole family — a Bloom icon
9
9
  * element, exactly like `TabsTrigger` and `FrostedIconButton` take. `sfSymbol`
@@ -17,13 +17,17 @@ import { View } from 'react-native';
17
17
  import { applyIconColor } from '../frosted-icon-button/shared';
18
18
  import type { TabBarGlyphProps } from './shared';
19
19
 
20
- export function TabBarGlyph({ item, tint, size }: TabBarGlyphProps) {
20
+ export function TabBarGlyph({ item, tint, size, active }: TabBarGlyphProps) {
21
21
  // The tint is injected as the icon's `fill` FALLBACK — an icon that sets its
22
22
  // own `fill` (a brand mark, say) keeps it, so the crossfade layers simply
23
23
  // render it twice at full opacity and it never flickers.
24
+ //
25
+ // `activeIcon` is what makes the crossfade able to swap NODES: an icon set
26
+ // whose selected state is a different SHAPE (outline → filled) cannot be
27
+ // expressed by tinting one node two ways. Absent, the layer renders `icon`.
24
28
  return (
25
29
  <View style={{ height: size, alignItems: 'center', justifyContent: 'center' }}>
26
- {applyIconColor(item.icon, tint)}
30
+ {applyIconColor(active ? (item.activeIcon ?? item.icon) : item.icon, tint)}
27
31
  </View>
28
32
  );
29
33
  }
@@ -29,4 +29,5 @@ export {
29
29
  useTabBarMinimized,
30
30
  } from './minimize-context';
31
31
  export type { MinimizeState } from './minimize-context';
32
+ export { useTabBarReservedSpace } from './use-reserved-space';
32
33
  export type { TabBarButtonProps, TabBarItem, TabBarProps, TabBarTheme } from './types';
@@ -31,4 +31,5 @@ export {
31
31
  useTabBarMinimized,
32
32
  } from './minimize-context';
33
33
  export type { MinimizeState } from './minimize-context';
34
+ export { useTabBarReservedSpace } from './use-reserved-space';
34
35
  export type { TabBarButtonProps, TabBarItem, TabBarProps, TabBarTheme } from './types';
@@ -51,6 +51,27 @@ export const BLUR_BLEED = 44;
51
51
  /** Label type size. Small by design — the icon carries the meaning. */
52
52
  export const LABEL_FONT_SIZE = 9.5;
53
53
 
54
+ /**
55
+ * How much of the bottom safe-area inset the bar's own gap absorbs. The pill
56
+ * already floats clear of the content, so sitting the FULL inset below it
57
+ * strands it in the middle of the screen on a home-indicator device.
58
+ */
59
+ const SAFE_AREA_ABSORBED = 16;
60
+ /** Gap floor, for a device (or a browser) reporting no bottom inset at all. */
61
+ const MIN_BOTTOM_GAP = 12;
62
+
63
+ /**
64
+ * Vertical gap between the bottom of the pill and the bottom of the window.
65
+ *
66
+ * The safe-area inset is folded in HERE, which is why anything reserving layout
67
+ * space for the bar must go through `useTabBarReservedSpace` (which derives its
68
+ * number from this same function) rather than adding `insets.bottom` to a
69
+ * hardcoded height — that double-counts the inset.
70
+ */
71
+ export function tabBarBottomGap(bottomInset: number): number {
72
+ return Math.max(bottomInset - SAFE_AREA_ABSORBED, MIN_BOTTOM_GAP);
73
+ }
74
+
54
75
  /**
55
76
  * Slide spring: interruptible by design — rapid tab-hopping retargets with
56
77
  * preserved velocity. Slight under-damping gives the pill a tiny settle, safe
@@ -58,6 +79,15 @@ export const LABEL_FONT_SIZE = 9.5;
58
79
  */
59
80
  export const SLIDE_SPRING = { duration: 420, dampingRatio: 0.82 };
60
81
 
82
+ /**
83
+ * How long a press must be held before `onIndexLongPress` fires.
84
+ *
85
+ * Comfortably above the tap gesture's own 400ms ceiling: the two can never both
86
+ * be candidates at the same instant, so arming the long press cannot cost the
87
+ * bar a tap. Matches the platform long-press feel (iOS ~0.5s).
88
+ */
89
+ export const LONG_PRESS_MIN_DURATION = 500;
90
+
61
91
  // ---------------------------------------------------------------------------
62
92
  // Theme
63
93
  // ---------------------------------------------------------------------------
@@ -156,4 +186,12 @@ export interface TabBarGlyphProps {
156
186
  tint: string;
157
187
  /** Rendered icon size in px ({@link ICON_SIZE}). */
158
188
  size: number;
189
+ /**
190
+ * Which crossfade layer this is: `false` for the inactive one underneath,
191
+ * `true` for the one fading in on top. The active layer renders
192
+ * `item.activeIcon` when the item has one, so a set that expresses selection
193
+ * by shape (outline → filled) crossfades between two different nodes rather
194
+ * than between two tints of the same node.
195
+ */
196
+ active: boolean;
159
197
  }
@@ -26,9 +26,31 @@ export type TabBarItem = {
26
26
  * type. This is the primary icon API — same convention as `Tabs`/`TabsTrigger`
27
27
  * and `FrostedIconButton`. The bar renders it TWICE (an inactive layer with the
28
28
  * active layer crossfading on top), injecting the layer's tint as the icon's
29
- * `fill` unless the element already sets one.
29
+ * `fill` unless the element already sets one. See {@link TabBarItem.activeIcon}
30
+ * for a set whose selected state is a different shape rather than a tint.
30
31
  */
31
32
  icon: ReactNode;
33
+ /**
34
+ * Optional second node rendered by the ACTIVE crossfade layer instead of
35
+ * `icon`; without it that layer renders `icon` again, which is the original
36
+ * behaviour.
37
+ *
38
+ * For icon sets that express selection by SHAPE rather than by tint — an
39
+ * outline glyph that becomes a filled one, with a different path — a tint
40
+ * crossfade cannot say what the set says, because both layers would draw the
41
+ * same shape in two colors. Supplying the filled variant here makes the
42
+ * existing crossfade swap NODES, so the outline dissolves into the fill.
43
+ *
44
+ * It is also the escape hatch for an icon that ignores the injected tint: the
45
+ * bar tints a glyph by cloning it with a `fill` prop (see `applyIconColor`),
46
+ * so an icon set that paints from a `color` prop instead never lights up, with
47
+ * no error. Pre-color both nodes yourself and the crossfade still works.
48
+ *
49
+ * On iOS an item carrying `sfSymbol` renders the symbol on both layers and
50
+ * ignores this — a symbol is tinted natively, so the tint crossfade is the
51
+ * right expression there.
52
+ */
53
+ activeIcon?: ReactNode;
32
54
  /**
33
55
  * Optional SF Symbol name, used INSTEAD of `icon` on iOS only. A pure
34
56
  * enhancement handled solely in `glyph.native.tsx`; the neutral and web
@@ -63,6 +85,20 @@ export type TabBarProps = ViewProps & {
63
85
  * or by keyboard/assistive-technology activation of a `TabBarButton`.
64
86
  */
65
87
  onIndexChange?: (index: number) => void;
88
+ /**
89
+ * Called with the index under the finger when a tab is pressed and HELD.
90
+ *
91
+ * This is the only way to reach a long press on the bar: its gesture detector
92
+ * consumes the touches (that is what makes scrubbing possible), so a button's
93
+ * own `onLongPress` never fires for a real finger. The gesture is added to the
94
+ * bar's gesture race only when this prop is supplied — holding still for half
95
+ * a second on a bar without it keeps behaving exactly as before, and can still
96
+ * start a scrub.
97
+ *
98
+ * Long-pressing does NOT move the highlight or select the tab; it is a
99
+ * secondary action (an account switcher on the profile tab, say).
100
+ */
101
+ onIndexLongPress?: (index: number) => void;
66
102
  /** Partial override of the theme resolved from Bloom's color tokens. */
67
103
  theme?: Partial<TabBarTheme>;
68
104
  /**
@@ -0,0 +1,44 @@
1
+ /**
2
+ * How much room a screen must leave at the bottom for the floating bar.
3
+ *
4
+ * The bar is `position: absolute` over the content, so nothing reserves space
5
+ * for it automatically — every scrollable screen under a tab layout has to pad
6
+ * its own content out from under the pill. That number is built from the bar's
7
+ * private geometry (its expanded height and the gap it keeps from the window's
8
+ * bottom edge), which is exactly why it is exported as a hook: hardcoding it in
9
+ * an app silently drifts the moment the bar changes by a pixel.
10
+ *
11
+ * Platform-neutral, like the rest of the family — `react-native-safe-area-context`
12
+ * is already a required Bloom peer and is what the bar itself measures with.
13
+ */
14
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
15
+
16
+ import { EXPANDED_HEIGHT, tabBarBottomGap } from './shared';
17
+
18
+ /**
19
+ * The vertical space the tab bar occupies above the bottom of the window, in
20
+ * px. Use it directly as the bottom padding (or bottom offset) of anything that
21
+ * would otherwise sit under the bar:
22
+ *
23
+ * ```tsx
24
+ * const reserved = useTabBarReservedSpace();
25
+ *
26
+ * <Animated.ScrollView contentContainerStyle={{ paddingBottom: reserved }} />
27
+ * <Fab style={{ position: 'absolute', right: 16, bottom: reserved + 12 }} />
28
+ * ```
29
+ *
30
+ * The value ALREADY INCLUDES the bottom safe-area inset — the bar folds the
31
+ * inset into its own bottom gap, so it is measured from the true window edge,
32
+ * not from the safe area. Never write `reserved + insets.bottom`: that counts
33
+ * the home indicator twice and strands a visible band of dead space under every
34
+ * list (a 76px bar reserving 110px on an iOS device or PWA). It is the whole
35
+ * number; nothing needs adding to it.
36
+ *
37
+ * It is the EXPANDED height, deliberately. The bar minimizes while scrolling
38
+ * down and re-expands on the way up, so reserving the minimized height would
39
+ * hide the end of a list the moment the user scrolled back to it.
40
+ */
41
+ export function useTabBarReservedSpace(): number {
42
+ const insets = useSafeAreaInsets();
43
+ return tabBarBottomGap(insets.bottom) + EXPANDED_HEIGHT;
44
+ }