@oxyhq/bloom 0.72.1 → 0.73.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 (151) hide show
  1. package/lib/commonjs/avatar-group/AvatarGroup.web.js +22 -21
  2. package/lib/commonjs/avatar-group/AvatarGroup.web.js.map +1 -1
  3. package/lib/commonjs/bottom-sheet/index.web.js +11 -6
  4. package/lib/commonjs/bottom-sheet/index.web.js.map +1 -1
  5. package/lib/commonjs/context-menu/index.web.js +20 -22
  6. package/lib/commonjs/context-menu/index.web.js.map +1 -1
  7. package/lib/commonjs/dialog/Dialog.js +66 -70
  8. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  9. package/lib/commonjs/dialog/Dialog.web.js +49 -51
  10. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  11. package/lib/commonjs/fonts/font-data.web.js +22 -0
  12. package/lib/commonjs/fonts/font-data.web.js.map +1 -0
  13. package/lib/commonjs/menu/index.web.js +15 -11
  14. package/lib/commonjs/menu/index.web.js.map +1 -1
  15. package/lib/commonjs/overlay/index.js +186 -39
  16. package/lib/commonjs/overlay/index.js.map +1 -1
  17. package/lib/commonjs/overlay/stack.js +170 -0
  18. package/lib/commonjs/overlay/stack.js.map +1 -0
  19. package/lib/commonjs/overlay/use-overlay-layer.js +37 -0
  20. package/lib/commonjs/overlay/use-overlay-layer.js.map +1 -0
  21. package/lib/commonjs/popover/index.web.js +31 -34
  22. package/lib/commonjs/popover/index.web.js.map +1 -1
  23. package/lib/commonjs/prompt-input/PromptInput.js +33 -29
  24. package/lib/commonjs/prompt-input/PromptInput.js.map +1 -1
  25. package/lib/commonjs/select/index.web.js +20 -22
  26. package/lib/commonjs/select/index.web.js.map +1 -1
  27. package/lib/commonjs/styles/z-index.js +33 -34
  28. package/lib/commonjs/styles/z-index.js.map +1 -1
  29. package/lib/commonjs/surfaces/SurfaceHost.js +3 -6
  30. package/lib/commonjs/surfaces/SurfaceHost.js.map +1 -1
  31. package/lib/commonjs/toast/ToastHost.js +9 -5
  32. package/lib/commonjs/toast/ToastHost.js.map +1 -1
  33. package/lib/commonjs/tooltip/index.js +9 -6
  34. package/lib/commonjs/tooltip/index.js.map +1 -1
  35. package/lib/commonjs/tooltip/index.web.js +8 -2
  36. package/lib/commonjs/tooltip/index.web.js.map +1 -1
  37. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +5 -5
  38. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  39. package/lib/module/avatar-group/AvatarGroup.web.js +21 -20
  40. package/lib/module/avatar-group/AvatarGroup.web.js.map +1 -1
  41. package/lib/module/bottom-sheet/index.web.js +11 -6
  42. package/lib/module/bottom-sheet/index.web.js.map +1 -1
  43. package/lib/module/context-menu/index.web.js +21 -23
  44. package/lib/module/context-menu/index.web.js.map +1 -1
  45. package/lib/module/dialog/Dialog.js +67 -71
  46. package/lib/module/dialog/Dialog.js.map +1 -1
  47. package/lib/module/dialog/Dialog.web.js +50 -52
  48. package/lib/module/dialog/Dialog.web.js.map +1 -1
  49. package/lib/module/fonts/font-data.web.js +18 -0
  50. package/lib/module/fonts/font-data.web.js.map +1 -0
  51. package/lib/module/menu/index.web.js +16 -12
  52. package/lib/module/menu/index.web.js.map +1 -1
  53. package/lib/module/overlay/index.js +152 -40
  54. package/lib/module/overlay/index.js.map +1 -1
  55. package/lib/module/overlay/stack.js +161 -0
  56. package/lib/module/overlay/stack.js.map +1 -0
  57. package/lib/module/overlay/use-overlay-layer.js +33 -0
  58. package/lib/module/overlay/use-overlay-layer.js.map +1 -0
  59. package/lib/module/popover/index.web.js +32 -34
  60. package/lib/module/popover/index.web.js.map +1 -1
  61. package/lib/module/prompt-input/PromptInput.js +33 -29
  62. package/lib/module/prompt-input/PromptInput.js.map +1 -1
  63. package/lib/module/select/index.web.js +21 -23
  64. package/lib/module/select/index.web.js.map +1 -1
  65. package/lib/module/styles/z-index.js +32 -30
  66. package/lib/module/styles/z-index.js.map +1 -1
  67. package/lib/module/surfaces/SurfaceHost.js +3 -6
  68. package/lib/module/surfaces/SurfaceHost.js.map +1 -1
  69. package/lib/module/toast/ToastHost.js +10 -6
  70. package/lib/module/toast/ToastHost.js.map +1 -1
  71. package/lib/module/tooltip/index.js +9 -6
  72. package/lib/module/tooltip/index.js.map +1 -1
  73. package/lib/module/tooltip/index.web.js +8 -2
  74. package/lib/module/tooltip/index.web.js.map +1 -1
  75. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +5 -5
  76. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  77. package/lib/typescript/commonjs/avatar-group/AvatarGroup.web.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/bottom-sheet/index.web.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/context-menu/index.web.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/dialog/types.d.ts +0 -10
  83. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/fonts/font-data.web.d.ts +5 -0
  85. package/lib/typescript/commonjs/fonts/font-data.web.d.ts.map +1 -0
  86. package/lib/typescript/commonjs/menu/index.web.d.ts.map +1 -1
  87. package/lib/typescript/commonjs/overlay/index.d.ts +47 -10
  88. package/lib/typescript/commonjs/overlay/index.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/overlay/stack.d.ts +122 -0
  90. package/lib/typescript/commonjs/overlay/stack.d.ts.map +1 -0
  91. package/lib/typescript/commonjs/overlay/use-overlay-layer.d.ts +7 -0
  92. package/lib/typescript/commonjs/overlay/use-overlay-layer.d.ts.map +1 -0
  93. package/lib/typescript/commonjs/popover/index.web.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/prompt-input/PromptInput.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/select/index.web.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/styles/z-index.d.ts +42 -29
  97. package/lib/typescript/commonjs/styles/z-index.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/surfaces/SurfaceHost.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/toast/ToastHost.d.ts.map +1 -1
  100. package/lib/typescript/commonjs/tooltip/index.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  102. package/lib/typescript/module/avatar-group/AvatarGroup.web.d.ts.map +1 -1
  103. package/lib/typescript/module/bottom-sheet/index.web.d.ts.map +1 -1
  104. package/lib/typescript/module/context-menu/index.web.d.ts.map +1 -1
  105. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  106. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  107. package/lib/typescript/module/dialog/types.d.ts +0 -10
  108. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  109. package/lib/typescript/module/fonts/font-data.web.d.ts +5 -0
  110. package/lib/typescript/module/fonts/font-data.web.d.ts.map +1 -0
  111. package/lib/typescript/module/menu/index.web.d.ts.map +1 -1
  112. package/lib/typescript/module/overlay/index.d.ts +47 -10
  113. package/lib/typescript/module/overlay/index.d.ts.map +1 -1
  114. package/lib/typescript/module/overlay/stack.d.ts +122 -0
  115. package/lib/typescript/module/overlay/stack.d.ts.map +1 -0
  116. package/lib/typescript/module/overlay/use-overlay-layer.d.ts +7 -0
  117. package/lib/typescript/module/overlay/use-overlay-layer.d.ts.map +1 -0
  118. package/lib/typescript/module/popover/index.web.d.ts.map +1 -1
  119. package/lib/typescript/module/prompt-input/PromptInput.d.ts.map +1 -1
  120. package/lib/typescript/module/select/index.web.d.ts.map +1 -1
  121. package/lib/typescript/module/styles/z-index.d.ts +42 -29
  122. package/lib/typescript/module/styles/z-index.d.ts.map +1 -1
  123. package/lib/typescript/module/surfaces/SurfaceHost.d.ts.map +1 -1
  124. package/lib/typescript/module/toast/ToastHost.d.ts.map +1 -1
  125. package/lib/typescript/module/tooltip/index.d.ts.map +1 -1
  126. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  127. package/package.json +2 -1
  128. package/src/__tests__/ToastHostWebFork.test.ts +11 -3
  129. package/src/__tests__/backdrop-nesting.test.tsx +95 -0
  130. package/src/__tests__/overlay-stack-order.test.tsx +270 -0
  131. package/src/avatar-group/AvatarGroup.web.tsx +35 -31
  132. package/src/bottom-sheet/index.web.tsx +10 -5
  133. package/src/context-menu/index.web.tsx +33 -30
  134. package/src/dialog/Dialog.tsx +9 -18
  135. package/src/dialog/Dialog.web.tsx +62 -60
  136. package/src/dialog/types.ts +0 -10
  137. package/src/fonts/font-data.web.ts +15 -0
  138. package/src/menu/index.web.tsx +33 -22
  139. package/src/overlay/OverlayStacking.stories.tsx +207 -0
  140. package/src/overlay/index.tsx +161 -40
  141. package/src/overlay/stack.ts +166 -0
  142. package/src/overlay/use-overlay-layer.ts +39 -0
  143. package/src/popover/index.web.tsx +47 -44
  144. package/src/prompt-input/PromptInput.tsx +36 -29
  145. package/src/select/index.web.tsx +35 -32
  146. package/src/styles/z-index.ts +32 -34
  147. package/src/surfaces/SurfaceHost.tsx +3 -10
  148. package/src/toast/ToastHost.tsx +9 -6
  149. package/src/tooltip/index.tsx +14 -7
  150. package/src/tooltip/index.web.tsx +8 -2
  151. package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +4 -4
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The overlay stacking authority.
3
+ *
4
+ * ## The rule
5
+ *
6
+ * An overlay opened LATER paints above one opened earlier. That is the whole
7
+ * contract, and it is decided here, once, for every Bloom overlay surface —
8
+ * never by a per-component constant.
9
+ *
10
+ * ## Why this module exists
11
+ *
12
+ * Stacking used to be a set of hand-picked rungs on a shared scale: menus at
13
+ * 40, dialogs at 50/60, tooltips at 70, sheets at 999999. Because those numbers
14
+ * are fixed per COMPONENT KIND, the answer to "which surface is on top" was
15
+ * decided by what each surface WAS rather than by when the user opened it — so
16
+ * some pairings were permanently inverted no matter the order:
17
+ *
18
+ * - A confirm dialog (50/60) opened from inside an open bottom sheet (999999)
19
+ * rendered fully, interactively, and completely underneath the sheet. It
20
+ * could be neither pressed nor dismissed.
21
+ * - A menu (40/41) opened from inside a dialog (50/60) landed behind the very
22
+ * dialog that launched it.
23
+ *
24
+ * Both failed silently: correct markup, correct styles, nothing in the console.
25
+ * Only a hit test in a real browser tells the difference, which is why
26
+ * `scripts/verify-overlay-stacking.mjs` exists alongside the unit tests here.
27
+ *
28
+ * ## How a rank is acquired
29
+ *
30
+ * `useOverlayLayer()` takes a rank on MOUNT and releases it on unmount, so the
31
+ * caller must be a component that mounts when the surface OPENS — which is how
32
+ * every Bloom surface is already built (`Dialog.web` returns null while closed;
33
+ * `BottomSheetBase` returns null until `rendered`). Acquiring per mount, in a
34
+ * `useState` initializer, is what makes the rank correct on the surface's FIRST
35
+ * paint: an effect-assigned z would leave one frame at the wrong depth, which
36
+ * on a 200ms fade-in is a visible flash of the surface behind its neighbour.
37
+ *
38
+ * It also keeps the acquisition out of any position the React Compiler may
39
+ * memoize. A counter read during render is exactly the external-mutable-state
40
+ * hazard the compiler mangles; a state initializer is not memoizable that way.
41
+ * (React StrictMode double-invokes initializers in development, so a rank can
42
+ * be consumed and dropped. That is harmless: only the ORDER of the numbers
43
+ * matters, never their density.)
44
+ *
45
+ * ## Toasts are deliberately not in this stack
46
+ *
47
+ * A toast is a notification, not a modal surface: it must stay visible over
48
+ * whatever is open, including a surface opened after it. It is pinned above the
49
+ * whole stack at `TOAST_LAYER_Z` instead of taking a rank. (Under the old
50
+ * scale a toast at 80 was already lost behind any open bottom sheet at 999999.)
51
+ */
52
+
53
+ /**
54
+ * First z-index handed to an overlay. Comfortably above app content while
55
+ * staying far below `TOAST_LAYER_Z`.
56
+ */
57
+ export const OVERLAY_STACK_BASE = 1000;
58
+
59
+ /**
60
+ * Z-index distance between consecutive overlays. A surface owns its whole band,
61
+ * so it can order its own parts (backdrop, panel, a floating control) without
62
+ * ever reaching the next surface's floor.
63
+ */
64
+ export const OVERLAY_STACK_BAND = 10;
65
+
66
+ /**
67
+ * Ceiling on simultaneously open overlays. Beyond this, ranks saturate and the
68
+ * topmost surfaces tie (falling back to DOM order) rather than climbing into
69
+ * the toast layer. Nothing in the ecosystem stacks remotely this deep; the
70
+ * clamp exists so a runaway caller degrades instead of breaking the toast
71
+ * layer's guarantee.
72
+ */
73
+ export const OVERLAY_STACK_MAX_RANK = 500;
74
+
75
+ /**
76
+ * The toast layer, pinned above every overlay rank (see the note above on why
77
+ * toasts are not part of the stack).
78
+ */
79
+ export const TOAST_LAYER_Z =
80
+ OVERLAY_STACK_BASE + OVERLAY_STACK_BAND * (OVERLAY_STACK_MAX_RANK + 1);
81
+
82
+ /** The z-indices one overlay surface may use, all within its own band. */
83
+ export interface OverlayLayer {
84
+ /** The surface's outermost node — everything it renders is inside this. */
85
+ root: number;
86
+ /** Its dimming layer. */
87
+ backdrop: number;
88
+ /** Its panel, above its own backdrop. */
89
+ surface: number;
90
+ }
91
+
92
+ /**
93
+ * Registry state.
94
+ *
95
+ * `sequence` only ever moves forward while anything is open, which is what
96
+ * guarantees the ordering. It resets once the last overlay closes so the
97
+ * numbers stay small over a long session.
98
+ */
99
+ interface Registry {
100
+ sequence: number;
101
+ live: Set<number>;
102
+ }
103
+
104
+ declare global {
105
+ // eslint-disable-next-line no-var
106
+ var __oxyhq_bloom_overlay_stack__: Registry | undefined;
107
+ }
108
+
109
+ /**
110
+ * `globalThis`-anchored, for the same reason as the portal group and the theme
111
+ * context: `exports` ships a `react-native` → `src` condition beside the
112
+ * `lib/module` and `lib/commonjs` forks, and overlay surfaces are imported
113
+ * cross-subpath (`./dialog`, `./bottom-sheet`, `./menu`, `./select`, …). A
114
+ * bundler can resolve those through different conditions, and two physical
115
+ * copies of this module would each run their own counter — handing out
116
+ * colliding ranks and reintroducing exactly the bug this module removes.
117
+ */
118
+ function registry(): Registry {
119
+ globalThis.__oxyhq_bloom_overlay_stack__ ??= { sequence: 0, live: new Set() };
120
+ return globalThis.__oxyhq_bloom_overlay_stack__;
121
+ }
122
+
123
+ /** Z-indices for a given rank. Exported for tests and for the native surfaces. */
124
+ export function layerForRank(rank: number): OverlayLayer {
125
+ const clamped = Math.min(Math.max(rank, 1), OVERLAY_STACK_MAX_RANK);
126
+ const root = OVERLAY_STACK_BASE + OVERLAY_STACK_BAND * clamped;
127
+ return { root, backdrop: root, surface: root + 1 };
128
+ }
129
+
130
+ /**
131
+ * Take the next rank. Exported for tests; components use `useOverlayLayer`.
132
+ */
133
+ export function acquireOverlayRank(): number {
134
+ const reg = registry();
135
+ reg.sequence += 1;
136
+ return reg.sequence;
137
+ }
138
+
139
+ /**
140
+ * Mark a rank as live. Called from an effect, which React balances against
141
+ * `releaseOverlayRank` even under StrictMode's mount/unmount/remount.
142
+ *
143
+ * It also re-floors `sequence` to at least this rank. That closes the only race
144
+ * the reset below could otherwise open: a surface takes rank N during render,
145
+ * and before its effect runs, the last previously-open surface unmounts and
146
+ * resets the counter to 0 — the next surface would then take rank 1 and sit
147
+ * BELOW the one still coming up at N. Re-flooring on register makes the reset
148
+ * unable to lose a rank that has been handed out.
149
+ */
150
+ export function registerOverlayRank(rank: number): void {
151
+ const reg = registry();
152
+ reg.live.add(rank);
153
+ if (rank > reg.sequence) reg.sequence = rank;
154
+ }
155
+
156
+ /** Release a rank; resets the counter once nothing is open. */
157
+ export function releaseOverlayRank(rank: number): void {
158
+ const reg = registry();
159
+ reg.live.delete(rank);
160
+ if (reg.live.size === 0) reg.sequence = 0;
161
+ }
162
+
163
+ /** Test seam — drops all registry state. */
164
+ export function resetOverlayStack(): void {
165
+ globalThis.__oxyhq_bloom_overlay_stack__ = { sequence: 0, live: new Set() };
166
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `useOverlayLayer` — the hook every Bloom overlay surface stacks with.
3
+ *
4
+ * See `./stack.ts` for the rule and the reasoning. The contract for callers is
5
+ * one line: **call this from a component that mounts when the surface OPENS**,
6
+ * not from one that stays mounted while it is closed. Every Bloom surface is
7
+ * already shaped that way (`Dialog.web` returns null while closed;
8
+ * `BottomSheetBase` returns null until `rendered`), and `OverlayRoot` — which
9
+ * lives inside those guards — is where the call actually sits for most of them.
10
+ */
11
+ import { useEffect, useState } from 'react';
12
+
13
+ import {
14
+ acquireOverlayRank,
15
+ layerForRank,
16
+ registerOverlayRank,
17
+ releaseOverlayRank,
18
+ type OverlayLayer,
19
+ } from './stack';
20
+
21
+ /**
22
+ * Reserve this surface's place in the overlay stack for as long as it is
23
+ * mounted. Returns the z-indices it should paint with.
24
+ */
25
+ export function useOverlayLayer(): OverlayLayer {
26
+ // A state initializer, not a render-body call: it runs once per mount, so the
27
+ // rank is fixed before the first paint (an effect would leave one frame at
28
+ // the wrong depth) and it is not a position the React Compiler can memoize
29
+ // into a stale read.
30
+ const [rank] = useState(acquireOverlayRank);
31
+
32
+ useEffect(() => {
33
+ registerOverlayRank(rank);
34
+ return () => releaseOverlayRank(rank);
35
+ }, [rank]);
36
+
37
+ const [layer] = useState(() => layerForRank(rank));
38
+ return layer;
39
+ }
@@ -10,9 +10,8 @@ import React, {
10
10
  import { Pressable, StyleSheet, View } from 'react-native';
11
11
 
12
12
  import { useTheme } from '../theme/use-theme';
13
- import { Backdrop } from '../overlay';
13
+ import { Backdrop, OverlayRoot } from '../overlay';
14
14
  import { Portal } from '../portal/index.web';
15
- import { createOverlayZIndex } from '../styles/z-index';
16
15
  import { WEB_POSITION_FIXED } from '../styles/web-view-style';
17
16
  import { bloomShadowStyle } from '../design-tokens/shadows';
18
17
  import { PopoverContext, usePopoverContext } from './context';
@@ -36,7 +35,6 @@ export type {
36
35
 
37
36
  export { usePopoverContext };
38
37
 
39
- const popoverZIndex = createOverlayZIndex();
40
38
 
41
39
  /**
42
40
  * Web self-contained control. Unlike native (which proxies a `BottomSheet`
@@ -262,45 +260,52 @@ export function PopoverContent({
262
260
 
263
261
  return (
264
262
  <Portal>
265
- {dismissible ? (
266
- <Backdrop
267
- style={styles.backdrop}
268
- onPress={() => control.close()}
269
- accessibilityLabel="Dismiss popover"
270
- />
271
- ) : null}
272
- <View
273
- ref={panelRef}
274
- accessibilityRole="none"
275
- aria-label={label}
276
- testID={testID}
277
- onLayout={(e) =>
278
- setPanelSize({
279
- width: e.nativeEvent.layout.width,
280
- height: e.nativeEvent.layout.height,
281
- })
282
- }
283
- style={[
284
- styles.panel,
285
- {
286
- left: position.left,
287
- top: position.top,
288
- minWidth: resolvedMinWidth,
289
- maxWidth,
290
- backgroundColor: theme.isDark
291
- ? theme.colors.backgroundSecondary
292
- : theme.colors.background,
293
- borderColor: theme.colors.borderLight,
294
- // Design-system overlay elevation (`shadow-m`) as a `boxShadow` —
295
- // RN-Web deprecated the `shadow*` style props.
296
- ...bloomShadowStyle('m'),
297
- // Hide until measured to avoid a one-frame jump.
298
- opacity: panelSize ? 1 : 0,
299
- },
300
- style,
301
- ]}>
302
- {children}
303
- </View>
263
+ {/* `OverlayRoot` takes this surface's place in the open-order overlay
264
+ stack, so it paints above anything opened before it (see
265
+ `src/overlay/stack.ts`). It is `box-none`, so the area outside the
266
+ panel stays click-through and the backdrop below still takes its own
267
+ presses. */}
268
+ <OverlayRoot>
269
+ {dismissible ? (
270
+ <Backdrop
271
+ style={styles.backdrop}
272
+ onPress={() => control.close()}
273
+ accessibilityLabel="Dismiss popover"
274
+ />
275
+ ) : null}
276
+ <View
277
+ ref={panelRef}
278
+ accessibilityRole="none"
279
+ aria-label={label}
280
+ testID={testID}
281
+ onLayout={(e) =>
282
+ setPanelSize({
283
+ width: e.nativeEvent.layout.width,
284
+ height: e.nativeEvent.layout.height,
285
+ })
286
+ }
287
+ style={[
288
+ styles.panel,
289
+ {
290
+ left: position.left,
291
+ top: position.top,
292
+ minWidth: resolvedMinWidth,
293
+ maxWidth,
294
+ backgroundColor: theme.isDark
295
+ ? theme.colors.backgroundSecondary
296
+ : theme.colors.background,
297
+ borderColor: theme.colors.borderLight,
298
+ // Design-system overlay elevation (`shadow-m`) as a `boxShadow` —
299
+ // RN-Web deprecated the `shadow*` style props.
300
+ ...bloomShadowStyle('m'),
301
+ // Hide until measured to avoid a one-frame jump.
302
+ opacity: panelSize ? 1 : 0,
303
+ },
304
+ style,
305
+ ]}>
306
+ {children}
307
+ </View>
308
+ </OverlayRoot>
304
309
  </Portal>
305
310
  );
306
311
  }
@@ -315,12 +320,10 @@ const styles = StyleSheet.create({
315
320
  left: 0,
316
321
  right: 0,
317
322
  bottom: 0,
318
- zIndex: popoverZIndex.backdrop,
319
323
  pointerEvents: 'auto',
320
324
  },
321
325
  panel: {
322
326
  position: WEB_POSITION_FIXED,
323
- zIndex: popoverZIndex.surface,
324
327
  borderRadius: 12,
325
328
  borderWidth: 1,
326
329
  paddingVertical: 4,
@@ -10,6 +10,7 @@ import {
10
10
  } from 'react-native';
11
11
 
12
12
  import { useTheme } from '../theme/use-theme';
13
+ import { OverlayRoot } from '../overlay';
13
14
  import { Portal } from '../portal';
14
15
  import { Z_INDEX } from '../styles/z-index';
15
16
  import { PromptInputContext, type Attachment } from './context';
@@ -230,36 +231,42 @@ export function PromptInput({
230
231
 
231
232
  {showFullscreen && (
232
233
  <Portal>
233
- <View
234
- style={[
235
- StyleSheet.absoluteFill,
236
- {
237
- zIndex: Z_INDEX.fullscreen,
238
- backgroundColor: theme.colors.background,
239
- // Opt back in from the Portal root's `pointer-events: none`
240
- // (web only — harmless on native).
241
- pointerEvents: 'auto',
242
- },
243
- ]}
244
- >
245
- <Pressable
246
- onPress={() => setShowFullscreen(false)}
247
- style={{
248
- position: 'absolute',
249
- top: 16,
250
- right: 16,
251
- zIndex: Z_INDEX.fullscreenControl,
252
- padding: 8,
253
- backgroundColor: theme.colors.background,
254
- borderRadius: 9999,
255
- }}
234
+ {/* `OverlayRoot` takes the fullscreen editor's place in the
235
+ open-order overlay stack (`src/overlay/stack.ts`). It mounts with
236
+ `showFullscreen`, so the rank tracks opening. */}
237
+ <OverlayRoot>
238
+ <View
239
+ style={[
240
+ StyleSheet.absoluteFill,
241
+ {
242
+ backgroundColor: theme.colors.background,
243
+ // Opt back in from the Portal root's `pointer-events: none`
244
+ // (web only — harmless on native).
245
+ pointerEvents: 'auto',
246
+ },
247
+ ]}
256
248
  >
257
- {collapseIcon ?? (
258
- <Text style={{ fontSize: 18, color: theme.colors.text }}>⤡</Text>
259
- )}
260
- </Pressable>
261
- <View style={{ flex: 1 }}>{content}</View>
262
- </View>
249
+ <Pressable
250
+ onPress={() => setShowFullscreen(false)}
251
+ style={{
252
+ position: 'absolute',
253
+ top: 16,
254
+ right: 16,
255
+ // Above the editor's own body; where the editor sits relative
256
+ // to OTHER surfaces is the overlay stack's call.
257
+ zIndex: Z_INDEX.raised,
258
+ padding: 8,
259
+ backgroundColor: theme.colors.background,
260
+ borderRadius: 9999,
261
+ }}
262
+ >
263
+ {collapseIcon ?? (
264
+ <Text style={{ fontSize: 18, color: theme.colors.text }}>⤡</Text>
265
+ )}
266
+ </Pressable>
267
+ <View style={{ flex: 1 }}>{content}</View>
268
+ </View>
269
+ </OverlayRoot>
263
270
  </Portal>
264
271
  )}
265
272
  </PromptInputContext.Provider>
@@ -12,9 +12,8 @@ import { Pressable, StyleSheet, View, type ViewStyle } from 'react-native';
12
12
 
13
13
  import { useTheme } from '../theme/use-theme';
14
14
  import { Text } from '../typography';
15
- import { Backdrop } from '../overlay';
15
+ import { Backdrop, OverlayRoot } from '../overlay';
16
16
  import { Portal } from '../portal/index.web';
17
- import { createOverlayZIndex } from '../styles/z-index';
18
17
  import { WEB_POSITION_FIXED } from '../styles/web-view-style';
19
18
  import { resolveDropdownPlacement } from '../overlay/dropdown-placement';
20
19
  import { bloomShadowStyle } from '../design-tokens/shadows';
@@ -39,7 +38,6 @@ import type {
39
38
 
40
39
  export { useSelectItemContext };
41
40
 
42
- const selectZIndex = createOverlayZIndex();
43
41
  const VIEWPORT_GUTTER = 8;
44
42
  const SELECT_OFFSET = 6;
45
43
 
@@ -259,33 +257,40 @@ export function SelectContent<T>({
259
257
 
260
258
  return (
261
259
  <Portal>
262
- <Backdrop
263
- style={styles.backdrop}
264
- onPress={ctx.close}
265
- accessibilityLabel="Close selection"
266
- />
267
- <View
268
- ref={attachDropdown}
269
- accessibilityRole="list"
270
- accessibilityLabel={label}
271
- style={[
272
- styles.dropdown,
273
- {
274
- backgroundColor: theme.isDark
275
- ? theme.colors.backgroundSecondary
276
- : theme.colors.background,
277
- borderColor: theme.colors.borderLight,
278
- ...bloomShadowStyle('m'),
279
- },
280
- position,
281
- ]}
282
- >
283
- {items.map((item, index) => (
284
- <React.Fragment key={valueExtractor(item)}>
285
- {renderItem(item, index, ctx.value)}
286
- </React.Fragment>
287
- ))}
288
- </View>
260
+ {/* `OverlayRoot` takes this surface's place in the open-order overlay
261
+ stack, so it paints above anything opened before it (see
262
+ `src/overlay/stack.ts`). It is `box-none`, so the area outside the
263
+ panel stays click-through and the backdrop below still takes its own
264
+ presses. */}
265
+ <OverlayRoot>
266
+ <Backdrop
267
+ style={styles.backdrop}
268
+ onPress={ctx.close}
269
+ accessibilityLabel="Close selection"
270
+ />
271
+ <View
272
+ ref={attachDropdown}
273
+ accessibilityRole="list"
274
+ accessibilityLabel={label}
275
+ style={[
276
+ styles.dropdown,
277
+ {
278
+ backgroundColor: theme.isDark
279
+ ? theme.colors.backgroundSecondary
280
+ : theme.colors.background,
281
+ borderColor: theme.colors.borderLight,
282
+ ...bloomShadowStyle('m'),
283
+ },
284
+ position,
285
+ ]}
286
+ >
287
+ {items.map((item, index) => (
288
+ <React.Fragment key={valueExtractor(item)}>
289
+ {renderItem(item, index, ctx.value)}
290
+ </React.Fragment>
291
+ ))}
292
+ </View>
293
+ </OverlayRoot>
289
294
  </Portal>
290
295
  );
291
296
  }
@@ -415,7 +420,6 @@ const styles = StyleSheet.create({
415
420
  left: 0,
416
421
  right: 0,
417
422
  bottom: 0,
418
- zIndex: selectZIndex.backdrop,
419
423
  // Opt back in from the Portal root's `pointer-events: none`.
420
424
  pointerEvents: 'auto',
421
425
  },
@@ -428,7 +432,6 @@ const styles = StyleSheet.create({
428
432
  position: WEB_POSITION_FIXED,
429
433
  top: 0,
430
434
  left: 0,
431
- zIndex: selectZIndex.surface,
432
435
  borderRadius: 8,
433
436
  borderWidth: 1,
434
437
  overflow: 'hidden',
@@ -1,47 +1,45 @@
1
1
  /**
2
- * Shared stacking scale for Bloom primitives.
2
+ * Stacking scale for stacking WITHIN a single context.
3
3
  *
4
- * Keep component layers on this scale instead of introducing local magic
5
- * numbers. App-specific surfaces can still sit above Bloom's inline layers,
6
- * while portaled overlays use `portalRoot` as the document-level host.
4
+ * ## What belongs here, and what does not
5
+ *
6
+ * These values order elements against their own siblings — a badge over its
7
+ * avatar, a floating control over the panel it sits on, a sheet's drag handle
8
+ * over the sheet body. That is a local decision with a local answer, so a
9
+ * constant is the right tool.
10
+ *
11
+ * **Which OVERLAY SURFACE is on top of which is NOT on this scale**, and must
12
+ * never be added back to it. That question is answered by open order — the
13
+ * surface the user opened last wins — and is owned by `src/overlay/stack.ts`.
14
+ *
15
+ * This file used to carry rungs for that too (`dropdown` 40/41, `overlay`
16
+ * 50/60, `tooltip` 70, `toast` 80, `fullscreen` 90/91), plus
17
+ * `createOverlayZIndex` / `createDropdownZIndex` / `Z_INDEX_LAYER_STEP` to
18
+ * offset them. Because a rung is fixed per COMPONENT KIND, it decided stacking
19
+ * by what a surface WAS rather than by when it opened, so some pairings were
20
+ * permanently inverted: a confirm dialog (50/60) opened from inside an open
21
+ * bottom sheet (999999) rendered underneath it and could not be pressed or
22
+ * dismissed, and a menu (40/41) opened from inside a dialog landed behind that
23
+ * dialog. Those rungs and helpers are gone; reintroducing one reintroduces the
24
+ * bug.
7
25
  */
8
26
  export const Z_INDEX = {
27
+ /** Default resting level. */
9
28
  base: 0,
29
+ /** One step above an immediate sibling — a badge over its avatar. */
10
30
  raised: 1,
31
+ /** A control floating over the content it belongs to. */
11
32
  floating: 10,
12
- sticky: 20,
33
+ /** A dropdown's INLINE trigger wrapper, still in the app's document flow. */
13
34
  dropdown: 40,
14
- dropdownSurface: 41,
15
- overlayBackdrop: 50,
16
- overlaySurface: 60,
17
- tooltip: 70,
18
- toast: 80,
19
- fullscreen: 90,
20
- fullscreenControl: 91,
35
+ /** A bottom sheet's drag handle, over the sheet's own body. */
21
36
  sheetHandle: 100,
37
+ /**
38
+ * The document-level portal container itself (`#bloom-portal-root`). Overlay
39
+ * surfaces render INSIDE it and order among themselves by open order — none
40
+ * of them may claim this value, which is what made the sheet unbeatable.
41
+ */
22
42
  portalRoot: 999999,
23
43
  } as const;
24
44
 
25
- export const Z_INDEX_LAYER_STEP = 10;
26
-
27
- function layerOffset(layer = 0) {
28
- return Math.max(0, Math.floor(layer)) * Z_INDEX_LAYER_STEP;
29
- }
30
-
31
- export function createDropdownZIndex(layer = 0) {
32
- const offset = layerOffset(layer);
33
- return {
34
- root: Z_INDEX.dropdown + offset,
35
- surface: Z_INDEX.dropdownSurface + offset,
36
- } as const;
37
- }
38
-
39
- export function createOverlayZIndex(layer = 0) {
40
- const offset = layerOffset(layer);
41
- return {
42
- backdrop: Z_INDEX.overlayBackdrop + offset,
43
- surface: Z_INDEX.overlaySurface + offset,
44
- } as const;
45
- }
46
-
47
45
  export const zIndex = Z_INDEX;
@@ -67,13 +67,7 @@ export function createSurfaceHost(Dialog: DialogComponent) {
67
67
  * dismiss REQUEST and has no post-exit callback, so it could never tell the
68
68
  * store WHEN to splice the entry after the exit animation.
69
69
  */
70
- function SurfaceLayer({
71
- entry,
72
- index,
73
- }: {
74
- entry: SurfaceEntry;
75
- index: number;
76
- }) {
70
+ function SurfaceLayer({ entry }: { entry: SurfaceEntry }) {
77
71
  const control = useDialogControl();
78
72
  const { id, status } = entry;
79
73
 
@@ -125,7 +119,6 @@ export function createSurfaceHost(Dialog: DialogComponent) {
125
119
  <Dialog
126
120
  control={control}
127
121
  onClose={handleClose}
128
- layer={index}
129
122
  startOpen={status !== 'closing'}
130
123
  {...placement}
131
124
  >
@@ -144,8 +137,8 @@ export function createSurfaceHost(Dialog: DialogComponent) {
144
137
 
145
138
  return (
146
139
  <>
147
- {stack.map((entry, index) => (
148
- <SurfaceLayer key={entry.id} entry={entry} index={index} />
140
+ {stack.map((entry) => (
141
+ <SurfaceLayer key={entry.id} entry={entry} />
149
142
  ))}
150
143
  </>
151
144
  );
@@ -26,10 +26,9 @@ import * as React from 'react';
26
26
  import { StyleSheet } from 'react-native';
27
27
  import { GestureHandlerRootView } from 'react-native-gesture-handler';
28
28
 
29
- import { OverlayRoot } from '../overlay';
29
+ import { OverlayRoot, TOAST_LAYER_Z } from '../overlay';
30
30
  import { Portal } from '../portal/index.web';
31
31
  import { WEB_POSITION_FIXED } from '../styles/web-view-style';
32
- import { Z_INDEX } from '../styles/z-index';
33
32
  import type { ToastHostProps } from './types';
34
33
 
35
34
  export function ToastHost({ children, ToasterOverlayWrapper }: ToastHostProps) {
@@ -37,7 +36,7 @@ export function ToastHost({ children, ToasterOverlayWrapper }: ToastHostProps) {
37
36
  // `OverlayRoot` carries the pointer-events opt-in as a PROP; as a style
38
37
  // entry it never reached the DOM, so the rows inherited the portal root's
39
38
  // `pointer-events: none` and no toast could be pressed or swiped away.
40
- <OverlayRoot style={styles.host}>
39
+ <OverlayRoot zIndex={TOAST_LAYER_Z} style={styles.host}>
41
40
  {/* Also `box-none`: as the direct child of a box-none root it would
42
41
  otherwise be handed `pointer-events: auto` and, spanning the whole
43
42
  viewport, block the app underneath while a toast is up. */}
@@ -68,8 +67,12 @@ const styles = StyleSheet.create({
68
67
  left: 0,
69
68
  right: 0,
70
69
  bottom: 0,
71
- // Inside `#bloom-portal-root` (which owns the document layer at 999999),
72
- // this keeps toasts above Dialog's surface (60) and tooltips (70).
73
- zIndex: Z_INDEX.toast,
70
+ // The depth is `TOAST_LAYER_Z`, applied by `OverlayRoot` via its `zIndex`
71
+ // prop a toast is a notification, not a modal surface, so it is pinned
72
+ // above the whole open-order overlay stack rather than taking a rank in it
73
+ // (see `src/overlay/stack.ts`). It must not be set here as well: a style
74
+ // z-index would fight the prop and, being a fixed number again, would land
75
+ // back under any surface that outranked it — which is how toasts were
76
+ // already being lost behind an open bottom sheet.
74
77
  },
75
78
  });