@oxyhq/bloom 0.72.2 → 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 (150) 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 +133 -7
  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 +99 -8
  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 +31 -6
  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 +31 -6
  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__/overlay-stack-order.test.tsx +270 -0
  130. package/src/avatar-group/AvatarGroup.web.tsx +35 -31
  131. package/src/bottom-sheet/index.web.tsx +10 -5
  132. package/src/context-menu/index.web.tsx +33 -30
  133. package/src/dialog/Dialog.tsx +9 -18
  134. package/src/dialog/Dialog.web.tsx +62 -60
  135. package/src/dialog/types.ts +0 -10
  136. package/src/fonts/font-data.web.ts +15 -0
  137. package/src/menu/index.web.tsx +33 -22
  138. package/src/overlay/OverlayStacking.stories.tsx +207 -0
  139. package/src/overlay/index.tsx +114 -9
  140. package/src/overlay/stack.ts +166 -0
  141. package/src/overlay/use-overlay-layer.ts +39 -0
  142. package/src/popover/index.web.tsx +47 -44
  143. package/src/prompt-input/PromptInput.tsx +36 -29
  144. package/src/select/index.web.tsx +35 -32
  145. package/src/styles/z-index.ts +32 -34
  146. package/src/surfaces/SurfaceHost.tsx +3 -10
  147. package/src/toast/ToastHost.tsx +9 -6
  148. package/src/tooltip/index.tsx +14 -7
  149. package/src/tooltip/index.web.tsx +8 -2
  150. package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +4 -4
@@ -15,8 +15,9 @@ import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'rea
15
15
  import { useTheme } from '../theme/use-theme';
16
16
  import { Text } from '../typography';
17
17
  import type { DialogControlProps } from '../dialog/types';
18
+ import { OverlayRoot } from '../overlay';
18
19
  import { Portal } from '../portal/index.web';
19
- import { createDropdownZIndex } from '../styles/z-index';
20
+ import { Z_INDEX } from '../styles/z-index';
20
21
  import { WEB_POSITION_FIXED } from '../styles/web-view-style';
21
22
  import { resolveDropdownPlacement } from '../overlay/dropdown-placement';
22
23
  import { bloomShadowStyle } from '../design-tokens/shadows';
@@ -37,7 +38,6 @@ import type {
37
38
 
38
39
  export { useMenuContext };
39
40
 
40
- const menuZIndex = createDropdownZIndex();
41
41
  const VIEWPORT_GUTTER = 8;
42
42
  const MENU_OFFSET = 6;
43
43
 
@@ -266,24 +266,33 @@ export function MenuContent({
266
266
 
267
267
  return (
268
268
  <Portal>
269
- <View
270
- ref={attachDropdown}
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
- style,
281
- styles.portaledDropdown,
282
- position,
283
- ]}
284
- >
285
- {children}
286
- </View>
269
+ {/* `OverlayRoot` takes this menu's place in the open-order overlay stack.
270
+ The dropdown used to carry a fixed `zIndex` on the `dropdown` rung
271
+ (41), below the `overlay` rung a Dialog sits on (50/60) — so a menu
272
+ opened from inside a dialog rendered behind that dialog, whichever
273
+ opened last. It is `box-none`, so the area around the dropdown stays
274
+ click-through and the existing outside-press dismissal still sees the
275
+ press. */}
276
+ <OverlayRoot>
277
+ <View
278
+ ref={attachDropdown}
279
+ style={[
280
+ styles.dropdown,
281
+ {
282
+ backgroundColor: theme.isDark
283
+ ? theme.colors.backgroundSecondary
284
+ : theme.colors.background,
285
+ borderColor: theme.colors.borderLight,
286
+ ...bloomShadowStyle('m'),
287
+ },
288
+ style,
289
+ styles.portaledDropdown,
290
+ position,
291
+ ]}
292
+ >
293
+ {children}
294
+ </View>
295
+ </OverlayRoot>
287
296
  </Portal>
288
297
  );
289
298
  }
@@ -405,7 +414,10 @@ const styles = StyleSheet.create({
405
414
  position: 'relative',
406
415
  },
407
416
  openRoot: {
408
- zIndex: menuZIndex.root,
417
+ // The INLINE trigger wrapper, still in the app's own document flow — this
418
+ // only lifts it over adjacent in-flow content while the menu is open. The
419
+ // portaled dropdown's depth is the overlay stack's business, not this.
420
+ zIndex: Z_INDEX.dropdown,
409
421
  },
410
422
  dropdown: {
411
423
  // Fixed from the outset, not only once positioned: the `Portal` root is a
@@ -420,7 +432,6 @@ const styles = StyleSheet.create({
420
432
  padding: 4,
421
433
  borderWidth: 1,
422
434
  overflow: 'hidden',
423
- zIndex: menuZIndex.surface,
424
435
  // Overlay elevation applied at the usage site via `bloomShadowStyle('m')`.
425
436
  minWidth: 180,
426
437
  },
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Stacked-overlay stories.
3
+ *
4
+ * These exist because overlay stacking used to be decided by hand-picked
5
+ * `zIndex` constants, so the answer to "which surface is on top" depended on
6
+ * WHICH KIND of surface each one was rather than on which one the user opened
7
+ * last. The failure was silent and total: the later surface rendered perfectly,
8
+ * fully interactive, entirely behind the earlier one — unreachable and
9
+ * undismissable.
10
+ *
11
+ * Every story opens a second surface from INSIDE a first one and puts a button
12
+ * in the second surface that writes into a result line. That makes the check a
13
+ * real one: drive it with actual input at the button's coordinates and the
14
+ * result only changes if the second surface genuinely received the press. A
15
+ * geometry read (`zIndex`, bounding boxes) would pass on a surface the user
16
+ * cannot touch. `src/__tests__/overlay-stack-order.test.ts` covers the ordering
17
+ * rule itself; `scripts/verify-overlay-stacking.mjs` drives these stories in a
18
+ * real browser.
19
+ */
20
+ import React, { useRef, useState } from 'react';
21
+ import { Text, View } from 'react-native';
22
+ import type { Meta, StoryObj } from '@storybook/react-vite';
23
+
24
+ import { BottomSheet, type BottomSheetRef } from '../bottom-sheet';
25
+ import { Button } from '../button';
26
+ import { Dialog } from '../dialog/Dialog';
27
+ import { useDialogControl } from '../dialog/context';
28
+ import { Menu, MenuContent, MenuItem, MenuTrigger } from '../menu';
29
+
30
+ const meta: Meta = {
31
+ title: 'Components/Overlay stacking',
32
+ };
33
+
34
+ export default meta;
35
+
36
+ type Story = StoryObj;
37
+
38
+ /** The line the harness reads. Starts at `idle`; the top surface writes to it. */
39
+ function Result({ value }: { value: string }) {
40
+ return <Text testID="result">result: {value}</Text>;
41
+ }
42
+
43
+ /**
44
+ * The reported bug: a confirm dialog opened from a control inside an open bottom
45
+ * sheet. The dialog opens LAST, so it must be the surface that takes the press.
46
+ */
47
+ function DialogOverSheet() {
48
+ const sheetRef = useRef<BottomSheetRef>(null);
49
+ const dialogControl = useDialogControl();
50
+ const [result, setResult] = useState('idle');
51
+
52
+ return (
53
+ <>
54
+ <Button testID="open-first" onPress={() => sheetRef.current?.present()}>
55
+ Open sheet
56
+ </Button>
57
+ <Result value={result} />
58
+
59
+ <BottomSheet ref={sheetRef}>
60
+ <View style={{ padding: 24, gap: 12 }}>
61
+ <Text style={{ fontSize: 20, fontWeight: '700' }}>Post options</Text>
62
+ <Button
63
+ testID="open-second"
64
+ variant="secondary"
65
+ onPress={() => dialogControl.open()}
66
+ >
67
+ Delete post
68
+ </Button>
69
+ </View>
70
+ </BottomSheet>
71
+
72
+ <Dialog
73
+ control={dialogControl}
74
+ testID="confirm-dialog"
75
+ title="Delete post?"
76
+ description="This cannot be undone."
77
+ actions={[
78
+ {
79
+ label: 'Delete',
80
+ color: 'destructive',
81
+ testID: 'top-action',
82
+ onPress: () => setResult('dialog'),
83
+ },
84
+ { label: 'Cancel', color: 'cancel' },
85
+ ]}
86
+ />
87
+ </>
88
+ );
89
+ }
90
+
91
+ /** The mirror image: a sheet opened from inside an already-open dialog. */
92
+ function SheetOverDialog() {
93
+ const sheetRef = useRef<BottomSheetRef>(null);
94
+ const dialogControl = useDialogControl();
95
+ const [result, setResult] = useState('idle');
96
+
97
+ return (
98
+ <>
99
+ <Button testID="open-first" onPress={() => dialogControl.open()}>
100
+ Open dialog
101
+ </Button>
102
+ <Result value={result} />
103
+
104
+ <Dialog control={dialogControl} testID="outer-dialog" title="Settings">
105
+ <Button
106
+ testID="open-second"
107
+ variant="secondary"
108
+ onPress={() => sheetRef.current?.present()}
109
+ >
110
+ Pick an option
111
+ </Button>
112
+ </Dialog>
113
+
114
+ <BottomSheet ref={sheetRef}>
115
+ <View style={{ padding: 24, gap: 12 }}>
116
+ <Text style={{ fontSize: 20, fontWeight: '700' }}>Options</Text>
117
+ <Button testID="top-action" variant="secondary" onPress={() => setResult('sheet')}>
118
+ Choose this
119
+ </Button>
120
+ </View>
121
+ </BottomSheet>
122
+ </>
123
+ );
124
+ }
125
+
126
+ /** Two dialogs. The second must sit above the first. */
127
+ function DialogOverDialog() {
128
+ const first = useDialogControl();
129
+ const second = useDialogControl();
130
+ const [result, setResult] = useState('idle');
131
+
132
+ return (
133
+ <>
134
+ <Button testID="open-first" onPress={() => first.open()}>
135
+ Open first dialog
136
+ </Button>
137
+ <Result value={result} />
138
+
139
+ <Dialog control={first} testID="first-dialog" title="First">
140
+ <Button testID="open-second" variant="secondary" onPress={() => second.open()}>
141
+ Open second dialog
142
+ </Button>
143
+ </Dialog>
144
+
145
+ <Dialog control={second} testID="second-dialog" title="Second">
146
+ <Button testID="top-action" variant="secondary" onPress={() => setResult('second')}>
147
+ Act
148
+ </Button>
149
+ </Dialog>
150
+ </>
151
+ );
152
+ }
153
+
154
+ /**
155
+ * A menu opened from inside a dialog. `Menu` sat on the `dropdown` rung of the
156
+ * scale (40/41) and the dialog on `overlay` (50/60), so the menu opened behind
157
+ * the dialog that launched it no matter what order they opened in.
158
+ */
159
+ function MenuOverDialog() {
160
+ const control = useDialogControl();
161
+ const [result, setResult] = useState('idle');
162
+
163
+ return (
164
+ <>
165
+ <Button testID="open-first" onPress={() => control.open()}>
166
+ Open dialog
167
+ </Button>
168
+ <Result value={result} />
169
+
170
+ <Dialog control={control} testID="menu-host-dialog" title="Filters">
171
+ <Menu>
172
+ <MenuTrigger label="Sort by">
173
+ {({ props }) => (
174
+ <Button {...props} testID="open-second" variant="secondary">
175
+ Sort by
176
+ </Button>
177
+ )}
178
+ </MenuTrigger>
179
+ <MenuContent>
180
+ <MenuItem testID="top-action" label="Newest" onPress={() => setResult('menu')} />
181
+ <MenuItem label="Oldest" onPress={() => setResult('menu-oldest')} />
182
+ </MenuContent>
183
+ </Menu>
184
+ </Dialog>
185
+ </>
186
+ );
187
+ }
188
+
189
+ export const DialogOverSheetStory: Story = {
190
+ name: 'Dialog over sheet',
191
+ render: () => <DialogOverSheet />,
192
+ };
193
+
194
+ export const SheetOverDialogStory: Story = {
195
+ name: 'Sheet over dialog',
196
+ render: () => <SheetOverDialog />,
197
+ };
198
+
199
+ export const DialogOverDialogStory: Story = {
200
+ name: 'Dialog over dialog',
201
+ render: () => <DialogOverDialog />,
202
+ };
203
+
204
+ export const MenuOverDialogStory: Story = {
205
+ name: 'Menu over dialog',
206
+ render: () => <MenuOverDialog />,
207
+ };
@@ -39,7 +39,7 @@
39
39
  * Use `<OverlayRoot>` for the surface's outermost node and `<Backdrop>` for its
40
40
  * dimming layer; do not re-implement either with raw `View`s.
41
41
  */
42
- import { memo, type ReactNode } from 'react';
42
+ import { createContext, memo, useContext, useMemo, type ReactNode } from 'react';
43
43
  import { BlurView } from 'expo-blur';
44
44
  import {
45
45
  Platform,
@@ -52,9 +52,28 @@ import {
52
52
  import Animated, { useAnimatedStyle, type SharedValue } from 'react-native-reanimated';
53
53
 
54
54
  import { WEB_POSITION_FIXED } from '../styles/web-view-style';
55
+ import { layerForRank, type OverlayLayer } from './stack';
56
+ import { useOverlayLayer } from './use-overlay-layer';
55
57
 
56
58
  const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
57
59
 
60
+ /**
61
+ * The z-indices of the nearest enclosing `OverlayRoot`. Descendants that order
62
+ * themselves within a surface (a dialog's panel above its own backdrop) read
63
+ * this instead of picking their own numbers.
64
+ *
65
+ * The default is the first rank rather than 0, so a surface part rendered
66
+ * outside any `OverlayRoot` still lands in the overlay band instead of behind
67
+ * the app.
68
+ */
69
+ const OverlayLayerContext = createContext<OverlayLayer>(layerForRank(1));
70
+ OverlayLayerContext.displayName = 'BloomOverlayLayerContext';
71
+
72
+ /** Z-indices of the enclosing overlay surface. See `OverlayRoot`. */
73
+ export function useOverlayLayerContext(): OverlayLayer {
74
+ return useContext(OverlayLayerContext);
75
+ }
76
+
58
77
  /**
59
78
  * One blur radius for every Bloom overlay. Surfaces differ in what they show,
60
79
  * not in how the app behind them recedes.
@@ -74,19 +93,92 @@ export interface OverlayRootProps {
74
93
  children?: ReactNode;
75
94
  style?: StyleProp<ViewStyle>;
76
95
  testID?: string;
96
+ /**
97
+ * Opt OUT of the open-order stack and pin to a fixed depth. Only the toast
98
+ * layer does this — a notification has to stay visible over whatever is open,
99
+ * including a surface opened after it. Everything else must leave this unset
100
+ * so it stacks by open order; a hand-picked number here is precisely the bug
101
+ * `./stack.ts` exists to remove.
102
+ */
103
+ zIndex?: number;
77
104
  }
78
105
 
79
106
  /**
80
- * Outermost node of a portaled surface. Fills the viewport and re-enables
81
- * pointer events for its own children while empty gaps stay click-through
82
- * (`box-none`), so a surface that only covers part of the screen never steals
83
- * clicks from the app behind it.
107
+ * Outermost node of a portaled surface. Three jobs:
108
+ *
109
+ * - Fills the viewport.
110
+ * - Re-enables pointer events for its own children while empty gaps stay
111
+ * click-through (`box-none`), so a surface that only covers part of the
112
+ * screen never steals clicks from the app behind it.
113
+ * - Takes this surface's place in the overlay stack, so a surface opened later
114
+ * paints above one opened earlier (see `./stack.ts`).
115
+ *
116
+ * Because the rank is taken on MOUNT, this must be rendered inside whatever
117
+ * guard makes the surface appear (`if (!isOpen) return null`), which is where
118
+ * every Bloom surface already puts it. Descendants that need to order
119
+ * themselves within the surface read `useOverlayLayerContext()`.
84
120
  */
85
- export function OverlayRoot({ children, style, testID }: OverlayRootProps) {
121
+ export function OverlayRoot({ children, style, testID, zIndex }: OverlayRootProps) {
122
+ // Split into two components rather than branching on the hook: a pinned root
123
+ // must not CONSUME a rank either. The toast host is pinned and mounts for the
124
+ // whole life of the app, so holding a rank would keep the live set permanently
125
+ // non-empty — the counter would never reset and depths would climb for the
126
+ // rest of the session.
127
+ return zIndex === undefined ? (
128
+ <StackedOverlayRoot style={style} testID={testID}>
129
+ {children}
130
+ </StackedOverlayRoot>
131
+ ) : (
132
+ <PinnedOverlayRoot zIndex={zIndex} style={style} testID={testID}>
133
+ {children}
134
+ </PinnedOverlayRoot>
135
+ );
136
+ }
137
+
138
+ function StackedOverlayRoot({ children, style, testID }: Omit<OverlayRootProps, 'zIndex'>) {
139
+ const layer = useOverlayLayer();
86
140
  return (
87
- <View pointerEvents="box-none" style={[styles.root, style]} testID={testID}>
141
+ <OverlayRootView layer={layer} style={style} testID={testID}>
88
142
  {children}
89
- </View>
143
+ </OverlayRootView>
144
+ );
145
+ }
146
+
147
+ function PinnedOverlayRoot({
148
+ children,
149
+ style,
150
+ testID,
151
+ zIndex,
152
+ }: OverlayRootProps & { zIndex: number }) {
153
+ // Outside the stack, so descendants must not read stack depths from it
154
+ // either — every slot is the pinned depth.
155
+ const layer = useMemo(
156
+ () => ({ root: zIndex, backdrop: zIndex, surface: zIndex }),
157
+ [zIndex],
158
+ );
159
+ return (
160
+ <OverlayRootView layer={layer} style={style} testID={testID}>
161
+ {children}
162
+ </OverlayRootView>
163
+ );
164
+ }
165
+
166
+ function OverlayRootView({
167
+ children,
168
+ style,
169
+ testID,
170
+ layer,
171
+ }: Omit<OverlayRootProps, 'zIndex'> & { layer: OverlayLayer }) {
172
+ return (
173
+ <OverlayLayerContext.Provider value={layer}>
174
+ <View
175
+ pointerEvents="box-none"
176
+ style={[styles.root, { zIndex: layer.root }, style]}
177
+ testID={testID}
178
+ >
179
+ {children}
180
+ </View>
181
+ </OverlayLayerContext.Provider>
90
182
  );
91
183
  }
92
184
 
@@ -112,7 +204,10 @@ export interface BackdropProps {
112
204
  /** Dim opacity, 0–1. */
113
205
  dimOpacity?: number;
114
206
  /**
115
- * Geometry for the press target: insets, z-index, layout. NOT opacity
207
+ * Geometry for the press target: insets, layout. NOT a z-index where this
208
+ * surface sits relative to others is `OverlayRoot`'s call (see `./stack.ts`),
209
+ * and within the surface the panel is simply rendered after this. NOT opacity
210
+ * either —
116
211
  * `backdrop-filter` samples nothing under an ancestor with `opacity < 1`
117
212
  * (the group composites in isolation), so a fade applied here silently kills
118
213
  * the blur. An `opacity` found in this style is redirected onto the layers;
@@ -240,3 +335,13 @@ const styles = StyleSheet.create({
240
335
  bottom: 0,
241
336
  },
242
337
  });
338
+
339
+ export {
340
+ layerForRank,
341
+ OVERLAY_STACK_BAND,
342
+ OVERLAY_STACK_BASE,
343
+ OVERLAY_STACK_MAX_RANK,
344
+ TOAST_LAYER_Z,
345
+ type OverlayLayer,
346
+ } from './stack';
347
+ export { useOverlayLayer } from './use-overlay-layer';
@@ -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
+ }