@oxyhq/bloom 1.10.1 → 1.11.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 (41) hide show
  1. package/docs/layout.mdx +33 -1
  2. package/lib/commonjs/fab/Fab.js +42 -0
  3. package/lib/commonjs/fab/Fab.js.map +1 -1
  4. package/lib/commonjs/fab/Fab.web.js +18 -3
  5. package/lib/commonjs/fab/Fab.web.js.map +1 -1
  6. package/lib/commonjs/layout/bottom-edge.js +72 -24
  7. package/lib/commonjs/layout/bottom-edge.js.map +1 -1
  8. package/lib/commonjs/layout/index.js +6 -0
  9. package/lib/commonjs/layout/index.js.map +1 -1
  10. package/lib/commonjs/tab-bar/TabBarBase.js +22 -6
  11. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  12. package/lib/module/fab/Fab.js +44 -2
  13. package/lib/module/fab/Fab.js.map +1 -1
  14. package/lib/module/fab/Fab.web.js +19 -4
  15. package/lib/module/fab/Fab.web.js.map +1 -1
  16. package/lib/module/layout/bottom-edge.js +71 -24
  17. package/lib/module/layout/bottom-edge.js.map +1 -1
  18. package/lib/module/layout/index.js +1 -1
  19. package/lib/module/layout/index.js.map +1 -1
  20. package/lib/module/tab-bar/TabBarBase.js +24 -8
  21. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  22. package/lib/typescript/commonjs/fab/Fab.d.ts.map +1 -1
  23. package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/layout/bottom-edge.d.ts +44 -10
  25. package/lib/typescript/commonjs/layout/bottom-edge.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/layout/index.d.ts +1 -1
  27. package/lib/typescript/commonjs/layout/index.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  29. package/lib/typescript/module/fab/Fab.d.ts.map +1 -1
  30. package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -1
  31. package/lib/typescript/module/layout/bottom-edge.d.ts +44 -10
  32. package/lib/typescript/module/layout/bottom-edge.d.ts.map +1 -1
  33. package/lib/typescript/module/layout/index.d.ts +1 -1
  34. package/lib/typescript/module/layout/index.d.ts.map +1 -1
  35. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/fab/Fab.tsx +41 -3
  38. package/src/fab/Fab.web.tsx +19 -4
  39. package/src/layout/bottom-edge.tsx +88 -31
  40. package/src/layout/index.ts +6 -1
  41. package/src/tab-bar/TabBarBase.tsx +31 -6
@@ -27,6 +27,21 @@
27
27
  * edge, so two surfaces at that edge overlap rather than stack; the tallest is
28
28
  * what a new surface has to clear. Summing would strand it at twice the height.
29
29
  *
30
+ * ## Two numbers, because there are two questions
31
+ *
32
+ * A surface that collapses — the tab bar minimizes 58 -> 44 on scroll — occupies
33
+ * less than it reserves. Readers want different halves of that:
34
+ *
35
+ * - RESERVED (`useBottomEdgeInset`) never shrinks while the claimant is
36
+ * mounted. It is what must be kept permanently free: a list's bottom
37
+ * padding, a toast stack's offset. Tracking the collapse here would jitter a
38
+ * list's content size on every scroll and jump a toast 14px mid-display.
39
+ * - LIVE (`useBottomEdgeLiveInset`) follows the collapse. It is what a surface
40
+ * SITTING ON the edge wants, so it rides down with the bar instead of
41
+ * leaving a hole.
42
+ *
43
+ * A claimant that never collapses passes one number and both answers agree.
44
+ *
30
45
  * ## Why an external store
31
46
  *
32
47
  * The claim set is mutable state living outside React. Reading it in a memoized
@@ -45,33 +60,46 @@ import {
45
60
  type PropsWithChildren,
46
61
  } from 'react';
47
62
 
63
+ interface Claim {
64
+ /** What the surface keeps permanently free, collapsed or not. */
65
+ reserved: number;
66
+ /** What it occupies right now. */
67
+ current: number;
68
+ }
69
+
48
70
  interface BottomEdgeStore {
49
71
  subscribe: (onChange: () => void) => () => void;
50
72
  /**
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.
73
+ * The cached totals. Each returns the SAME number until a claim actually
74
+ * changes it — `useSyncExternalStore` re-renders forever if the snapshot is
75
+ * recomputed per call, and a reader of one channel must not re-render when
76
+ * only the other moved.
54
77
  */
55
- getInset: () => number;
56
- claim: (id: string, height: number) => void;
78
+ getReserved: () => number;
79
+ getLive: () => number;
80
+ claim: (id: string, reserved: number, current: number) => void;
57
81
  release: (id: string) => void;
58
82
  }
59
83
 
60
84
  function createBottomEdgeStore(): BottomEdgeStore {
61
- const claims = new Map<string, number>();
85
+ const claims = new Map<string, Claim>();
62
86
  const listeners = new Set<() => void>();
63
- let inset = 0;
87
+ let reserved = 0;
88
+ let live = 0;
64
89
 
65
90
  const recompute = () => {
66
- let next = 0;
67
- for (const height of claims.values()) {
68
- if (height > next) next = height;
91
+ let nextReserved = 0;
92
+ let nextLive = 0;
93
+ for (const claim of claims.values()) {
94
+ if (claim.reserved > nextReserved) nextReserved = claim.reserved;
95
+ if (claim.current > nextLive) nextLive = claim.current;
69
96
  }
70
- // Bail before notifying: a re-registration at an unchanged height (every
97
+ // Bail before notifying: a re-registration at unchanged heights (every
71
98
  // render of a claimant whose footprint did not move) must not re-render
72
99
  // every reader.
73
- if (next === inset) return;
74
- inset = next;
100
+ if (nextReserved === reserved && nextLive === live) return;
101
+ reserved = nextReserved;
102
+ live = nextLive;
75
103
  for (const listener of listeners) listener();
76
104
  };
77
105
 
@@ -82,10 +110,12 @@ function createBottomEdgeStore(): BottomEdgeStore {
82
110
  listeners.delete(onChange);
83
111
  };
84
112
  },
85
- getInset: () => inset,
86
- claim(id, height) {
87
- if (claims.get(id) === height) return;
88
- claims.set(id, height);
113
+ getReserved: () => reserved,
114
+ getLive: () => live,
115
+ claim(id, nextReserved, nextCurrent) {
116
+ const existing = claims.get(id);
117
+ if (existing?.reserved === nextReserved && existing.current === nextCurrent) return;
118
+ claims.set(id, { reserved: nextReserved, current: nextCurrent });
89
119
  recompute();
90
120
  },
91
121
  release(id) {
@@ -116,13 +146,15 @@ const NO_SUBSCRIPTION = () => () => {};
116
146
  const NO_INSET = () => 0;
117
147
 
118
148
  /**
119
- * How much of the bottom edge is already occupied, in px.
149
+ * How much of the bottom edge is permanently RESERVED, in px.
120
150
  *
121
- * Add it to whatever offset the surface would otherwise use:
151
+ * Never shrinks while its claimant is mounted, so it is the number for anything
152
+ * that must not move as the user scrolls — a list's bottom padding, a toast
153
+ * stack's offset:
122
154
  *
123
155
  * ```tsx
124
- * const occupied = useBottomEdgeInset();
125
- * <View style={{ position: 'absolute', bottom: windowEdgeGap(insets.bottom) + occupied }} />
156
+ * const reserved = useBottomEdgeInset();
157
+ * <FlatList contentContainerStyle={{ paddingBottom: reserved + 12 }} />
126
158
  * ```
127
159
  *
128
160
  * `0` outside a provider, and `0` on the first commit even inside one — a claim
@@ -134,29 +166,54 @@ export function useBottomEdgeInset(): number {
134
166
  const store = useContext(BottomEdgeContext);
135
167
  return useSyncExternalStore(
136
168
  store?.subscribe ?? NO_SUBSCRIPTION,
137
- store?.getInset ?? NO_INSET,
138
- store?.getInset ?? NO_INSET,
169
+ store?.getReserved ?? NO_INSET,
170
+ store?.getReserved ?? NO_INSET,
171
+ );
172
+ }
173
+
174
+ /**
175
+ * How much of the bottom edge is occupied RIGHT NOW, in px — the same number as
176
+ * {@link useBottomEdgeInset} for a surface that does not collapse, and smaller
177
+ * while one does.
178
+ *
179
+ * This is what a surface sitting ON the edge wants, so it rides down with a
180
+ * minimizing tab bar rather than leaving a hole above it. It changes when the
181
+ * collapse STATE changes, not per frame: a claimant reports its settled
182
+ * footprint and the reader animates the difference itself, which keeps the
183
+ * motion on whatever animation system that reader already uses instead of
184
+ * forcing a shared one through React state.
185
+ */
186
+ export function useBottomEdgeLiveInset(): number {
187
+ const store = useContext(BottomEdgeContext);
188
+ return useSyncExternalStore(
189
+ store?.subscribe ?? NO_SUBSCRIPTION,
190
+ store?.getLive ?? NO_INSET,
191
+ store?.getLive ?? NO_INSET,
139
192
  );
140
193
  }
141
194
 
142
195
  /**
143
- * Claim `height` px of the bottom edge for as long as the caller is mounted.
196
+ * Claim the bottom edge for as long as the caller is mounted.
144
197
  *
145
198
  * 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.
199
+ * the space it occupies so that everything reading the edge stays off it. Pass
200
+ * the FULL footprint (the surface's height plus the gap it holds off the window
201
+ * edge), which is the same number the surface positions itself with.
202
+ *
203
+ * `current` is what the surface occupies right now; it defaults to `reserved`,
204
+ * which is correct for anything that does not collapse. A surface that shrinks
205
+ * on scroll passes its live footprint as `current` and keeps `reserved` at its
206
+ * full size — see the two channels above for why both are needed.
150
207
  *
151
208
  * A no-op outside a provider, so a surface stays usable standalone.
152
209
  */
153
- export function useClaimBottomEdge(height: number): void {
210
+ export function useClaimBottomEdge(reserved: number, current: number = reserved): void {
154
211
  const store = useContext(BottomEdgeContext);
155
212
  const id = useId();
156
213
 
157
214
  useEffect(() => {
158
215
  if (!store) return;
159
- store.claim(id, height);
216
+ store.claim(id, reserved, current);
160
217
  return () => store.release(id);
161
- }, [store, id, height]);
218
+ }, [store, id, reserved, current]);
162
219
  }
@@ -5,4 +5,9 @@
5
5
  * parked there". A surface that floats at the bottom needs both.
6
6
  */
7
7
  export { EDGE_GAP, windowEdgeGap } from './edge';
8
- export { BottomEdgeProvider, useBottomEdgeInset, useClaimBottomEdge } from './bottom-edge';
8
+ export {
9
+ BottomEdgeProvider,
10
+ useBottomEdgeInset,
11
+ useBottomEdgeLiveInset,
12
+ useClaimBottomEdge,
13
+ } from './bottom-edge';
@@ -20,6 +20,7 @@ import {
20
20
  useContext,
21
21
  useEffect,
22
22
  useMemo,
23
+ useState,
23
24
  type ComponentType,
24
25
  } from 'react';
25
26
  import { Pressable, StyleSheet, useWindowDimensions, View, type ViewStyle } from 'react-native';
@@ -29,6 +30,7 @@ import Animated, {
29
30
  interpolate,
30
31
  interpolateColor,
31
32
  runOnJS,
33
+ useAnimatedReaction,
32
34
  useAnimatedStyle,
33
35
  useSharedValue,
34
36
  withSpring,
@@ -422,13 +424,36 @@ function TabBarBody({
422
424
  // its own layout can never drift from where the bar actually sits.
423
425
  const bottomOffset = windowEdgeGap(insets.bottom);
424
426
 
427
+ // Whether the bar has settled into its minimized size, as REACT state.
428
+ //
429
+ // Reacting on `target` rather than on `progress` is what keeps this cheap:
430
+ // `target` is the binary 0/1 the minimize spring is heading for, so this fires
431
+ // twice per scroll gesture instead of once per frame. Crossing `progress > 0.5`
432
+ // would work too and would cost the same, but it would report the change
433
+ // halfway through the animation rather than when it was decided.
434
+ const [isMinimized, setIsMinimized] = useState(false);
435
+ useAnimatedReaction(
436
+ () => minimized.target.value,
437
+ (target, previous) => {
438
+ if (target !== previous) runOnJS(setIsMinimized)(target === 1);
439
+ },
440
+ [minimized],
441
+ );
442
+
425
443
  // 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);
444
+ // rather than behind it. Same numbers `useTabBarFootprint` reports, derived
445
+ // from the same geometry, so a consumer reading either can never disagree with
446
+ // where the bar actually sits.
447
+ //
448
+ // RESERVED stays the EXPANDED height: the bar re-expands the moment the user
449
+ // scrolls back up, so anything that permanently reserves space (a list's
450
+ // bottom padding, a toast) must keep room for the full pill or it would jitter
451
+ // on every scroll. LIVE follows the minimize, so a surface sitting ON the edge
452
+ // rides down with the bar instead of leaving a 14px hole above it.
453
+ useClaimBottomEdge(
454
+ bottomOffset + EXPANDED_HEIGHT,
455
+ bottomOffset + (isMinimized ? MINIMIZED_HEIGHT : EXPANDED_HEIGHT),
456
+ );
432
457
 
433
458
  // How centring and the animated inset compose: centring is STATIC and belongs
434
459
  // to the wrap, the inset stays ANIMATED on the pill inside it. The wrap is