@oxyhq/bloom 0.44.1 → 0.46.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 (137) hide show
  1. package/lib/commonjs/dialog/Dialog.js +12 -3
  2. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  3. package/lib/commonjs/dialog/Dialog.web.js +23 -8
  4. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  5. package/lib/commonjs/index.js +29 -0
  6. package/lib/commonjs/index.js.map +1 -1
  7. package/lib/commonjs/index.web.js +73 -44
  8. package/lib/commonjs/index.web.js.map +1 -1
  9. package/lib/commonjs/surfaces/SurfaceHost.js +189 -0
  10. package/lib/commonjs/surfaces/SurfaceHost.js.map +1 -0
  11. package/lib/commonjs/surfaces/api.js +83 -0
  12. package/lib/commonjs/surfaces/api.js.map +1 -0
  13. package/lib/commonjs/surfaces/index.js +82 -0
  14. package/lib/commonjs/surfaces/index.js.map +1 -0
  15. package/lib/commonjs/surfaces/index.web.js +77 -0
  16. package/lib/commonjs/surfaces/index.web.js.map +1 -0
  17. package/lib/commonjs/surfaces/prompts.js +154 -0
  18. package/lib/commonjs/surfaces/prompts.js.map +1 -0
  19. package/lib/commonjs/surfaces/surfaceStore.js +172 -0
  20. package/lib/commonjs/surfaces/surfaceStore.js.map +1 -0
  21. package/lib/commonjs/surfaces/types.js +6 -0
  22. package/lib/commonjs/surfaces/types.js.map +1 -0
  23. package/lib/commonjs/surfaces/useSurface.js +36 -0
  24. package/lib/commonjs/surfaces/useSurface.js.map +1 -0
  25. package/lib/commonjs/theme/BloomThemeProvider.js +34 -15
  26. package/lib/commonjs/theme/BloomThemeProvider.js.map +1 -1
  27. package/lib/commonjs/theme/ambient-store.js +153 -0
  28. package/lib/commonjs/theme/ambient-store.js.map +1 -0
  29. package/lib/commonjs/theme/index.js +13 -0
  30. package/lib/commonjs/theme/index.js.map +1 -1
  31. package/lib/module/dialog/Dialog.js +13 -4
  32. package/lib/module/dialog/Dialog.js.map +1 -1
  33. package/lib/module/dialog/Dialog.web.js +23 -8
  34. package/lib/module/dialog/Dialog.web.js.map +1 -1
  35. package/lib/module/index.js +5 -0
  36. package/lib/module/index.js.map +1 -1
  37. package/lib/module/index.web.js +5 -0
  38. package/lib/module/index.web.js.map +1 -1
  39. package/lib/module/surfaces/SurfaceHost.js +184 -0
  40. package/lib/module/surfaces/SurfaceHost.js.map +1 -0
  41. package/lib/module/surfaces/api.js +48 -0
  42. package/lib/module/surfaces/api.js.map +1 -0
  43. package/lib/module/surfaces/index.js +24 -0
  44. package/lib/module/surfaces/index.js.map +1 -0
  45. package/lib/module/surfaces/index.web.js +19 -0
  46. package/lib/module/surfaces/index.web.js.map +1 -0
  47. package/lib/module/surfaces/prompts.js +148 -0
  48. package/lib/module/surfaces/prompts.js.map +1 -0
  49. package/lib/module/surfaces/surfaceStore.js +159 -0
  50. package/lib/module/surfaces/surfaceStore.js.map +1 -0
  51. package/lib/module/surfaces/types.js +4 -0
  52. package/lib/module/surfaces/types.js.map +1 -0
  53. package/lib/module/surfaces/useSurface.js +31 -0
  54. package/lib/module/surfaces/useSurface.js.map +1 -0
  55. package/lib/module/theme/BloomThemeProvider.js +34 -15
  56. package/lib/module/theme/BloomThemeProvider.js.map +1 -1
  57. package/lib/module/theme/ambient-store.js +146 -0
  58. package/lib/module/theme/ambient-store.js.map +1 -0
  59. package/lib/module/theme/index.js +1 -0
  60. package/lib/module/theme/index.js.map +1 -1
  61. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/dialog/types.d.ts +10 -0
  64. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/index.d.ts +2 -0
  66. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/index.web.d.ts +2 -0
  68. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/surfaces/SurfaceHost.d.ts +25 -0
  70. package/lib/typescript/commonjs/surfaces/SurfaceHost.d.ts.map +1 -0
  71. package/lib/typescript/commonjs/surfaces/api.d.ts +41 -0
  72. package/lib/typescript/commonjs/surfaces/api.d.ts.map +1 -0
  73. package/lib/typescript/commonjs/surfaces/index.d.ts +25 -0
  74. package/lib/typescript/commonjs/surfaces/index.d.ts.map +1 -0
  75. package/lib/typescript/commonjs/surfaces/index.web.d.ts +20 -0
  76. package/lib/typescript/commonjs/surfaces/index.web.d.ts.map +1 -0
  77. package/lib/typescript/commonjs/surfaces/prompts.d.ts +13 -0
  78. package/lib/typescript/commonjs/surfaces/prompts.d.ts.map +1 -0
  79. package/lib/typescript/commonjs/surfaces/surfaceStore.d.ts +51 -0
  80. package/lib/typescript/commonjs/surfaces/surfaceStore.d.ts.map +1 -0
  81. package/lib/typescript/commonjs/surfaces/types.d.ts +145 -0
  82. package/lib/typescript/commonjs/surfaces/types.d.ts.map +1 -0
  83. package/lib/typescript/commonjs/surfaces/useSurface.d.ts +21 -0
  84. package/lib/typescript/commonjs/surfaces/useSurface.d.ts.map +1 -0
  85. package/lib/typescript/commonjs/theme/BloomThemeProvider.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/theme/ambient-store.d.ts +61 -0
  87. package/lib/typescript/commonjs/theme/ambient-store.d.ts.map +1 -0
  88. package/lib/typescript/commonjs/theme/index.d.ts +2 -0
  89. package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
  90. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  91. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  92. package/lib/typescript/module/dialog/types.d.ts +10 -0
  93. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  94. package/lib/typescript/module/index.d.ts +2 -0
  95. package/lib/typescript/module/index.d.ts.map +1 -1
  96. package/lib/typescript/module/index.web.d.ts +2 -0
  97. package/lib/typescript/module/index.web.d.ts.map +1 -1
  98. package/lib/typescript/module/surfaces/SurfaceHost.d.ts +25 -0
  99. package/lib/typescript/module/surfaces/SurfaceHost.d.ts.map +1 -0
  100. package/lib/typescript/module/surfaces/api.d.ts +41 -0
  101. package/lib/typescript/module/surfaces/api.d.ts.map +1 -0
  102. package/lib/typescript/module/surfaces/index.d.ts +25 -0
  103. package/lib/typescript/module/surfaces/index.d.ts.map +1 -0
  104. package/lib/typescript/module/surfaces/index.web.d.ts +20 -0
  105. package/lib/typescript/module/surfaces/index.web.d.ts.map +1 -0
  106. package/lib/typescript/module/surfaces/prompts.d.ts +13 -0
  107. package/lib/typescript/module/surfaces/prompts.d.ts.map +1 -0
  108. package/lib/typescript/module/surfaces/surfaceStore.d.ts +51 -0
  109. package/lib/typescript/module/surfaces/surfaceStore.d.ts.map +1 -0
  110. package/lib/typescript/module/surfaces/types.d.ts +145 -0
  111. package/lib/typescript/module/surfaces/types.d.ts.map +1 -0
  112. package/lib/typescript/module/surfaces/useSurface.d.ts +21 -0
  113. package/lib/typescript/module/surfaces/useSurface.d.ts.map +1 -0
  114. package/lib/typescript/module/theme/BloomThemeProvider.d.ts.map +1 -1
  115. package/lib/typescript/module/theme/ambient-store.d.ts +61 -0
  116. package/lib/typescript/module/theme/ambient-store.d.ts.map +1 -0
  117. package/lib/typescript/module/theme/index.d.ts +2 -0
  118. package/lib/typescript/module/theme/index.d.ts.map +1 -1
  119. package/package.json +17 -1
  120. package/src/__tests__/ambient-theme.test.tsx +136 -0
  121. package/src/__tests__/surfaceStore.test.ts +219 -0
  122. package/src/dialog/Dialog.tsx +14 -3
  123. package/src/dialog/Dialog.web.tsx +25 -6
  124. package/src/dialog/types.ts +10 -0
  125. package/src/index.ts +15 -0
  126. package/src/index.web.ts +15 -0
  127. package/src/surfaces/SurfaceHost.tsx +207 -0
  128. package/src/surfaces/api.ts +55 -0
  129. package/src/surfaces/index.ts +38 -0
  130. package/src/surfaces/index.web.ts +33 -0
  131. package/src/surfaces/prompts.tsx +178 -0
  132. package/src/surfaces/surfaceStore.ts +168 -0
  133. package/src/surfaces/types.ts +156 -0
  134. package/src/surfaces/useSurface.ts +33 -0
  135. package/src/theme/BloomThemeProvider.tsx +52 -13
  136. package/src/theme/ambient-store.ts +186 -0
  137. package/src/theme/index.ts +7 -0
@@ -0,0 +1,219 @@
1
+ import {
2
+ __resetSurfacesForTests,
3
+ dismissAll,
4
+ dismissToRoot,
5
+ dismissTop,
6
+ finalizeClose,
7
+ getSnapshot,
8
+ present,
9
+ requestDismiss,
10
+ resetSurfaces,
11
+ subscribe,
12
+ } from '../surfaces/surfaceStore';
13
+ import { confirm, prompt, surfaces } from '../surfaces/api';
14
+
15
+ const noop = () => null;
16
+
17
+ /** Id of the current top surface (guarded so strict tsc is satisfied). */
18
+ function topId(): string {
19
+ const stack = getSnapshot();
20
+ const top = stack[stack.length - 1];
21
+ if (!top) throw new Error('expected a surface on the stack');
22
+ return top.id;
23
+ }
24
+
25
+ afterEach(() => {
26
+ // Resolve any lingering promises so awaiting tests never hang, then reset the
27
+ // module store + id counter for isolation.
28
+ resetSurfaces();
29
+ __resetSurfacesForTests();
30
+ });
31
+
32
+ describe('surfaceStore', () => {
33
+ it('present pushes an entry and resolves its promise on dismiss', async () => {
34
+ const p = present(noop);
35
+ const stack = getSnapshot();
36
+ expect(stack).toHaveLength(1);
37
+ expect(stack[0]?.status).toBe('open');
38
+ requestDismiss(topId(), 'hello');
39
+ await expect(p).resolves.toBe('hello');
40
+ });
41
+
42
+ it('stacks bottom→top; the last entry is the top', () => {
43
+ void present(noop);
44
+ void present(noop);
45
+ void present(noop);
46
+ expect(getSnapshot().map((e) => e.id)).toEqual([
47
+ 'bloom-surface-1',
48
+ 'bloom-surface-2',
49
+ 'bloom-surface-3',
50
+ ]);
51
+ });
52
+
53
+ it('resolves ONCE — a second dismiss with a different result is ignored', async () => {
54
+ const p = present(noop);
55
+ const id = topId();
56
+ requestDismiss(id, 'first');
57
+ requestDismiss(id, 'second');
58
+ await expect(p).resolves.toBe('first');
59
+ });
60
+
61
+ it('requestDismiss flips the entry to closing but does not splice it', () => {
62
+ void present(noop);
63
+ const id = topId();
64
+ requestDismiss(id, 1);
65
+ const stack = getSnapshot();
66
+ expect(stack).toHaveLength(1);
67
+ expect(stack[0]?.status).toBe('closing');
68
+ expect(stack[0]?.generation).toBe(1);
69
+ });
70
+
71
+ it('finalizeClose removes a CLOSING entry; is a no-op for an OPEN entry', async () => {
72
+ const p = present(noop);
73
+ const id = topId();
74
+ // Open → no-op.
75
+ finalizeClose(id);
76
+ expect(getSnapshot()).toHaveLength(1);
77
+ // Closing → removed.
78
+ requestDismiss(id, 'x');
79
+ finalizeClose(id);
80
+ expect(getSnapshot()).toHaveLength(0);
81
+ await expect(p).resolves.toBe('x');
82
+ });
83
+
84
+ it('dismissTop dismisses only the top surface', async () => {
85
+ const a = present(noop);
86
+ const b = present(noop);
87
+ dismissTop('B');
88
+ await expect(b).resolves.toBe('B');
89
+ const stack = getSnapshot();
90
+ expect(stack.find((e) => e.id === 'bloom-surface-1')?.status).toBe('open');
91
+ expect(stack.find((e) => e.id === 'bloom-surface-2')?.status).toBe('closing');
92
+ // Clean up the still-open root.
93
+ void a;
94
+ });
95
+
96
+ it('dismissToRoot resolves everything above the root with undefined, keeps the root', async () => {
97
+ const root = present(noop);
98
+ const mid = present(noop);
99
+ const top = present(noop);
100
+ dismissToRoot();
101
+ await expect(mid).resolves.toBeUndefined();
102
+ await expect(top).resolves.toBeUndefined();
103
+ const stack = getSnapshot();
104
+ expect(stack.find((e) => e.id === 'bloom-surface-1')?.status).toBe('open');
105
+ void root;
106
+ });
107
+
108
+ it('dismissToRoot is a no-op with 0 or 1 surfaces', async () => {
109
+ dismissToRoot();
110
+ expect(getSnapshot()).toHaveLength(0);
111
+ const only = present(noop);
112
+ dismissToRoot();
113
+ expect(getSnapshot()[0]?.status).toBe('open');
114
+ void only;
115
+ });
116
+
117
+ it('dismissAll resolves ALL pending awaiters with undefined', async () => {
118
+ const a = present(noop);
119
+ const b = present(noop);
120
+ const c = present(noop);
121
+ dismissAll();
122
+ await expect(a).resolves.toBeUndefined();
123
+ await expect(b).resolves.toBeUndefined();
124
+ await expect(c).resolves.toBeUndefined();
125
+ });
126
+
127
+ it('resetSurfaces resolves pending awaiters and clears the stack', async () => {
128
+ const a = present(noop);
129
+ const b = present(noop);
130
+ resetSurfaces();
131
+ await expect(a).resolves.toBeUndefined();
132
+ await expect(b).resolves.toBeUndefined();
133
+ expect(getSnapshot()).toHaveLength(0);
134
+ });
135
+
136
+ it('requestDismiss / finalizeClose ignore an unknown id', () => {
137
+ expect(() => requestDismiss('does-not-exist', 1)).not.toThrow();
138
+ expect(() => finalizeClose('does-not-exist')).not.toThrow();
139
+ });
140
+
141
+ it('getSnapshot returns a STABLE reference while unchanged', () => {
142
+ const s1 = getSnapshot();
143
+ expect(getSnapshot()).toBe(s1);
144
+ void present(noop);
145
+ const s2 = getSnapshot();
146
+ expect(s2).not.toBe(s1);
147
+ expect(getSnapshot()).toBe(s2);
148
+ });
149
+
150
+ it('notifies subscribers on each stack mutation', () => {
151
+ let calls = 0;
152
+ const unsub = subscribe(() => {
153
+ calls += 1;
154
+ });
155
+ void present(noop); // +1
156
+ dismissTop(); // requestDismiss emits +1
157
+ expect(calls).toBe(2);
158
+ unsub();
159
+ });
160
+ });
161
+
162
+ describe('surfaces imperative API', () => {
163
+ it('surfaces.present + surfaces.dismiss resolve with the top result', async () => {
164
+ const p = surfaces.present(noop);
165
+ surfaces.dismiss('done');
166
+ await expect(p).resolves.toBe('done');
167
+ });
168
+
169
+ it('surfaces.dismissById targets a specific surface', async () => {
170
+ const a = surfaces.present(noop);
171
+ const b = surfaces.present(noop);
172
+ const bottom = getSnapshot()[0];
173
+ if (!bottom) throw new Error('expected a surface on the stack');
174
+ surfaces.dismissById(bottom.id, 'A');
175
+ await expect(a).resolves.toBe('A');
176
+ void b;
177
+ });
178
+
179
+ it('surfaces.dismissAll clears the stack and resolves pending', async () => {
180
+ const a = surfaces.present(noop);
181
+ const b = surfaces.present(noop);
182
+ surfaces.dismissAll();
183
+ await expect(a).resolves.toBeUndefined();
184
+ await expect(b).resolves.toBeUndefined();
185
+ });
186
+ });
187
+
188
+ describe('confirm / prompt surfaces', () => {
189
+ it('confirm resolves true when dismissed with true, false otherwise', async () => {
190
+ const yes = confirm({ title: 'Delete?' });
191
+ requestDismiss(topId(), true);
192
+ await expect(yes).resolves.toBe(true);
193
+
194
+ const no = confirm({ title: 'Delete?' });
195
+ requestDismiss(topId(), false);
196
+ await expect(no).resolves.toBe(false);
197
+ });
198
+
199
+ it('confirm resolves false when dismissed without a result (backdrop/Escape)', async () => {
200
+ const c = confirm({ title: 'Proceed?' });
201
+ dismissTop(); // undefined → coerced to false
202
+ await expect(c).resolves.toBe(false);
203
+ });
204
+
205
+ it('confirm presents a centered surface', () => {
206
+ void confirm({ title: 'Hi' });
207
+ expect(getSnapshot()[0]?.presentation.placement).toBe('center');
208
+ });
209
+
210
+ it('prompt resolves the entered string, or null when dismissed without one', async () => {
211
+ const p = prompt({ title: 'Name?' });
212
+ requestDismiss(topId(), 'Ada');
213
+ await expect(p).resolves.toBe('Ada');
214
+
215
+ const cancelled = prompt({ title: 'Name?' });
216
+ dismissTop(); // undefined → coerced to null
217
+ await expect(cancelled).resolves.toBeNull();
218
+ });
219
+ });
@@ -25,7 +25,7 @@ import Animated, {
25
25
  } from 'react-native-reanimated';
26
26
 
27
27
  import { BottomSheet, type BottomSheetRef } from '../bottom-sheet';
28
- import { Z_INDEX } from '../styles/z-index';
28
+ import { Z_INDEX, Z_INDEX_LAYER_STEP } from '../styles/z-index';
29
29
  import { useTheme } from '../theme/use-theme';
30
30
  import { Context, useDialogControl } from './context';
31
31
  import { DialogBody } from './DialogContent';
@@ -104,6 +104,7 @@ function CenteredOrSideDialog({
104
104
  description,
105
105
  actions,
106
106
  placement,
107
+ layer,
107
108
  width = DEFAULT_SIDE_WIDTH,
108
109
  inset,
109
110
  dismissOnBackdrop = true,
@@ -241,6 +242,7 @@ function CenteredOrSideDialog({
241
242
  open={sideOpen}
242
243
  onDismiss={handleDismiss}
243
244
  side={placement}
245
+ layer={layer}
244
246
  width={width}
245
247
  inset={inset}
246
248
  dismissOnBackdrop={dismissOnBackdrop}
@@ -311,6 +313,7 @@ function SideSheet({
311
313
  open,
312
314
  onDismiss,
313
315
  side,
316
+ layer,
314
317
  width,
315
318
  inset,
316
319
  dismissOnBackdrop,
@@ -331,6 +334,7 @@ function SideSheet({
331
334
  open: boolean;
332
335
  onDismiss: () => void;
333
336
  side: 'left' | 'right';
337
+ layer?: number;
334
338
  width: number;
335
339
  inset?: DialogInset;
336
340
  dismissOnBackdrop: boolean;
@@ -433,7 +437,13 @@ function SideSheet({
433
437
 
434
438
  return (
435
439
  <View
436
- style={[sideStyles.root, containerStyle]}
440
+ style={[
441
+ sideStyles.root,
442
+ // Per-layer offset so a side dialog stacked on top of another (surface
443
+ // stack) paints above it. Layer 0 → offset 0 → unchanged.
444
+ { zIndex: Z_INDEX.fullscreen + Z_INDEX_LAYER_STEP * (layer ?? 0) },
445
+ containerStyle,
446
+ ]}
437
447
  {...(containerClassName ? ({ className: containerClassName } as Record<string, string>) : {})}
438
448
  pointerEvents="box-none"
439
449
  >
@@ -473,9 +483,10 @@ function SideSheet({
473
483
  }
474
484
 
475
485
  const sideStyles = StyleSheet.create({
486
+ // `zIndex` is applied inline (`Z_INDEX.fullscreen + layer offset`) so a side
487
+ // dialog stacked on top of another paints above it in the surface stack.
476
488
  root: {
477
489
  ...StyleSheet.absoluteFill,
478
- zIndex: Z_INDEX.fullscreen,
479
490
  },
480
491
  backdrop: {
481
492
  ...StyleSheet.absoluteFill,
@@ -44,7 +44,6 @@ import type {
44
44
  } from './types';
45
45
 
46
46
  const FADE_OUT_DURATION = CENTER_FADE_OUT_DURATION;
47
- const dialogZIndex = createOverlayZIndex();
48
47
 
49
48
  const stopPropagation = (e: { stopPropagation: () => void }) => e.stopPropagation();
50
49
 
@@ -132,6 +131,7 @@ function CenterOrSideDialog({
132
131
  description,
133
132
  actions,
134
133
  placement,
134
+ layer,
135
135
  width = DEFAULT_SIDE_WIDTH,
136
136
  maxWidth = DEFAULT_CENTER_MAX_WIDTH,
137
137
  inset,
@@ -150,6 +150,11 @@ function CenterOrSideDialog({
150
150
  // stylesheet is present by the time the animated surface mounts).
151
151
  useDialogCss();
152
152
 
153
+ // Per-layer overlay z-indices, so a dialog presented on top of another (via
154
+ // the surface stack) paints above it. Defaults to layer 0 — the offset is 0,
155
+ // so a lone dialog's z is byte-for-byte unchanged.
156
+ const dialogZIndex = useMemo(() => createOverlayZIndex(layer ?? 0), [layer]);
157
+
153
158
  // Controlled mode is opt-in: when `open` is a boolean the host owns the
154
159
  // visible state; otherwise the legacy imperative `control` path drives it.
155
160
  const isControlled = controlledOpen !== undefined;
@@ -297,6 +302,7 @@ function CenterOrSideDialog({
297
302
  style={style}
298
303
  maxWidth={maxWidth}
299
304
  contentPadding={contentPadding}
305
+ surfaceZIndex={dialogZIndex.surface}
300
306
  isClosing={isClosing}
301
307
  >
302
308
  {children}
@@ -325,6 +331,8 @@ function CenterOrSideDialog({
325
331
  inset={inset}
326
332
  dismissOnBackdrop={dismissOnBackdrop}
327
333
  contentPadding={contentPadding}
334
+ backdropZIndex={dialogZIndex.backdrop}
335
+ surfaceZIndex={dialogZIndex.surface}
328
336
  onDismiss={close}
329
337
  panelStyle={panelStyle}
330
338
  panelClassName={panelClassName}
@@ -349,6 +357,7 @@ function DialogPanel({
349
357
  style,
350
358
  maxWidth,
351
359
  contentPadding,
360
+ surfaceZIndex,
352
361
  isClosing,
353
362
  children,
354
363
  }: {
@@ -360,6 +369,7 @@ function DialogPanel({
360
369
  style?: DialogProps['style'];
361
370
  maxWidth: number;
362
371
  contentPadding: number;
372
+ surfaceZIndex: number;
363
373
  isClosing: boolean;
364
374
  children?: React.ReactNode;
365
375
  }) {
@@ -391,7 +401,7 @@ function DialogPanel({
391
401
  shadowRadius: 30,
392
402
  shadowOffset: { width: 0, height: 4 },
393
403
  padding: contentPadding,
394
- zIndex: dialogZIndex.surface,
404
+ zIndex: surfaceZIndex,
395
405
  },
396
406
  isClosing
397
407
  ? ({ animation: `bloomDialogZoomFadeOut ease-in ${FADE_OUT_DURATION}ms forwards` } as ViewStyle)
@@ -435,6 +445,8 @@ function SheetSurface({
435
445
  inset,
436
446
  dismissOnBackdrop,
437
447
  contentPadding,
448
+ backdropZIndex,
449
+ surfaceZIndex,
438
450
  onDismiss,
439
451
  panelStyle,
440
452
  panelClassName,
@@ -454,6 +466,8 @@ function SheetSurface({
454
466
  inset?: DialogInset;
455
467
  dismissOnBackdrop: boolean;
456
468
  contentPadding: number;
469
+ backdropZIndex: number;
470
+ surfaceZIndex: number;
457
471
  onDismiss: () => void;
458
472
  panelStyle?: StyleProp<ViewStyle>;
459
473
  panelClassName?: string;
@@ -532,7 +546,7 @@ function SheetSurface({
532
546
 
533
547
  return (
534
548
  <View
535
- style={[sheetStyles.root, containerStyle]}
549
+ style={[sheetStyles.root, { zIndex: backdropZIndex }, containerStyle]}
536
550
  {...(containerClassName ? ({ className: containerClassName } as Record<string, string>) : {})}
537
551
  pointerEvents="box-none"
538
552
  >
@@ -562,7 +576,11 @@ function SheetSurface({
562
576
  {...(panelClassName ? ({ className: panelClassName } as Record<string, string>) : {})}
563
577
  style={[
564
578
  sheetStyles.panel,
565
- { backgroundColor: theme.colors.background, shadowColor: theme.colors.shadow },
579
+ {
580
+ backgroundColor: theme.colors.background,
581
+ shadowColor: theme.colors.shadow,
582
+ zIndex: surfaceZIndex,
583
+ },
566
584
  panelGeometry,
567
585
  panelTransition,
568
586
  panelStyle,
@@ -659,13 +677,15 @@ export function AutoMountedDialog({
659
677
  }
660
678
 
661
679
  const sheetStyles = {
680
+ // `zIndex` for the root (backdrop) and panel (surface) is applied inline from
681
+ // the per-layer `createOverlayZIndex(layer)` in `SheetSurface`, so a side
682
+ // dialog stacked on top of another (surface stack) paints above it.
662
683
  root: {
663
684
  position: 'fixed' as 'absolute',
664
685
  top: 0,
665
686
  left: 0,
666
687
  right: 0,
667
688
  bottom: 0,
668
- zIndex: dialogZIndex.backdrop,
669
689
  } as ViewStyle,
670
690
  backdrop: {
671
691
  position: 'absolute',
@@ -681,7 +701,6 @@ const sheetStyles = {
681
701
  shadowOpacity: 0.18,
682
702
  shadowRadius: 24,
683
703
  shadowOffset: { width: 0, height: 8 },
684
- zIndex: dialogZIndex.surface,
685
704
  } as ViewStyle,
686
705
  };
687
706
 
@@ -163,6 +163,16 @@ export type DialogProps = React.PropsWithChildren<{
163
163
  containerClassName?: string;
164
164
  /** Accessibility label, applied to the dialog role on web. */
165
165
  label?: string;
166
+ /**
167
+ * Stacking layer index within a coordinated surface stack. Multiplies
168
+ * `Z_INDEX_LAYER_STEP` onto this dialog's overlay z-indices (backdrop +
169
+ * panel) so a dialog presented on top of another paints above it. Defaults to
170
+ * `0` — the single-dialog case, byte-for-byte unchanged. Set by the shared
171
+ * surface stack (`src/surfaces`); direct consumers rarely need it. No-op for
172
+ * the `bottom` placement, which already stacks in mount order (native RN
173
+ * `<Modal>` / the web portal), so `layer` is harmless there.
174
+ */
175
+ layer?: number;
166
176
  }>;
167
177
 
168
178
  /**
package/src/index.ts CHANGED
@@ -72,6 +72,21 @@ export type {
72
72
  DialogProps,
73
73
  ResponsiveDialogPlacement,
74
74
  } from './dialog';
75
+ // Surface stack — the shared, coordinated overlay system. The imperative API is
76
+ // the `surfaces` object (its members would otherwise clash with alert-dialog's
77
+ // `confirm`); the individual functions are also named exports of
78
+ // `@oxyhq/bloom/surfaces`.
79
+ export { surfaces, SurfaceProvider, SurfaceHost, useSurface } from './surfaces';
80
+ export type {
81
+ SurfacePresentation,
82
+ PresentOptions,
83
+ SurfaceControls,
84
+ SurfaceRenderFn,
85
+ SurfaceStatus,
86
+ SurfaceEntry,
87
+ SurfaceConfirmOptions,
88
+ SurfacePromptOptions,
89
+ } from './surfaces';
75
90
  export * from './button';
76
91
  export { Fab } from './fab';
77
92
  export type { FabProps, FabVariant, FabSize, FabPlacement } from './fab';
package/src/index.web.ts CHANGED
@@ -77,6 +77,21 @@ export type {
77
77
  DialogProps,
78
78
  ResponsiveDialogPlacement,
79
79
  } from './dialog/index.web';
80
+ // Surface stack — the shared, coordinated overlay system. The imperative API is
81
+ // the `surfaces` object (its members would otherwise clash with alert-dialog's
82
+ // `confirm`); the individual functions are also named exports of
83
+ // `@oxyhq/bloom/surfaces`.
84
+ export { surfaces, SurfaceProvider, SurfaceHost, useSurface } from './surfaces/index.web';
85
+ export type {
86
+ SurfacePresentation,
87
+ PresentOptions,
88
+ SurfaceControls,
89
+ SurfaceRenderFn,
90
+ SurfaceStatus,
91
+ SurfaceEntry,
92
+ SurfaceConfirmOptions,
93
+ SurfacePromptOptions,
94
+ } from './surfaces/index.web';
80
95
  export * from './button/index.web';
81
96
  export { Fab } from './fab/index.web';
82
97
  export type { FabProps, FabVariant, FabSize, FabPlacement } from './fab/index.web';
@@ -0,0 +1,207 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useSyncExternalStore,
6
+ } from 'react';
7
+ import { BackHandler, Platform } from 'react-native';
8
+
9
+ import { useDialogControl } from '../dialog/context';
10
+ import type { DialogProps } from '../dialog/types';
11
+ import {
12
+ finalizeClose,
13
+ getSnapshot,
14
+ present,
15
+ requestDismiss,
16
+ resetSurfaces,
17
+ subscribe,
18
+ } from './surfaceStore';
19
+ import { SurfaceContext } from './useSurface';
20
+ import type { SurfaceControls, SurfaceEntry, SurfacePresentation } from './types';
21
+
22
+ type DialogComponent = React.ComponentType<DialogProps>;
23
+
24
+ /** Default placement — the ecosystem's responsive "sheet" (bottom → centered). */
25
+ const DEFAULT_PLACEMENT: SurfacePresentation['placement'] = {
26
+ base: 'bottom',
27
+ md: 'center',
28
+ };
29
+
30
+ /** Map a surface's presentation config onto the shared `Dialog` props. */
31
+ function placementFor(p: SurfacePresentation): Partial<DialogProps> {
32
+ return {
33
+ placement: p.placement ?? DEFAULT_PLACEMENT,
34
+ width: p.width,
35
+ maxWidth: p.maxWidth,
36
+ maxHeightRatio: p.maxHeightRatio,
37
+ inset: p.inset,
38
+ showHandle: p.showHandle,
39
+ dismissOnBackdrop: p.dismissOnBackdrop,
40
+ contentPadding: p.contentPadding,
41
+ style: p.style,
42
+ panelStyle: p.panelStyle,
43
+ panelClassName: p.panelClassName,
44
+ containerStyle: p.containerStyle,
45
+ containerClassName: p.containerClassName,
46
+ label: p.label,
47
+ testID: p.testID,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Build the `<SurfaceHost>` + `<SurfaceProvider>` bound to a platform `Dialog`.
53
+ *
54
+ * `Dialog` is platform-forked (native `BottomSheet`-backed vs. pure-DOM web
55
+ * overlay), so — like `createAlertDialog` — the platform entry files
56
+ * (`index.ts` / `index.web.ts`) inject the correct one. The stack logic below is
57
+ * single-source, no duplication.
58
+ */
59
+ export function createSurfaceHost(Dialog: DialogComponent) {
60
+ /**
61
+ * One stacked surface. Bridges a store entry onto an IMPERATIVE `Dialog`
62
+ * (`useDialogControl()` + `open()`/`close()`) — never the controlled
63
+ * `open`/`onClose` boolean path, which fires `onClose` synchronously as the
64
+ * dismiss REQUEST and has no post-exit callback, so it could never tell the
65
+ * store WHEN to splice the entry after the exit animation.
66
+ */
67
+ function SurfaceLayer({
68
+ entry,
69
+ index,
70
+ }: {
71
+ entry: SurfaceEntry;
72
+ index: number;
73
+ }) {
74
+ const control = useDialogControl();
75
+ const { id, status } = entry;
76
+
77
+ const controls = useMemo<SurfaceControls>(
78
+ () => ({
79
+ dismiss: (result?: unknown) => requestDismiss(id, result),
80
+ present: (render, opts) => present(render, opts),
81
+ }),
82
+ [id],
83
+ );
84
+
85
+ // Open imperatively on mount (mirrors `AlertDialog`'s canonical
86
+ // fresh-mount open-from-effect; `control` is stable for the layer's life).
87
+ useEffect(() => {
88
+ control.open();
89
+ }, [control]);
90
+
91
+ // When the store flips this entry to 'closing' (a programmatic dismiss,
92
+ // dismissAll, dismissToRoot, Escape, or Android-back), run the exit
93
+ // animation. A user backdrop/Escape handled by the Dialog itself instead
94
+ // drives its own internal close; either way `onClose` fires post-exit.
95
+ useEffect(() => {
96
+ if (status === 'closing') control.close();
97
+ }, [status, control]);
98
+
99
+ // Fires AFTER the exit animation settles (imperative mode), for ANY
100
+ // dismissal path — user backdrop/Escape/back OR a programmatic dismiss.
101
+ // `requestDismiss` resolves-once (a programmatic dismiss already resolved
102
+ // with its result; this is a no-op then), then `finalizeClose` splices the
103
+ // entry out. Guarded by unique ids + `'closing'` status in the store.
104
+ const handleClose = useCallback(() => {
105
+ requestDismiss(id);
106
+ finalizeClose(id);
107
+ }, [id]);
108
+
109
+ const placement = useMemo(
110
+ () => placementFor(entry.presentation),
111
+ [entry.presentation],
112
+ );
113
+
114
+ return (
115
+ <Dialog control={control} onClose={handleClose} layer={index} {...placement}>
116
+ <SurfaceContext.Provider value={controls}>
117
+ {entry.render(controls)}
118
+ </SurfaceContext.Provider>
119
+ </Dialog>
120
+ );
121
+ }
122
+
123
+ function SurfaceHost() {
124
+ const stack = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
125
+
126
+ useAndroidBackDismissesTop();
127
+ useEscapeDismissesTop();
128
+
129
+ return (
130
+ <>
131
+ {stack.map((entry, index) => (
132
+ <SurfaceLayer key={entry.id} entry={entry} index={index} />
133
+ ))}
134
+ </>
135
+ );
136
+ }
137
+ SurfaceHost.displayName = 'SurfaceHost';
138
+
139
+ /**
140
+ * Mount ONCE near your app root — renders `children` plus the stack host.
141
+ * Requires the Bloom Portal provider to be mounted too (same as any `<Dialog>`
142
+ * usage): on web the Portal auto-creates `#bloom-portal-root`; on native mount
143
+ * `<Provider>`/`<Outlet>` from `@oxyhq/bloom/portal`. Can equally be folded
144
+ * next to that Portal provider — `<SurfaceHost>` used directly does the same
145
+ * job without wrapping `children`.
146
+ */
147
+ function SurfaceProvider({ children }: { children: React.ReactNode }) {
148
+ // Resolve any pending awaiters if the provider unmounts so no `present()`
149
+ // promise is stranded.
150
+ useEffect(() => () => resetSurfaces(), []);
151
+ return (
152
+ <>
153
+ {children}
154
+ <SurfaceHost />
155
+ </>
156
+ );
157
+ }
158
+ SurfaceProvider.displayName = 'SurfaceProvider';
159
+
160
+ return { SurfaceHost, SurfaceProvider };
161
+ }
162
+
163
+ /**
164
+ * ONE Android hardware-back handler for the whole stack → dismiss the TOP
165
+ * surface. Reads the live top from the store so a single registration always
166
+ * targets the current top. Returns `false` when the stack is empty so app-level
167
+ * back navigation is untouched.
168
+ */
169
+ function useAndroidBackDismissesTop(): void {
170
+ useEffect(() => {
171
+ if (Platform.OS !== 'android') return;
172
+ const sub = BackHandler.addEventListener('hardwareBackPress', () => {
173
+ const stack = getSnapshot();
174
+ const top = stack[stack.length - 1];
175
+ if (!top) return false;
176
+ if (top.presentation.dismissOnBackdrop === false) return true;
177
+ requestDismiss(top.id);
178
+ return true;
179
+ });
180
+ return () => sub.remove();
181
+ }, []);
182
+ }
183
+
184
+ /**
185
+ * Web-only: route Escape to the TOP surface only. A window-CAPTURE listener runs
186
+ * before each `Dialog`'s own document-level keydown, so `stopImmediatePropagation`
187
+ * prevents the lower stacked dialogs from ALSO handling Escape (they each add a
188
+ * document listener) — Escape dismisses strictly the top, LIFO. No-op on native.
189
+ */
190
+ function useEscapeDismissesTop(): void {
191
+ useEffect(() => {
192
+ if (Platform.OS !== 'web') return;
193
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
194
+ const handler = (e: KeyboardEvent) => {
195
+ if (e.key !== 'Escape') return;
196
+ const stack = getSnapshot();
197
+ const top = stack[stack.length - 1];
198
+ if (!top) return;
199
+ if (top.presentation.dismissOnBackdrop === false) return;
200
+ e.stopImmediatePropagation();
201
+ e.preventDefault();
202
+ requestDismiss(top.id);
203
+ };
204
+ window.addEventListener('keydown', handler, true);
205
+ return () => window.removeEventListener('keydown', handler, true);
206
+ }, []);
207
+ }