@oxyhq/bloom 0.4.0 → 0.5.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 (186) hide show
  1. package/README.md +105 -90
  2. package/lib/commonjs/bottom-sheet/index.js +2 -2
  3. package/lib/commonjs/context-menu/index.js +18 -19
  4. package/lib/commonjs/context-menu/index.js.map +1 -1
  5. package/lib/commonjs/dialog/BloomDialogProvider.js +61 -0
  6. package/lib/commonjs/dialog/BloomDialogProvider.js.map +1 -0
  7. package/lib/commonjs/dialog/BloomDialogProvider.web.js +45 -0
  8. package/lib/commonjs/dialog/BloomDialogProvider.web.js.map +1 -0
  9. package/lib/commonjs/dialog/Dialog.js +197 -100
  10. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  11. package/lib/commonjs/dialog/Dialog.web.js +194 -84
  12. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  13. package/lib/commonjs/dialog/SheetShell.js +149 -0
  14. package/lib/commonjs/dialog/SheetShell.js.map +1 -0
  15. package/lib/commonjs/dialog/alert-store.js +116 -0
  16. package/lib/commonjs/dialog/alert-store.js.map +1 -0
  17. package/lib/commonjs/dialog/alert.js +38 -0
  18. package/lib/commonjs/dialog/alert.js.map +1 -0
  19. package/lib/commonjs/dialog/context.js +10 -2
  20. package/lib/commonjs/dialog/context.js.map +1 -1
  21. package/lib/commonjs/dialog/index.js +8 -24
  22. package/lib/commonjs/dialog/index.js.map +1 -1
  23. package/lib/commonjs/dialog/index.web.js +10 -20
  24. package/lib/commonjs/dialog/index.web.js.map +1 -1
  25. package/lib/commonjs/index.js +101 -66
  26. package/lib/commonjs/index.js.map +1 -1
  27. package/lib/commonjs/index.web.js +101 -66
  28. package/lib/commonjs/index.web.js.map +1 -1
  29. package/lib/commonjs/menu/index.js +21 -23
  30. package/lib/commonjs/menu/index.js.map +1 -1
  31. package/lib/commonjs/select/index.js +26 -27
  32. package/lib/commonjs/select/index.js.map +1 -1
  33. package/lib/commonjs/toast/index.js +42 -13
  34. package/lib/commonjs/toast/index.js.map +1 -1
  35. package/lib/commonjs/toast/index.web.js +19 -15
  36. package/lib/commonjs/toast/index.web.js.map +1 -1
  37. package/lib/module/bottom-sheet/index.js +2 -2
  38. package/lib/module/context-menu/index.js +15 -16
  39. package/lib/module/context-menu/index.js.map +1 -1
  40. package/lib/module/dialog/BloomDialogProvider.js +57 -0
  41. package/lib/module/dialog/BloomDialogProvider.js.map +1 -0
  42. package/lib/module/dialog/BloomDialogProvider.web.js +41 -0
  43. package/lib/module/dialog/BloomDialogProvider.web.js.map +1 -0
  44. package/lib/module/dialog/Dialog.js +199 -87
  45. package/lib/module/dialog/Dialog.js.map +1 -1
  46. package/lib/module/dialog/Dialog.web.js +195 -70
  47. package/lib/module/dialog/Dialog.web.js.map +1 -1
  48. package/lib/module/dialog/SheetShell.js +143 -0
  49. package/lib/module/dialog/SheetShell.js.map +1 -0
  50. package/lib/module/dialog/alert-store.js +107 -0
  51. package/lib/module/dialog/alert-store.js.map +1 -0
  52. package/lib/module/dialog/alert.js +35 -0
  53. package/lib/module/dialog/alert.js.map +1 -0
  54. package/lib/module/dialog/context.js +10 -2
  55. package/lib/module/dialog/context.js.map +1 -1
  56. package/lib/module/dialog/index.js +3 -1
  57. package/lib/module/dialog/index.js.map +1 -1
  58. package/lib/module/dialog/index.web.js +9 -7
  59. package/lib/module/dialog/index.web.js.map +1 -1
  60. package/lib/module/index.js +2 -3
  61. package/lib/module/index.js.map +1 -1
  62. package/lib/module/index.web.js +2 -3
  63. package/lib/module/index.web.js.map +1 -1
  64. package/lib/module/menu/index.js +11 -13
  65. package/lib/module/menu/index.js.map +1 -1
  66. package/lib/module/select/index.js +27 -28
  67. package/lib/module/select/index.js.map +1 -1
  68. package/lib/module/toast/index.js +41 -11
  69. package/lib/module/toast/index.js.map +1 -1
  70. package/lib/module/toast/index.web.js +18 -13
  71. package/lib/module/toast/index.web.js.map +1 -1
  72. package/lib/typescript/commonjs/__tests__/Dialog.test.d.ts +2 -0
  73. package/lib/typescript/commonjs/__tests__/Dialog.test.d.ts.map +1 -0
  74. package/lib/typescript/commonjs/bottom-sheet/index.d.ts +1 -1
  75. package/lib/typescript/commonjs/context-menu/index.d.ts +4 -3
  76. package/lib/typescript/commonjs/context-menu/index.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/dialog/BloomDialogProvider.d.ts +27 -0
  78. package/lib/typescript/commonjs/dialog/BloomDialogProvider.d.ts.map +1 -0
  79. package/lib/typescript/commonjs/dialog/BloomDialogProvider.web.d.ts +15 -0
  80. package/lib/typescript/commonjs/dialog/BloomDialogProvider.web.d.ts.map +1 -0
  81. package/lib/typescript/commonjs/dialog/Dialog.d.ts +37 -10
  82. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts +26 -10
  84. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/dialog/SheetShell.d.ts +31 -0
  86. package/lib/typescript/commonjs/dialog/SheetShell.d.ts.map +1 -0
  87. package/lib/typescript/commonjs/dialog/alert-store.d.ts +70 -0
  88. package/lib/typescript/commonjs/dialog/alert-store.d.ts.map +1 -0
  89. package/lib/typescript/commonjs/dialog/alert.d.ts +27 -0
  90. package/lib/typescript/commonjs/dialog/alert.d.ts.map +1 -0
  91. package/lib/typescript/commonjs/dialog/context.d.ts +7 -0
  92. package/lib/typescript/commonjs/dialog/context.d.ts.map +1 -1
  93. package/lib/typescript/commonjs/dialog/index.d.ts +5 -2
  94. package/lib/typescript/commonjs/dialog/index.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/dialog/index.web.d.ts +5 -2
  96. package/lib/typescript/commonjs/dialog/index.web.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/dialog/types.d.ts +70 -15
  98. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/index.d.ts +3 -3
  100. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/index.web.d.ts +3 -3
  102. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/menu/index.d.ts +4 -4
  104. package/lib/typescript/commonjs/menu/index.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/select/index.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/toast/index.d.ts +32 -3
  107. package/lib/typescript/commonjs/toast/index.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/toast/index.web.d.ts +14 -7
  109. package/lib/typescript/commonjs/toast/index.web.d.ts.map +1 -1
  110. package/lib/typescript/module/__tests__/Dialog.test.d.ts +2 -0
  111. package/lib/typescript/module/__tests__/Dialog.test.d.ts.map +1 -0
  112. package/lib/typescript/module/bottom-sheet/index.d.ts +1 -1
  113. package/lib/typescript/module/context-menu/index.d.ts +4 -3
  114. package/lib/typescript/module/context-menu/index.d.ts.map +1 -1
  115. package/lib/typescript/module/dialog/BloomDialogProvider.d.ts +27 -0
  116. package/lib/typescript/module/dialog/BloomDialogProvider.d.ts.map +1 -0
  117. package/lib/typescript/module/dialog/BloomDialogProvider.web.d.ts +15 -0
  118. package/lib/typescript/module/dialog/BloomDialogProvider.web.d.ts.map +1 -0
  119. package/lib/typescript/module/dialog/Dialog.d.ts +37 -10
  120. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  121. package/lib/typescript/module/dialog/Dialog.web.d.ts +26 -10
  122. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  123. package/lib/typescript/module/dialog/SheetShell.d.ts +31 -0
  124. package/lib/typescript/module/dialog/SheetShell.d.ts.map +1 -0
  125. package/lib/typescript/module/dialog/alert-store.d.ts +70 -0
  126. package/lib/typescript/module/dialog/alert-store.d.ts.map +1 -0
  127. package/lib/typescript/module/dialog/alert.d.ts +27 -0
  128. package/lib/typescript/module/dialog/alert.d.ts.map +1 -0
  129. package/lib/typescript/module/dialog/context.d.ts +7 -0
  130. package/lib/typescript/module/dialog/context.d.ts.map +1 -1
  131. package/lib/typescript/module/dialog/index.d.ts +5 -2
  132. package/lib/typescript/module/dialog/index.d.ts.map +1 -1
  133. package/lib/typescript/module/dialog/index.web.d.ts +5 -2
  134. package/lib/typescript/module/dialog/index.web.d.ts.map +1 -1
  135. package/lib/typescript/module/dialog/types.d.ts +70 -15
  136. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  137. package/lib/typescript/module/index.d.ts +3 -3
  138. package/lib/typescript/module/index.d.ts.map +1 -1
  139. package/lib/typescript/module/index.web.d.ts +3 -3
  140. package/lib/typescript/module/index.web.d.ts.map +1 -1
  141. package/lib/typescript/module/menu/index.d.ts +4 -4
  142. package/lib/typescript/module/menu/index.d.ts.map +1 -1
  143. package/lib/typescript/module/select/index.d.ts.map +1 -1
  144. package/lib/typescript/module/toast/index.d.ts +32 -3
  145. package/lib/typescript/module/toast/index.d.ts.map +1 -1
  146. package/lib/typescript/module/toast/index.web.d.ts +14 -7
  147. package/lib/typescript/module/toast/index.web.d.ts.map +1 -1
  148. package/package.json +3 -14
  149. package/src/__tests__/Dialog.test.tsx +177 -0
  150. package/src/bottom-sheet/index.tsx +3 -3
  151. package/src/context-menu/index.tsx +12 -12
  152. package/src/dialog/BloomDialogProvider.tsx +61 -0
  153. package/src/dialog/BloomDialogProvider.web.tsx +46 -0
  154. package/src/dialog/Dialog.tsx +217 -64
  155. package/src/dialog/Dialog.web.tsx +240 -75
  156. package/src/dialog/SheetShell.tsx +154 -0
  157. package/src/dialog/alert-store.ts +126 -0
  158. package/src/dialog/alert.ts +42 -0
  159. package/src/dialog/context.ts +14 -3
  160. package/src/dialog/index.ts +14 -2
  161. package/src/dialog/index.web.ts +20 -8
  162. package/src/dialog/types.ts +73 -16
  163. package/src/index.ts +17 -3
  164. package/src/index.web.ts +17 -3
  165. package/src/menu/index.tsx +13 -17
  166. package/src/select/index.tsx +30 -30
  167. package/src/toast/index.tsx +55 -11
  168. package/src/toast/index.web.tsx +33 -13
  169. package/lib/commonjs/prompt/Prompt.js +0 -267
  170. package/lib/commonjs/prompt/Prompt.js.map +0 -1
  171. package/lib/commonjs/prompt/index.js +0 -61
  172. package/lib/commonjs/prompt/index.js.map +0 -1
  173. package/lib/module/prompt/Prompt.js +0 -250
  174. package/lib/module/prompt/Prompt.js.map +0 -1
  175. package/lib/module/prompt/index.js +0 -4
  176. package/lib/module/prompt/index.js.map +0 -1
  177. package/lib/typescript/commonjs/prompt/Prompt.d.ts +0 -42
  178. package/lib/typescript/commonjs/prompt/Prompt.d.ts.map +0 -1
  179. package/lib/typescript/commonjs/prompt/index.d.ts +0 -3
  180. package/lib/typescript/commonjs/prompt/index.d.ts.map +0 -1
  181. package/lib/typescript/module/prompt/Prompt.d.ts +0 -42
  182. package/lib/typescript/module/prompt/Prompt.d.ts.map +0 -1
  183. package/lib/typescript/module/prompt/index.d.ts +0 -3
  184. package/lib/typescript/module/prompt/index.d.ts.map +0 -1
  185. package/src/prompt/Prompt.tsx +0 -247
  186. package/src/prompt/index.ts +0 -13
@@ -1,14 +1,34 @@
1
- import React, { createContext, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
2
- import { Pressable, Text, View, type ViewStyle } from 'react-native';
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useId,
7
+ useImperativeHandle,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from 'react';
12
+ import {
13
+ Pressable,
14
+ Text,
15
+ TouchableOpacity,
16
+ View,
17
+ type GestureResponderEvent,
18
+ type ViewStyle,
19
+ } from 'react-native';
3
20
  import { RemoveScrollBar } from 'react-remove-scroll-bar';
4
21
 
5
- import { useTheme } from '../theme/use-theme';
6
22
  import { Portal } from '../portal/index.web';
7
- import { Context, useDialogContext } from './context';
8
- import type { DialogControlProps, DialogInnerProps, DialogOuterProps } from './types';
9
-
10
- export { useDialogContext, useDialogControl } from './context';
11
- export type { DialogControlProps, DialogOuterProps, DialogInnerProps } from './types';
23
+ import type { ThemeColors } from '../theme/types';
24
+ import { useTheme } from '../theme/use-theme';
25
+ import { Context, useDialogContext, useDialogControl } from './context';
26
+ import type {
27
+ DialogAction,
28
+ DialogActionColor,
29
+ DialogControlProps,
30
+ DialogProps,
31
+ } from './types';
12
32
 
13
33
  const FADE_OUT_DURATION = 150;
14
34
 
@@ -16,13 +36,30 @@ const stopPropagation = (e: { stopPropagation: () => void }) => e.stopPropagatio
16
36
 
17
37
  const ClosingContext = createContext(false);
18
38
 
19
- export function Outer({
20
- children,
39
+ /**
40
+ * Web variant of `<Dialog>`.
41
+ *
42
+ * A centered modal card rendered into the bloom Portal at the end of
43
+ * `document.body`. Same prop API as native — `title`, `description`,
44
+ * `actions`, or arbitrary `children` — so call sites are platform
45
+ * agnostic.
46
+ *
47
+ * Accessibility: the panel has `role="dialog"` and the `title`/`description`
48
+ * props (when provided) become the `aria-labelledby` / `aria-describedby`
49
+ * targets. Pressing the backdrop dismisses. Pressing `Escape` dismisses.
50
+ * Focus is locked inside the dialog while it's open.
51
+ */
52
+ export function Dialog({
21
53
  control,
22
54
  onClose,
23
55
  testID,
24
- webOptions,
25
- }: React.PropsWithChildren<DialogOuterProps>) {
56
+ title,
57
+ description,
58
+ actions,
59
+ style,
60
+ label,
61
+ children,
62
+ }: DialogProps) {
26
63
  const [isOpen, setIsOpen] = useState(false);
27
64
  const [isClosing, setIsClosing] = useState(false);
28
65
  const closeCallbacksRef = useRef<(() => void)[]>([]);
@@ -45,20 +82,38 @@ export function Outer({
45
82
  const timer = setTimeout(() => {
46
83
  setIsOpen(false);
47
84
  setIsClosing(false);
48
- for (const cb of closeCallbacksRef.current) {
85
+ const queued = closeCallbacksRef.current;
86
+ closeCallbacksRef.current = [];
87
+ for (const cb of queued) {
49
88
  try {
50
89
  cb();
51
90
  } catch (e) {
52
- console.error('Dialog close callback error:', e);
91
+ if (typeof console !== 'undefined' && console.error) {
92
+ console.error('Dialog close callback error:', e);
93
+ }
53
94
  }
54
95
  }
55
- closeCallbacksRef.current = [];
56
96
  onClose?.();
57
97
  }, FADE_OUT_DURATION);
58
98
 
59
99
  return () => clearTimeout(timer);
60
100
  }, [isClosing, onClose]);
61
101
 
102
+ // Escape-to-close while open. The listener is intentionally scoped to the
103
+ // open lifetime so stacked dialogs don't fight for the keydown — the
104
+ // top-most one wins via document-level event order.
105
+ useEffect(() => {
106
+ if (!isOpen || typeof document === 'undefined') return;
107
+ const handler = (e: KeyboardEvent) => {
108
+ if (e.key === 'Escape') {
109
+ e.stopPropagation();
110
+ close();
111
+ }
112
+ };
113
+ document.addEventListener('keydown', handler);
114
+ return () => document.removeEventListener('keydown', handler);
115
+ }, [close, isOpen]);
116
+
62
117
  useImperativeHandle(
63
118
  control.ref,
64
119
  () => ({ open, close }),
@@ -87,24 +142,22 @@ export function Outer({
87
142
  bottom: 0,
88
143
  zIndex: 50,
89
144
  alignItems: 'center',
90
- justifyContent: webOptions?.alignCenter ? 'center' : undefined,
145
+ justifyContent: 'center',
91
146
  paddingHorizontal: 20,
92
- paddingVertical: '10vh' as unknown as number,
93
- ...({ overflowY: 'auto' } as Record<string, string>),
94
147
  }}
95
148
  >
96
149
  <DialogBackdrop isClosing={isClosing} />
97
- <View
150
+ <DialogPanel
98
151
  testID={testID}
99
- style={{
100
- width: '100%',
101
- zIndex: 60,
102
- alignItems: 'center',
103
- minHeight: webOptions?.alignCenter ? undefined : '60%',
104
- }}
152
+ label={label}
153
+ title={title}
154
+ description={description}
155
+ actions={actions}
156
+ style={style}
157
+ isClosing={isClosing}
105
158
  >
106
159
  {children}
107
- </View>
160
+ </DialogPanel>
108
161
  </Pressable>
109
162
  </ClosingContext.Provider>
110
163
  </Context.Provider>
@@ -112,29 +165,45 @@ export function Outer({
112
165
  );
113
166
  }
114
167
 
115
- export function Inner({
116
- children,
117
- style,
168
+ function DialogPanel({
169
+ testID,
118
170
  label,
119
- header,
120
- contentContainerStyle,
121
- }: DialogInnerProps) {
171
+ title,
172
+ description,
173
+ actions,
174
+ style,
175
+ isClosing,
176
+ children,
177
+ }: {
178
+ testID?: string;
179
+ label?: string;
180
+ title?: string;
181
+ description?: string;
182
+ actions?: DialogAction[];
183
+ style?: DialogProps['style'];
184
+ isClosing: boolean;
185
+ children?: React.ReactNode;
186
+ }) {
122
187
  const theme = useTheme();
123
- const isClosing = useContext(ClosingContext);
188
+ const titleId = useId();
189
+ const descriptionId = useId();
124
190
 
125
191
  return (
126
192
  <View
127
193
  role="dialog"
128
194
  aria-label={label}
195
+ aria-labelledby={title ? titleId : undefined}
196
+ aria-describedby={description ? descriptionId : undefined}
197
+ testID={testID}
129
198
  onStartShouldSetResponder={() => true}
130
199
  onResponderRelease={stopPropagation}
131
200
  {...({ onClick: stopPropagation } as Record<string, unknown>)}
132
201
  style={[
133
202
  {
134
203
  position: 'relative',
135
- borderRadius: 10,
204
+ borderRadius: 20,
136
205
  width: '100%',
137
- maxWidth: 600,
206
+ maxWidth: 480,
138
207
  backgroundColor: theme.colors.background,
139
208
  borderWidth: 1,
140
209
  borderColor: theme.isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
@@ -142,63 +211,130 @@ export function Inner({
142
211
  shadowOpacity: theme.isDark ? 0.4 : 0.1,
143
212
  shadowRadius: 30,
144
213
  shadowOffset: { width: 0, height: 4 },
145
- overflow: 'hidden',
214
+ padding: 20,
215
+ zIndex: 60,
146
216
  },
147
217
  isClosing
148
- ? { animation: `bloomDialogZoomFadeOut ease-in ${FADE_OUT_DURATION}ms forwards` } as ViewStyle
149
- : { animation: 'bloomDialogZoomFadeIn cubic-bezier(0.16, 1, 0.3, 1) 0.3s' } as ViewStyle,
218
+ ? ({ animation: `bloomDialogZoomFadeOut ease-in ${FADE_OUT_DURATION}ms forwards` } as ViewStyle)
219
+ : ({ animation: 'bloomDialogZoomFadeIn cubic-bezier(0.16, 1, 0.3, 1) 0.3s' } as ViewStyle),
150
220
  style,
151
221
  ]}
152
222
  >
153
- {header}
154
- <View style={[{ padding: 20 }, contentContainerStyle]}>
155
- {children}
156
- </View>
223
+ {title ? (
224
+ <Text
225
+ nativeID={titleId}
226
+ style={{
227
+ fontSize: 22,
228
+ fontWeight: '600',
229
+ color: theme.colors.text,
230
+ paddingBottom: description ? 4 : 16,
231
+ lineHeight: 30,
232
+ }}
233
+ >
234
+ {title}
235
+ </Text>
236
+ ) : null}
237
+ {description ? (
238
+ <Text
239
+ nativeID={descriptionId}
240
+ style={{
241
+ fontSize: 16,
242
+ color: theme.colors.textSecondary,
243
+ paddingBottom: 16,
244
+ lineHeight: 22,
245
+ }}
246
+ >
247
+ {description}
248
+ </Text>
249
+ ) : null}
250
+ {children}
251
+ {actions && actions.length > 0 ? <ActionRow actions={actions} /> : null}
157
252
  </View>
158
253
  );
159
254
  }
160
255
 
161
- export function ScrollableInner(props: DialogInnerProps) {
162
- return <Inner {...props} />;
163
- }
164
-
165
- export function Handle() {
166
- return null;
256
+ function ActionRow({ actions }: { actions: DialogAction[] }) {
257
+ return (
258
+ <View style={{ width: '100%', gap: 8, justifyContent: 'flex-end' }}>
259
+ {actions.map((action, idx) => (
260
+ <ActionButton
261
+ key={`${action.label}-${idx}`}
262
+ action={action}
263
+ />
264
+ ))}
265
+ </View>
266
+ );
167
267
  }
168
268
 
169
- export function Close() {
269
+ function ActionButton({ action }: { action: DialogAction }) {
170
270
  const { close } = useDialogContext();
171
271
  const theme = useTheme();
272
+ const color: DialogActionColor = action.color ?? 'default';
273
+ const shouldCloseOnPress = action.shouldCloseOnPress ?? true;
274
+
275
+ const { background, foreground } = getActionPalette(color, theme.colors);
276
+
277
+ const handlePress = useCallback(
278
+ (e: GestureResponderEvent) => {
279
+ const onPress = action.onPress;
280
+ if (color === 'cancel') {
281
+ close(onPress ? () => onPress(e) : undefined);
282
+ return;
283
+ }
284
+ if (shouldCloseOnPress) {
285
+ close(onPress ? () => onPress(e) : undefined);
286
+ } else {
287
+ onPress?.(e);
288
+ }
289
+ },
290
+ [action.onPress, close, color, shouldCloseOnPress],
291
+ );
172
292
 
173
293
  return (
174
- <View
294
+ <TouchableOpacity
175
295
  style={{
176
- position: 'absolute',
177
- top: 10,
178
- right: 10,
179
- zIndex: 10,
296
+ borderRadius: 9999,
297
+ alignItems: 'center',
298
+ justifyContent: 'center',
299
+ backgroundColor: background,
300
+ opacity: action.disabled ? 0.5 : 1,
301
+ paddingVertical: 12,
302
+ paddingHorizontal: 24,
180
303
  }}
304
+ onPress={handlePress}
305
+ disabled={action.disabled}
306
+ activeOpacity={0.7}
307
+ testID={action.testID}
181
308
  >
182
- <Pressable
183
- onPress={() => close()}
184
- accessibilityLabel="Close dialog"
185
- style={{
186
- width: 34,
187
- height: 34,
188
- borderRadius: 17,
189
- backgroundColor: theme.isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)',
190
- alignItems: 'center',
191
- justifyContent: 'center',
192
- }}
193
- >
194
- <Text style={{ fontSize: 18, color: theme.colors.text, lineHeight: 20 }}>
195
- {'\u00D7'}
196
- </Text>
197
- </Pressable>
198
- </View>
309
+ <Text style={{ fontSize: 16, fontWeight: '500', color: foreground }}>
310
+ {action.label}
311
+ </Text>
312
+ </TouchableOpacity>
199
313
  );
200
314
  }
201
315
 
316
+ function getActionPalette(
317
+ color: DialogActionColor,
318
+ colors: ThemeColors,
319
+ ): { background: string; foreground: string } {
320
+ switch (color) {
321
+ case 'destructive':
322
+ return {
323
+ background: colors.negative,
324
+ foreground: colors.negativeForeground,
325
+ };
326
+ case 'cancel':
327
+ return { background: colors.contrast50, foreground: colors.text };
328
+ case 'default':
329
+ return { background: colors.primary, foreground: '#FFFFFF' };
330
+ /* c8 ignore next 3 -- TS exhaustiveness guard */
331
+ default: {
332
+ const _exhaustive: never = color;
333
+ return { background: colors.primary, foreground: '#FFFFFF' };
334
+ }
335
+ }
336
+ }
337
+
202
338
  function DialogBackdrop({ isClosing }: { isClosing: boolean }) {
203
339
  const style: ViewStyle[] = [
204
340
  {
@@ -210,15 +346,44 @@ function DialogBackdrop({ isClosing }: { isClosing: boolean }) {
210
346
  backgroundColor: 'rgba(0,0,0,0.8)',
211
347
  },
212
348
  isClosing
213
- ? { animation: `bloomDialogFadeOut ease-in ${FADE_OUT_DURATION}ms forwards` } as ViewStyle
214
- : { animation: 'bloomDialogFadeIn ease-out 0.15s' } as ViewStyle,
349
+ ? ({ animation: `bloomDialogFadeOut ease-in ${FADE_OUT_DURATION}ms forwards` } as ViewStyle)
350
+ : ({ animation: 'bloomDialogFadeIn ease-out 0.15s' } as ViewStyle),
215
351
  ];
216
352
 
217
353
  return <View style={style} />;
218
354
  }
219
355
 
220
- export function Backdrop() {
221
- return null;
356
+ /**
357
+ * Inline imperative dialog used by `alert()`. Mounts and immediately
358
+ * presents; resolves the host's `onResolve` once the dialog has finished
359
+ * its exit animation.
360
+ */
361
+ export function AutoMountedDialog({
362
+ title,
363
+ description,
364
+ actions,
365
+ onResolve,
366
+ }: {
367
+ title?: string;
368
+ description?: string;
369
+ actions: DialogAction[];
370
+ onResolve: () => void;
371
+ }) {
372
+ const control = useDialogControl();
373
+
374
+ useEffect(() => {
375
+ control.open();
376
+ }, [control]);
377
+
378
+ return (
379
+ <Dialog
380
+ control={control}
381
+ title={title}
382
+ description={description}
383
+ actions={actions}
384
+ onClose={onResolve}
385
+ />
386
+ );
222
387
  }
223
388
 
224
389
  /**
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Internal bottom-sheet shell shared by `Menu`, `Select` and `ContextMenu`.
3
+ *
4
+ * Not exported from the public `@oxyhq/bloom` surface — these three
5
+ * components historically used the low-level `Dialog.Outer / Inner /
6
+ * Handle` primitives, which are gone in 0.5.0. This shell captures the
7
+ * shared shape (BottomSheet + drag handle + close-on-tap context) in a
8
+ * single place so the three internal call sites stay symmetrical.
9
+ *
10
+ * Consumers needing the same behaviour for app code should use the public
11
+ * `BottomSheet` primitive directly.
12
+ */
13
+ import React, { useCallback, useImperativeHandle, useMemo, useRef } from 'react';
14
+ import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
15
+
16
+ import { BottomSheet, type BottomSheetRef } from '../bottom-sheet';
17
+ import { useTheme } from '../theme/use-theme';
18
+ import { Context } from './context';
19
+ import type { DialogControlProps } from './types';
20
+
21
+ export interface SheetShellProps {
22
+ control: DialogControlProps;
23
+ label?: string;
24
+ header?: React.ReactNode;
25
+ onClose?: () => void;
26
+ /** Style overrides applied to the inner padded content container. */
27
+ contentStyle?: StyleProp<ViewStyle>;
28
+ children?: React.ReactNode;
29
+ }
30
+
31
+ /**
32
+ * Bottom-sheet shell with an embedded drag handle. Exposes the bloom
33
+ * dialog `close()` context to descendants — `Menu.Item`, `Select.Item`
34
+ * etc. rely on it to dismiss the sheet after selection.
35
+ */
36
+ export function SheetShell({
37
+ control,
38
+ label,
39
+ header,
40
+ onClose,
41
+ contentStyle,
42
+ children,
43
+ }: SheetShellProps) {
44
+ const theme = useTheme();
45
+ const ref = useRef<BottomSheetRef>(null);
46
+ const closeCallbacks = useRef<(() => void)[]>([]);
47
+
48
+ const callQueuedCallbacks = useCallback(() => {
49
+ const queued = closeCallbacks.current;
50
+ closeCallbacks.current = [];
51
+ for (const cb of queued) {
52
+ try {
53
+ cb();
54
+ } catch (e) {
55
+ if (typeof console !== 'undefined' && console.error) {
56
+ console.error('SheetShell close callback error:', e);
57
+ }
58
+ }
59
+ }
60
+ }, []);
61
+
62
+ const open = useCallback(() => {
63
+ ref.current?.present();
64
+ }, []);
65
+
66
+ const close = useCallback<DialogControlProps['close']>((cb) => {
67
+ if (typeof cb === 'function') {
68
+ closeCallbacks.current.push(cb);
69
+ }
70
+ ref.current?.dismiss();
71
+ }, []);
72
+
73
+ const handleDismiss = useCallback(() => {
74
+ callQueuedCallbacks();
75
+ onClose?.();
76
+ }, [callQueuedCallbacks, onClose]);
77
+
78
+ useImperativeHandle(control.ref, () => ({ open, close }), [open, close]);
79
+
80
+ const context = useMemo(
81
+ () => ({ close, isWithinDialog: true }),
82
+ [close],
83
+ );
84
+
85
+ const sheetStyle = useMemo(
86
+ () => ({
87
+ maxWidth: 500,
88
+ backgroundColor: theme.colors.background,
89
+ borderRadius: 20,
90
+ }),
91
+ [theme.colors.background],
92
+ );
93
+
94
+ return (
95
+ <BottomSheet
96
+ ref={ref}
97
+ onDismiss={handleDismiss}
98
+ enablePanDownToClose
99
+ detached
100
+ backdropOpacity={0.7}
101
+ style={sheetStyle}
102
+ >
103
+ <Context.Provider value={context}>
104
+ <SheetHandle onPress={() => close()} />
105
+ {header}
106
+ <View
107
+ accessibilityLabel={label}
108
+ style={[styles.body, { backgroundColor: theme.colors.background }, contentStyle]}
109
+ >
110
+ {children}
111
+ </View>
112
+ </Context.Provider>
113
+ </BottomSheet>
114
+ );
115
+ }
116
+
117
+ function SheetHandle({ onPress }: { onPress: () => void }) {
118
+ const theme = useTheme();
119
+ return (
120
+ <View style={styles.handleContainer}>
121
+ <Pressable
122
+ onPress={onPress}
123
+ accessibilityLabel="Dismiss"
124
+ accessibilityHint="Tap to close"
125
+ hitSlop={{ top: 10, bottom: 10, left: 40, right: 40 }}
126
+ >
127
+ <View style={[styles.handleBar, { backgroundColor: theme.colors.text }]} />
128
+ </Pressable>
129
+ </View>
130
+ );
131
+ }
132
+
133
+ const styles = StyleSheet.create({
134
+ handleContainer: {
135
+ position: 'absolute',
136
+ width: '100%',
137
+ alignItems: 'center',
138
+ zIndex: 10,
139
+ height: 20,
140
+ },
141
+ handleBar: {
142
+ top: 8,
143
+ width: 35,
144
+ height: 5,
145
+ borderRadius: 3,
146
+ alignSelf: 'center',
147
+ opacity: 0.5,
148
+ },
149
+ body: {
150
+ paddingTop: 20,
151
+ paddingHorizontal: 20,
152
+ paddingBottom: 20,
153
+ },
154
+ });
@@ -0,0 +1,126 @@
1
+ import type { DialogAction } from './types';
2
+
3
+ let idCounter = 0;
4
+ function genId(): string {
5
+ idCounter += 1;
6
+ return `bloom-alert-${idCounter}`;
7
+ }
8
+
9
+ /**
10
+ * Imperative-alert store.
11
+ *
12
+ * The store sits in module scope so `alert()` works from anywhere — event
13
+ * handlers, async callbacks, top-level helpers — without threading a
14
+ * provider context through every call site.
15
+ *
16
+ * The visible UI is owned by a single subscriber (the `<BloomDialogProvider>`
17
+ * mounted inside the app's React tree). When there is no subscriber yet —
18
+ * because `alert()` was called before the provider mounted, or because the
19
+ * app forgot to mount the provider — entries accumulate in the queue and
20
+ * drain as soon as a subscriber attaches.
21
+ *
22
+ * Multiple subscribers are not supported by design. Two providers would
23
+ * race for the same alert; we instead enforce a single listener and let
24
+ * the most recent subscription win (the older one falls back to no-op).
25
+ */
26
+
27
+ export type AlertButtonStyle = 'default' | 'cancel' | 'destructive';
28
+
29
+ export interface AlertButton {
30
+ /** Button label. Required. */
31
+ text: string;
32
+ /** Tap handler. Invoked after the dialog has finished its close animation. */
33
+ onPress?: () => void;
34
+ /** Visual treatment. Defaults to `'default'`. */
35
+ style?: AlertButtonStyle;
36
+ }
37
+
38
+ export interface AlertEntry {
39
+ id: string;
40
+ title: string;
41
+ message?: string;
42
+ buttons: AlertButton[];
43
+ }
44
+
45
+ type Listener = (queue: AlertEntry[]) => void;
46
+
47
+ let queue: AlertEntry[] = [];
48
+ let listener: Listener | null = null;
49
+
50
+ function emit(): void {
51
+ if (listener) listener(queue);
52
+ }
53
+
54
+ /**
55
+ * Enqueue an alert. Returns the generated id so callers can dismiss it
56
+ * imperatively (rare — usually the dialog dismisses itself via a button
57
+ * tap). The runtime guarantees this enqueues even before any provider
58
+ * mounts; the provider drains pending entries on subscribe.
59
+ */
60
+ export function enqueueAlert(entry: Omit<AlertEntry, 'id'>): string {
61
+ const id = genId();
62
+ queue = [...queue, { ...entry, id }];
63
+ emit();
64
+ return id;
65
+ }
66
+
67
+ /**
68
+ * Remove an alert from the queue. Called by the provider once the bloom
69
+ * Dialog has finished closing for that entry.
70
+ */
71
+ export function dismissAlert(id: string): void {
72
+ const next = queue.filter((e) => e.id !== id);
73
+ if (next.length === queue.length) return;
74
+ queue = next;
75
+ emit();
76
+ }
77
+
78
+ /**
79
+ * Subscribe to queue changes. Replaces any previously-registered listener
80
+ * (single-subscriber model — see header). Returns an unsubscribe function.
81
+ *
82
+ * On subscribe, the current queue is delivered synchronously so the
83
+ * subscriber can render pending entries that arrived before mount.
84
+ */
85
+ export function subscribeAlerts(fn: Listener): () => void {
86
+ listener = fn;
87
+ // Deliver the current queue immediately so a freshly-mounted provider
88
+ // catches up with whatever accumulated before it subscribed.
89
+ fn(queue);
90
+ return () => {
91
+ if (listener === fn) listener = null;
92
+ };
93
+ }
94
+
95
+ /** Inspect the current queue. Used internally and by tests. */
96
+ export function getAlertQueue(): readonly AlertEntry[] {
97
+ return queue;
98
+ }
99
+
100
+ /**
101
+ * Translate an alert button to the action shape the unified `Dialog`
102
+ * accepts. Pure — no React, no theme — so it can be reused on web and
103
+ * native without forking.
104
+ */
105
+ export function buttonToAction(button: AlertButton): DialogAction {
106
+ return {
107
+ label: button.text,
108
+ onPress: button.onPress ? () => button.onPress?.() : undefined,
109
+ color:
110
+ button.style === 'destructive'
111
+ ? 'destructive'
112
+ : button.style === 'cancel'
113
+ ? 'cancel'
114
+ : 'default',
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Compute the effective button set for an alert. Mirrors React Native's
120
+ * `Alert.alert` semantics — an empty/omitted buttons array implies a
121
+ * single `OK` confirmation button.
122
+ */
123
+ export function resolveButtons(buttons: AlertButton[] | undefined): AlertButton[] {
124
+ if (buttons && buttons.length > 0) return buttons;
125
+ return [{ text: 'OK', style: 'default' }];
126
+ }