@oxyhq/bloom 1.10.0 → 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 (48) 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/commonjs/theme/color-scope/ColorScope.web.js +25 -13
  13. package/lib/commonjs/theme/color-scope/ColorScope.web.js.map +1 -1
  14. package/lib/module/fab/Fab.js +44 -2
  15. package/lib/module/fab/Fab.js.map +1 -1
  16. package/lib/module/fab/Fab.web.js +19 -4
  17. package/lib/module/fab/Fab.web.js.map +1 -1
  18. package/lib/module/layout/bottom-edge.js +71 -24
  19. package/lib/module/layout/bottom-edge.js.map +1 -1
  20. package/lib/module/layout/index.js +1 -1
  21. package/lib/module/layout/index.js.map +1 -1
  22. package/lib/module/tab-bar/TabBarBase.js +24 -8
  23. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  24. package/lib/module/theme/color-scope/ColorScope.web.js +25 -13
  25. package/lib/module/theme/color-scope/ColorScope.web.js.map +1 -1
  26. package/lib/typescript/commonjs/fab/Fab.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/layout/bottom-edge.d.ts +44 -10
  29. package/lib/typescript/commonjs/layout/bottom-edge.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/layout/index.d.ts +1 -1
  31. package/lib/typescript/commonjs/layout/index.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/theme/color-scope/ColorScope.web.d.ts.map +1 -1
  34. package/lib/typescript/module/fab/Fab.d.ts.map +1 -1
  35. package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -1
  36. package/lib/typescript/module/layout/bottom-edge.d.ts +44 -10
  37. package/lib/typescript/module/layout/bottom-edge.d.ts.map +1 -1
  38. package/lib/typescript/module/layout/index.d.ts +1 -1
  39. package/lib/typescript/module/layout/index.d.ts.map +1 -1
  40. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  41. package/lib/typescript/module/theme/color-scope/ColorScope.web.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/fab/Fab.tsx +41 -3
  44. package/src/fab/Fab.web.tsx +19 -4
  45. package/src/layout/bottom-edge.tsx +88 -31
  46. package/src/layout/index.ts +6 -1
  47. package/src/tab-bar/TabBarBase.tsx +31 -6
  48. package/src/theme/color-scope/ColorScope.web.tsx +23 -13
@@ -7,7 +7,7 @@ import React, {
7
7
  type MouseEvent,
8
8
  } from 'react';
9
9
 
10
- import { useBottomEdgeInset } from '../layout/bottom-edge';
10
+ import { useBottomEdgeLiveInset } from '../layout/bottom-edge';
11
11
  import { useTheme } from '../theme/use-theme';
12
12
  import { animation, borderRadius } from '../styles/tokens';
13
13
  import { pressedSurface } from '../theme/press-colors';
@@ -83,8 +83,11 @@ const BLOOM_FAB_CSS = interactiveWebCss({
83
83
  box-shadow: var(--bloom-fab-shadow);
84
84
  font-family: inherit;
85
85
  `,
86
+ // `bottom` is the collapse follow (see `placementStyle`) and is deliberately
87
+ // the slow one: it tracks the tab bar's 380ms minimize spring, while every
88
+ // other property here is interaction feedback and wants to feel instant.
86
89
  transition:
87
- 'opacity 120ms ease, transform 120ms ease, box-shadow 160ms ease, background-color 120ms ease',
90
+ 'opacity 120ms ease, transform 120ms ease, box-shadow 160ms ease, background-color 120ms ease, bottom 380ms ease',
88
91
  // A FAB lifts on hover rather than dimming: it floats over the content, so a
89
92
  // deeper shadow is the affordance an opacity dip cannot express.
90
93
  hover: { declarations: 'box-shadow: var(--bloom-fab-shadow-hover);' },
@@ -129,6 +132,16 @@ const BLOOM_FAB_CSS = interactiveWebCss({
129
132
  * behind it. A z-index cannot fix that pairing: the bar's host is the last
130
133
  * sibling of the app shell and paints over every descendant, so the FAB has to be
131
134
  * somewhere else, not merely on top.
135
+ *
136
+ * It is the LIVE inset here, and `bottom` is transitioned — where the native fork
137
+ * follows a collapsing bar with an Animated TRANSFORM instead. The forks differ
138
+ * because the constraint does. Native must stay off the JS thread mid-scroll, so
139
+ * the follow has to be a native-driver transform. On web a transform cannot carry
140
+ * it: `:active` already sets `transform: scale(...)` from the shared
141
+ * `interactive-web-css` rule, and an inline transform would beat that stylesheet
142
+ * rule and eat the press scale — and one property cannot hold the press's 120ms
143
+ * and the collapse's 380ms at once. `bottom` is a separate property with its own
144
+ * duration, and it moves twice per scroll gesture rather than per frame.
132
145
  */
133
146
  function placementStyle(
134
147
  placement: FabPlacement,
@@ -197,7 +210,9 @@ const FabWebComponent: React.FC<FabProps> = ({
197
210
  type = 'button',
198
211
  }) => {
199
212
  useInteractiveWebCss(STYLE_ID, BLOOM_FAB_CSS);
200
- const bottomEdgeInset = useBottomEdgeInset();
213
+ // LIVE, not reserved: the FAB sits ON the edge, so it rides down with a
214
+ // minimizing tab bar rather than leaving a 14px hole above it.
215
+ const bottomEdgeLive = useBottomEdgeLiveInset();
201
216
  const theme = useTheme();
202
217
  const reactId = useId();
203
218
  const resolvedId = id ?? `bloom-fab-${reactId}`;
@@ -232,7 +247,7 @@ const FabWebComponent: React.FC<FabProps> = ({
232
247
  variantColors.foreground,
233
248
  ),
234
249
  ['--bloom-fab-press-scale' as string]: animation.pressScale,
235
- ...placementStyle(placement, offset, bottomEdgeInset),
250
+ ...placementStyle(placement, offset, bottomEdgeLive),
236
251
  };
237
252
  if (isExtended) {
238
253
  const pad = sizeConfig.diameter <= 44 ? 14 : 20;
@@ -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
@@ -35,13 +35,21 @@ export interface BloomColorScopeProps {
35
35
  }
36
36
 
37
37
  /**
38
- * On web, the single `asChild` child is frequently a react-native-web
39
- * component (e.g. RN `<View>`) whose `style` prop is a *style array* (or a
40
- * numeric registered-style id), not a plain `React.CSSProperties` object.
41
- * react-native-web flattens nested style arrays, so the cloned child must
42
- * receive an array spreading an array into an object literal would copy its
43
- * numeric indices as keys and crash RNW when it commits them to the DOM
44
- * (`Failed to set an indexed property [0] on 'CSSStyleDeclaration'`).
38
+ * On web, the single `asChild` child can be either kind of element, and the two
39
+ * kinds want different `style` shapes:
40
+ *
41
+ * - A react-native-web component (RN `<View>`, `<Pressable>`) may already
42
+ * carry a style ARRAY or a numeric registered-style id. Spreading that into
43
+ * an object literal copies its numeric indices as keys, and RNW then commits
44
+ * `0`, `1`, to the DOM. RNW flattens arrays, so it gets an array.
45
+ * - Anything that ends on a DOM element — an `<a>`, a router `<Link>`, any
46
+ * component that forwards `style` to its host node — hands the prop straight
47
+ * to React DOM, which walks the own keys of whatever it is given. An array
48
+ * there throws `Failed to set an indexed property [0] on
49
+ * 'CSSStyleDeclaration'` during commit, blanking the tree.
50
+ *
51
+ * So the array form is used only when the child's own style is already
52
+ * RN-shaped; every other child gets a plain object, which both runtimes accept.
45
53
  */
46
54
  type WebStyle =
47
55
  | React.CSSProperties
@@ -96,13 +104,15 @@ export function BloomColorScope({
96
104
  'BloomColorScope with `asChild` requires a single React element child that accepts a `style` prop.',
97
105
  );
98
106
  }
99
- // Merge as a style ARRAY (the RNW-safe form): scope vars first, then the
100
- // caller's `style`, then the child's own `style` last so its explicit
101
- // styles win. react-native-web flattens nested arrays correctly; spreading
102
- // the child's style (which is often an RN style array or numeric id) into an
103
- // object literal would copy numeric indices as keys and crash RNW.
107
+ // Scope vars first, then the caller's `style`, then the child's own so its
108
+ // explicit styles win. The shape follows the child: an array only when the
109
+ // child's style is already RN-shaped (see the note above), an object
110
+ // otherwise an array reaching a DOM node throws on commit.
104
111
  const childStyle = child.props.style;
105
- const mergedStyle: WebStyle = [varsStyle, style, childStyle];
112
+ const childIsRnStyled = Array.isArray(childStyle) || typeof childStyle === 'number';
113
+ const mergedStyle: WebStyle = childIsRnStyled
114
+ ? [varsStyle, style, childStyle]
115
+ : { ...varsStyle, ...style, ...(childStyle || undefined) };
106
116
  content = cloneElement(child, { style: mergedStyle });
107
117
  } else {
108
118
  // A plain DOM `<div>` does NOT accept style arrays — only the cloned child