@oxyhq/bloom 0.72.2 → 0.73.1

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 (162) 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/button/Button.js +104 -42
  6. package/lib/commonjs/button/Button.js.map +1 -1
  7. package/lib/commonjs/button/Button.web.js +11 -3
  8. package/lib/commonjs/button/Button.web.js.map +1 -1
  9. package/lib/commonjs/context-menu/index.web.js +20 -22
  10. package/lib/commonjs/context-menu/index.web.js.map +1 -1
  11. package/lib/commonjs/dialog/Dialog.js +66 -70
  12. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  13. package/lib/commonjs/dialog/Dialog.web.js +49 -51
  14. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  15. package/lib/commonjs/menu/index.web.js +15 -11
  16. package/lib/commonjs/menu/index.web.js.map +1 -1
  17. package/lib/commonjs/overlay/index.js +133 -7
  18. package/lib/commonjs/overlay/index.js.map +1 -1
  19. package/lib/commonjs/overlay/stack.js +170 -0
  20. package/lib/commonjs/overlay/stack.js.map +1 -0
  21. package/lib/commonjs/overlay/use-overlay-layer.js +37 -0
  22. package/lib/commonjs/overlay/use-overlay-layer.js.map +1 -0
  23. package/lib/commonjs/popover/index.web.js +31 -34
  24. package/lib/commonjs/popover/index.web.js.map +1 -1
  25. package/lib/commonjs/prompt-input/PromptInput.js +33 -29
  26. package/lib/commonjs/prompt-input/PromptInput.js.map +1 -1
  27. package/lib/commonjs/select/index.web.js +20 -22
  28. package/lib/commonjs/select/index.web.js.map +1 -1
  29. package/lib/commonjs/styles/z-index.js +33 -34
  30. package/lib/commonjs/styles/z-index.js.map +1 -1
  31. package/lib/commonjs/surfaces/SurfaceHost.js +3 -6
  32. package/lib/commonjs/surfaces/SurfaceHost.js.map +1 -1
  33. package/lib/commonjs/toast/ToastHost.js +9 -5
  34. package/lib/commonjs/toast/ToastHost.js.map +1 -1
  35. package/lib/commonjs/tooltip/index.js +9 -6
  36. package/lib/commonjs/tooltip/index.js.map +1 -1
  37. package/lib/commonjs/tooltip/index.web.js +8 -2
  38. package/lib/commonjs/tooltip/index.web.js.map +1 -1
  39. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +5 -5
  40. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  41. package/lib/module/avatar-group/AvatarGroup.web.js +21 -20
  42. package/lib/module/avatar-group/AvatarGroup.web.js.map +1 -1
  43. package/lib/module/bottom-sheet/index.web.js +11 -6
  44. package/lib/module/bottom-sheet/index.web.js.map +1 -1
  45. package/lib/module/button/Button.js +104 -42
  46. package/lib/module/button/Button.js.map +1 -1
  47. package/lib/module/button/Button.web.js +11 -3
  48. package/lib/module/button/Button.web.js.map +1 -1
  49. package/lib/module/context-menu/index.web.js +21 -23
  50. package/lib/module/context-menu/index.web.js.map +1 -1
  51. package/lib/module/dialog/Dialog.js +67 -71
  52. package/lib/module/dialog/Dialog.js.map +1 -1
  53. package/lib/module/dialog/Dialog.web.js +50 -52
  54. package/lib/module/dialog/Dialog.web.js.map +1 -1
  55. package/lib/module/menu/index.web.js +16 -12
  56. package/lib/module/menu/index.web.js.map +1 -1
  57. package/lib/module/overlay/index.js +99 -8
  58. package/lib/module/overlay/index.js.map +1 -1
  59. package/lib/module/overlay/stack.js +161 -0
  60. package/lib/module/overlay/stack.js.map +1 -0
  61. package/lib/module/overlay/use-overlay-layer.js +33 -0
  62. package/lib/module/overlay/use-overlay-layer.js.map +1 -0
  63. package/lib/module/popover/index.web.js +32 -34
  64. package/lib/module/popover/index.web.js.map +1 -1
  65. package/lib/module/prompt-input/PromptInput.js +33 -29
  66. package/lib/module/prompt-input/PromptInput.js.map +1 -1
  67. package/lib/module/select/index.web.js +21 -23
  68. package/lib/module/select/index.web.js.map +1 -1
  69. package/lib/module/styles/z-index.js +32 -30
  70. package/lib/module/styles/z-index.js.map +1 -1
  71. package/lib/module/surfaces/SurfaceHost.js +3 -6
  72. package/lib/module/surfaces/SurfaceHost.js.map +1 -1
  73. package/lib/module/toast/ToastHost.js +10 -6
  74. package/lib/module/toast/ToastHost.js.map +1 -1
  75. package/lib/module/tooltip/index.js +9 -6
  76. package/lib/module/tooltip/index.js.map +1 -1
  77. package/lib/module/tooltip/index.web.js +8 -2
  78. package/lib/module/tooltip/index.web.js.map +1 -1
  79. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +5 -5
  80. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  81. package/lib/typescript/commonjs/avatar-group/AvatarGroup.web.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/bottom-sheet/index.web.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/button/Button.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/button/Button.web.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/button/types.d.ts +8 -0
  86. package/lib/typescript/commonjs/button/types.d.ts.map +1 -1
  87. package/lib/typescript/commonjs/context-menu/index.web.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/dialog/types.d.ts +0 -10
  91. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/menu/index.web.d.ts.map +1 -1
  93. package/lib/typescript/commonjs/overlay/index.d.ts +31 -6
  94. package/lib/typescript/commonjs/overlay/index.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/overlay/stack.d.ts +122 -0
  96. package/lib/typescript/commonjs/overlay/stack.d.ts.map +1 -0
  97. package/lib/typescript/commonjs/overlay/use-overlay-layer.d.ts +7 -0
  98. package/lib/typescript/commonjs/overlay/use-overlay-layer.d.ts.map +1 -0
  99. package/lib/typescript/commonjs/popover/index.web.d.ts.map +1 -1
  100. package/lib/typescript/commonjs/prompt-input/PromptInput.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/select/index.web.d.ts.map +1 -1
  102. package/lib/typescript/commonjs/styles/z-index.d.ts +42 -29
  103. package/lib/typescript/commonjs/styles/z-index.d.ts.map +1 -1
  104. package/lib/typescript/commonjs/surfaces/SurfaceHost.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/toast/ToastHost.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/tooltip/index.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  108. package/lib/typescript/module/avatar-group/AvatarGroup.web.d.ts.map +1 -1
  109. package/lib/typescript/module/bottom-sheet/index.web.d.ts.map +1 -1
  110. package/lib/typescript/module/button/Button.d.ts.map +1 -1
  111. package/lib/typescript/module/button/Button.web.d.ts.map +1 -1
  112. package/lib/typescript/module/button/types.d.ts +8 -0
  113. package/lib/typescript/module/button/types.d.ts.map +1 -1
  114. package/lib/typescript/module/context-menu/index.web.d.ts.map +1 -1
  115. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  116. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  117. package/lib/typescript/module/dialog/types.d.ts +0 -10
  118. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  119. package/lib/typescript/module/menu/index.web.d.ts.map +1 -1
  120. package/lib/typescript/module/overlay/index.d.ts +31 -6
  121. package/lib/typescript/module/overlay/index.d.ts.map +1 -1
  122. package/lib/typescript/module/overlay/stack.d.ts +122 -0
  123. package/lib/typescript/module/overlay/stack.d.ts.map +1 -0
  124. package/lib/typescript/module/overlay/use-overlay-layer.d.ts +7 -0
  125. package/lib/typescript/module/overlay/use-overlay-layer.d.ts.map +1 -0
  126. package/lib/typescript/module/popover/index.web.d.ts.map +1 -1
  127. package/lib/typescript/module/prompt-input/PromptInput.d.ts.map +1 -1
  128. package/lib/typescript/module/select/index.web.d.ts.map +1 -1
  129. package/lib/typescript/module/styles/z-index.d.ts +42 -29
  130. package/lib/typescript/module/styles/z-index.d.ts.map +1 -1
  131. package/lib/typescript/module/surfaces/SurfaceHost.d.ts.map +1 -1
  132. package/lib/typescript/module/toast/ToastHost.d.ts.map +1 -1
  133. package/lib/typescript/module/tooltip/index.d.ts.map +1 -1
  134. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  135. package/package.json +2 -1
  136. package/src/__tests__/Button.test.tsx +166 -1
  137. package/src/__tests__/Button.web.test.tsx +16 -0
  138. package/src/__tests__/ToastHostWebFork.test.ts +11 -3
  139. package/src/__tests__/overlay-stack-order.test.tsx +270 -0
  140. package/src/avatar-group/AvatarGroup.web.tsx +35 -31
  141. package/src/bottom-sheet/index.web.tsx +10 -5
  142. package/src/button/Button.tsx +123 -40
  143. package/src/button/Button.web.tsx +11 -3
  144. package/src/button/types.ts +8 -0
  145. package/src/context-menu/index.web.tsx +33 -30
  146. package/src/dialog/Dialog.tsx +9 -18
  147. package/src/dialog/Dialog.web.tsx +62 -60
  148. package/src/dialog/types.ts +0 -10
  149. package/src/menu/index.web.tsx +33 -22
  150. package/src/overlay/OverlayStacking.stories.tsx +207 -0
  151. package/src/overlay/index.tsx +114 -9
  152. package/src/overlay/stack.ts +166 -0
  153. package/src/overlay/use-overlay-layer.ts +39 -0
  154. package/src/popover/index.web.tsx +47 -44
  155. package/src/prompt-input/PromptInput.tsx +36 -29
  156. package/src/select/index.web.tsx +35 -32
  157. package/src/styles/z-index.ts +32 -34
  158. package/src/surfaces/SurfaceHost.tsx +3 -10
  159. package/src/toast/ToastHost.tsx +9 -6
  160. package/src/tooltip/index.tsx +14 -7
  161. package/src/tooltip/index.web.tsx +8 -2
  162. package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +4 -4
@@ -1,4 +1,4 @@
1
- import React, { useMemo, memo } from 'react';
1
+ import React, { useMemo, memo, type ComponentType } from 'react';
2
2
  import {
3
3
  ActivityIndicator,
4
4
  Animated,
@@ -7,9 +7,12 @@ import {
7
7
  StyleSheet,
8
8
  Text,
9
9
  View,
10
+ type PressableProps,
11
+ type StyleProp,
10
12
  type TextStyle,
11
13
  type ViewStyle,
12
14
  } from 'react-native';
15
+ import { styled } from 'react-native-css';
13
16
 
14
17
  import { useTheme } from '../theme/use-theme';
15
18
  import { usePressAnimation } from '../hooks/usePressAnimation';
@@ -69,6 +72,73 @@ const PILL_RADIUS = 999;
69
72
  const PRESS_SCALE = 0.97;
70
73
  const SCALE_VARIANTS = new Set<string>(['primary', 'secondary', 'inverse']);
71
74
 
75
+ // ---------------------------------------------------------------------------
76
+ // The button renders ONE node — the same shape the web fork renders (one
77
+ // `<button>`).
78
+ //
79
+ // It used to be two: an unstyled `Animated.View` holding the press-scale
80
+ // transform, wrapping the `Pressable` that carried `className`, `style` and
81
+ // every visual. That wrapper — not the pressable — was the flex child of
82
+ // whatever laid the button out, and it hugged its content, so a caller's
83
+ // `className="flex-1"` (and `style={{ alignSelf }}`, and every other layout
84
+ // declaration) applied INSIDE a box that never grew. Nothing errored; the same
85
+ // call site behaved on web and did nothing on native, and consumers worked
86
+ // around it by wrapping every Bloom `Button` in their own `<View>`.
87
+ //
88
+ // Collapsing the two nodes settles "which node does a class belong on?" by
89
+ // removing the choice: layout and visuals are one box here exactly as they are
90
+ // in the DOM, so a layout class and a visual class cannot land in different
91
+ // places. The touch target now scales with the press (0.97 — ~1px on a 40dp
92
+ // button, and the press is registered before it starts), the same trade
93
+ // `PressableScale` made when it dropped its own two-node structure.
94
+ //
95
+ // `styled()` comes from Bloom's own `react-native-css` dependency rather than
96
+ // passing `className` as a bare prop and hoping the consumer's NativeWind
97
+ // interop recognises a component Bloom built at module scope. Same reasoning,
98
+ // and the same silent failure if skipped, as `typography/styled-text.ts`.
99
+ //
100
+ // Both wrappers are built ONCE at module scope so the element type is stable
101
+ // across renders — one built during render remounts the subtree every time.
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Exactly the prop surface `Button` hands to the pressable, and no wider.
106
+ * `Animated.createAnimatedComponent` maps `WithAnimatedValue` over every prop of
107
+ * the component it wraps, and doing that to the whole of `PressableProps`
108
+ * overflows the checker (`TS2590: union type that is too complex to represent`).
109
+ * Narrowing is also honest: the function form of `style` is deliberately
110
+ * excluded — see the `useInteractionState` comment in the component body for why
111
+ * Bloom never uses it.
112
+ */
113
+ type ButtonPressableProps = Pick<
114
+ PressableProps,
115
+ | 'accessibilityHint'
116
+ | 'accessibilityLabel'
117
+ | 'accessibilityRole'
118
+ | 'accessibilityState'
119
+ | 'children'
120
+ | 'className'
121
+ | 'disabled'
122
+ | 'hitSlop'
123
+ | 'onPress'
124
+ | 'onPressIn'
125
+ | 'onPressOut'
126
+ | 'testID'
127
+ > & { style?: StyleProp<ViewStyle> };
128
+
129
+ /**
130
+ * The same `Pressable` object, typed down to {@link ButtonPressableProps}.
131
+ * `styled()` derives its mapping type from a dot-path union over the wrapped
132
+ * component's ENTIRE prop type, and the one over `PressableProps` also overflows
133
+ * the checker. Nothing changes at runtime — this is the identical component.
134
+ */
135
+ const ButtonPressable: ComponentType<ButtonPressableProps> = Pressable;
136
+
137
+ const StyledPressable: ComponentType<ButtonPressableProps> = styled(ButtonPressable, {
138
+ className: 'style',
139
+ });
140
+ const AnimatedPressable = Animated.createAnimatedComponent(StyledPressable);
141
+
72
142
  const ButtonComponent: React.FC<ButtonProps> = ({
73
143
  onPress,
74
144
  children,
@@ -170,6 +240,16 @@ const ButtonComponent: React.FC<ButtonProps> = ({
170
240
  styles.borderRadius = PILL_RADIUS;
171
241
  break;
172
242
  case 'icon':
243
+ // Resolved tokens, not `className="bg-background border border-border"`.
244
+ // The class form made the caller's `className` and the icon's own chrome
245
+ // compete for one slot — `<IconButton className="flex-1" />` replaced the
246
+ // default and shipped a transparent, borderless icon button — and it left
247
+ // the chrome dependent on the consumer's Tailwind pipeline resolving
248
+ // Bloom's color names. These are the SAME tokens `Button.web.tsx`'s
249
+ // `resolveVariantStyle('icon')` already writes inline.
250
+ styles.backgroundColor = theme.colors.background;
251
+ styles.borderWidth = 1;
252
+ styles.borderColor = theme.colors.border;
173
253
  styles.borderRadius = PILL_RADIUS;
174
254
  styles.padding = 8;
175
255
  styles.width = sizeConfig.minHeight;
@@ -220,7 +300,6 @@ const ButtonComponent: React.FC<ButtonProps> = ({
220
300
 
221
301
  const defaultHitSlop = variant === 'icon' ? ICON_HIT_SLOP : undefined;
222
302
  const resolvedActiveOpacity = activeOpacity ?? (variant === 'icon' ? 0.7 : 0.8);
223
- const resolvedClassName = className ?? (variant === 'icon' ? 'bg-background border border-border' : undefined);
224
303
 
225
304
  const content = (
226
305
  <>
@@ -233,44 +312,48 @@ const ButtonComponent: React.FC<ButtonProps> = ({
233
312
  );
234
313
 
235
314
  return (
236
- <Animated.View style={hasScaleFeedback ? { transform: [{ scale: scaleAnim }] } : undefined}>
237
- <Pressable
238
- {...(resolvedClassName ? { className: resolvedClassName } as Record<string, string> : {})}
239
- style={[
240
- baseStyles,
241
- disabled && !loading && { opacity: 0.5 },
242
- pressed && !hasScaleFeedback && !isInteractionBlocked && { opacity: resolvedActiveOpacity },
243
- style,
244
- ]}
245
- onPress={isInteractionBlocked ? undefined : onPress}
246
- onPressIn={handlePressIn}
247
- onPressOut={handlePressOut}
248
- disabled={isInteractionBlocked}
249
- hitSlop={hitSlop ?? defaultHitSlop}
250
- accessibilityLabel={accessibilityLabel}
251
- accessibilityHint={accessibilityHint}
252
- accessibilityRole="button"
253
- accessibilityState={loading ? { disabled: isInteractionBlocked, busy: true } : { disabled: isInteractionBlocked }}
254
- testID={testID}
255
- >
256
- {loading ? (
257
- <>
258
- <View
259
- style={styles.loadingHiddenContent}
260
- importantForAccessibility="no-hide-descendants"
261
- accessibilityElementsHidden
262
- >
263
- {content}
264
- </View>
265
- <View style={styles.loadingOverlay}>
266
- <ActivityIndicator size="small" color={loadingColor ?? resolvedTextColor} />
267
- </View>
268
- </>
269
- ) : (
270
- content
271
- )}
272
- </Pressable>
273
- </Animated.View>
315
+ <AnimatedPressable
316
+ className={className}
317
+ style={[
318
+ baseStyles,
319
+ disabled && !loading && { opacity: 0.5 },
320
+ pressed && !hasScaleFeedback && !isInteractionBlocked && { opacity: resolvedActiveOpacity },
321
+ // Before the caller's `style`, so `style` keeps winning the whole array
322
+ // the way it always has (and the way the web fork spreads it last). The
323
+ // cost is that a caller who sets `transform` on a scale variant replaces
324
+ // the press scale instead of composing with it — when the transform sat
325
+ // on a separate wrapper node the two multiplied.
326
+ hasScaleFeedback ? { transform: [{ scale: scaleAnim }] } : null,
327
+ style,
328
+ ]}
329
+ onPress={isInteractionBlocked ? undefined : onPress}
330
+ onPressIn={handlePressIn}
331
+ onPressOut={handlePressOut}
332
+ disabled={isInteractionBlocked}
333
+ hitSlop={hitSlop ?? defaultHitSlop}
334
+ accessibilityLabel={accessibilityLabel}
335
+ accessibilityHint={accessibilityHint}
336
+ accessibilityRole="button"
337
+ accessibilityState={loading ? { disabled: isInteractionBlocked, busy: true } : { disabled: isInteractionBlocked }}
338
+ testID={testID}
339
+ >
340
+ {loading ? (
341
+ <>
342
+ <View
343
+ style={styles.loadingHiddenContent}
344
+ importantForAccessibility="no-hide-descendants"
345
+ accessibilityElementsHidden
346
+ >
347
+ {content}
348
+ </View>
349
+ <View style={styles.loadingOverlay}>
350
+ <ActivityIndicator size="small" color={loadingColor ?? resolvedTextColor} />
351
+ </View>
352
+ </>
353
+ ) : (
354
+ content
355
+ )}
356
+ </AnimatedPressable>
274
357
  );
275
358
  };
276
359
 
@@ -18,9 +18,17 @@ import type { ButtonProps, ButtonSize, ButtonVariant } from './types';
18
18
  export type { ButtonProps, ButtonVariant, ButtonSize } from './types';
19
19
 
20
20
  // ---------------------------------------------------------------------------
21
- // Geometry — mirrors the native (`Button.tsx`) impl exactly so the web button
22
- // is pixel-consistent with native. Sizes below are the resolved primitives;
23
- // the shadcn-style aliases (`sm | md | lg | icon`) are normalized first.
21
+ // Geometry — mirrors the native (`Button.tsx`) impl so the web button is
22
+ // pixel-consistent with native. Sizes below are the resolved primitives; the
23
+ // shadcn-style aliases (`sm | md | lg | icon`) are normalized first.
24
+ //
25
+ // ONE deliberate exception, in `containerStyle`: `text`/`link` clear
26
+ // `minHeight` here so the control hugs its label and sits inline (which is what
27
+ // makes `asChild` anchors work), while native keeps the size config's value to
28
+ // preserve the touch target. The 4/8 padding override those variants get is NOT
29
+ // an exception — native applies exactly the same one, so `text` is a compact
30
+ // affordance and `ghost` a full-size button without a background on both
31
+ // platforms. Only the variant COLOR table below is shared between them.
24
32
  // ---------------------------------------------------------------------------
25
33
 
26
34
  type NativeSize = 'small' | 'medium' | 'large';
@@ -11,6 +11,14 @@ import type { StyleProp, ViewStyle, TextStyle } from 'react-native';
11
11
  * the set. On native they fall back to a sensible equivalent
12
12
  * (`outline → secondary`, `link → text`, `destructive → primary` tinted with
13
13
  * the negative token) so passing them never crashes a native consumer.
14
+ *
15
+ * `text` (and its web alias `link`) is DELIBERATELY not a transparent `ghost`:
16
+ * it is the compact inline affordance, so it overrides the size config's padding
17
+ * down to 4/8 on BOTH platforms. `ghost` keeps the full padding — it is a
18
+ * regular button that happens to have no background. The one geometry that does
19
+ * differ by platform is `minHeight`: web clears it for `text`/`link` so the
20
+ * control hugs its label and sits inline, native keeps the size config's value
21
+ * because a finger needs the target and a cursor does not.
14
22
  */
15
23
  export type ButtonVariant =
16
24
  | 'primary'
@@ -18,10 +18,9 @@ import { Pressable, StyleSheet, View } from 'react-native';
18
18
 
19
19
  import { useTheme } from '../theme/use-theme';
20
20
  import { Text } from '../typography';
21
- import { Backdrop } from '../overlay';
21
+ import { Backdrop, OverlayRoot } from '../overlay';
22
22
  import { Portal } from '../portal/index.web';
23
23
  import { useInteractionState } from '../hooks/useInteractionState';
24
- import { createOverlayZIndex } from '../styles/z-index';
25
24
  import { WEB_POSITION_FIXED } from '../styles/web-view-style';
26
25
  import {
27
26
  resolveDropdownPlacement,
@@ -40,7 +39,6 @@ import type {
40
39
  ItemContextValue,
41
40
  } from './types';
42
41
 
43
- const contextMenuZIndex = createOverlayZIndex();
44
42
  const VIEWPORT_GUTTER = 8;
45
43
 
46
44
  // ---------------------------------------------------------------------------
@@ -211,31 +209,38 @@ export function ContextMenuContent({ children, style }: ContextMenuContentProps)
211
209
 
212
210
  return (
213
211
  <Portal>
214
- <Backdrop
215
- style={styles.backdrop}
216
- onPress={close}
217
- accessibilityLabel="Close context menu"
218
- />
219
- <View
220
- ref={attachMenu}
221
- style={[
222
- styles.dropdown,
223
- {
224
- // The raw click point is the pre-measurement anchor; the layout
225
- // effect above replaces it before the browser paints.
226
- top: placement?.top ?? position.y,
227
- left: placement?.left ?? position.x,
228
- backgroundColor: theme.isDark
229
- ? theme.colors.backgroundSecondary
230
- : theme.colors.background,
231
- borderColor: theme.colors.borderLight,
232
- ...bloomShadowStyle('m'),
233
- },
234
- style,
235
- ]}
236
- >
237
- {children}
238
- </View>
212
+ {/* `OverlayRoot` takes this surface's place in the open-order overlay
213
+ stack, so it paints above anything opened before it (see
214
+ `src/overlay/stack.ts`). It is `box-none`, so the area outside the
215
+ panel stays click-through and the backdrop below still takes its own
216
+ presses. */}
217
+ <OverlayRoot>
218
+ <Backdrop
219
+ style={styles.backdrop}
220
+ onPress={close}
221
+ accessibilityLabel="Close context menu"
222
+ />
223
+ <View
224
+ ref={attachMenu}
225
+ style={[
226
+ styles.dropdown,
227
+ {
228
+ // The raw click point is the pre-measurement anchor; the layout
229
+ // effect above replaces it before the browser paints.
230
+ top: placement?.top ?? position.y,
231
+ left: placement?.left ?? position.x,
232
+ backgroundColor: theme.isDark
233
+ ? theme.colors.backgroundSecondary
234
+ : theme.colors.background,
235
+ borderColor: theme.colors.borderLight,
236
+ ...bloomShadowStyle('m'),
237
+ },
238
+ style,
239
+ ]}
240
+ >
241
+ {children}
242
+ </View>
243
+ </OverlayRoot>
239
244
  </Portal>
240
245
  );
241
246
  }
@@ -374,13 +379,11 @@ const styles = StyleSheet.create({
374
379
  left: 0,
375
380
  right: 0,
376
381
  bottom: 0,
377
- zIndex: contextMenuZIndex.backdrop,
378
382
  // Opt back in from the Portal root's `pointer-events: none`.
379
383
  pointerEvents: 'auto',
380
384
  },
381
385
  dropdown: {
382
386
  position: WEB_POSITION_FIXED,
383
- zIndex: contextMenuZIndex.surface,
384
387
  borderRadius: 8,
385
388
  padding: 4,
386
389
  borderWidth: 1,
@@ -26,8 +26,7 @@ import Animated, {
26
26
  } from 'react-native-reanimated';
27
27
 
28
28
  import { BottomSheet, type BottomSheetRef } from '../bottom-sheet';
29
- import { Backdrop } from '../overlay';
30
- import { Z_INDEX, Z_INDEX_LAYER_STEP } from '../styles/z-index';
29
+ import { Backdrop, OverlayRoot } from '../overlay';
31
30
  import { useTheme } from '../theme/use-theme';
32
31
  import { Context, useDialogControl } from './context';
33
32
  import { DialogBody } from './DialogContent';
@@ -117,7 +116,6 @@ function CenteredOrSideDialog({
117
116
  scrollable,
118
117
  morph,
119
118
  placement,
120
- layer,
121
119
  width = DEFAULT_SIDE_WIDTH,
122
120
  inset,
123
121
  dismissOnBackdrop = true,
@@ -297,7 +295,6 @@ function CenteredOrSideDialog({
297
295
  open={sideOpen}
298
296
  onDismiss={handleDismiss}
299
297
  side={placement}
300
- layer={layer}
301
298
  width={width}
302
299
  inset={inset}
303
300
  dismissOnBackdrop={dismissOnBackdrop}
@@ -393,7 +390,6 @@ function SideSheet({
393
390
  open,
394
391
  onDismiss,
395
392
  side,
396
- layer,
397
393
  width,
398
394
  inset,
399
395
  dismissOnBackdrop,
@@ -417,7 +413,6 @@ function SideSheet({
417
413
  open: boolean;
418
414
  onDismiss: () => void;
419
415
  side: 'left' | 'right';
420
- layer?: number;
421
416
  width: number;
422
417
  inset?: DialogInset;
423
418
  dismissOnBackdrop: boolean;
@@ -518,15 +513,10 @@ function SideSheet({
518
513
  if (!mounted) return null;
519
514
 
520
515
  return (
521
- <View
522
- pointerEvents="box-none"
523
- style={[
524
- sideStyles.root,
525
- // Per-layer offset so a side dialog stacked on top of another (surface
526
- // stack) paints above it. Layer 0 → offset 0 → unchanged.
527
- { zIndex: Z_INDEX.fullscreen + Z_INDEX_LAYER_STEP * (layer ?? 0) },
528
- containerStyle,
529
- ]}
516
+ // `OverlayRoot` takes this drawer's place in the open-order overlay stack.
517
+ // It mounts here, past the `mounted` guard, so the rank tracks OPENING.
518
+ <OverlayRoot
519
+ style={[sideStyles.root, containerStyle]}
530
520
  {...(containerClassName ? ({ className: containerClassName } as Record<string, string>) : {})}
531
521
  >
532
522
  <Backdrop
@@ -575,13 +565,14 @@ function SideSheet({
575
565
  <View style={{ padding: contentPadding }}>{children}</View>
576
566
  )}
577
567
  </Animated.View>
578
- </View>
568
+ </OverlayRoot>
579
569
  );
580
570
  }
581
571
 
582
572
  const sideStyles = StyleSheet.create({
583
- // `zIndex` is applied inline (`Z_INDEX.fullscreen + layer offset`) so a side
584
- // dialog stacked on top of another paints above it in the surface stack.
573
+ // No `zIndex` here: the drawer's depth relative to other surfaces is the
574
+ // overlay stack's decision, applied by `OverlayRoot` (see
575
+ // `src/overlay/stack.ts`).
585
576
  root: {
586
577
  ...StyleSheet.absoluteFill,
587
578
  },
@@ -25,7 +25,7 @@ import { RemoveScrollBar } from 'react-remove-scroll-bar';
25
25
 
26
26
  import { Backdrop, OverlayRoot } from '../overlay';
27
27
  import { Portal } from '../portal/index.web';
28
- import { createOverlayZIndex } from '../styles/z-index';
28
+ import { Z_INDEX } from '../styles/z-index';
29
29
  import { WEB_POSITION_FIXED, type WebCssStyle } from '../styles/web-view-style';
30
30
  import { bloomShadowStyle } from '../design-tokens/shadows';
31
31
  import { useTheme } from '../theme/use-theme';
@@ -170,7 +170,6 @@ function CenterOrSideDialog({
170
170
  actions,
171
171
  header,
172
172
  placement,
173
- layer,
174
173
  width = DEFAULT_SIDE_WIDTH,
175
174
  maxWidth = DEFAULT_CENTER_MAX_WIDTH,
176
175
  inset,
@@ -192,11 +191,6 @@ function CenterOrSideDialog({
192
191
  // stylesheet is present by the time the animated surface mounts).
193
192
  useDialogCss();
194
193
 
195
- // Per-layer overlay z-indices, so a dialog presented on top of another (via
196
- // the surface stack) paints above it. Defaults to layer 0 — the offset is 0,
197
- // so a lone dialog's z is byte-for-byte unchanged.
198
- const dialogZIndex = useMemo(() => createOverlayZIndex(layer ?? 0), [layer]);
199
-
200
194
  // Controlled mode is opt-in: when `open` is a boolean the host owns the
201
195
  // visible state; otherwise the legacy imperative `control` path drives it.
202
196
  const isControlled = controlledOpen !== undefined;
@@ -342,46 +336,51 @@ function CenterOrSideDialog({
342
336
  <Context.Provider value={context}>
343
337
  <ClosingContext.Provider value={isClosing}>
344
338
  <RemoveScrollBar />
345
- {/* The press target IS the full-viewport box, so it uses
346
- `Backdrop` (which opts back in from the Portal root's
347
- `pointer-events: none` via the `pointerEvents` PROP — the style
348
- form is dropped before it reaches the DOM, see `src/overlay`)
349
- and lays the panel out inside itself. */}
350
- <Backdrop
351
- onPress={() => close()}
352
- disabled={!dismissOnBackdrop}
353
- accessibilityLabel={label ? `Dismiss ${label}` : 'Dismiss dialog'}
354
- // The fade rides on the LAYERS, never on the press target: an
355
- // opacity animation on the blur's ancestor composites the group in
356
- // isolation and leaves `backdrop-filter` nothing to sample.
357
- progress={backdropFade}
358
- style={{
359
- position: WEB_POSITION_FIXED,
360
- zIndex: dialogZIndex.backdrop,
361
- alignItems: 'center',
362
- justifyContent: 'center',
363
- paddingHorizontal: 20,
364
- }}
365
- >
366
- <DialogPanel
367
- testID={testID}
368
- label={label}
369
- title={title}
370
- description={description}
371
- actions={actions}
372
- header={header}
373
- style={style}
374
- maxWidth={maxWidth}
375
- contentPadding={contentPadding}
376
- maxHeightRatio={maxHeightRatio}
377
- scrollable={scrollable}
378
- morph={morph}
379
- surfaceZIndex={dialogZIndex.surface}
380
- isClosing={isClosing}
339
+ {/* `OverlayRoot` takes this dialog's place in the open-order
340
+ overlay stack. It mounts here, inside the `isOpen` guard, which
341
+ is what makes the rank track OPENING rather than mounting — the
342
+ dialog is declared once at the app root and stays mounted for
343
+ the app's lifetime. */}
344
+ <OverlayRoot>
345
+ {/* The press target IS the full-viewport box, so it uses
346
+ `Backdrop` (which opts back in from the Portal root's
347
+ `pointer-events: none` via the `pointerEvents` PROP the style
348
+ form is dropped before it reaches the DOM, see `src/overlay`)
349
+ and lays the panel out inside itself. */}
350
+ <Backdrop
351
+ onPress={() => close()}
352
+ disabled={!dismissOnBackdrop}
353
+ accessibilityLabel={label ? `Dismiss ${label}` : 'Dismiss dialog'}
354
+ // The fade rides on the LAYERS, never on the press target: an
355
+ // opacity animation on the blur's ancestor composites the group in
356
+ // isolation and leaves `backdrop-filter` nothing to sample.
357
+ progress={backdropFade}
358
+ style={{
359
+ position: WEB_POSITION_FIXED,
360
+ alignItems: 'center',
361
+ justifyContent: 'center',
362
+ paddingHorizontal: 20,
363
+ }}
381
364
  >
382
- {children}
383
- </DialogPanel>
384
- </Backdrop>
365
+ <DialogPanel
366
+ testID={testID}
367
+ label={label}
368
+ title={title}
369
+ description={description}
370
+ actions={actions}
371
+ header={header}
372
+ style={style}
373
+ maxWidth={maxWidth}
374
+ contentPadding={contentPadding}
375
+ maxHeightRatio={maxHeightRatio}
376
+ scrollable={scrollable}
377
+ morph={morph}
378
+ isClosing={isClosing}
379
+ >
380
+ {children}
381
+ </DialogPanel>
382
+ </Backdrop>
383
+ </OverlayRoot>
385
384
  </ClosingContext.Provider>
386
385
  </Context.Provider>
387
386
  </Portal>
@@ -407,8 +406,6 @@ function CenterOrSideDialog({
407
406
  inset={inset}
408
407
  dismissOnBackdrop={dismissOnBackdrop}
409
408
  contentPadding={contentPadding}
410
- backdropZIndex={dialogZIndex.backdrop}
411
- surfaceZIndex={dialogZIndex.surface}
412
409
  onDismiss={close}
413
410
  panelStyle={panelStyle}
414
411
  panelClassName={panelClassName}
@@ -437,7 +434,6 @@ function DialogPanel({
437
434
  maxHeightRatio,
438
435
  scrollable,
439
436
  morph,
440
- surfaceZIndex,
441
437
  isClosing,
442
438
  children,
443
439
  }: {
@@ -453,7 +449,6 @@ function DialogPanel({
453
449
  maxHeightRatio?: number;
454
450
  scrollable?: boolean;
455
451
  morph?: boolean;
456
- surfaceZIndex: number;
457
452
  isClosing: boolean;
458
453
  children?: React.ReactNode;
459
454
  }) {
@@ -518,7 +513,12 @@ function DialogPanel({
518
513
  // Design-system overlay elevation (`shadow-m`) as a `boxShadow` — RN-Web
519
514
  // deprecated the `shadow*` style props.
520
515
  ...bloomShadowStyle('m'),
521
- zIndex: surfaceZIndex,
516
+ // Above this surface's OWN backdrop, and nothing more: `OverlayRoot`
517
+ // sets a z-index and a fixed position, so it is a stacking context and
518
+ // this value is scoped inside it. Where this dialog sits relative to
519
+ // OTHER surfaces is the overlay stack's decision, made once on
520
+ // `OverlayRoot` (see `src/overlay/stack.ts`) — never a number here.
521
+ zIndex: Z_INDEX.raised,
522
522
  },
523
523
  isClosing ? ZOOM_FADE_OUT : ZOOM_FADE_IN,
524
524
  // Drives `height` (and `maxWidth`) only while a morph is in flight; at
@@ -612,8 +612,6 @@ function SheetSurface({
612
612
  inset,
613
613
  dismissOnBackdrop,
614
614
  contentPadding,
615
- backdropZIndex,
616
- surfaceZIndex,
617
615
  onDismiss,
618
616
  panelStyle,
619
617
  panelClassName,
@@ -635,8 +633,6 @@ function SheetSurface({
635
633
  inset?: DialogInset;
636
634
  dismissOnBackdrop: boolean;
637
635
  contentPadding: number;
638
- backdropZIndex: number;
639
- surfaceZIndex: number;
640
636
  onDismiss: () => void;
641
637
  panelStyle?: StyleProp<ViewStyle>;
642
638
  panelClassName?: string;
@@ -714,7 +710,7 @@ function SheetSurface({
714
710
 
715
711
  return (
716
712
  <OverlayRoot
717
- style={[sheetStyles.root, { zIndex: backdropZIndex }, containerStyle]}
713
+ style={[sheetStyles.root, containerStyle]}
718
714
  {...(containerClassName ? ({ className: containerClassName } as Record<string, string>) : {})}
719
715
  >
720
716
  <Backdrop
@@ -744,7 +740,12 @@ function SheetSurface({
744
740
  sheetStyles.panel,
745
741
  {
746
742
  backgroundColor: theme.colors.background,
747
- zIndex: surfaceZIndex,
743
+ // Above this surface's OWN backdrop, and nothing more: `OverlayRoot`
744
+ // sets a z-index and a fixed position, so it is a stacking context and
745
+ // this value is scoped inside it. Where this dialog sits relative to
746
+ // OTHER surfaces is the overlay stack's decision, made once on
747
+ // `OverlayRoot` (see `src/overlay/stack.ts`) — never a number here.
748
+ zIndex: Z_INDEX.raised,
748
749
  pointerEvents: 'auto',
749
750
  // Design-system overlay elevation (`shadow-m`) as a `boxShadow`.
750
751
  ...bloomShadowStyle('m'),
@@ -866,9 +867,10 @@ export function AutoMountedDialog({
866
867
  // The annotation supplies the contextual type instead, so the literals narrow and
867
868
  // a genuine mistake still fails.
868
869
  const sheetStyles: Record<'root' | 'backdrop' | 'panel', ViewStyle> = {
869
- // `zIndex` for the root (backdrop) and panel (surface) is applied inline from
870
- // the per-layer `createOverlayZIndex(layer)` in `SheetSurface`, so a side
871
- // dialog stacked on top of another (surface stack) paints above it.
870
+ // No overlay `zIndex` here: `SheetSurface` renders an `OverlayRoot`, which
871
+ // assigns this drawer's depth from the open-order overlay stack, so a drawer
872
+ // opened on top of another surface paints above it (see
873
+ // `src/overlay/stack.ts`).
872
874
  root: {
873
875
  position: WEB_POSITION_FIXED,
874
876
  top: 0,
@@ -350,16 +350,6 @@ export type DialogProps = React.PropsWithChildren<{
350
350
  containerClassName?: string;
351
351
  /** Accessibility label, applied to the dialog role on web. */
352
352
  label?: string;
353
- /**
354
- * Stacking layer index within a coordinated surface stack. Multiplies
355
- * `Z_INDEX_LAYER_STEP` onto this dialog's overlay z-indices (backdrop +
356
- * panel) so a dialog presented on top of another paints above it. Defaults to
357
- * `0` — the single-dialog case, byte-for-byte unchanged. Set by the shared
358
- * surface stack (`src/surfaces`); direct consumers rarely need it. No-op for
359
- * the `bottom` placement, which already stacks in mount order (native RN
360
- * `<Modal>` / the web portal), so `layer` is harmless there.
361
- */
362
- layer?: number;
363
353
  }>;
364
354
 
365
355
  /**