@oxyhq/bloom 0.45.0 → 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 (111) 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/module/dialog/Dialog.js +13 -4
  26. package/lib/module/dialog/Dialog.js.map +1 -1
  27. package/lib/module/dialog/Dialog.web.js +23 -8
  28. package/lib/module/dialog/Dialog.web.js.map +1 -1
  29. package/lib/module/index.js +5 -0
  30. package/lib/module/index.js.map +1 -1
  31. package/lib/module/index.web.js +5 -0
  32. package/lib/module/index.web.js.map +1 -1
  33. package/lib/module/surfaces/SurfaceHost.js +184 -0
  34. package/lib/module/surfaces/SurfaceHost.js.map +1 -0
  35. package/lib/module/surfaces/api.js +48 -0
  36. package/lib/module/surfaces/api.js.map +1 -0
  37. package/lib/module/surfaces/index.js +24 -0
  38. package/lib/module/surfaces/index.js.map +1 -0
  39. package/lib/module/surfaces/index.web.js +19 -0
  40. package/lib/module/surfaces/index.web.js.map +1 -0
  41. package/lib/module/surfaces/prompts.js +148 -0
  42. package/lib/module/surfaces/prompts.js.map +1 -0
  43. package/lib/module/surfaces/surfaceStore.js +159 -0
  44. package/lib/module/surfaces/surfaceStore.js.map +1 -0
  45. package/lib/module/surfaces/types.js +4 -0
  46. package/lib/module/surfaces/types.js.map +1 -0
  47. package/lib/module/surfaces/useSurface.js +31 -0
  48. package/lib/module/surfaces/useSurface.js.map +1 -0
  49. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/dialog/types.d.ts +10 -0
  52. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/index.d.ts +2 -0
  54. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/index.web.d.ts +2 -0
  56. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/surfaces/SurfaceHost.d.ts +25 -0
  58. package/lib/typescript/commonjs/surfaces/SurfaceHost.d.ts.map +1 -0
  59. package/lib/typescript/commonjs/surfaces/api.d.ts +41 -0
  60. package/lib/typescript/commonjs/surfaces/api.d.ts.map +1 -0
  61. package/lib/typescript/commonjs/surfaces/index.d.ts +25 -0
  62. package/lib/typescript/commonjs/surfaces/index.d.ts.map +1 -0
  63. package/lib/typescript/commonjs/surfaces/index.web.d.ts +20 -0
  64. package/lib/typescript/commonjs/surfaces/index.web.d.ts.map +1 -0
  65. package/lib/typescript/commonjs/surfaces/prompts.d.ts +13 -0
  66. package/lib/typescript/commonjs/surfaces/prompts.d.ts.map +1 -0
  67. package/lib/typescript/commonjs/surfaces/surfaceStore.d.ts +51 -0
  68. package/lib/typescript/commonjs/surfaces/surfaceStore.d.ts.map +1 -0
  69. package/lib/typescript/commonjs/surfaces/types.d.ts +145 -0
  70. package/lib/typescript/commonjs/surfaces/types.d.ts.map +1 -0
  71. package/lib/typescript/commonjs/surfaces/useSurface.d.ts +21 -0
  72. package/lib/typescript/commonjs/surfaces/useSurface.d.ts.map +1 -0
  73. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  74. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  75. package/lib/typescript/module/dialog/types.d.ts +10 -0
  76. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  77. package/lib/typescript/module/index.d.ts +2 -0
  78. package/lib/typescript/module/index.d.ts.map +1 -1
  79. package/lib/typescript/module/index.web.d.ts +2 -0
  80. package/lib/typescript/module/index.web.d.ts.map +1 -1
  81. package/lib/typescript/module/surfaces/SurfaceHost.d.ts +25 -0
  82. package/lib/typescript/module/surfaces/SurfaceHost.d.ts.map +1 -0
  83. package/lib/typescript/module/surfaces/api.d.ts +41 -0
  84. package/lib/typescript/module/surfaces/api.d.ts.map +1 -0
  85. package/lib/typescript/module/surfaces/index.d.ts +25 -0
  86. package/lib/typescript/module/surfaces/index.d.ts.map +1 -0
  87. package/lib/typescript/module/surfaces/index.web.d.ts +20 -0
  88. package/lib/typescript/module/surfaces/index.web.d.ts.map +1 -0
  89. package/lib/typescript/module/surfaces/prompts.d.ts +13 -0
  90. package/lib/typescript/module/surfaces/prompts.d.ts.map +1 -0
  91. package/lib/typescript/module/surfaces/surfaceStore.d.ts +51 -0
  92. package/lib/typescript/module/surfaces/surfaceStore.d.ts.map +1 -0
  93. package/lib/typescript/module/surfaces/types.d.ts +145 -0
  94. package/lib/typescript/module/surfaces/types.d.ts.map +1 -0
  95. package/lib/typescript/module/surfaces/useSurface.d.ts +21 -0
  96. package/lib/typescript/module/surfaces/useSurface.d.ts.map +1 -0
  97. package/package.json +17 -1
  98. package/src/__tests__/surfaceStore.test.ts +219 -0
  99. package/src/dialog/Dialog.tsx +14 -3
  100. package/src/dialog/Dialog.web.tsx +25 -6
  101. package/src/dialog/types.ts +10 -0
  102. package/src/index.ts +15 -0
  103. package/src/index.web.ts +15 -0
  104. package/src/surfaces/SurfaceHost.tsx +207 -0
  105. package/src/surfaces/api.ts +55 -0
  106. package/src/surfaces/index.ts +38 -0
  107. package/src/surfaces/index.web.ts +33 -0
  108. package/src/surfaces/prompts.tsx +178 -0
  109. package/src/surfaces/surfaceStore.ts +168 -0
  110. package/src/surfaces/types.ts +156 -0
  111. package/src/surfaces/useSurface.ts +33 -0
@@ -0,0 +1,145 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
3
+ import type { DialogInset, ResponsiveDialogPlacement } from '../dialog/types';
4
+ /**
5
+ * How a surface renders — the placement + the `Dialog` sizing/chrome options a
6
+ * surface needs. This is the CONTENT-AGNOSTIC presentation config: it never
7
+ * references a route or screen name (Bloom's stack knows nothing about routes;
8
+ * the SDK layers its route registry on top).
9
+ *
10
+ * The values map 1:1 onto the shared {@link ../dialog/types#DialogProps}, so a
11
+ * surface reads/behaves identically to a hand-mounted `<Dialog>` with the same
12
+ * props — only stacked/coordinated by {@link SurfaceHost}.
13
+ */
14
+ export interface SurfacePresentation {
15
+ /**
16
+ * Surface anchor. Defaults to `{ base: 'bottom', md: 'center' }` — a bottom
17
+ * sheet on narrow viewports that becomes a centered modal on wide ones (the
18
+ * ecosystem's canonical responsive "sheet"). Pass `'center'` for a plain
19
+ * modal, `'left'`/`'right'` for a drawer, or any responsive placement map.
20
+ */
21
+ placement?: ResponsiveDialogPlacement;
22
+ /** Side-sheet width (px) on wide screens. Forwarded to `Dialog`. */
23
+ width?: number;
24
+ /** Centered-card max width (px). Forwarded to `Dialog`. */
25
+ maxWidth?: number;
26
+ /** Bottom-sheet max height as a fraction of the viewport height. */
27
+ maxHeightRatio?: number;
28
+ /** Side-sheet inset (px) from the root overlay's edges. */
29
+ inset?: DialogInset;
30
+ /** Whether to render the drag handle in bottom-sheet mode. */
31
+ showHandle?: boolean;
32
+ /**
33
+ * Whether tapping the backdrop / pressing Escape / Android-back dismisses the
34
+ * surface. Defaults to `true`. Set `false` for a blocking surface that must be
35
+ * dismissed programmatically (e.g. an unanswered required prompt).
36
+ */
37
+ dismissOnBackdrop?: boolean;
38
+ /** Inner padding (px) of the surface content container. Forwarded to `Dialog`. */
39
+ contentPadding?: number;
40
+ /** Style overrides applied to the inner content container. */
41
+ style?: StyleProp<ViewStyle>;
42
+ /** Style overrides for the side/bottom placement panel surface. */
43
+ panelStyle?: StyleProp<ViewStyle>;
44
+ /** NativeWind classes for the side/bottom placement panel surface. */
45
+ panelClassName?: string;
46
+ /** Style for the root overlay (theme-var scope, rail offset, etc.). */
47
+ containerStyle?: StyleProp<ViewStyle>;
48
+ /** NativeWind classes for the root overlay. */
49
+ containerClassName?: string;
50
+ /** Accessibility label applied to the surface's dialog role. */
51
+ label?: string;
52
+ /** Stable testID forwarded to the underlying `Dialog`. */
53
+ testID?: string;
54
+ }
55
+ /** Options accepted by `present` / `SurfaceControls.present`. */
56
+ export type PresentOptions = SurfacePresentation;
57
+ /**
58
+ * Controls handed to a presented surface — both as the argument to its render
59
+ * function AND via {@link ../surfaces/useSurface#useSurface}. This is the DEPTH
60
+ * axis only: a surface can dismiss ITSELF (resolving its own `present()`
61
+ * promise) or `present` a CHILD surface stacked above it. Navigation WITHIN a
62
+ * surface (route history / drill-in) is deliberately NOT here — that is the
63
+ * SDK's route layer, which composes on top of this stack.
64
+ */
65
+ export interface SurfaceControls {
66
+ /**
67
+ * Dismiss THIS surface, resolving the promise returned by the `present` call
68
+ * that created it with `result` (or `undefined`). Resolves immediately; the
69
+ * exit animation is cosmetic.
70
+ */
71
+ dismiss: (result?: unknown) => void;
72
+ /**
73
+ * Stack a CHILD surface directly above this one. Resolves with the child's
74
+ * dismissal result. The child paints above (higher layer) and captures
75
+ * backdrop/Escape/back until it is dismissed.
76
+ */
77
+ present: <Result = unknown>(render: SurfaceRenderFn, opts?: PresentOptions) => Promise<Result>;
78
+ }
79
+ /** A surface's content factory — receives its own {@link SurfaceControls}. */
80
+ export type SurfaceRenderFn = (surface: SurfaceControls) => ReactNode;
81
+ /** Lifecycle status of a stacked surface. */
82
+ export type SurfaceStatus = 'open' | 'closing';
83
+ /**
84
+ * One entry in the surface stack. `resolve` fulfils the `present()` promise; the
85
+ * `settled` guard makes resolution idempotent (resolve-once); `generation` is
86
+ * the per-entry stale-callback guard (carried from `BottomSheetBase`'s
87
+ * `closeGenerationRef`, keyed per entry id) so a completion callback from a
88
+ * superseded close cycle can never tear down the wrong state.
89
+ */
90
+ export interface SurfaceEntry {
91
+ id: string;
92
+ render: SurfaceRenderFn;
93
+ presentation: SurfacePresentation;
94
+ status: SurfaceStatus;
95
+ resolve: (result: unknown) => void;
96
+ settled: boolean;
97
+ generation: number;
98
+ }
99
+ /** Options for the built-in {@link ../surfaces/prompts#confirm} surface. */
100
+ export interface SurfaceConfirmOptions {
101
+ /** Headline. */
102
+ title: string;
103
+ /** Supporting copy. */
104
+ message?: string;
105
+ /** Confirm button label. Defaults to `'Confirm'`. */
106
+ confirmLabel?: string;
107
+ /** Cancel button label. Defaults to `'Cancel'`. */
108
+ cancelLabel?: string;
109
+ /** Style the confirm button as destructive (negative color). */
110
+ destructive?: boolean;
111
+ /** Hide the cancel button (a single-action acknowledgement). */
112
+ hideCancel?: boolean;
113
+ /**
114
+ * Backdrop / Escape / back dismiss (resolving `false`). Defaults to `true`;
115
+ * pass `false` for a blocking confirm that must be answered by a button.
116
+ */
117
+ dismissible?: boolean;
118
+ /** Placement override. Defaults to `'center'`. */
119
+ placement?: ResponsiveDialogPlacement;
120
+ }
121
+ /** Options for the built-in {@link ../surfaces/prompts#prompt} surface. */
122
+ export interface SurfacePromptOptions {
123
+ /** Headline. */
124
+ title: string;
125
+ /** Supporting copy. */
126
+ message?: string;
127
+ /** Input placeholder. */
128
+ placeholder?: string;
129
+ /** Initial input value. */
130
+ defaultValue?: string;
131
+ /** Accessibility label for the input. Defaults to the `title`. */
132
+ inputLabel?: string;
133
+ /** Submit button label. Defaults to `'OK'`. */
134
+ confirmLabel?: string;
135
+ /** Cancel button label. Defaults to `'Cancel'`. */
136
+ cancelLabel?: string;
137
+ /**
138
+ * Backdrop / Escape / back dismiss (resolving `null`). Defaults to `true`;
139
+ * pass `false` for a blocking prompt.
140
+ */
141
+ dismissible?: boolean;
142
+ /** Placement override. Defaults to `'center'`. */
143
+ placement?: ResponsiveDialogPlacement;
144
+ }
145
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/surfaces/types.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,21 @@
1
+ import type { SurfaceControls } from './types';
2
+ /**
3
+ * Context carrying the CURRENT surface's controls. The host wraps each presented
4
+ * surface's content in a provider bound to that entry's id, so a nested surface
5
+ * reads its OWN controls — `dismiss` targets this surface, `present` stacks a
6
+ * child above it.
7
+ */
8
+ export declare const SurfaceContext: import("react").Context<SurfaceControls | null>;
9
+ /**
10
+ * Read the current surface's {@link SurfaceControls} from inside a presented
11
+ * surface. Equivalent to the `surface` argument the render function receives —
12
+ * use whichever is convenient (props vs. context).
13
+ *
14
+ * DEPTH only: `dismiss` (resolve this surface's promise + close) and `present`
15
+ * (stack a child). Navigation within a surface is the SDK's route layer, not
16
+ * this hook.
17
+ *
18
+ * @throws if called outside a presented surface.
19
+ */
20
+ export declare function useSurface(): SurfaceControls;
21
+ //# sourceMappingURL=useSurface.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSurface.d.ts","sourceRoot":"","sources":["../../../../src/surfaces/useSurface.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAE/C;;;;;GAKG;AACH,eAAO,MAAM,cAAc,iDAA8C,CAAC;AAG1E;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,IAAI,eAAe,CAQ5C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -28,6 +28,22 @@
28
28
  "default": "./lib/commonjs/index.js"
29
29
  }
30
30
  },
31
+ "./surfaces": {
32
+ "react-native": "./src/surfaces/index.ts",
33
+ "browser": {
34
+ "types": "./lib/typescript/module/surfaces/index.web.d.ts",
35
+ "import": "./lib/module/surfaces/index.web.js",
36
+ "require": "./lib/commonjs/surfaces/index.web.js"
37
+ },
38
+ "import": {
39
+ "types": "./lib/typescript/module/surfaces/index.d.ts",
40
+ "default": "./lib/module/surfaces/index.js"
41
+ },
42
+ "require": {
43
+ "types": "./lib/typescript/commonjs/surfaces/index.d.ts",
44
+ "default": "./lib/commonjs/surfaces/index.js"
45
+ }
46
+ },
31
47
  "./image-resolver": {
32
48
  "react-native": "./src/image-resolver/index.ts",
33
49
  "import": {
@@ -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';