@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.
- package/lib/commonjs/dialog/Dialog.js +12 -3
- package/lib/commonjs/dialog/Dialog.js.map +1 -1
- package/lib/commonjs/dialog/Dialog.web.js +23 -8
- package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
- package/lib/commonjs/index.js +29 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +73 -44
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/surfaces/SurfaceHost.js +189 -0
- package/lib/commonjs/surfaces/SurfaceHost.js.map +1 -0
- package/lib/commonjs/surfaces/api.js +83 -0
- package/lib/commonjs/surfaces/api.js.map +1 -0
- package/lib/commonjs/surfaces/index.js +82 -0
- package/lib/commonjs/surfaces/index.js.map +1 -0
- package/lib/commonjs/surfaces/index.web.js +77 -0
- package/lib/commonjs/surfaces/index.web.js.map +1 -0
- package/lib/commonjs/surfaces/prompts.js +154 -0
- package/lib/commonjs/surfaces/prompts.js.map +1 -0
- package/lib/commonjs/surfaces/surfaceStore.js +172 -0
- package/lib/commonjs/surfaces/surfaceStore.js.map +1 -0
- package/lib/commonjs/surfaces/types.js +6 -0
- package/lib/commonjs/surfaces/types.js.map +1 -0
- package/lib/commonjs/surfaces/useSurface.js +36 -0
- package/lib/commonjs/surfaces/useSurface.js.map +1 -0
- package/lib/commonjs/theme/BloomThemeProvider.js +34 -15
- package/lib/commonjs/theme/BloomThemeProvider.js.map +1 -1
- package/lib/commonjs/theme/ambient-store.js +153 -0
- package/lib/commonjs/theme/ambient-store.js.map +1 -0
- package/lib/commonjs/theme/index.js +13 -0
- package/lib/commonjs/theme/index.js.map +1 -1
- package/lib/module/dialog/Dialog.js +13 -4
- package/lib/module/dialog/Dialog.js.map +1 -1
- package/lib/module/dialog/Dialog.web.js +23 -8
- package/lib/module/dialog/Dialog.web.js.map +1 -1
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +5 -0
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/surfaces/SurfaceHost.js +184 -0
- package/lib/module/surfaces/SurfaceHost.js.map +1 -0
- package/lib/module/surfaces/api.js +48 -0
- package/lib/module/surfaces/api.js.map +1 -0
- package/lib/module/surfaces/index.js +24 -0
- package/lib/module/surfaces/index.js.map +1 -0
- package/lib/module/surfaces/index.web.js +19 -0
- package/lib/module/surfaces/index.web.js.map +1 -0
- package/lib/module/surfaces/prompts.js +148 -0
- package/lib/module/surfaces/prompts.js.map +1 -0
- package/lib/module/surfaces/surfaceStore.js +159 -0
- package/lib/module/surfaces/surfaceStore.js.map +1 -0
- package/lib/module/surfaces/types.js +4 -0
- package/lib/module/surfaces/types.js.map +1 -0
- package/lib/module/surfaces/useSurface.js +31 -0
- package/lib/module/surfaces/useSurface.js.map +1 -0
- package/lib/module/theme/BloomThemeProvider.js +34 -15
- package/lib/module/theme/BloomThemeProvider.js.map +1 -1
- package/lib/module/theme/ambient-store.js +146 -0
- package/lib/module/theme/ambient-store.js.map +1 -0
- package/lib/module/theme/index.js +1 -0
- package/lib/module/theme/index.js.map +1 -1
- package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/types.d.ts +10 -0
- package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +2 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.web.d.ts +2 -0
- package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/surfaces/SurfaceHost.d.ts +25 -0
- package/lib/typescript/commonjs/surfaces/SurfaceHost.d.ts.map +1 -0
- package/lib/typescript/commonjs/surfaces/api.d.ts +41 -0
- package/lib/typescript/commonjs/surfaces/api.d.ts.map +1 -0
- package/lib/typescript/commonjs/surfaces/index.d.ts +25 -0
- package/lib/typescript/commonjs/surfaces/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/surfaces/index.web.d.ts +20 -0
- package/lib/typescript/commonjs/surfaces/index.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/surfaces/prompts.d.ts +13 -0
- package/lib/typescript/commonjs/surfaces/prompts.d.ts.map +1 -0
- package/lib/typescript/commonjs/surfaces/surfaceStore.d.ts +51 -0
- package/lib/typescript/commonjs/surfaces/surfaceStore.d.ts.map +1 -0
- package/lib/typescript/commonjs/surfaces/types.d.ts +145 -0
- package/lib/typescript/commonjs/surfaces/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/surfaces/useSurface.d.ts +21 -0
- package/lib/typescript/commonjs/surfaces/useSurface.d.ts.map +1 -0
- package/lib/typescript/commonjs/theme/BloomThemeProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/ambient-store.d.ts +61 -0
- package/lib/typescript/commonjs/theme/ambient-store.d.ts.map +1 -0
- package/lib/typescript/commonjs/theme/index.d.ts +2 -0
- package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
- package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
- package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/module/dialog/types.d.ts +10 -0
- package/lib/typescript/module/dialog/types.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +2 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/index.web.d.ts +2 -0
- package/lib/typescript/module/index.web.d.ts.map +1 -1
- package/lib/typescript/module/surfaces/SurfaceHost.d.ts +25 -0
- package/lib/typescript/module/surfaces/SurfaceHost.d.ts.map +1 -0
- package/lib/typescript/module/surfaces/api.d.ts +41 -0
- package/lib/typescript/module/surfaces/api.d.ts.map +1 -0
- package/lib/typescript/module/surfaces/index.d.ts +25 -0
- package/lib/typescript/module/surfaces/index.d.ts.map +1 -0
- package/lib/typescript/module/surfaces/index.web.d.ts +20 -0
- package/lib/typescript/module/surfaces/index.web.d.ts.map +1 -0
- package/lib/typescript/module/surfaces/prompts.d.ts +13 -0
- package/lib/typescript/module/surfaces/prompts.d.ts.map +1 -0
- package/lib/typescript/module/surfaces/surfaceStore.d.ts +51 -0
- package/lib/typescript/module/surfaces/surfaceStore.d.ts.map +1 -0
- package/lib/typescript/module/surfaces/types.d.ts +145 -0
- package/lib/typescript/module/surfaces/types.d.ts.map +1 -0
- package/lib/typescript/module/surfaces/useSurface.d.ts +21 -0
- package/lib/typescript/module/surfaces/useSurface.d.ts.map +1 -0
- package/lib/typescript/module/theme/BloomThemeProvider.d.ts.map +1 -1
- package/lib/typescript/module/theme/ambient-store.d.ts +61 -0
- package/lib/typescript/module/theme/ambient-store.d.ts.map +1 -0
- package/lib/typescript/module/theme/index.d.ts +2 -0
- package/lib/typescript/module/theme/index.d.ts.map +1 -1
- package/package.json +17 -1
- package/src/__tests__/ambient-theme.test.tsx +136 -0
- package/src/__tests__/surfaceStore.test.ts +219 -0
- package/src/dialog/Dialog.tsx +14 -3
- package/src/dialog/Dialog.web.tsx +25 -6
- package/src/dialog/types.ts +10 -0
- package/src/index.ts +15 -0
- package/src/index.web.ts +15 -0
- package/src/surfaces/SurfaceHost.tsx +207 -0
- package/src/surfaces/api.ts +55 -0
- package/src/surfaces/index.ts +38 -0
- package/src/surfaces/index.web.ts +33 -0
- package/src/surfaces/prompts.tsx +178 -0
- package/src/surfaces/surfaceStore.ts +168 -0
- package/src/surfaces/types.ts +156 -0
- package/src/surfaces/useSurface.ts +33 -0
- package/src/theme/BloomThemeProvider.tsx +52 -13
- package/src/theme/ambient-store.ts +186 -0
- 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
|
+
});
|
package/src/dialog/Dialog.tsx
CHANGED
|
@@ -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={[
|
|
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:
|
|
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
|
-
{
|
|
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
|
|
package/src/dialog/types.ts
CHANGED
|
@@ -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
|
+
}
|