@oxyhq/bloom 1.9.2 → 1.10.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/docs/layout.mdx +79 -0
- package/lib/commonjs/bottom-sheet/BottomSheetBase.js +14 -4
- package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
- package/lib/commonjs/fab/Fab.js +12 -3
- package/lib/commonjs/fab/Fab.js.map +1 -1
- package/lib/commonjs/fab/Fab.web.js +12 -4
- package/lib/commonjs/fab/Fab.web.js.map +1 -1
- package/lib/commonjs/index.js +137 -101
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +137 -101
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/layout/bottom-edge.js +148 -0
- package/lib/commonjs/layout/bottom-edge.js.map +1 -0
- package/lib/commonjs/layout/edge.js +67 -0
- package/lib/commonjs/layout/edge.js.map +1 -0
- package/lib/commonjs/layout/index.js +38 -0
- package/lib/commonjs/layout/index.js.map +1 -0
- package/lib/commonjs/provider/BloomProvider.js +5 -2
- package/lib/commonjs/provider/BloomProvider.js.map +1 -1
- package/lib/commonjs/tab-bar/TabBarBase.js +11 -1
- package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
- package/lib/commonjs/tab-bar/shared.js +0 -22
- package/lib/commonjs/tab-bar/shared.js.map +1 -1
- package/lib/commonjs/tab-bar/use-footprint.js +17 -8
- package/lib/commonjs/tab-bar/use-footprint.js.map +1 -1
- package/lib/commonjs/theme/color-scope/ColorScope.web.js +25 -13
- package/lib/commonjs/theme/color-scope/ColorScope.web.js.map +1 -1
- package/lib/commonjs/toast/Positioner.js +6 -1
- package/lib/commonjs/toast/Positioner.js.map +1 -1
- package/lib/commonjs/toast/container-geometry.js +19 -13
- package/lib/commonjs/toast/container-geometry.js.map +1 -1
- package/lib/module/bottom-sheet/BottomSheetBase.js +14 -4
- package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
- package/lib/module/fab/Fab.js +12 -3
- package/lib/module/fab/Fab.js.map +1 -1
- package/lib/module/fab/Fab.web.js +12 -4
- package/lib/module/fab/Fab.web.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +1 -0
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/layout/bottom-edge.js +141 -0
- package/lib/module/layout/bottom-edge.js.map +1 -0
- package/lib/module/layout/edge.js +62 -0
- package/lib/module/layout/edge.js.map +1 -0
- package/lib/module/layout/index.js +11 -0
- package/lib/module/layout/index.js.map +1 -0
- package/lib/module/provider/BloomProvider.js +5 -2
- package/lib/module/provider/BloomProvider.js.map +1 -1
- package/lib/module/tab-bar/TabBarBase.js +12 -2
- package/lib/module/tab-bar/TabBarBase.js.map +1 -1
- package/lib/module/tab-bar/shared.js +0 -21
- package/lib/module/tab-bar/shared.js.map +1 -1
- package/lib/module/tab-bar/use-footprint.js +18 -9
- package/lib/module/tab-bar/use-footprint.js.map +1 -1
- package/lib/module/theme/color-scope/ColorScope.web.js +25 -13
- package/lib/module/theme/color-scope/ColorScope.web.js.map +1 -1
- package/lib/module/toast/Positioner.js +6 -1
- package/lib/module/toast/Positioner.js.map +1 -1
- package/lib/module/toast/container-geometry.js +20 -13
- package/lib/module/toast/container-geometry.js.map +1 -1
- package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/fab/Fab.d.ts.map +1 -1
- package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +1 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.web.d.ts +1 -0
- package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/layout/bottom-edge.d.ts +77 -0
- package/lib/typescript/commonjs/layout/bottom-edge.d.ts.map +1 -0
- package/lib/typescript/commonjs/layout/edge.d.ts +56 -0
- package/lib/typescript/commonjs/layout/edge.d.ts.map +1 -0
- package/lib/typescript/commonjs/layout/index.d.ts +9 -0
- package/lib/typescript/commonjs/layout/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/provider/BloomProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/shared.d.ts +0 -9
- package/lib/typescript/commonjs/tab-bar/shared.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/use-footprint.d.ts +11 -7
- package/lib/typescript/commonjs/tab-bar/use-footprint.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-scope/ColorScope.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/Positioner.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/container-geometry.d.ts +14 -5
- package/lib/typescript/commonjs/toast/container-geometry.d.ts.map +1 -1
- package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
- package/lib/typescript/module/fab/Fab.d.ts.map +1 -1
- package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +1 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/index.web.d.ts +1 -0
- package/lib/typescript/module/index.web.d.ts.map +1 -1
- package/lib/typescript/module/layout/bottom-edge.d.ts +77 -0
- package/lib/typescript/module/layout/bottom-edge.d.ts.map +1 -0
- package/lib/typescript/module/layout/edge.d.ts +56 -0
- package/lib/typescript/module/layout/edge.d.ts.map +1 -0
- package/lib/typescript/module/layout/index.d.ts +9 -0
- package/lib/typescript/module/layout/index.d.ts.map +1 -0
- package/lib/typescript/module/provider/BloomProvider.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/shared.d.ts +0 -9
- package/lib/typescript/module/tab-bar/shared.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/use-footprint.d.ts +11 -7
- package/lib/typescript/module/tab-bar/use-footprint.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-scope/ColorScope.web.d.ts.map +1 -1
- package/lib/typescript/module/toast/Positioner.d.ts.map +1 -1
- package/lib/typescript/module/toast/container-geometry.d.ts +14 -5
- package/lib/typescript/module/toast/container-geometry.d.ts.map +1 -1
- package/package.json +15 -1
- package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
- package/src/__tests__/support/constructed-style-sheets.ts +68 -0
- package/src/__tests__/support/press-host.ts +30 -0
- package/src/__tests__/support/rendered-style.ts +99 -0
- package/src/__tests__/support/unread-hook-fixture.ts +33 -0
- package/src/bottom-sheet/BottomSheetBase.tsx +14 -4
- package/src/fab/Fab.tsx +16 -3
- package/src/fab/Fab.web.tsx +16 -4
- package/src/index.ts +7 -0
- package/src/index.web.ts +7 -0
- package/src/layout/bottom-edge.tsx +162 -0
- package/src/layout/edge.ts +59 -0
- package/src/layout/index.ts +8 -0
- package/src/provider/BloomProvider.tsx +11 -1
- package/src/tab-bar/TabBarBase.tsx +11 -2
- package/src/tab-bar/shared.ts +0 -20
- package/src/tab-bar/use-footprint.ts +18 -9
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +7682 -0
- package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
- package/src/theme/color-scope/ColorScope.web.tsx +23 -13
- package/src/toast/Positioner.tsx +5 -0
- package/src/toast/container-geometry.ts +20 -8
package/src/fab/Fab.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from 'react-native';
|
|
12
12
|
import { styled } from 'react-native-css';
|
|
13
13
|
|
|
14
|
+
import { useBottomEdgeInset } from '../layout/bottom-edge';
|
|
14
15
|
import { useTheme } from '../theme/use-theme';
|
|
15
16
|
import { animation, borderRadius } from '../styles/tokens';
|
|
16
17
|
import { bloomShadowStyle } from '../design-tokens/shadows';
|
|
@@ -65,12 +66,23 @@ const HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 } as const;
|
|
|
65
66
|
* the nearest positioned ancestor (the consumer's column container), NOT the
|
|
66
67
|
* screen — so it never escapes a constrained layout. `static` returns no
|
|
67
68
|
* positioning (consumer-controlled).
|
|
69
|
+
*
|
|
70
|
+
* `bottomEdgeInset` applies to the BOTTOM axis only, and only for a bottom
|
|
71
|
+
* placement. It is not a gap preference — `offset` is that — but the height of
|
|
72
|
+
* whatever floating surface has already claimed the bottom edge, so the FAB
|
|
73
|
+
* lands above a floating tab bar instead of behind it. A z-index cannot fix that
|
|
74
|
+
* pairing: the bar's host is the last sibling of the app shell and paints over
|
|
75
|
+
* every descendant, so the FAB has to be somewhere else, not merely on top.
|
|
68
76
|
*/
|
|
69
|
-
function placementStyle(
|
|
77
|
+
function placementStyle(
|
|
78
|
+
placement: FabPlacement,
|
|
79
|
+
offset: number,
|
|
80
|
+
bottomEdgeInset: number,
|
|
81
|
+
): ViewStyle {
|
|
70
82
|
if (placement === 'static') return {};
|
|
71
83
|
const style: ViewStyle = { position: 'absolute' };
|
|
72
84
|
if (placement === 'bottom-right' || placement === 'bottom-left') {
|
|
73
|
-
style.bottom = offset;
|
|
85
|
+
style.bottom = offset + bottomEdgeInset;
|
|
74
86
|
} else {
|
|
75
87
|
style.top = offset;
|
|
76
88
|
}
|
|
@@ -145,6 +157,7 @@ const FabComponent: React.FC<FabProps> = ({
|
|
|
145
157
|
testID,
|
|
146
158
|
zIndex = DEFAULT_Z_INDEX,
|
|
147
159
|
}) => {
|
|
160
|
+
const bottomEdgeInset = useBottomEdgeInset();
|
|
148
161
|
const theme = useTheme();
|
|
149
162
|
const sizeConfig = useMemo(() => resolveSize(size), [size]);
|
|
150
163
|
const isExtended = label != null && label.length > 0;
|
|
@@ -213,7 +226,7 @@ const FabComponent: React.FC<FabProps> = ({
|
|
|
213
226
|
<AnimatedPressable
|
|
214
227
|
className={className}
|
|
215
228
|
style={[
|
|
216
|
-
placementStyle(placement, offset),
|
|
229
|
+
placementStyle(placement, offset, bottomEdgeInset),
|
|
217
230
|
{ zIndex },
|
|
218
231
|
containerStyle,
|
|
219
232
|
disabled && { opacity: 0.5 },
|
package/src/fab/Fab.web.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import React, {
|
|
|
7
7
|
type MouseEvent,
|
|
8
8
|
} from 'react';
|
|
9
9
|
|
|
10
|
+
import { useBottomEdgeInset } from '../layout/bottom-edge';
|
|
10
11
|
import { useTheme } from '../theme/use-theme';
|
|
11
12
|
import { animation, borderRadius } from '../styles/tokens';
|
|
12
13
|
import { pressedSurface } from '../theme/press-colors';
|
|
@@ -121,14 +122,24 @@ const BLOOM_FAB_CSS = interactiveWebCss({
|
|
|
121
122
|
* Horizontal placement uses `align-self` (sticky elements can't be pushed by
|
|
122
123
|
* `left`/`right` the way absolute ones are) plus an inline-edge margin for the
|
|
123
124
|
* `offset`. The consumer column MUST be a flex column that fills the available
|
|
124
|
-
* height, and the FAB MUST be its last child — documented in the usage story.
|
|
125
|
+
* height, and the FAB MUST be its last child — documented in the usage story. *
|
|
126
|
+
* `bottomEdgeInset` applies to the BOTTOM axis only. It is not a gap preference
|
|
127
|
+
* — `offset` is that — but the height of whatever floating surface has already
|
|
128
|
+
* claimed the bottom edge, so the FAB lands above a floating tab bar instead of
|
|
129
|
+
* behind it. A z-index cannot fix that pairing: the bar's host is the last
|
|
130
|
+
* sibling of the app shell and paints over every descendant, so the FAB has to be
|
|
131
|
+
* somewhere else, not merely on top.
|
|
125
132
|
*/
|
|
126
|
-
function placementStyle(
|
|
133
|
+
function placementStyle(
|
|
134
|
+
placement: FabPlacement,
|
|
135
|
+
offset: number,
|
|
136
|
+
bottomEdgeInset: number,
|
|
137
|
+
): CSSProperties {
|
|
127
138
|
if (placement === 'static') return {};
|
|
128
139
|
const style: CSSProperties = { position: 'sticky' };
|
|
129
140
|
const isBottom = placement === 'bottom-right' || placement === 'bottom-left';
|
|
130
141
|
if (isBottom) {
|
|
131
|
-
style.bottom = offset;
|
|
142
|
+
style.bottom = offset + bottomEdgeInset;
|
|
132
143
|
// Push the FAB to the bottom of a (flex-column) container even when the
|
|
133
144
|
// content is too short to scroll, so it never floats mid-column.
|
|
134
145
|
style.marginTop = 'auto';
|
|
@@ -186,6 +197,7 @@ const FabWebComponent: React.FC<FabProps> = ({
|
|
|
186
197
|
type = 'button',
|
|
187
198
|
}) => {
|
|
188
199
|
useInteractiveWebCss(STYLE_ID, BLOOM_FAB_CSS);
|
|
200
|
+
const bottomEdgeInset = useBottomEdgeInset();
|
|
189
201
|
const theme = useTheme();
|
|
190
202
|
const reactId = useId();
|
|
191
203
|
const resolvedId = id ?? `bloom-fab-${reactId}`;
|
|
@@ -220,7 +232,7 @@ const FabWebComponent: React.FC<FabProps> = ({
|
|
|
220
232
|
variantColors.foreground,
|
|
221
233
|
),
|
|
222
234
|
['--bloom-fab-press-scale' as string]: animation.pressScale,
|
|
223
|
-
...placementStyle(placement, offset),
|
|
235
|
+
...placementStyle(placement, offset, bottomEdgeInset),
|
|
224
236
|
};
|
|
225
237
|
if (isExtended) {
|
|
226
238
|
const pad = sizeConfig.diameter <= 44 ? 14 : 20;
|
package/src/index.ts
CHANGED
|
@@ -153,6 +153,13 @@ export type {
|
|
|
153
153
|
SurfacePromptOptions,
|
|
154
154
|
} from './surfaces';
|
|
155
155
|
export * from './button';
|
|
156
|
+
export {
|
|
157
|
+
EDGE_GAP,
|
|
158
|
+
windowEdgeGap,
|
|
159
|
+
BottomEdgeProvider,
|
|
160
|
+
useBottomEdgeInset,
|
|
161
|
+
useClaimBottomEdge,
|
|
162
|
+
} from './layout';
|
|
156
163
|
export { Fab } from './fab';
|
|
157
164
|
export type { FabProps, FabVariant, FabSize, FabPlacement } from './fab';
|
|
158
165
|
export { FrostedIconButton } from './frosted-icon-button';
|
package/src/index.web.ts
CHANGED
|
@@ -158,6 +158,13 @@ export type {
|
|
|
158
158
|
SurfacePromptOptions,
|
|
159
159
|
} from './surfaces/index.web';
|
|
160
160
|
export * from './button/index.web';
|
|
161
|
+
export {
|
|
162
|
+
EDGE_GAP,
|
|
163
|
+
windowEdgeGap,
|
|
164
|
+
BottomEdgeProvider,
|
|
165
|
+
useBottomEdgeInset,
|
|
166
|
+
useClaimBottomEdge,
|
|
167
|
+
} from './layout';
|
|
161
168
|
export { Fab } from './fab/index.web';
|
|
162
169
|
export type { FabProps, FabVariant, FabSize, FabPlacement } from './fab/index.web';
|
|
163
170
|
export { FrostedIconButton } from './frosted-icon-button/index.web';
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bottom edge's OCCUPANCY — how much of it is already claimed by a floating
|
|
3
|
+
* surface, so the next surface can stack above it instead of underneath it.
|
|
4
|
+
*
|
|
5
|
+
* ## The problem
|
|
6
|
+
*
|
|
7
|
+
* `windowEdgeGap` tells a surface where the window edge is. It cannot tell it
|
|
8
|
+
* that a tab bar is already parked there. Nothing could, so nothing did: Bloom's
|
|
9
|
+
* `Fab` anchored itself 16px off the edge and landed squarely behind the
|
|
10
|
+
* floating tab bar, and every app that hit it patched around Bloom instead —
|
|
11
|
+
* Mention grew a `BottomBarAwareFab` that read `useTabBarFootprint()` and lifted
|
|
12
|
+
* the FAB by hand, on web only, leaving native broken across seven screens.
|
|
13
|
+
*
|
|
14
|
+
* A z-index cannot rescue that placement either. The tab bar host is the last
|
|
15
|
+
* sibling of the app shell, so it paints above every descendant regardless of
|
|
16
|
+
* what they claim; the FAB has to be somewhere ELSE, not merely on top.
|
|
17
|
+
*
|
|
18
|
+
* ## The model
|
|
19
|
+
*
|
|
20
|
+
* A surface that parks at the bottom edge CLAIMS its footprint. A surface that
|
|
21
|
+
* wants to avoid it READS the total and offsets itself. Neither one imports the
|
|
22
|
+
* other, so a consumer composes them in any combination — a tab bar with no FAB,
|
|
23
|
+
* a FAB with no tab bar, both, or a third surface written later — and the
|
|
24
|
+
* geometry still resolves.
|
|
25
|
+
*
|
|
26
|
+
* Claims combine with MAX, not sum. Every claim is measured from the same window
|
|
27
|
+
* edge, so two surfaces at that edge overlap rather than stack; the tallest is
|
|
28
|
+
* what a new surface has to clear. Summing would strand it at twice the height.
|
|
29
|
+
*
|
|
30
|
+
* ## Why an external store
|
|
31
|
+
*
|
|
32
|
+
* The claim set is mutable state living outside React. Reading it in a memoized
|
|
33
|
+
* position is exactly the stale-read the React Compiler produces, so it is read
|
|
34
|
+
* through `useSyncExternalStore` and never touched directly. The store is
|
|
35
|
+
* created per provider (not at module scope) so tests, and any app mounting two
|
|
36
|
+
* roots, stay isolated from each other.
|
|
37
|
+
*/
|
|
38
|
+
import {
|
|
39
|
+
createContext,
|
|
40
|
+
useContext,
|
|
41
|
+
useEffect,
|
|
42
|
+
useId,
|
|
43
|
+
useState,
|
|
44
|
+
useSyncExternalStore,
|
|
45
|
+
type PropsWithChildren,
|
|
46
|
+
} from 'react';
|
|
47
|
+
|
|
48
|
+
interface BottomEdgeStore {
|
|
49
|
+
subscribe: (onChange: () => void) => () => void;
|
|
50
|
+
/**
|
|
51
|
+
* The cached total. Returns the SAME number until a claim actually changes it
|
|
52
|
+
* — `useSyncExternalStore` re-renders forever if the snapshot is recomputed
|
|
53
|
+
* per call.
|
|
54
|
+
*/
|
|
55
|
+
getInset: () => number;
|
|
56
|
+
claim: (id: string, height: number) => void;
|
|
57
|
+
release: (id: string) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createBottomEdgeStore(): BottomEdgeStore {
|
|
61
|
+
const claims = new Map<string, number>();
|
|
62
|
+
const listeners = new Set<() => void>();
|
|
63
|
+
let inset = 0;
|
|
64
|
+
|
|
65
|
+
const recompute = () => {
|
|
66
|
+
let next = 0;
|
|
67
|
+
for (const height of claims.values()) {
|
|
68
|
+
if (height > next) next = height;
|
|
69
|
+
}
|
|
70
|
+
// Bail before notifying: a re-registration at an unchanged height (every
|
|
71
|
+
// render of a claimant whose footprint did not move) must not re-render
|
|
72
|
+
// every reader.
|
|
73
|
+
if (next === inset) return;
|
|
74
|
+
inset = next;
|
|
75
|
+
for (const listener of listeners) listener();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
subscribe(onChange) {
|
|
80
|
+
listeners.add(onChange);
|
|
81
|
+
return () => {
|
|
82
|
+
listeners.delete(onChange);
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
getInset: () => inset,
|
|
86
|
+
claim(id, height) {
|
|
87
|
+
if (claims.get(id) === height) return;
|
|
88
|
+
claims.set(id, height);
|
|
89
|
+
recompute();
|
|
90
|
+
},
|
|
91
|
+
release(id) {
|
|
92
|
+
if (!claims.delete(id)) return;
|
|
93
|
+
recompute();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const BottomEdgeContext = createContext<BottomEdgeStore | null>(null);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Wrap the app once — `BloomProvider` already does. Rendering a second one
|
|
102
|
+
* scopes a nested subtree to its own claims, which is a real (if rare) thing to
|
|
103
|
+
* want: a bottom-edge surface inside a full-screen modal should not be offset by
|
|
104
|
+
* the tab bar the modal is covering.
|
|
105
|
+
*/
|
|
106
|
+
export function BottomEdgeProvider({ children }: PropsWithChildren) {
|
|
107
|
+
const [store] = useState(createBottomEdgeStore);
|
|
108
|
+
return <BottomEdgeContext.Provider value={store}>{children}</BottomEdgeContext.Provider>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
BottomEdgeProvider.displayName = 'BottomEdgeProvider';
|
|
112
|
+
|
|
113
|
+
// Stable module-scope identities for the no-provider path. Fresh closures here
|
|
114
|
+
// would resubscribe `useSyncExternalStore` on every render.
|
|
115
|
+
const NO_SUBSCRIPTION = () => () => {};
|
|
116
|
+
const NO_INSET = () => 0;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* How much of the bottom edge is already occupied, in px.
|
|
120
|
+
*
|
|
121
|
+
* Add it to whatever offset the surface would otherwise use:
|
|
122
|
+
*
|
|
123
|
+
* ```tsx
|
|
124
|
+
* const occupied = useBottomEdgeInset();
|
|
125
|
+
* <View style={{ position: 'absolute', bottom: windowEdgeGap(insets.bottom) + occupied }} />
|
|
126
|
+
* ```
|
|
127
|
+
*
|
|
128
|
+
* `0` outside a provider, and `0` on the first commit even inside one — a claim
|
|
129
|
+
* registers in an effect, so a reader mounted in the same commit as its claimant
|
|
130
|
+
* settles one frame later. That is a layout offset rather than a measurement, so
|
|
131
|
+
* the settle is invisible in practice; nothing here waits on a real layout pass.
|
|
132
|
+
*/
|
|
133
|
+
export function useBottomEdgeInset(): number {
|
|
134
|
+
const store = useContext(BottomEdgeContext);
|
|
135
|
+
return useSyncExternalStore(
|
|
136
|
+
store?.subscribe ?? NO_SUBSCRIPTION,
|
|
137
|
+
store?.getInset ?? NO_INSET,
|
|
138
|
+
store?.getInset ?? NO_INSET,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Claim `height` px of the bottom edge for as long as the caller is mounted.
|
|
144
|
+
*
|
|
145
|
+
* The claimant owns its own placement — claiming does not move it. It declares
|
|
146
|
+
* the space it occupies so that everything reading `useBottomEdgeInset()` stays
|
|
147
|
+
* off it. Pass the FULL footprint (the surface's height plus the gap it holds
|
|
148
|
+
* off the window edge), which is the same number the surface positions itself
|
|
149
|
+
* with.
|
|
150
|
+
*
|
|
151
|
+
* A no-op outside a provider, so a surface stays usable standalone.
|
|
152
|
+
*/
|
|
153
|
+
export function useClaimBottomEdge(height: number): void {
|
|
154
|
+
const store = useContext(BottomEdgeContext);
|
|
155
|
+
const id = useId();
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!store) return;
|
|
159
|
+
store.claim(id, height);
|
|
160
|
+
return () => store.release(id);
|
|
161
|
+
}, [store, id, height]);
|
|
162
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far a floating surface sits from a window edge.
|
|
3
|
+
*
|
|
4
|
+
* ## The rule this file encodes
|
|
5
|
+
*
|
|
6
|
+
* A floating surface CLEARS THE OS'S RESERVED BAND IN FULL. The safe-area inset
|
|
7
|
+
* is the space the platform has reserved for its own affordance — the iOS home
|
|
8
|
+
* indicator, the Android gesture handle — and a surface that sits inside it is
|
|
9
|
+
* sitting on top of a system control.
|
|
10
|
+
*
|
|
11
|
+
* Bloom used to answer this question three times, differently:
|
|
12
|
+
*
|
|
13
|
+
* - a detached `BottomSheet` `insets.bottom + 16`
|
|
14
|
+
* - the toast stack `bottom > 0 ? bottom + 8 : 16`
|
|
15
|
+
* - the floating tab bar `max(bottom - 16, 12)`
|
|
16
|
+
*
|
|
17
|
+
* The tab bar was the only one that SUBTRACTED, and subtracting is what put it
|
|
18
|
+
* on the Android gesture handle: a gesture-navigation device reserves ~24dp, the
|
|
19
|
+
* bar absorbed 16 of it, hit its own 12dp floor, and landed inside the band with
|
|
20
|
+
* the handle drawn through it. The other two were never reachable by that bug
|
|
21
|
+
* because they add.
|
|
22
|
+
*
|
|
23
|
+
* One function now answers it for all three, and the additive `gap` reproduces
|
|
24
|
+
* the sheet and the toast exactly — only the tab bar moves.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Breathing room from a window edge where the platform reserves nothing at all
|
|
29
|
+
* (web, a browser with no visual viewport inset, an Android device with the
|
|
30
|
+
* gesture bar hidden).
|
|
31
|
+
*
|
|
32
|
+
* It is also the FLOOR for every surface: a small-but-nonzero inset must never
|
|
33
|
+
* pull a surface closer to the edge than a device reporting no inset would.
|
|
34
|
+
*/
|
|
35
|
+
export const EDGE_GAP = 16;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The distance a floating surface holds off a window edge, given that edge's
|
|
39
|
+
* safe-area inset.
|
|
40
|
+
*
|
|
41
|
+
* `gap` is the surface's own breathing room ON TOP of the reserved band, not an
|
|
42
|
+
* allowance to spend inside it — a bigger `gap` floats the surface further from
|
|
43
|
+
* the affordance, never closer. Pass `0` for a surface that should hug the safe
|
|
44
|
+
* area (the tab bar), a positive number for one that should stand clear of it (a
|
|
45
|
+
* detached sheet, a toast stack).
|
|
46
|
+
*
|
|
47
|
+
* ```ts
|
|
48
|
+
* windowEdgeGap(insets.bottom) // 34pt iOS · 24dp Android · 16 web
|
|
49
|
+
* windowEdgeGap(insets.bottom, 16) // 50pt iOS · 40dp Android · 16 web
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* Anything laying out AROUND such a surface wants its total footprint (this gap
|
|
53
|
+
* plus the surface's own height) and must get it from the surface — never by
|
|
54
|
+
* adding `insets.bottom` to a hardcoded height, which double-counts the inset
|
|
55
|
+
* because the inset is already folded in here.
|
|
56
|
+
*/
|
|
57
|
+
export function windowEdgeGap(inset: number, gap = 0): number {
|
|
58
|
+
return Math.max(inset + gap, EDGE_GAP);
|
|
59
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Window-edge geometry, shared by every floating surface.
|
|
3
|
+
*
|
|
4
|
+
* `edge` answers "how far from the edge"; `bottom-edge` answers "what is already
|
|
5
|
+
* parked there". A surface that floats at the bottom needs both.
|
|
6
|
+
*/
|
|
7
|
+
export { EDGE_GAP, windowEdgeGap } from './edge';
|
|
8
|
+
export { BottomEdgeProvider, useBottomEdgeInset, useClaimBottomEdge } from './bottom-edge';
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
import type { ReactNode } from 'react';
|
|
35
35
|
|
|
36
36
|
import { GlassBlurTargetProvider } from '../glass/blur-target';
|
|
37
|
+
import { BottomEdgeProvider } from '../layout/bottom-edge';
|
|
37
38
|
import { ImageResolverProvider, type ImageResolver } from '../image-resolver';
|
|
38
39
|
import { BloomHapticsProvider } from '../hooks/use-haptics';
|
|
39
40
|
import { TabBarMinimizeProvider } from '../tab-bar/context';
|
|
@@ -67,7 +68,16 @@ export function BloomProvider({
|
|
|
67
68
|
provider already does with `children`.
|
|
68
69
|
*/}
|
|
69
70
|
<GlassBlurTargetProvider>
|
|
70
|
-
|
|
71
|
+
{/*
|
|
72
|
+
The bottom edge's claim registry. Also a provider, not an outlet:
|
|
73
|
+
it renders nothing and holds only the set of surfaces currently
|
|
74
|
+
parked at the bottom edge, so a tab bar can publish its footprint
|
|
75
|
+
and a FAB or a toast stack can read it without either importing
|
|
76
|
+
the other.
|
|
77
|
+
*/}
|
|
78
|
+
<BottomEdgeProvider>
|
|
79
|
+
<TabBarMinimizeProvider>{children}</TabBarMinimizeProvider>
|
|
80
|
+
</BottomEdgeProvider>
|
|
71
81
|
</GlassBlurTargetProvider>
|
|
72
82
|
</BloomHapticsProvider>
|
|
73
83
|
</ScrollRestorationProvider>
|
|
@@ -38,6 +38,8 @@ import Animated, {
|
|
|
38
38
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
39
39
|
|
|
40
40
|
import { useHaptics } from '../hooks/use-haptics';
|
|
41
|
+
import { useClaimBottomEdge } from '../layout/bottom-edge';
|
|
42
|
+
import { windowEdgeGap } from '../layout/edge';
|
|
41
43
|
import type { ProgressiveBlurProps } from '../progressive-blur/types';
|
|
42
44
|
import { setMinimized, useMinimizeState } from './context';
|
|
43
45
|
import {
|
|
@@ -56,7 +58,6 @@ import {
|
|
|
56
58
|
MINIMIZED_INSET,
|
|
57
59
|
ROW_PAD_H,
|
|
58
60
|
SLIDE_SPRING,
|
|
59
|
-
tabBarBottomGap,
|
|
60
61
|
useTabBarTheme,
|
|
61
62
|
type TabBarGlyphProps,
|
|
62
63
|
type TabBarSurfaceProps,
|
|
@@ -419,7 +420,15 @@ function TabBarBody({
|
|
|
419
420
|
|
|
420
421
|
// Shared with `useTabBarFootprint`, so a consumer accounting for the bar in
|
|
421
422
|
// its own layout can never drift from where the bar actually sits.
|
|
422
|
-
const bottomOffset =
|
|
423
|
+
const bottomOffset = windowEdgeGap(insets.bottom);
|
|
424
|
+
|
|
425
|
+
// Publish what the bar occupies so anything else at this edge stacks above it
|
|
426
|
+
// rather than behind it. Same number `useTabBarFootprint` reports, derived from
|
|
427
|
+
// the same two values, so a consumer reading either can never disagree with
|
|
428
|
+
// where the bar actually sits. The EXPANDED height on purpose: the bar
|
|
429
|
+
// minimizes on scroll and re-expands, so claiming the minimized height would
|
|
430
|
+
// drop a FAB onto the pill the moment the user scrolled back up.
|
|
431
|
+
useClaimBottomEdge(bottomOffset + EXPANDED_HEIGHT);
|
|
423
432
|
|
|
424
433
|
// How centring and the animated inset compose: centring is STATIC and belongs
|
|
425
434
|
// to the wrap, the inset stays ANIMATED on the pill inside it. The wrap is
|
package/src/tab-bar/shared.ts
CHANGED
|
@@ -51,26 +51,6 @@ 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 accounting for
|
|
67
|
-
* the bar in its own layout must go through `useTabBarFootprint` (which derives
|
|
68
|
-
* its 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
56
|
* Slide spring: interruptible by design — rapid tab-hopping retargets with
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { windowEdgeGap } from '../layout/edge';
|
|
17
|
+
import { EXPANDED_HEIGHT } from './shared';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* The vertical space the tab bar OCCUPIES above the bottom of the window, in
|
|
@@ -30,15 +31,19 @@ import { EXPANDED_HEIGHT, tabBarBottomGap } from './shared';
|
|
|
30
31
|
* const footprint = useTabBarFootprint();
|
|
31
32
|
*
|
|
32
33
|
* <Animated.ScrollView contentContainerStyle={{ paddingBottom: footprint + 12 }} />
|
|
33
|
-
* <Fab style={{ position: 'absolute', right: 16, bottom: footprint }} />
|
|
34
34
|
* ```
|
|
35
35
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
36
|
+
* A FAB no longer needs this hook at all — `Fab` reads the bottom edge's
|
|
37
|
+
* occupancy itself (see `layout/bottom-edge`), which is what the bar publishes
|
|
38
|
+
* this exact number to. Reach for the hook when laying out something that is not
|
|
39
|
+
* a Bloom surface.
|
|
40
|
+
*
|
|
41
|
+
* The value ALREADY INCLUDES the bottom safe-area inset — `windowEdgeGap` clears
|
|
42
|
+
* the OS's reserved band in full — so this is measured from the true window
|
|
43
|
+
* edge, not from the safe area. Never write `footprint + insets.bottom`: that
|
|
44
|
+
* counts the home indicator twice and strands a visible band of dead space under
|
|
45
|
+
* every list. Whatever you add on top is your own clearance, never the inset a
|
|
46
|
+
* second time.
|
|
42
47
|
*
|
|
43
48
|
* It is the EXPANDED height, deliberately. The bar minimizes while scrolling
|
|
44
49
|
* down and re-expands on the way up, so measuring the minimized height would
|
|
@@ -46,5 +51,9 @@ import { EXPANDED_HEIGHT, tabBarBottomGap } from './shared';
|
|
|
46
51
|
*/
|
|
47
52
|
export function useTabBarFootprint(): number {
|
|
48
53
|
const insets = useSafeAreaInsets();
|
|
49
|
-
|
|
54
|
+
// Gap 0: the bar hugs the safe area, the tightest any Bloom surface floats. It
|
|
55
|
+
// is a compact pill, not a panel, and a detached-sheet-sized gap under it would
|
|
56
|
+
// strand it mid-screen. It used to ABSORB 16px of the inset and clamp at a 12px
|
|
57
|
+
// floor, which is how it ended up drawn through the Android gesture handle.
|
|
58
|
+
return windowEdgeGap(insets.bottom) + EXPANDED_HEIGHT;
|
|
50
59
|
}
|