@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.
- 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/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/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/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/package.json +17 -1
- 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
|
@@ -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
|
+
}
|