@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.
- package/lib/commonjs/frosted-icon-button/shared.js +10 -0
- package/lib/commonjs/frosted-icon-button/shared.js.map +1 -1
- package/lib/commonjs/tab-bar/TabBarBase.js +30 -5
- package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
- package/lib/commonjs/tab-bar/glyph.js +9 -4
- package/lib/commonjs/tab-bar/glyph.js.map +1 -1
- package/lib/commonjs/tab-bar/glyph.native.js +7 -2
- package/lib/commonjs/tab-bar/glyph.native.js.map +1 -1
- package/lib/commonjs/tab-bar/index.js +7 -0
- package/lib/commonjs/tab-bar/index.js.map +1 -1
- package/lib/commonjs/tab-bar/index.web.js +7 -0
- package/lib/commonjs/tab-bar/index.web.js.map +1 -1
- package/lib/commonjs/tab-bar/shared.js +32 -1
- package/lib/commonjs/tab-bar/shared.js.map +1 -1
- package/lib/commonjs/tab-bar/use-reserved-space.js +50 -0
- package/lib/commonjs/tab-bar/use-reserved-space.js.map +1 -0
- package/lib/module/frosted-icon-button/shared.js +10 -0
- package/lib/module/frosted-icon-button/shared.js.map +1 -1
- package/lib/module/tab-bar/TabBarBase.js +31 -6
- package/lib/module/tab-bar/TabBarBase.js.map +1 -1
- package/lib/module/tab-bar/glyph.js +9 -4
- package/lib/module/tab-bar/glyph.js.map +1 -1
- package/lib/module/tab-bar/glyph.native.js +7 -2
- package/lib/module/tab-bar/glyph.native.js.map +1 -1
- package/lib/module/tab-bar/index.js +1 -0
- package/lib/module/tab-bar/index.js.map +1 -1
- package/lib/module/tab-bar/index.web.js +1 -0
- package/lib/module/tab-bar/index.web.js.map +1 -1
- package/lib/module/tab-bar/shared.js +30 -0
- package/lib/module/tab-bar/shared.js.map +1 -1
- package/lib/module/tab-bar/use-reserved-space.js +46 -0
- package/lib/module/tab-bar/use-reserved-space.js.map +1 -0
- package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts +10 -0
- package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/glyph.d.ts +1 -1
- package/lib/typescript/commonjs/tab-bar/glyph.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts +1 -1
- package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/index.d.ts +1 -0
- package/lib/typescript/commonjs/tab-bar/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/index.web.d.ts +1 -0
- package/lib/typescript/commonjs/tab-bar/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/shared.d.ts +25 -0
- package/lib/typescript/commonjs/tab-bar/shared.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/types.d.ts +37 -1
- package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/use-reserved-space.d.ts +25 -0
- package/lib/typescript/commonjs/tab-bar/use-reserved-space.d.ts.map +1 -0
- package/lib/typescript/module/frosted-icon-button/shared.d.ts +10 -0
- package/lib/typescript/module/frosted-icon-button/shared.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/glyph.d.ts +1 -1
- package/lib/typescript/module/tab-bar/glyph.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/glyph.native.d.ts +1 -1
- package/lib/typescript/module/tab-bar/glyph.native.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/index.d.ts +1 -0
- package/lib/typescript/module/tab-bar/index.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/index.web.d.ts +1 -0
- package/lib/typescript/module/tab-bar/index.web.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/shared.d.ts +25 -0
- package/lib/typescript/module/tab-bar/shared.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/types.d.ts +37 -1
- package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/use-reserved-space.d.ts +25 -0
- package/lib/typescript/module/tab-bar/use-reserved-space.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/TabBar.test.tsx +82 -2
- package/src/__tests__/TabBarLongPress.test.tsx +258 -0
- package/src/__tests__/TabBarReservedSpace.test.tsx +150 -0
- package/src/__tests__/TabBarWebFork.test.ts +1 -0
- package/src/frosted-icon-button/shared.ts +10 -0
- package/src/tab-bar/TabBarBase.tsx +39 -5
- package/src/tab-bar/glyph.native.tsx +5 -2
- package/src/tab-bar/glyph.tsx +8 -4
- package/src/tab-bar/index.ts +1 -0
- package/src/tab-bar/index.web.tsx +1 -0
- package/src/tab-bar/shared.ts +38 -0
- package/src/tab-bar/types.ts +37 -1
- 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
|
+
});
|
|
@@ -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
|
-
|
|
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
|
}
|
package/src/tab-bar/glyph.tsx
CHANGED
|
@@ -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
|
}
|
package/src/tab-bar/index.ts
CHANGED
|
@@ -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';
|
package/src/tab-bar/shared.ts
CHANGED
|
@@ -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
|
}
|
package/src/tab-bar/types.ts
CHANGED
|
@@ -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
|
+
}
|