@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,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
+ }
@@ -0,0 +1,55 @@
1
+ import {
2
+ dismissAll,
3
+ dismissToRoot,
4
+ dismissTop,
5
+ present,
6
+ requestDismiss,
7
+ } from './surfaceStore';
8
+ import { confirm, prompt } from './prompts';
9
+ import type { PresentOptions, SurfaceRenderFn } from './types';
10
+
11
+ /**
12
+ * Dismiss the TOP surface, resolving its `present()` promise with `result`.
13
+ * The public counterpart of the store's `dismissTop`.
14
+ */
15
+ function dismiss(result?: unknown): void {
16
+ dismissTop(result);
17
+ }
18
+
19
+ /**
20
+ * Dismiss a SPECIFIC surface by id, resolving its `present()` promise with
21
+ * `result`. Rarely needed — most call sites use `dismiss()` (top) or the
22
+ * per-surface `SurfaceControls.dismiss`.
23
+ */
24
+ function dismissById(id: string, result?: unknown): void {
25
+ requestDismiss(id, result);
26
+ }
27
+
28
+ /**
29
+ * The shared imperative surface-stack API — a module singleton (mirrors
30
+ * `alert()`'s shape) so any consumer, in the SDK or an app, presents into the
31
+ * SAME coordinated Bloom stack from anywhere without threading a provider.
32
+ *
33
+ * Exposed at the package root as `surfaces` (avoids a bare `present`/`confirm`
34
+ * name clash with the existing `alert-dialog` `confirm`); the individual
35
+ * functions are also named exports of `@oxyhq/bloom/surfaces` for direct import.
36
+ */
37
+ export const surfaces = {
38
+ /** Present a new surface on top. Resolves with the dismissal result. */
39
+ present: <Result = unknown>(render: SurfaceRenderFn, opts?: PresentOptions) =>
40
+ present<Result>(render, opts),
41
+ /** Dismiss the top surface. */
42
+ dismiss,
43
+ /** Dismiss a specific surface by id. */
44
+ dismissById,
45
+ /** Dismiss everything above the root surface. */
46
+ dismissToRoot,
47
+ /** Dismiss the entire stack (resolves pending awaiters with `undefined`). */
48
+ dismissAll,
49
+ /** Present a confirm surface; resolves `true`/`false`. */
50
+ confirm,
51
+ /** Present a prompt surface; resolves the string or `null`. */
52
+ prompt,
53
+ } as const;
54
+
55
+ export { confirm, dismiss, dismissAll, dismissById, dismissToRoot, present, prompt };
@@ -0,0 +1,38 @@
1
+ import { Dialog } from '../dialog';
2
+ import { createSurfaceHost } from './SurfaceHost';
3
+
4
+ /**
5
+ * The shared, content-agnostic SURFACE STACK.
6
+ *
7
+ * `<SurfaceProvider>`/`<SurfaceHost>` render the coordinated stack; the
8
+ * imperative `surfaces` API (+ its named functions) present into it from
9
+ * anywhere; `useSurface()` gives a presented surface its own controls.
10
+ *
11
+ * `SurfaceHost`/`SurfaceProvider` are bound to the NATIVE `Dialog` here; the web
12
+ * fork (`./index.web`) binds the web `Dialog`. The store, imperative API, hook,
13
+ * and types are platform-agnostic and shared by both.
14
+ */
15
+ const { SurfaceHost, SurfaceProvider } = createSurfaceHost(Dialog);
16
+
17
+ export { SurfaceHost, SurfaceProvider };
18
+ export {
19
+ surfaces,
20
+ present,
21
+ dismiss,
22
+ dismissById,
23
+ dismissToRoot,
24
+ dismissAll,
25
+ confirm,
26
+ prompt,
27
+ } from './api';
28
+ export { useSurface } from './useSurface';
29
+ export type {
30
+ SurfacePresentation,
31
+ PresentOptions,
32
+ SurfaceControls,
33
+ SurfaceRenderFn,
34
+ SurfaceStatus,
35
+ SurfaceEntry,
36
+ SurfaceConfirmOptions,
37
+ SurfacePromptOptions,
38
+ } from './types';
@@ -0,0 +1,33 @@
1
+ import { Dialog } from '../dialog/index.web';
2
+ import { createSurfaceHost } from './SurfaceHost';
3
+
4
+ /**
5
+ * Web variant of the surface stack barrel. Identical surface area to the native
6
+ * `./index.ts` — it just binds the web-fork `Dialog` (`Dialog.web.tsx`, a
7
+ * pure-DOM overlay) instead of the native `BottomSheet`-backed one. The store,
8
+ * imperative API, hook, and types are shared, platform-agnostic modules.
9
+ */
10
+ const { SurfaceHost, SurfaceProvider } = createSurfaceHost(Dialog);
11
+
12
+ export { SurfaceHost, SurfaceProvider };
13
+ export {
14
+ surfaces,
15
+ present,
16
+ dismiss,
17
+ dismissById,
18
+ dismissToRoot,
19
+ dismissAll,
20
+ confirm,
21
+ prompt,
22
+ } from './api';
23
+ export { useSurface } from './useSurface';
24
+ export type {
25
+ SurfacePresentation,
26
+ PresentOptions,
27
+ SurfaceControls,
28
+ SurfaceRenderFn,
29
+ SurfaceStatus,
30
+ SurfaceEntry,
31
+ SurfaceConfirmOptions,
32
+ SurfacePromptOptions,
33
+ } from './types';
@@ -0,0 +1,178 @@
1
+ import React, { useCallback, useState } from 'react';
2
+ import { Text, View } from 'react-native';
3
+
4
+ import { Button } from '../button';
5
+ import { TextFieldInput } from '../text-field';
6
+ import { useTheme } from '../theme/use-theme';
7
+ import { present } from './surfaceStore';
8
+ import type {
9
+ SurfaceConfirmOptions,
10
+ SurfaceControls,
11
+ SurfacePromptOptions,
12
+ } from './types';
13
+
14
+ /**
15
+ * Built-in `confirm` / `prompt` surfaces.
16
+ *
17
+ * They are implemented AS surfaces — each calls `present(...)` so it STACKS
18
+ * correctly above any open surface with the right layer/z (unlike Bloom's
19
+ * one-at-a-time `alert()` FIFO queue, which cannot layer over another surface).
20
+ * Their buttons resolve through the surface's own `dismiss(result)` (NOT the
21
+ * underlying `Dialog`'s close), so the value flows back through the `present()`
22
+ * promise.
23
+ */
24
+
25
+ const TITLE_STYLE = { fontSize: 22, fontWeight: '600' as const, lineHeight: 30 };
26
+ const MESSAGE_STYLE = { fontSize: 16, lineHeight: 22 };
27
+
28
+ function ConfirmSurface({
29
+ options,
30
+ surface,
31
+ }: {
32
+ options: SurfaceConfirmOptions;
33
+ surface: SurfaceControls;
34
+ }) {
35
+ const theme = useTheme();
36
+ const onConfirm = useCallback(() => surface.dismiss(true), [surface]);
37
+ const onCancel = useCallback(() => surface.dismiss(false), [surface]);
38
+
39
+ return (
40
+ <View>
41
+ <Text
42
+ style={[
43
+ TITLE_STYLE,
44
+ {
45
+ color: theme.colors.text,
46
+ paddingBottom: options.message ? 4 : 16,
47
+ },
48
+ ]}
49
+ >
50
+ {options.title}
51
+ </Text>
52
+ {options.message ? (
53
+ <Text
54
+ style={[
55
+ MESSAGE_STYLE,
56
+ { color: theme.colors.textSecondary, paddingBottom: 16 },
57
+ ]}
58
+ >
59
+ {options.message}
60
+ </Text>
61
+ ) : null}
62
+ <View style={{ gap: 8 }}>
63
+ <Button
64
+ variant={options.destructive ? 'destructive' : 'primary'}
65
+ onPress={onConfirm}
66
+ testID="bloom-surface-confirm-confirm"
67
+ >
68
+ {options.confirmLabel ?? 'Confirm'}
69
+ </Button>
70
+ {options.hideCancel ? null : (
71
+ <Button
72
+ variant="secondary"
73
+ onPress={onCancel}
74
+ testID="bloom-surface-confirm-cancel"
75
+ >
76
+ {options.cancelLabel ?? 'Cancel'}
77
+ </Button>
78
+ )}
79
+ </View>
80
+ </View>
81
+ );
82
+ }
83
+
84
+ function PromptSurface({
85
+ options,
86
+ surface,
87
+ }: {
88
+ options: SurfacePromptOptions;
89
+ surface: SurfaceControls;
90
+ }) {
91
+ const theme = useTheme();
92
+ const [value, setValue] = useState(options.defaultValue ?? '');
93
+ const onSubmit = useCallback(() => surface.dismiss(value), [surface, value]);
94
+ const onCancel = useCallback(() => surface.dismiss(null), [surface]);
95
+
96
+ return (
97
+ <View>
98
+ <Text
99
+ style={[
100
+ TITLE_STYLE,
101
+ {
102
+ color: theme.colors.text,
103
+ paddingBottom: options.message ? 4 : 16,
104
+ },
105
+ ]}
106
+ >
107
+ {options.title}
108
+ </Text>
109
+ {options.message ? (
110
+ <Text
111
+ style={[
112
+ MESSAGE_STYLE,
113
+ { color: theme.colors.textSecondary, paddingBottom: 16 },
114
+ ]}
115
+ >
116
+ {options.message}
117
+ </Text>
118
+ ) : null}
119
+ <TextFieldInput
120
+ label={options.inputLabel ?? options.title}
121
+ placeholder={options.placeholder}
122
+ value={value}
123
+ onChangeText={setValue}
124
+ autoFocus
125
+ returnKeyType="done"
126
+ onSubmitEditing={onSubmit}
127
+ testID="bloom-surface-prompt-input"
128
+ />
129
+ <View style={{ gap: 8, paddingTop: 16 }}>
130
+ <Button
131
+ variant="primary"
132
+ onPress={onSubmit}
133
+ testID="bloom-surface-prompt-confirm"
134
+ >
135
+ {options.confirmLabel ?? 'OK'}
136
+ </Button>
137
+ <Button
138
+ variant="secondary"
139
+ onPress={onCancel}
140
+ testID="bloom-surface-prompt-cancel"
141
+ >
142
+ {options.cancelLabel ?? 'Cancel'}
143
+ </Button>
144
+ </View>
145
+ </View>
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Present a confirm surface stacked on top of anything currently open. Resolves
151
+ * `true` on confirm, `false` on cancel OR on backdrop/Escape/back dismissal.
152
+ */
153
+ export function confirm(options: SurfaceConfirmOptions): Promise<boolean> {
154
+ return present<unknown>(
155
+ (surface) => <ConfirmSurface options={options} surface={surface} />,
156
+ {
157
+ placement: options.placement ?? 'center',
158
+ dismissOnBackdrop: options.dismissible ?? true,
159
+ label: options.title,
160
+ },
161
+ ).then((result) => result === true);
162
+ }
163
+
164
+ /**
165
+ * Present a prompt surface stacked on top of anything currently open. Resolves
166
+ * the entered string on submit, or `null` on cancel OR backdrop/Escape/back
167
+ * dismissal.
168
+ */
169
+ export function prompt(options: SurfacePromptOptions): Promise<string | null> {
170
+ return present<unknown>(
171
+ (surface) => <PromptSurface options={options} surface={surface} />,
172
+ {
173
+ placement: options.placement ?? 'center',
174
+ dismissOnBackdrop: options.dismissible ?? true,
175
+ label: options.title,
176
+ },
177
+ ).then((result) => (typeof result === 'string' ? result : null));
178
+ }
@@ -0,0 +1,168 @@
1
+ import type {
2
+ PresentOptions,
3
+ SurfaceEntry,
4
+ SurfaceRenderFn,
5
+ } from './types';
6
+
7
+ /**
8
+ * The shared, content-agnostic SURFACE STACK.
9
+ *
10
+ * A module-singleton store (same pattern as `theme/ambient-store.ts` and
11
+ * `dialog/alert-store.ts`) holding an ordered stack of surfaces presented ON TOP
12
+ * of each other — bottom→top, the last entry is the top. One coordinated stack
13
+ * that the whole ecosystem (the SDK AND consumer apps) presents into, so
14
+ * overlays never clash: per-layer z, top-only interactivity, backdrop/Escape/
15
+ * back routed to the TOP surface, lower layers dimmed.
16
+ *
17
+ * It is CONTENT-AGNOSTIC: `present` takes a render function, not a route name.
18
+ * Bloom must never know about routes; the SDK layers its route registry on top.
19
+ *
20
+ * `getSnapshot` returns a STABLE reference while unchanged (the array identity
21
+ * only changes on a real mutation), so `useSyncExternalStore` — and the React
22
+ * Compiler — never see a silently-stale value.
23
+ */
24
+
25
+ let idCounter = 0;
26
+ function genId(): string {
27
+ idCounter += 1;
28
+ return `bloom-surface-${idCounter}`;
29
+ }
30
+
31
+ /** A single frozen "empty stack" snapshot returned by `getSnapshot` when idle. */
32
+ const EMPTY: readonly SurfaceEntry[] = Object.freeze([]);
33
+
34
+ let surfaces: readonly SurfaceEntry[] = EMPTY;
35
+ const listeners = new Set<() => void>();
36
+
37
+ function emit(): void {
38
+ for (const listener of listeners) listener();
39
+ }
40
+
41
+ /** Subscribe to stack changes. Returns an unsubscribe fn. */
42
+ export function subscribe(listener: () => void): () => void {
43
+ listeners.add(listener);
44
+ return () => {
45
+ listeners.delete(listener);
46
+ };
47
+ }
48
+
49
+ /** Current stack snapshot (stable ref while unchanged). Bottom→top. */
50
+ export function getSnapshot(): readonly SurfaceEntry[] {
51
+ return surfaces;
52
+ }
53
+
54
+ /**
55
+ * Present a new surface on TOP of the stack. Returns a promise that resolves
56
+ * with the value passed to `dismiss(result)` (or `undefined` when the surface is
57
+ * dismissed without a result / by a stack-wide clear).
58
+ */
59
+ export function present<Result = unknown>(
60
+ render: SurfaceRenderFn,
61
+ opts?: PresentOptions,
62
+ ): Promise<Result> {
63
+ return new Promise<Result>((resolve) => {
64
+ const entry: SurfaceEntry = {
65
+ id: genId(),
66
+ render,
67
+ presentation: opts ?? {},
68
+ status: 'open',
69
+ resolve: resolve as (result: unknown) => void,
70
+ settled: false,
71
+ generation: 0,
72
+ };
73
+ surfaces = [...surfaces, entry];
74
+ emit();
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Request dismissal of a surface. RESOLVES THE PROMISE IMMEDIATELY (the exit
80
+ * animation that follows is cosmetic) and flips the entry to `'closing'` so the
81
+ * host runs its exit animation. Resolution is resolve-once via the `settled`
82
+ * guard — a second request (or the host's post-exit `onClose`) never re-resolves
83
+ * with a different value. Bumps the entry `generation` so a completion callback
84
+ * from a superseded cycle is inert.
85
+ */
86
+ export function requestDismiss(id: string, result?: unknown): void {
87
+ const entry = surfaces.find((e) => e.id === id);
88
+ if (!entry) return;
89
+
90
+ // Resolve-once. The mapped replacement below carries `settled: true`, so a
91
+ // later `requestDismiss(id)` reads the settled entry and skips this branch —
92
+ // no in-place mutation of the live entry is needed.
93
+ if (!entry.settled) {
94
+ entry.resolve(result);
95
+ }
96
+
97
+ if (entry.status === 'closing') return;
98
+
99
+ surfaces = surfaces.map((e) =>
100
+ e.id === id
101
+ ? { ...e, settled: true, status: 'closing', generation: e.generation + 1 }
102
+ : e,
103
+ );
104
+ emit();
105
+ }
106
+
107
+ /**
108
+ * Splice a surface out of the stack once its exit animation has settled. Called
109
+ * by the host from the `Dialog`'s post-exit `onClose`. Idempotent and gated on
110
+ * `'closing'` status: a stale/duplicate call — or one for an entry that is not
111
+ * actually closing — is a no-op, which (together with unique entry ids) is the
112
+ * stale-callback hygiene `BottomSheetBase`'s generation guard provides for its
113
+ * reused instance.
114
+ */
115
+ export function finalizeClose(id: string): void {
116
+ const entry = surfaces.find((e) => e.id === id);
117
+ if (!entry || entry.status !== 'closing') return;
118
+ const next = surfaces.filter((e) => e.id !== id);
119
+ surfaces = next.length === 0 ? EMPTY : next;
120
+ emit();
121
+ }
122
+
123
+ /** Dismiss the TOP surface, resolving its promise with `result`. */
124
+ export function dismissTop(result?: unknown): void {
125
+ const top = surfaces[surfaces.length - 1];
126
+ if (top) requestDismiss(top.id, result);
127
+ }
128
+
129
+ /**
130
+ * Dismiss every surface ABOVE the bottom-most one, keeping the root surface
131
+ * open. Pending awaiters of the dismissed surfaces resolve with `undefined` so
132
+ * they never hang. No-op with 0 or 1 surfaces.
133
+ */
134
+ export function dismissToRoot(): void {
135
+ if (surfaces.length <= 1) return;
136
+ const above = surfaces.slice(1).map((e) => e.id);
137
+ for (const id of above) requestDismiss(id, undefined);
138
+ }
139
+
140
+ /**
141
+ * Dismiss the ENTIRE stack. Every pending awaiter resolves with `undefined` so
142
+ * none hang.
143
+ */
144
+ export function dismissAll(): void {
145
+ const ids = surfaces.map((e) => e.id);
146
+ for (const id of ids) requestDismiss(id, undefined);
147
+ }
148
+
149
+ /**
150
+ * Hard-reset the stack synchronously (no exit animation), resolving all pending
151
+ * awaiters with `undefined`. Used when the host provider unmounts so no
152
+ * `present()` promise is stranded.
153
+ */
154
+ export function resetSurfaces(): void {
155
+ if (surfaces.length === 0) return;
156
+ const pending = surfaces;
157
+ surfaces = EMPTY;
158
+ for (const entry of pending) {
159
+ if (!entry.settled) entry.resolve(undefined);
160
+ }
161
+ emit();
162
+ }
163
+
164
+ /** Test-only: hard-reset without notifying (drops listeners' work). */
165
+ export function __resetSurfacesForTests(): void {
166
+ surfaces = EMPTY;
167
+ idCounter = 0;
168
+ }