@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
@@ -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;
@@ -124,16 +219,28 @@ export interface BackdropProps {
124
219
  * express — the web dialog's CSS keyframes, for instance.
125
220
  */
126
221
  layerStyle?: StyleProp<ViewStyle>;
127
- /** Rendered ON TOP of the dim, inside the press target (Dialog's panel does this). */
222
+ /** Rendered ON TOP of the dim, as a sibling of the press target (Dialog's panel does this). */
128
223
  children?: ReactNode;
129
224
  accessibilityLabel?: string;
130
225
  testID?: string;
131
226
  }
132
227
 
133
228
  /**
134
- * Full-bleed blur + dim that dismisses the surface when pressed. Always takes
135
- * pointer events (that is its whole job), so anything that must stay pressable
136
- * goes INSIDE it as `children`, never as a sibling rendered over it.
229
+ * Full-bleed blur + dim that dismisses the surface when pressed.
230
+ *
231
+ * The dismiss target is a hit box that fills this component and sits BEHIND
232
+ * `children`, never around them. It used to wrap them, which read fine on
233
+ * native but is invalid on web: the hit box carries `accessibilityRole="button"`,
234
+ * so react-native-web renders it as a real `<button>` — and every control inside
235
+ * any Bloom surface (a dialog's own buttons, a menu's rows) became a nested
236
+ * `<button>`. React reports that as a hydration error, and the nested control's
237
+ * activation behaviour is undefined per the HTML spec.
238
+ *
239
+ * Hit testing is unchanged: the hit box still covers the whole area, `children`
240
+ * render above it and take their own presses, and a press that lands on empty
241
+ * space falls through to the hit box. Layout styles passed via `style` stay on
242
+ * the outer box, so a caller that centres its panel with this component (the
243
+ * centred dialog) keeps doing so.
137
244
  */
138
245
  export const Backdrop = memo(function Backdrop({
139
246
  onPress,
@@ -175,36 +282,40 @@ export const Backdrop = memo(function Backdrop({
175
282
  );
176
283
 
177
284
  return (
178
- <Pressable
179
- pointerEvents="auto"
180
- onPress={inert ? undefined : onPress}
181
- disabled={inert}
182
- // A dimming layer is not a focus stop on web: Escape and the panel's own
183
- // controls are the keyboard paths out. It stays labelled for screen
184
- // readers that surface it as the dismiss affordance.
185
- focusable={false}
186
- accessibilityRole={inert ? undefined : 'button'}
187
- accessibilityLabel={inert ? undefined : accessibilityLabel}
188
- testID={testID}
189
- style={[StyleSheet.absoluteFill, rootStyle]}
190
- >
191
- {blurIntensity > 0 ? (
192
- <AnimatedBlurView
193
- intensity={blurIntensity}
194
- tint={blurTint}
195
- // Android's default blur is a no-op on many devices; this is the
196
- // implementation that actually renders there.
197
- experimentalBlurMethod="dimezisBlurView"
285
+ // `box-none` so this box never takes a press itself: the hit box below and
286
+ // `children` above are what receive them.
287
+ <View pointerEvents="box-none" style={[StyleSheet.absoluteFill, rootStyle]}>
288
+ <Pressable
289
+ pointerEvents="auto"
290
+ onPress={inert ? undefined : onPress}
291
+ disabled={inert}
292
+ // A dimming layer is not a focus stop on web: Escape and the panel's own
293
+ // controls are the keyboard paths out. It stays labelled for screen
294
+ // readers that surface it as the dismiss affordance.
295
+ focusable={false}
296
+ accessibilityRole={inert ? undefined : 'button'}
297
+ accessibilityLabel={inert ? undefined : accessibilityLabel}
298
+ testID={testID}
299
+ style={StyleSheet.absoluteFill}
300
+ >
301
+ {blurIntensity > 0 ? (
302
+ <AnimatedBlurView
303
+ intensity={blurIntensity}
304
+ tint={blurTint}
305
+ // Android's default blur is a no-op on many devices; this is the
306
+ // implementation that actually renders there.
307
+ experimentalBlurMethod="dimezisBlurView"
308
+ pointerEvents="none"
309
+ style={[StyleSheet.absoluteFill, layerStyle, blurFade]}
310
+ />
311
+ ) : null}
312
+ <Animated.View
198
313
  pointerEvents="none"
199
- style={[StyleSheet.absoluteFill, layerStyle, blurFade]}
314
+ style={[StyleSheet.absoluteFill, { backgroundColor: resolvedDimColor }, layerStyle, dimFade]}
200
315
  />
201
- ) : null}
202
- <Animated.View
203
- pointerEvents="none"
204
- style={[StyleSheet.absoluteFill, { backgroundColor: resolvedDimColor }, layerStyle, dimFade]}
205
- />
316
+ </Pressable>
206
317
  {children}
207
- </Pressable>
318
+ </View>
208
319
  );
209
320
  });
210
321
 
@@ -224,3 +335,13 @@ const styles = StyleSheet.create({
224
335
  bottom: 0,
225
336
  },
226
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';