@oxyhq/bloom 1.11.0 → 1.12.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 (38) hide show
  1. package/docs/layout.mdx +27 -22
  2. package/lib/commonjs/fab/Fab.js +46 -37
  3. package/lib/commonjs/fab/Fab.js.map +1 -1
  4. package/lib/commonjs/fab/Fab.web.js +21 -8
  5. package/lib/commonjs/fab/Fab.web.js.map +1 -1
  6. package/lib/commonjs/layout/bottom-edge.js +63 -44
  7. package/lib/commonjs/layout/bottom-edge.js.map +1 -1
  8. package/lib/commonjs/layout/index.js +4 -4
  9. package/lib/commonjs/tab-bar/TabBarBase.js +6 -6
  10. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  11. package/lib/module/fab/Fab.js +47 -38
  12. package/lib/module/fab/Fab.js.map +1 -1
  13. package/lib/module/fab/Fab.web.js +22 -9
  14. package/lib/module/fab/Fab.web.js.map +1 -1
  15. package/lib/module/layout/bottom-edge.js +62 -43
  16. package/lib/module/layout/bottom-edge.js.map +1 -1
  17. package/lib/module/layout/index.js +1 -1
  18. package/lib/module/layout/index.js.map +1 -1
  19. package/lib/module/tab-bar/TabBarBase.js +6 -6
  20. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  21. package/lib/typescript/commonjs/fab/Fab.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -1
  23. package/lib/typescript/commonjs/layout/bottom-edge.d.ts +42 -28
  24. package/lib/typescript/commonjs/layout/bottom-edge.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/layout/index.d.ts +1 -1
  26. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  27. package/lib/typescript/module/fab/Fab.d.ts.map +1 -1
  28. package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -1
  29. package/lib/typescript/module/layout/bottom-edge.d.ts +42 -28
  30. package/lib/typescript/module/layout/bottom-edge.d.ts.map +1 -1
  31. package/lib/typescript/module/layout/index.d.ts +1 -1
  32. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/fab/Fab.tsx +47 -32
  35. package/src/fab/Fab.web.tsx +23 -9
  36. package/src/layout/bottom-edge.tsx +67 -48
  37. package/src/layout/index.ts +1 -1
  38. package/src/tab-bar/TabBarBase.tsx +6 -9
@@ -7,7 +7,7 @@ import React, {
7
7
  type MouseEvent,
8
8
  } from 'react';
9
9
 
10
- import { useBottomEdgeLiveInset } from '../layout/bottom-edge';
10
+ import { useBottomEdgeCollapsed, useBottomEdgeInset } 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,11 +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
+ // `opacity` carries two things: the disabled dim and the collapse fade. 200ms
87
+ // is the collapse's number the dim is imperceptibly slower for it, and one
88
+ // property cannot hold two durations.
89
89
  transition:
90
- 'opacity 120ms ease, transform 120ms ease, box-shadow 160ms ease, background-color 120ms ease, bottom 380ms ease',
90
+ 'opacity 200ms ease, transform 120ms ease, box-shadow 160ms ease, background-color 120ms ease',
91
91
  // A FAB lifts on hover rather than dimming: it floats over the content, so a
92
92
  // deeper shadow is the affordance an opacity dip cannot express.
93
93
  hover: { declarations: 'box-shadow: var(--bloom-fab-shadow-hover);' },
@@ -210,9 +210,14 @@ const FabWebComponent: React.FC<FabProps> = ({
210
210
  type = 'button',
211
211
  }) => {
212
212
  useInteractiveWebCss(STYLE_ID, BLOOM_FAB_CSS);
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();
213
+ const bottomEdgeInset = useBottomEdgeInset();
214
+ // The FAB belongs to the chrome: when the chrome retracts, so does it. Only a
215
+ // BOTTOM-anchored FAB is affected by the bottom edge collapsing.
216
+ const bottomEdgeCollapsed = useBottomEdgeCollapsed();
217
+ // A faded-out FAB must not be tabbable, clickable or announced — an invisible
218
+ // target that still takes a click is worse than a visible one.
219
+ const hidden =
220
+ bottomEdgeCollapsed && (placement === 'bottom-right' || placement === 'bottom-left');
216
221
  const theme = useTheme();
217
222
  const reactId = useId();
218
223
  const resolvedId = id ?? `bloom-fab-${reactId}`;
@@ -247,8 +252,15 @@ const FabWebComponent: React.FC<FabProps> = ({
247
252
  variantColors.foreground,
248
253
  ),
249
254
  ['--bloom-fab-press-scale' as string]: animation.pressScale,
250
- ...placementStyle(placement, offset, bottomEdgeLive),
255
+ ...placementStyle(placement, offset, bottomEdgeInset),
251
256
  };
257
+ // Only while HIDDEN, so the `:disabled { opacity }` rule still owns the
258
+ // dim the rest of the time — an inline opacity would beat that stylesheet
259
+ // rule unconditionally and a disabled FAB would stop dimming.
260
+ if (hidden) {
261
+ base.opacity = 0;
262
+ base.pointerEvents = 'none';
263
+ }
252
264
  if (isExtended) {
253
265
  const pad = sizeConfig.diameter <= 44 ? 14 : 20;
254
266
  base.paddingLeft = pad;
@@ -302,6 +314,8 @@ const FabWebComponent: React.FC<FabProps> = ({
302
314
  onClick={handleClick}
303
315
  disabled={disabled}
304
316
  aria-disabled={disabled || undefined}
317
+ aria-hidden={hidden || undefined}
318
+ tabIndex={hidden ? -1 : undefined}
305
319
  aria-label={ariaLabel}
306
320
  title={title ?? accessibilityHint}
307
321
  data-testid={testID}
@@ -27,20 +27,32 @@
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
30
+ * ## A size and a state, because they travel differently
31
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:
32
+ * A surface that collapses — the tab bar minimizes 58 -> 44 on scroll — publishes
33
+ * two things, and only one of them is a measurement:
34
34
  *
35
35
  * - RESERVED (`useBottomEdgeInset`) never shrinks while the claimant is
36
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.
37
+ * padding, a toast stack's offset. Shrinking it on collapse would jitter a
38
+ * list's content size on every scroll and jump a toast 14px mid-display,
39
+ * because the bar re-expands the instant the user scrolls back up.
40
+ * - COLLAPSED (`useBottomEdgeCollapsed`) is a boolean, deliberately, and this
41
+ * is the part that was learned the hard way. It first shipped as a live
42
+ * PIXEL height so a FAB could ride down with the bar — and it felt broken on
43
+ * a device. The bar animates on the UI thread; a React consumer learns about
44
+ * the change through `runOnJS` plus two render passes, so its motion STARTS
45
+ * one to three frames late, worst exactly while scrolling because that is
46
+ * when the JS thread is busiest. No spring config fixes a variable start
47
+ * delay.
48
+ *
49
+ * Two things moving together betray that lag; a state change does not. A reader
50
+ * that FADES on this boolean has no spatial relationship to violate, so the same
51
+ * few frames of latency are imperceptible. That is why the channel is a state and
52
+ * not a geometry: it is the shape of signal that survives the trip to the JS
53
+ * thread. Anything that genuinely must track the collapse per-frame has to read
54
+ * the tab bar's own shared value on the UI thread instead — React state is the
55
+ * wrong transport for it, at any width.
44
56
  *
45
57
  * ## Why an external store
46
58
  *
@@ -63,8 +75,8 @@ import {
63
75
  interface Claim {
64
76
  /** What the surface keeps permanently free, collapsed or not. */
65
77
  reserved: number;
66
- /** What it occupies right now. */
67
- current: number;
78
+ /** Whether it is currently in its collapsed state. */
79
+ collapsed: boolean;
68
80
  }
69
81
 
70
82
  interface BottomEdgeStore {
@@ -76,8 +88,8 @@ interface BottomEdgeStore {
76
88
  * only the other moved.
77
89
  */
78
90
  getReserved: () => number;
79
- getLive: () => number;
80
- claim: (id: string, reserved: number, current: number) => void;
91
+ getCollapsed: () => boolean;
92
+ claim: (id: string, reserved: number, collapsed: boolean) => void;
81
93
  release: (id: string) => void;
82
94
  }
83
95
 
@@ -85,21 +97,25 @@ function createBottomEdgeStore(): BottomEdgeStore {
85
97
  const claims = new Map<string, Claim>();
86
98
  const listeners = new Set<() => void>();
87
99
  let reserved = 0;
88
- let live = 0;
100
+ let collapsed = false;
89
101
 
90
102
  const recompute = () => {
91
103
  let nextReserved = 0;
92
- let nextLive = 0;
104
+ // ANY claimant collapsing collapses the edge. With the one floating bar this
105
+ // is written for the two readings coincide; where they would not, "something
106
+ // at this edge just retracted" is still the signal a reader wants, and it is
107
+ // the safe direction — a FAB that fades when it did not strictly have to
108
+ // beats one that stays put over a bar that left.
109
+ let nextCollapsed = false;
93
110
  for (const claim of claims.values()) {
94
111
  if (claim.reserved > nextReserved) nextReserved = claim.reserved;
95
- if (claim.current > nextLive) nextLive = claim.current;
112
+ if (claim.collapsed) nextCollapsed = true;
96
113
  }
97
- // Bail before notifying: a re-registration at unchanged heights (every
98
- // render of a claimant whose footprint did not move) must not re-render
99
- // every reader.
100
- if (nextReserved === reserved && nextLive === live) return;
114
+ // Bail before notifying: a re-registration at an unchanged footprint (every
115
+ // render of a claimant that did not move) must not re-render every reader.
116
+ if (nextReserved === reserved && nextCollapsed === collapsed) return;
101
117
  reserved = nextReserved;
102
- live = nextLive;
118
+ collapsed = nextCollapsed;
103
119
  for (const listener of listeners) listener();
104
120
  };
105
121
 
@@ -111,11 +127,11 @@ function createBottomEdgeStore(): BottomEdgeStore {
111
127
  };
112
128
  },
113
129
  getReserved: () => reserved,
114
- getLive: () => live,
115
- claim(id, nextReserved, nextCurrent) {
130
+ getCollapsed: () => collapsed,
131
+ claim(id, nextReserved, nextCollapsed) {
116
132
  const existing = claims.get(id);
117
- if (existing?.reserved === nextReserved && existing.current === nextCurrent) return;
118
- claims.set(id, { reserved: nextReserved, current: nextCurrent });
133
+ if (existing?.reserved === nextReserved && existing.collapsed === nextCollapsed) return;
134
+ claims.set(id, { reserved: nextReserved, collapsed: nextCollapsed });
119
135
  recompute();
120
136
  },
121
137
  release(id) {
@@ -144,6 +160,7 @@ BottomEdgeProvider.displayName = 'BottomEdgeProvider';
144
160
  // would resubscribe `useSyncExternalStore` on every render.
145
161
  const NO_SUBSCRIPTION = () => () => {};
146
162
  const NO_INSET = () => 0;
163
+ const NO_COLLAPSE = () => false;
147
164
 
148
165
  /**
149
166
  * How much of the bottom edge is permanently RESERVED, in px.
@@ -172,23 +189,28 @@ export function useBottomEdgeInset(): number {
172
189
  }
173
190
 
174
191
  /**
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.
192
+ * Whether the surface owning the bottom edge is currently COLLAPSED — the tab
193
+ * bar minimized on scroll.
194
+ *
195
+ * A boolean rather than a live height, on purpose: see the note at the top of
196
+ * this file. A reader should FADE or otherwise change state on it, never try to
197
+ * stay geometrically locked to the collapsing surface that lock is what a
198
+ * React-state channel cannot deliver, because the surface animates on the UI
199
+ * thread and this arrives one to three frames later.
200
+ *
201
+ * ```tsx
202
+ * const collapsed = useBottomEdgeCollapsed();
203
+ * // fade out; do not chase the bar's new top edge
204
+ * ```
205
+ *
206
+ * `false` outside a provider.
185
207
  */
186
- export function useBottomEdgeLiveInset(): number {
208
+ export function useBottomEdgeCollapsed(): boolean {
187
209
  const store = useContext(BottomEdgeContext);
188
210
  return useSyncExternalStore(
189
211
  store?.subscribe ?? NO_SUBSCRIPTION,
190
- store?.getLive ?? NO_INSET,
191
- store?.getLive ?? NO_INSET,
212
+ store?.getCollapsed ?? NO_COLLAPSE,
213
+ store?.getCollapsed ?? NO_COLLAPSE,
192
214
  );
193
215
  }
194
216
 
@@ -198,22 +220,19 @@ export function useBottomEdgeLiveInset(): number {
198
220
  * The claimant owns its own placement — claiming does not move it. It declares
199
221
  * the space it occupies so that everything reading the edge stays off it. Pass
200
222
  * 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.
223
+ * edge), which is the same number the surface positions itself with, and keep it
224
+ * at the surface's FULL size even while collapsed: `reserved` is what must stay
225
+ * permanently free, and the collapse is reported separately by `collapsed`.
207
226
  *
208
227
  * A no-op outside a provider, so a surface stays usable standalone.
209
228
  */
210
- export function useClaimBottomEdge(reserved: number, current: number = reserved): void {
229
+ export function useClaimBottomEdge(reserved: number, collapsed = false): void {
211
230
  const store = useContext(BottomEdgeContext);
212
231
  const id = useId();
213
232
 
214
233
  useEffect(() => {
215
234
  if (!store) return;
216
- store.claim(id, reserved, current);
235
+ store.claim(id, reserved, collapsed);
217
236
  return () => store.release(id);
218
- }, [store, id, reserved, current]);
237
+ }, [store, id, reserved, collapsed]);
219
238
  }
@@ -8,6 +8,6 @@ export { EDGE_GAP, windowEdgeGap } from './edge';
8
8
  export {
9
9
  BottomEdgeProvider,
10
10
  useBottomEdgeInset,
11
- useBottomEdgeLiveInset,
11
+ useBottomEdgeCollapsed,
12
12
  useClaimBottomEdge,
13
13
  } from './bottom-edge';
@@ -445,15 +445,12 @@ function TabBarBody({
445
445
  // from the same geometry, so a consumer reading either can never disagree with
446
446
  // where the bar actually sits.
447
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
- );
448
+ // RESERVED stays the EXPANDED height even while minimized: the bar re-expands
449
+ // the moment the user scrolls back up, so anything that permanently reserves
450
+ // space (a list's bottom padding, a toast) must keep room for the full pill or
451
+ // it would jitter on every scroll. The collapse travels as its own STATE see
452
+ // `layout/bottom-edge` for why it is a boolean and not the live height.
453
+ useClaimBottomEdge(bottomOffset + EXPANDED_HEIGHT, isMinimized);
457
454
 
458
455
  // How centring and the animated inset compose: centring is STATIC and belongs
459
456
  // to the wrap, the inset stays ANIMATED on the pill inside it. The wrap is